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 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/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 0000000000000..0bd7d79cf491b Binary files /dev/null and b/apps/meteor/tests/data/apps/app-packages/media-call-reader-test_0.0.1.zip differ diff --git a/apps/meteor/tests/data/apps/helper.ts b/apps/meteor/tests/data/apps/helper.ts index 7871b51dafb3d..044bc744a96dd 100644 --- a/apps/meteor/tests/data/apps/helper.ts +++ b/apps/meteor/tests/data/apps/helper.ts @@ -1,8 +1,39 @@ -import type { App } from '@rocket.chat/core-typings'; +import type { App, AppPermission } from '@rocket.chat/core-typings'; +import AdmZip from 'adm-zip'; import { request, credentials } from '../api-data'; import { apps, APP_URL, installedApps } from './apps-data'; +/** + * Gets the permissions declared in the app manifest file of a test app package. + * + * @param pathToApp Path to test app package + * @returns A promise that resolves to the list of permission objects, or undefined if not available. Rejects if not able to read the manifest file. + */ +async function getPermissionsFromAppManifest(pathToApp: string): Promise { + 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); + }); + }); + }); +}); 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',