From dc40ed1f4b917e5aca41ede298474c5a4d3edd56 Mon Sep 17 00:00:00 2001 From: Pierre Lehnen Date: Thu, 14 May 2026 12:56:55 -0300 Subject: [PATCH 1/3] add apps.engine bridge for media calls fix(apps): align type definitions chore(apps): better type alignment --- .../meteor/app/apps/server/bridges/bridges.js | 6 +++ .../app/apps/server/bridges/mediaCalls.ts | 16 ++++++++ .../definition/accessors/IMediaCallRead.ts | 15 +++++++ .../src/definition/accessors/IRead.ts | 3 ++ .../src/definition/accessors/index.ts | 1 + .../src/definition/mediaCalls/IMediaCall.ts | 41 +++++++++++++++++++ .../src/definition/mediaCalls/index.ts | 1 + .../src/definition/metadata/AppPermissions.ts | 3 ++ .../apps/deno-runtime/lib/accessors/mod.ts | 1 + packages/apps/src/AppsEngine.ts | 1 + .../src/server/accessors/MediaCallRead.ts | 15 +++++++ packages/apps/src/server/accessors/Reader.ts | 6 +++ packages/apps/src/server/accessors/index.ts | 2 + .../apps/src/server/bridges/AppBridges.ts | 6 ++- .../src/server/bridges/MediaCallBridge.ts | 33 +++++++++++++++ packages/apps/src/server/bridges/index.ts | 2 + .../src/server/managers/AppAccessorManager.ts | 3 ++ .../server/accessors/MediaCallRead.test.ts | 27 ++++++++++++ .../tests/server/accessors/Reader.test.ts | 25 ++++++++++- .../tests/test-data/bridges/appBridges.ts | 9 ++++ .../test-data/bridges/mediaCallBridge.ts | 9 ++++ packages/apps/tests/test-data/utilities.ts | 37 +++++++++++++++++ 22 files changed, 259 insertions(+), 3 deletions(-) create mode 100644 apps/meteor/app/apps/server/bridges/mediaCalls.ts create mode 100644 packages/apps-engine/src/definition/accessors/IMediaCallRead.ts create mode 100644 packages/apps-engine/src/definition/mediaCalls/IMediaCall.ts create mode 100644 packages/apps-engine/src/definition/mediaCalls/index.ts create mode 100644 packages/apps/src/server/accessors/MediaCallRead.ts create mode 100644 packages/apps/src/server/bridges/MediaCallBridge.ts create mode 100644 packages/apps/tests/server/accessors/MediaCallRead.test.ts create mode 100644 packages/apps/tests/test-data/bridges/mediaCallBridge.ts diff --git a/apps/meteor/app/apps/server/bridges/bridges.js b/apps/meteor/app/apps/server/bridges/bridges.js index 4205c2afa91bc..e4c604621ee99 100644 --- a/apps/meteor/app/apps/server/bridges/bridges.js +++ b/apps/meteor/app/apps/server/bridges/bridges.js @@ -14,6 +14,7 @@ import { AppInternalBridge } from './internal'; import { AppInternalFederationBridge } from './internalFederation'; import { AppListenerBridge } from './listeners'; import { AppLivechatBridge } from './livechat'; +import { AppMediaCallBridge } from './mediaCalls'; import { AppMessageBridge } from './messages'; import { AppModerationBridge } from './moderation'; import { AppOAuthAppsBridge } from './oauthApps'; @@ -61,6 +62,7 @@ export class RealAppBridges extends AppBridges { this._contactBridge = new AppContactBridge(orch); this._outboundMessageBridge = new OutboundCommunicationBridge(orch); this._experimentalBridge = new AppExperimentalBridge(orch); + this._mediaCallBridge = new AppMediaCallBridge(orch); } getCommandBridge() { @@ -174,4 +176,8 @@ export class RealAppBridges extends AppBridges { getExperimentalBridge() { return this._experimentalBridge; } + + getMediaCallBridge() { + return this._mediaCallBridge; + } } diff --git a/apps/meteor/app/apps/server/bridges/mediaCalls.ts b/apps/meteor/app/apps/server/bridges/mediaCalls.ts new file mode 100644 index 0000000000000..815c8bfa6f9eb --- /dev/null +++ b/apps/meteor/app/apps/server/bridges/mediaCalls.ts @@ -0,0 +1,16 @@ +import type { IAppServerOrchestrator } from '@rocket.chat/apps'; +import { MediaCallBridge } from '@rocket.chat/apps/dist/server/bridges/MediaCallBridge'; +import type { IMediaCall } from '@rocket.chat/apps-engine/definition/mediaCalls'; +import { MediaCalls } from '@rocket.chat/models'; + +export class AppMediaCallBridge extends MediaCallBridge { + constructor(private readonly orch: IAppServerOrchestrator) { + super(); + } + + protected async getById(callId: string, appId: string): Promise { + this.orch.debugLog(`The App ${appId} is getting the media call byId: "${callId}"`); + + return (await MediaCalls.findOneById(callId)) ?? undefined; + } +} diff --git a/packages/apps-engine/src/definition/accessors/IMediaCallRead.ts b/packages/apps-engine/src/definition/accessors/IMediaCallRead.ts new file mode 100644 index 0000000000000..fcb0ee28db15e --- /dev/null +++ b/packages/apps-engine/src/definition/accessors/IMediaCallRead.ts @@ -0,0 +1,15 @@ +import type { IMediaCall } from '../mediaCalls/IMediaCall'; + +/** + * This accessor provides methods for accessing + * media calls in a read-only-fashion. + */ +export interface IMediaCallRead { + /** + * Gets a media call by an id. + * + * @param id the id of the media call + * @returns the media call + */ + getById(id: string): Promise; +} diff --git a/packages/apps-engine/src/definition/accessors/IRead.ts b/packages/apps-engine/src/definition/accessors/IRead.ts index 9ab0984122c5e..45c6a3e35030c 100644 --- a/packages/apps-engine/src/definition/accessors/IRead.ts +++ b/packages/apps-engine/src/definition/accessors/IRead.ts @@ -3,6 +3,7 @@ import type { IContactRead } from './IContactRead'; import type { IEnvironmentRead } from './IEnvironmentRead'; import type { IExperimentalRead } from './IExperimentalRead'; import type { ILivechatRead } from './ILivechatRead'; +import type { IMediaCallRead } from './IMediaCallRead'; import type { IMessageRead } from './IMessageRead'; import type { INotifier } from './INotifier'; import type { IOAuthAppsReader } from './IOAuthAppsReader'; @@ -54,4 +55,6 @@ export interface IRead { getContactReader(): IContactRead; getExperimentalReader(): IExperimentalRead; + + getMediaCallReader(): IMediaCallRead; } diff --git a/packages/apps-engine/src/definition/accessors/index.ts b/packages/apps-engine/src/definition/accessors/index.ts index 1105f80344773..45c37b36088be 100644 --- a/packages/apps-engine/src/definition/accessors/index.ts +++ b/packages/apps-engine/src/definition/accessors/index.ts @@ -19,6 +19,7 @@ export type * from './ILivechatRead'; export type * from './ILivechatUpdater'; export * from './ILogEntry'; export type * from './ILogger'; +export type * from './IMediaCallRead'; export type * from './IMessageBuilder'; export type * from './IMessageExtender'; export type * from './IMessageRead'; 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..5979f2c01e807 --- /dev/null +++ b/packages/apps-engine/src/definition/mediaCalls/IMediaCall.ts @@ -0,0 +1,41 @@ +type MediaCallActorType = 'user' | 'sip'; + +type MediaCallContact = { + type: MediaCallActorType; + id: string; + contractId?: string; + displayName?: string; + username?: string; + sipExtension?: string; +}; + +type MediaCallSignedContact = MediaCallContact & { contractId: string }; + +type MediaCallState = 'none' | 'ringing' | 'accepted' | 'active' | 'hangup'; + +export interface IMediaCall { + _id: string; + _updatedAt: Date; + service: string; + kind: string; + state: MediaCallState; + createdBy: MediaCallContact; + createdAt: Date; + caller: MediaCallSignedContact; + callee: MediaCallContact; + ended: boolean; + endedBy?: { type: MediaCallActorType | 'server'; id: string; contractId?: string }; + endedAt?: Date; + hangupReason?: string; + expiresAt: Date; + acceptedAt?: Date; + activatedAt?: Date; + callerRequestedId?: string; + parentCallId?: string; + transferredBy?: MediaCallSignedContact; + transferredTo?: MediaCallContact; + transferredAt?: Date; + uids: string[]; + features: string[]; + sipCallId?: string; +} 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..d733f6a1ed7c5 --- /dev/null +++ b/packages/apps-engine/src/definition/mediaCalls/index.ts @@ -0,0 +1 @@ +export type * from './IMediaCall'; diff --git a/packages/apps-engine/src/definition/metadata/AppPermissions.ts b/packages/apps-engine/src/definition/metadata/AppPermissions.ts index 4d02fe63abd9e..d7c8202312c0d 100644 --- a/packages/apps-engine/src/definition/metadata/AppPermissions.ts +++ b/packages/apps-engine/src/definition/metadata/AppPermissions.ts @@ -124,6 +124,9 @@ export const AppPermissions = { 'abac': { read: { name: 'abac.read' }, }, + 'mediaCall': { + read: { name: 'media-call.read' }, + }, }; /** diff --git a/packages/apps/deno-runtime/lib/accessors/mod.ts b/packages/apps/deno-runtime/lib/accessors/mod.ts index cfc6bcf629316..ae5b70f88ef03 100644 --- a/packages/apps/deno-runtime/lib/accessors/mod.ts +++ b/packages/apps/deno-runtime/lib/accessors/mod.ts @@ -254,6 +254,7 @@ export class AppAccessors { getRoleReader: () => this.proxify('getReader:getRoleReader'), getContactReader: () => this.proxify('getReader:getContactReader'), getExperimentalReader: () => this.proxify('getReader:getExperimentalReader'), + getMediaCallReader: () => this.proxify('getReader:getMediaCallReader'), }; } diff --git a/packages/apps/src/AppsEngine.ts b/packages/apps/src/AppsEngine.ts index 1184c237b060d..fbc3a3ddd9a06 100644 --- a/packages/apps/src/AppsEngine.ts +++ b/packages/apps/src/AppsEngine.ts @@ -8,6 +8,7 @@ export type { IVisitorPhone as IAppsVisitorPhone, ILivechatContact as IAppsLivechatContact, } from '@rocket.chat/apps-engine/definition/livechat'; +export type { IMediaCall as IAppsMediaCall } from '@rocket.chat/apps-engine/definition/mediaCalls'; export type { IMessage as IAppsMessage } from '@rocket.chat/apps-engine/definition/messages'; export type { IMessageRaw as IAppsMesssageRaw } from '@rocket.chat/apps-engine/definition/messages'; export { AppInterface as AppEvents } from '@rocket.chat/apps-engine/definition/metadata'; diff --git a/packages/apps/src/server/accessors/MediaCallRead.ts b/packages/apps/src/server/accessors/MediaCallRead.ts new file mode 100644 index 0000000000000..2e19aa5511090 --- /dev/null +++ b/packages/apps/src/server/accessors/MediaCallRead.ts @@ -0,0 +1,15 @@ +import type { IMediaCallRead } from '@rocket.chat/apps-engine/definition/accessors'; +import type { IMediaCall } from '@rocket.chat/apps-engine/definition/mediaCalls'; + +import type { MediaCallBridge } from '../bridges'; + +export class MediaCallRead implements IMediaCallRead { + constructor( + private mediaCallBridge: MediaCallBridge, + private appId: string, + ) {} + + public getById(id: string): Promise { + return this.mediaCallBridge.doGetById(id, this.appId); + } +} diff --git a/packages/apps/src/server/accessors/Reader.ts b/packages/apps/src/server/accessors/Reader.ts index 2f73452b1af85..9064a6c41c6af 100644 --- a/packages/apps/src/server/accessors/Reader.ts +++ b/packages/apps/src/server/accessors/Reader.ts @@ -3,6 +3,7 @@ import type { IEnvironmentRead, IExperimentalRead, ILivechatRead, + IMediaCallRead, IMessageRead, INotifier, IPersistenceRead, @@ -34,6 +35,7 @@ export class Reader implements IRead { private thread: IThreadRead, private role: IRoleRead, private experimental: IExperimentalRead, + private mediaCall: IMediaCallRead, ) {} public getEnvironmentReader(): IEnvironmentRead { @@ -95,4 +97,8 @@ export class Reader implements IRead { public getExperimentalReader(): IExperimentalRead { return this.experimental; } + + public getMediaCallReader(): IMediaCallRead { + return this.mediaCall; + } } diff --git a/packages/apps/src/server/accessors/index.ts b/packages/apps/src/server/accessors/index.ts index eb5cbdc0218c7..c308b554a37a1 100644 --- a/packages/apps/src/server/accessors/index.ts +++ b/packages/apps/src/server/accessors/index.ts @@ -9,6 +9,7 @@ import { ExternalComponentsExtend } from './ExternalComponentsExtend'; import { Http } from './Http'; import { HttpExtend } from './HttpExtend'; import { LivechatRead } from './LivechatRead'; +import { MediaCallRead } from './MediaCallRead'; import { MessageBuilder } from './MessageBuilder'; import { MessageExtender } from './MessageExtender'; import { MessageRead } from './MessageRead'; @@ -58,6 +59,7 @@ export { Http, HttpExtend, LivechatRead, + MediaCallRead, MessageBuilder, MessageExtender, MessageRead, diff --git a/packages/apps/src/server/bridges/AppBridges.ts b/packages/apps/src/server/bridges/AppBridges.ts index 5e5ef7ca12106..275de22ef581c 100644 --- a/packages/apps/src/server/bridges/AppBridges.ts +++ b/packages/apps/src/server/bridges/AppBridges.ts @@ -12,6 +12,7 @@ import type { IInternalBridge } from './IInternalBridge'; import type { IInternalFederationBridge } from './IInternalFederationBridge'; import type { IListenerBridge } from './IListenerBridge'; import type { LivechatBridge } from './LivechatBridge'; +import type { MediaCallBridge } from './MediaCallBridge'; import type { MessageBridge } from './MessageBridge'; import type { ModerationBridge } from './ModerationBridge'; import type { OAuthAppsBridge } from './OAuthAppsBridge'; @@ -52,7 +53,8 @@ export type Bridge = | OAuthAppsBridge | ModerationBridge | RoleBridge - | OutboundMessageBridge; + | OutboundMessageBridge + | MediaCallBridge; export abstract class AppBridges { public abstract getCommandBridge(): CommandBridge; @@ -110,4 +112,6 @@ export abstract class AppBridges { public abstract getOutboundMessageBridge(): OutboundMessageBridge; public abstract getExperimentalBridge(): ExperimentalBridge; + + public abstract getMediaCallBridge(): MediaCallBridge; } diff --git a/packages/apps/src/server/bridges/MediaCallBridge.ts b/packages/apps/src/server/bridges/MediaCallBridge.ts new file mode 100644 index 0000000000000..421ba13ddefde --- /dev/null +++ b/packages/apps/src/server/bridges/MediaCallBridge.ts @@ -0,0 +1,33 @@ +import type { IMediaCall } from '@rocket.chat/apps-engine/definition/mediaCalls/IMediaCall'; + +import { BaseBridge } from './BaseBridge'; +import { PermissionDeniedError } from '../errors/PermissionDeniedError'; +import { AppPermissionManager } from '../managers/AppPermissionManager'; +import { AppPermissions } from '../permissions/AppPermissions'; + +export abstract class MediaCallBridge extends BaseBridge { + public async doGetById(callId: string, appId: string): Promise { + if (this.hasReadPermission(appId)) { + return this.getById(callId, appId); + } + + return null; + } + + protected abstract getById(callId: string, appId: string): Promise; + + private hasReadPermission(appId: string): boolean { + if (AppPermissionManager.hasPermission(appId, AppPermissions.mediaCall.read)) { + return true; + } + + AppPermissionManager.notifyAboutError( + new PermissionDeniedError({ + appId, + missingPermissions: [AppPermissions.mediaCall.read], + }), + ); + + return false; + } +} diff --git a/packages/apps/src/server/bridges/index.ts b/packages/apps/src/server/bridges/index.ts index 6381da83a562c..eb9865cb94ea5 100644 --- a/packages/apps/src/server/bridges/index.ts +++ b/packages/apps/src/server/bridges/index.ts @@ -14,6 +14,7 @@ import type { IInternalBridge } from './IInternalBridge'; import type { IInternalFederationBridge } from './IInternalFederationBridge'; import type { IListenerBridge } from './IListenerBridge'; import { LivechatBridge } from './LivechatBridge'; +import { MediaCallBridge } from './MediaCallBridge'; import { MessageBridge } from './MessageBridge'; import { ModerationBridge } from './ModerationBridge'; import { OutboundMessageBridge } from './OutboundMessagesBridge'; @@ -35,6 +36,7 @@ export { EnvironmentalVariableBridge, HttpBridge, LivechatBridge, + MediaCallBridge, MessageBridge, PersistenceBridge, AppActivationBridge, diff --git a/packages/apps/src/server/managers/AppAccessorManager.ts b/packages/apps/src/server/managers/AppAccessorManager.ts index 57681c85856d5..797983bebf06b 100644 --- a/packages/apps/src/server/managers/AppAccessorManager.ts +++ b/packages/apps/src/server/managers/AppAccessorManager.ts @@ -22,6 +22,7 @@ import { Http, HttpExtend, LivechatRead, + MediaCallRead, MessageRead, Modify, Notifier, @@ -192,6 +193,7 @@ export class AppAccessorManager { const thread = new ThreadRead(this.bridges.getThreadBridge(), appId); const role = new RoleRead(this.bridges.getRoleBridge(), appId); const experimental = new ExperimentalRead(this.bridges.getExperimentalBridge(), appId); + const mediaCall = new MediaCallRead(this.bridges.getMediaCallBridge(), appId); this.readers.set( appId, @@ -211,6 +213,7 @@ export class AppAccessorManager { thread, role, experimental, + mediaCall, ), ); } diff --git a/packages/apps/tests/server/accessors/MediaCallRead.test.ts b/packages/apps/tests/server/accessors/MediaCallRead.test.ts new file mode 100644 index 0000000000000..d93d1f400b35c --- /dev/null +++ b/packages/apps/tests/server/accessors/MediaCallRead.test.ts @@ -0,0 +1,27 @@ +import * as assert from 'node:assert'; +import { describe, it } from 'node:test'; + +import type { IMediaCall } from '@rocket.chat/apps-engine/definition/mediaCalls'; + +import { MediaCallRead } from '../../../src/server/accessors'; +import type { MediaCallBridge } from '../../../src/server/bridges'; +import { TestData } from '../../test-data/utilities'; + +describe('MediaCallRead', () => { + it('expectDataFromMediaCallRead', async () => { + const call = TestData.getMediaCall(); + + const mockMediaCallBridge = { + doGetById(id, appId): Promise { + return Promise.resolve(call); + }, + } as MediaCallBridge; + + assert.doesNotThrow(() => new MediaCallRead(mockMediaCallBridge, 'testing-app')); + + const read = new MediaCallRead(mockMediaCallBridge, 'testing-app'); + + assert.ok((await read.getById('fake')) !== undefined); + assert.strictEqual(await read.getById('fake'), call); + }); +}); diff --git a/packages/apps/tests/server/accessors/Reader.test.ts b/packages/apps/tests/server/accessors/Reader.test.ts index cbfdf00256fab..c2841928b8404 100644 --- a/packages/apps/tests/server/accessors/Reader.test.ts +++ b/packages/apps/tests/server/accessors/Reader.test.ts @@ -6,6 +6,7 @@ import type { IEnvironmentRead, IExperimentalRead, ILivechatRead, + IMediaCallRead, IMessageRead, INotifier, IPersistenceRead, @@ -37,13 +38,32 @@ describe('Reader', () => { const role = {} as IRoleRead; const contact = {} as IContactRead; const experimental = {} as IExperimentalRead; + const mediaCall = {} as IMediaCallRead; it('useReader', () => { assert.doesNotThrow( - () => new Reader(env, msg, pr, rm, ur, ni, livechat, upload, cloud, videoConf, contact, oauthApps, thread, role, experimental), + () => + new Reader(env, msg, pr, rm, ur, ni, livechat, upload, cloud, videoConf, contact, oauthApps, thread, role, experimental, mediaCall), ); - const rd = new Reader(env, msg, pr, rm, ur, ni, livechat, upload, cloud, videoConf, contact, oauthApps, thread, role, experimental); + const rd = new Reader( + env, + msg, + pr, + rm, + ur, + ni, + livechat, + upload, + cloud, + videoConf, + contact, + oauthApps, + thread, + role, + experimental, + mediaCall, + ); assert.ok(rd.getEnvironmentReader() !== undefined); assert.ok(rd.getMessageReader() !== undefined); @@ -55,5 +75,6 @@ describe('Reader', () => { assert.ok(rd.getUploadReader() !== undefined); assert.ok(rd.getVideoConferenceReader() !== undefined); assert.ok(rd.getRoleReader() !== undefined); + assert.ok(rd.getMediaCallReader() !== undefined); }); }); diff --git a/packages/apps/tests/test-data/bridges/appBridges.ts b/packages/apps/tests/test-data/bridges/appBridges.ts index b2a35e6aac804..da12048377102 100644 --- a/packages/apps/tests/test-data/bridges/appBridges.ts +++ b/packages/apps/tests/test-data/bridges/appBridges.ts @@ -12,6 +12,7 @@ import { TestsHttpBridge } from './httpBridge'; import { TestsInternalBridge } from './internalBridge'; import { TestsInternalFederationBridge } from './internalFederationBridge'; import { TestLivechatBridge } from './livechatBridge'; +import { TestsMediaCallBridge } from './mediaCallBridge'; import { TestsMessageBridge } from './messageBridge'; import { TestsModerationBridge } from './moderationBridge'; import { TestOutboundCommunicationBridge } from './outboundComms'; @@ -36,6 +37,7 @@ import type { IInternalBridge, IListenerBridge, LivechatBridge, + MediaCallBridge, MessageBridge, ModerationBridge, OutboundMessageBridge, @@ -110,6 +112,8 @@ export class TestsAppBridges extends AppBridges { private readonly experimentalBridge: TestExperimentalBridge; + private readonly mediaCallsBridge: TestsMediaCallBridge; + constructor() { super(); this.appDetails = new TestsAppDetailChangesBridge(); @@ -139,6 +143,7 @@ export class TestsAppBridges extends AppBridges { this.contactBridge = new TestContactBridge(); this.outboundCommsBridge = new TestOutboundCommunicationBridge(); this.experimentalBridge = new TestExperimentalBridge(); + this.mediaCallsBridge = new TestsMediaCallBridge(); } public getCommandBridge(): TestsCommandBridge { @@ -252,4 +257,8 @@ export class TestsAppBridges extends AppBridges { public getExperimentalBridge(): ExperimentalBridge { return this.experimentalBridge; } + + public getMediaCallBridge(): MediaCallBridge { + return this.mediaCallsBridge; + } } diff --git a/packages/apps/tests/test-data/bridges/mediaCallBridge.ts b/packages/apps/tests/test-data/bridges/mediaCallBridge.ts new file mode 100644 index 0000000000000..42918100bb4f0 --- /dev/null +++ b/packages/apps/tests/test-data/bridges/mediaCallBridge.ts @@ -0,0 +1,9 @@ +import type { IMediaCall } from '@rocket.chat/apps-engine/definition/mediaCalls'; + +import { MediaCallBridge } from '../../../src/server/bridges'; + +export class TestsMediaCallBridge extends MediaCallBridge { + public getById(callId: string, appId: string): Promise { + throw new Error('Method not implemented.'); + } +} diff --git a/packages/apps/tests/test-data/utilities.ts b/packages/apps/tests/test-data/utilities.ts index df4e0b7da7a2e..36884115402c1 100644 --- a/packages/apps/tests/test-data/utilities.ts +++ b/packages/apps/tests/test-data/utilities.ts @@ -6,6 +6,7 @@ import { HttpStatusCode } from '@rocket.chat/apps-engine/definition/accessors'; import type { IApi, IApiRequest, IApiResponse } from '@rocket.chat/apps-engine/definition/api'; import { ApiSecurity, ApiVisibility } from '@rocket.chat/apps-engine/definition/api'; import type { IApiEndpointInfo } from '@rocket.chat/apps-engine/definition/api/IApiEndpointInfo'; +import type { IMediaCall } from '@rocket.chat/apps-engine/definition/mediaCalls'; import type { IMessage, IMessageAttachment, IMessageRaw } from '@rocket.chat/apps-engine/definition/messages'; import type { IOutboundEmailMessageProvider, @@ -504,6 +505,42 @@ export class TestData { }; } + public static getMediaCall(): IMediaCall { + return { + _id: 'first-call', + _updatedAt: new Date(), + service: 'webrtc', + kind: 'direct', + state: 'hangup', + createdBy: { + type: 'user', + id: 'johnId', + }, + createdAt: new Date(), + caller: { + type: 'user', + id: 'johnId', + contractId: 'johnContractId', + }, + callee: { + type: 'user', + id: 'janeId', + }, + ended: true, + endedBy: { + type: 'user', + id: 'janeId', + }, + endedAt: new Date(), + hangupReason: 'normal', + expiresAt: new Date(), + acceptedAt: new Date(), + activatedAt: new Date(), + uids: ['johnId', 'janeId'], + features: ['voice'], + }; + } + public static getOutboundPhoneMessageProvider(name = 'Test Phone Provider'): IOutboundPhoneMessageProvider { return { type: 'phone', From f3da48df731d989fca7c59efe51c0c3c2e31ddeb Mon Sep 17 00:00:00 2001 From: Douglas Gubert Date: Tue, 9 Jun 2026 16:18:47 -0300 Subject: [PATCH 2/3] add changeset chore: better wording for changeset --- .changeset/tired-teeth-cheat.md | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 .changeset/tired-teeth-cheat.md diff --git a/.changeset/tired-teeth-cheat.md b/.changeset/tired-teeth-cheat.md new file mode 100644 index 0000000000000..23a32c0391c0b --- /dev/null +++ b/.changeset/tired-teeth-cheat.md @@ -0,0 +1,7 @@ +--- +'@rocket.chat/apps-engine': minor +'@rocket.chat/apps': minor +'@rocket.chat/meteor': minor +--- + +Added the new MediaCallRead accessor that allows apps to read information from the system's media calls From 93430f70e5b4f3beb597fe7f416e1999f778ca9f Mon Sep 17 00:00:00 2001 From: Douglas Gubert Date: Tue, 9 Jun 2026 19:39:49 -0300 Subject: [PATCH 3/3] tests(apps): end-to-end tests for new reader --- .../tests/data/apps/app-packages/README.md | 71 ++++++++++++ .../tests/data/apps/app-packages/index.ts | 2 + .../media-call-reader-test_0.0.1.zip | Bin 0 -> 3812 bytes apps/meteor/tests/data/apps/helper.ts | 61 +++++++--- .../end-to-end/apps/app-media-call-reader.ts | 104 ++++++++++++++++++ 5 files changed, 224 insertions(+), 14 deletions(-) create mode 100644 apps/meteor/tests/data/apps/app-packages/media-call-reader-test_0.0.1.zip create mode 100644 apps/meteor/tests/end-to-end/apps/app-media-call-reader.ts diff --git a/apps/meteor/tests/data/apps/app-packages/README.md b/apps/meteor/tests/data/apps/app-packages/README.md index 092b049e3487c..c77e7ff4ea0db 100644 --- a/apps/meteor/tests/data/apps/app-packages/README.md +++ b/apps/meteor/tests/data/apps/app-packages/README.md @@ -667,3 +667,74 @@ export class UiKitRoomTestApp extends App implements IUIKitInteractionHandler { ``` + +#### Media Call Reader Test + +File name: `media-call-reader-test_0.0.1.zip` + +An app with the `media-call.read` permission that exposes a public API endpoint for testing the `MediaCallRead` accessor. The endpoint `GET /read-call?callId=` calls `read.getMediaCallReader().getById(callId)` and returns `{ call: }`. + +Used by `apps/meteor/tests/end-to-end/apps/app-media-call-reader.ts`. + +
+App source code + +**app.json** (relevant excerpt) +```json +{ + "permissions": [ + { "name": "media-call.read" } + ] +} +``` + +**MediaCallReaderTestApp.ts** +```typescript +import { + IAppAccessors, IConfigurationExtend, IHttp, ILogger, + IModify, IPersistence, IRead, +} from '@rocket.chat/apps-engine/definition/accessors'; +import { ApiSecurity, ApiVisibility } from '@rocket.chat/apps-engine/definition/api'; +import { ApiEndpoint, IApiEndpointInfo, IApiRequest, IApiResponse } from '@rocket.chat/apps-engine/definition/api'; +import { App } from '@rocket.chat/apps-engine/definition/App'; +import { IAppInfo } from '@rocket.chat/apps-engine/definition/metadata'; + +class ReadCallEndpoint extends ApiEndpoint { + public path = 'read-call'; + + public async get( + request: IApiRequest, + _endpoint: IApiEndpointInfo, + read: IRead, + _modify: IModify, + _http: IHttp, + _persis: IPersistence, + ): Promise { + const { callId } = request.query; + + if (!callId) { + return { status: 400, content: { error: 'callId query parameter is required' } }; + } + + const call = await read.getMediaCallReader().getById(callId); + + return { status: 200, content: { call: call || null } }; + } +} + +export class MediaCallReaderTestApp extends App { + constructor(info: IAppInfo, logger: ILogger, accessors: IAppAccessors) { + super(info, logger, accessors); + } + + protected async extendConfiguration(configuration: IConfigurationExtend): Promise { + await configuration.api.provideApi({ + visibility: ApiVisibility.PUBLIC, + security: ApiSecurity.UNSECURE, + endpoints: [new ReadCallEndpoint(this)], + }); + } +} +``` + +
diff --git a/apps/meteor/tests/data/apps/app-packages/index.ts b/apps/meteor/tests/data/apps/app-packages/index.ts index 73442172e121b..76db87317e3c7 100644 --- a/apps/meteor/tests/data/apps/app-packages/index.ts +++ b/apps/meteor/tests/data/apps/app-packages/index.ts @@ -13,3 +13,5 @@ export const appUpdateStatusTest = path.resolve(__dirname, './update-status-test export const appExternalIdTest = path.resolve(__dirname, './external-id-test_0.0.1.zip'); export const messageReactionTest = path.resolve(__dirname, './message-updater-test_0.0.1.zip'); + +export const mediaCallReaderTest = path.resolve(__dirname, './media-call-reader-test_0.0.1.zip'); diff --git a/apps/meteor/tests/data/apps/app-packages/media-call-reader-test_0.0.1.zip b/apps/meteor/tests/data/apps/app-packages/media-call-reader-test_0.0.1.zip new file mode 100644 index 0000000000000000000000000000000000000000..0bd7d79cf491b9f34cccb7313d1b0cf13c64c85a GIT binary patch literal 3812 zcmZ`+cQhPK_g-Dph~8uM9%U1vMTy>4XZ3EeqD%CMva6RM$`VBHExI5HLTvQ5SS@4` zMAQVI_x*l3@At>=otb;?%$#}7opa}&InSf7g@;cA01y)cBx8mxtzssm4FLdv(T$Pb zn8*`52X{LcCrA6IRVD(%C=|HSz=!XX-k7fNxYn4u(72uuAK#45*s#$ASU^iyU~Cj+ z*k~X=B{3_Zf3t&9C-6=rOBnSdzZpWxWWqJtLNy7($J#=P#Tif>{l8-q|6goHbKnRM z4glbYdvl$e*mh5zh(LY4VA10;UITY;12A&ExMG9&27;7c;1ZbCM5WeCO&S|}BahmM zPL->DTCNQ+2up|#d=ki_`r@{dzmA}i%j71EBE)o*ovw>dx-OnljQWFppUcRm5ixhE zU&!bg;a1C@J(EiwJmfp3YL)^^i=8EXN?$F+h*B3^l!M}l(ae%{h9(WmOU{fVSSIB2 z{Tp=x9Hx2kTVak;hcqw`Qod8TBu8N#4|QM(mi}0-$bS{7{Y1(*%_j7@#Yl^!GhQR% zDXzTDtD&ZEr>QNe{B8+aLY87qC8zDH3bhc9Mod#}pJ16-o-;j8YMi4BYr6)I0sp~r zM;;twvA6z3u4ZVleQ$)%i%E-?zQK{sz0?JiaIN*Sye`PX^JuHpwJ=(wgsHW7mFS;b zo<%#W28-LSEZ6%-9+M#Z5c~J6u#1ox5O+mL0RY+ex|SYcc137Bd;-CHgzb` zeH(?|E(y#0Jw=2X&CSy-`PKUqMaU5YQqGUcx&Z*$DKvxt5K$_K?@R=UFcNqSObwD{ zBO^>#FF8E)W@XK9ZEfw+k0by9NLDGV!GJ>aA<7Yn1kd0DqF?5=&e}FSUO5k4{>>cR z0sIXryF3oZHqPdB&N;m*@}h)yBdJ5NVPh8Bb}Kl19KQqYiK#|IfOFqe(E84k^Kjey zWk8JLTgEe<^QR)bW`t*1=gqR~1*STt(s5V)^NKs~W_(`Qbu4hokird$Gt4RqJ(^U& zNugXNEyERl!NQJH--ZZM^B7u`1uRR*T(y^%sxbj&dAsk5m6wJYt#z<;0V$8#=XH_U zt!6$DwPIS5MZ2*Bcj?WBqKN~~vL;sb!^fX&)gF)ruvZnB?JajLF-7rp#k2mLK?@?X zZIp}0s^6K=BVy}6xpKyBUzHzki=x@1CX13+6i)T!3mjfdZU z-%gkIL0TM{fhDGkiNAB^Tm)t0Y+cHhI#o=wkKgIcSc17eJ}&AE4F^p`nKOL0e28B# zMQ*r0)(%e@0j0gB;sWm1pcZnqjFxk?BDSHB_Gcc2)MZh0$Vj=6l+ah? z228`;PD9_8SrfO_B`Aw!YNj{CBlJXAUU+7KcBmTcUbWaYjeT{}c%F)EN6A85$oZW@ z9tF%xZ=85JO1rq>GnlU{5&{21)>A9&E39Y41{+PA! z$z`IYztLsCM^3xRo7HDy&2O*5>LP1ciG1I_m!UhOhU4c8*e!}d{fRJPPGg))7EO!1 z7Qn7_TT6LPa$}#6z&@iEm5p5k!?;M{POEsPfjJw?u9|c(eR!A^H-w6uoMj`YYZBGK zE#^bo%sv8=uYVgW-C#PKL)OHlIxc=0${+dJS@!#v`t*;{=Ghok$uD!2Q97Psl1;g} zJF(Damd}#?2p7Q@&ln{mzr!4@S`djRQN9I5R8ag?`aIk9z~Rh21HI1#1BGEh?Z(zd z%9rJB6Dk!U+AA4|Qm@>C(>w1St}@i zd0KAw)bpNi+!IVY~lqMdUcUu z=ES(C4ZY3A-km=^)c8L7nX_QM1X)Gej$qBm?wzL{Z(jtu_|ACEl?h! zQsLU8u6yUvZ5(mahE3jBx>_2e^7D$f_xP9ay$~p8b3S0iUfIebEXRpw4)HyU3B(@k z2^%FJDN&P0f6ZSqBKzq;Lb*6De^CD<)0rix(xmMjKc$S0m^SIb`CZQHQRq55 zCDaj&ADa+-=d3lkmCCj!U$i`se#jRh38xoZxgYQ#?B|$$7BPQ$P;gQ>HDzlSdkGp`6B55CR(#p`4aGpVU;&6}{BWnt8wAw}11G4^CBKf%?ShS*T|$#i}V3ym=GP zpGk)Gk0QlKq5HS8H%egZLIN6ul_n78$^MlOb?YQo=hrdb*D10WRUpIQ8uC=9mQ~38 zjhM48r^9rI;CqH+ik)Cw>>F#{pe7?&KbEPfq83kWDH3#2^O4%mTn;CsZ-P&_p_Hrj zd76cQ+q|fJS_Ph$ru8$AUgjXVsF|>~5H8U}uASrJ!27}ToNDfOH!%1Ojdi!6BRrQD z8+khd8T__`Enl>Eo5qZKWOa3RzvK^DwaRsB15xJ@Z^EJTLX#}V4EeI z&8`A0XeAjLg>f*0ByxUx7!$71{9{K!ikx1=`stTfQP^AY8o$KKH6tJfyh?L-X@D`v zz0Ve5Su|wl1kK;&r!!5a48N&4!?IVKr+x(g#2YQtvjcw@z{F^DCi<$j6;bNdS{Zn& zjfHm_=dC4cSq!~vqoDD&m6?w3{n>qZhS$9JQ?o#7KVVZwzbx&-Cj(--#0@3f*8ciN z3ssZJy#hb(qo|Jr8Y|T^^mzL`?@FFcUENvr@Dzq!Z%;I^Ox0r3>Gs#CBUKYZJNTW? z*5#{pfmTF_Cqx~qb4Bmsc9>G=%7QCb!p|xa1sI&Oo~bQ_s$I|$2kyIa!21ZnI} zNA%9fv>~z$FKD&XO@XQnaowPB2WTsBZb0Pz*C?Yg8^a$#^sApTLv&@_I6r%_odyPY z%=Z`M&&UrX$?@a};Pe5d%)mE4W06GC1F1<8&?!I^X@Y*6m!oe8z6lGIO`>ZNpwvjX zMPmGnRWr9Pj5*#ieE3ce``1b3ZQQlgJM*lnxU&|W6V>xMO>a*=#E%%DNLgMR>AerE zLWEhX4a0re1PPaFeJ`^~8r04Av`L9~9`4^;lY}N#;zp@_j%)Cy3j&FsxHhu zK6F0gL+%TMcJ@%kpoc-AxWc-r2VOO38FWGEp^lJMX<$blmo{iG`buSB zVeJz^S7CNaTiv19wXQZ6O6AR87ttI!hHFb?-79ONU-bDDt#g1p@&)=mbV6iaU7-|RzI%#vCaX^4mG z94?W`34P8~$3pFo>-jt0MZq~FXa`cE(i>?h&>GN}4zwLc2(sK(O?^;WMcjvEI_P2I zIPHrA`6PbgH?gLj9_izx?2LTlX}hbxLSNG3Uv+`c#X(P^?2JjvrJSq~7rRFYBjiF% z84(%ma->FJC>S-`gpz_6p)8POA#EZ`j9I-kXKzJ$X6Hatn45dbp8M=+c*pl$rlX%U zh|_hcB!OP|C#<<<9dJSPmrPldl&ZJwSFvhz$L(E`axg`#OHs^*Us0Psoy+j_z>HJi zPS<+Rtxlz$Zr4!n&=WUlQ%0TFS-+}^CuR%k=#F+qqWc}9+t1k!6!p6WHLn;prLOjl zzMd>e=xgEND&YOQ#=Cj*zgu(UU+#Yt-yh(=3AF#h0D$Jmkelfrx%R)P|4)kigBrT2 mx { + try { + const zip = new AdmZip(pathToApp); + + const { promise, resolve, reject } = Promise.withResolvers(); + + zip.readAsTextAsync('app.json', (data, err) => { + if (err) { + return reject(err); + } + + resolve(data); + }); + + const appJsonContent = await promise; + + const appJson = JSON.parse(appJsonContent); + + return appJson.permissions; + } catch (e) { + throw new Error(`Failed to read app manifest from "${pathToApp}"`, { cause: e }); + } +} + const getApps = () => new Promise((resolve) => { void request @@ -39,16 +70,18 @@ export const installTestApp = () => }); }); -export const installLocalTestPackage = (path: string) => - new Promise((resolve, reject) => { - void request - .post(apps()) - .set(credentials) - .attach('app', path) - .end((err, res) => { - if (err) { - return reject(err); - } - return resolve(res.body.app); - }); - }); +export const installLocalTestPackage = async (path: string, { withPermissions = true }: { withPermissions?: boolean } = {}) => { + const req = request.post(apps()).set(credentials).attach('app', path); + + if (withPermissions) { + const permissions = await getPermissionsFromAppManifest(path); + + if (permissions) { + req.field('permissions', JSON.stringify(permissions)); + } + } + + const res = await req; + + return res.body.app as App; +}; diff --git a/apps/meteor/tests/end-to-end/apps/app-media-call-reader.ts b/apps/meteor/tests/end-to-end/apps/app-media-call-reader.ts new file mode 100644 index 0000000000000..bd698040d1ec2 --- /dev/null +++ b/apps/meteor/tests/end-to-end/apps/app-media-call-reader.ts @@ -0,0 +1,104 @@ +import type { App } from '@rocket.chat/core-typings'; +import { expect } from 'chai'; +import { after, before, describe, it } from 'mocha'; +import type { Response } from 'supertest'; + +import { getCredentials, request, credentials } from '../../data/api-data'; +import { mediaCallReaderTest } from '../../data/apps/app-packages'; +import { apps } from '../../data/apps/apps-data'; +import { cleanupApps, installLocalTestPackage } from '../../data/apps/helper'; +import { IS_EE } from '../../e2e/config/constants'; + +// Test fixture call IDs seeded by callHistoryTestData.ts +const INTERNAL_CALL_ID = 'rocketchat.internal.call.test'; +const EXTERNAL_CALL_ID = 'rocketchat.external.call.test.outbound'; + +(IS_EE ? describe : describe.skip)('Apps - MediaCallRead accessor', () => { + describe('[with media-call.read permission]', () => { + let app: App; + + before((done) => getCredentials(done)); + + before(async () => { + await cleanupApps(); + app = await installLocalTestPackage(mediaCallReaderTest); + }); + + after(() => cleanupApps()); + + it('should return an internal call by ID', async () => { + await request + .get(apps(`/public/${app.id}/read-call`)) + .set(credentials) + .query({ callId: INTERNAL_CALL_ID }) + .expect('Content-Type', /json/) + .expect(200) + .expect((res: Response) => { + expect(res.body).to.have.property('call').that.is.an('object'); + + const { call } = res.body; + expect(call).to.have.property('_id', INTERNAL_CALL_ID); + expect(call).to.have.property('service', 'webrtc'); + expect(call).to.have.property('kind', 'direct'); + expect(call).to.have.property('state', 'hangup'); + expect(call).to.have.property('ended', true); + expect(call).to.have.property('createdBy').that.is.an('object'); + expect(call.createdBy).to.have.property('type', 'user'); + expect(call).to.have.property('caller').that.is.an('object'); + expect(call.caller).to.have.property('type', 'user'); + expect(call).to.have.property('callee').that.is.an('object'); + expect(call.callee).to.have.property('type', 'user'); + expect(call).to.have.property('uids').that.is.an('array').with.lengthOf(2); + expect(call).to.have.property('features').that.is.an('array'); + }); + }); + + it('should return an external call by ID', async () => { + await request + .get(apps(`/public/${app.id}/read-call`)) + .set(credentials) + .query({ callId: EXTERNAL_CALL_ID }) + .expect('Content-Type', /json/) + .expect(200) + .expect((res: Response) => { + expect(res.body).to.have.property('call').that.is.an('object'); + + const { call } = res.body; + expect(call).to.have.property('_id', EXTERNAL_CALL_ID); + expect(call).to.have.property('service', 'webrtc'); + expect(call).to.have.property('kind', 'direct'); + expect(call).to.have.property('state', 'hangup'); + expect(call).to.have.property('ended', true); + expect(call).to.have.property('sipCallId').that.is.a('string'); + expect(call).to.have.property('callee').that.is.an('object'); + expect(call.callee).to.have.property('type', 'sip'); + expect(call).to.have.property('uids').that.is.an('array').with.lengthOf(1); + }); + }); + }); + + describe('[without media-call.read permission]', () => { + let app: App; + + before((done) => getCredentials(done)); + + before(async () => { + await cleanupApps(); + app = await installLocalTestPackage(mediaCallReaderTest, { withPermissions: false }); + }); + + after(() => cleanupApps()); + + it('should return null when the app does not have the media-call.read permission', async () => { + await request + .get(apps(`/public/${app.id}/read-call`)) + .set(credentials) + .query({ callId: INTERNAL_CALL_ID }) + .expect('Content-Type', /json/) + .expect(200) + .expect((res: Response) => { + expect(res.body).to.have.property('call', null); + }); + }); + }); +});