Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/tired-teeth-cheat.md
Original file line number Diff line number Diff line change
@@ -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
6 changes: 6 additions & 0 deletions apps/meteor/app/apps/server/bridges/bridges.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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() {
Expand Down Expand Up @@ -174,4 +176,8 @@ export class RealAppBridges extends AppBridges {
getExperimentalBridge() {
return this._experimentalBridge;
}

getMediaCallBridge() {
return this._mediaCallBridge;
}
}
16 changes: 16 additions & 0 deletions apps/meteor/app/apps/server/bridges/mediaCalls.ts
Original file line number Diff line number Diff line change
@@ -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<IMediaCall | undefined> {
this.orch.debugLog(`The App ${appId} is getting the media call byId: "${callId}"`);

return (await MediaCalls.findOneById(callId)) ?? undefined;
}
}
71 changes: 71 additions & 0 deletions apps/meteor/tests/data/apps/app-packages/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -667,3 +667,74 @@ export class UiKitRoomTestApp extends App implements IUIKitInteractionHandler {
```

</details>

#### 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=<id>` calls `read.getMediaCallReader().getById(callId)` and returns `{ call: <IMediaCall | null> }`.

Used by `apps/meteor/tests/end-to-end/apps/app-media-call-reader.ts`.

<details>
<summary>App source code</summary>

**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<IApiResponse> {
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<void> {
await configuration.api.provideApi({
visibility: ApiVisibility.PUBLIC,
security: ApiSecurity.UNSECURE,
endpoints: [new ReadCallEndpoint(this)],
});
}
}
```

</details>
2 changes: 2 additions & 0 deletions apps/meteor/tests/data/apps/app-packages/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Binary file not shown.
61 changes: 47 additions & 14 deletions apps/meteor/tests/data/apps/helper.ts
Original file line number Diff line number Diff line change
@@ -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<AppPermission[] | undefined> {
try {
const zip = new AdmZip(pathToApp);

const { promise, resolve, reject } = Promise.withResolvers<string>();

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<App[]>((resolve) => {
void request
Expand Down Expand Up @@ -39,16 +70,18 @@ export const installTestApp = () =>
});
});

export const installLocalTestPackage = (path: string) =>
new Promise<App>((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;
};
104 changes: 104 additions & 0 deletions apps/meteor/tests/end-to-end/apps/app-media-call-reader.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
});
});
15 changes: 15 additions & 0 deletions packages/apps-engine/src/definition/accessors/IMediaCallRead.ts
Original file line number Diff line number Diff line change
@@ -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<IMediaCall | undefined>;
Comment thread
d-gubert marked this conversation as resolved.
}
3 changes: 3 additions & 0 deletions packages/apps-engine/src/definition/accessors/IRead.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -54,4 +55,6 @@ export interface IRead {
getContactReader(): IContactRead;

getExperimentalReader(): IExperimentalRead;

getMediaCallReader(): IMediaCallRead;
}
1 change: 1 addition & 0 deletions packages/apps-engine/src/definition/accessors/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
41 changes: 41 additions & 0 deletions packages/apps-engine/src/definition/mediaCalls/IMediaCall.ts
Original file line number Diff line number Diff line change
@@ -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;
}
1 change: 1 addition & 0 deletions packages/apps-engine/src/definition/mediaCalls/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export type * from './IMediaCall';
Loading
Loading