diff --git a/.changeset/real-teeth-clean.md b/.changeset/real-teeth-clean.md new file mode 100644 index 0000000000000..3d92bc237eee0 --- /dev/null +++ b/.changeset/real-teeth-clean.md @@ -0,0 +1,9 @@ +--- +'@rocket.chat/media-calls': minor +'@rocket.chat/model-typings': minor +'@rocket.chat/core-typings': minor +'@rocket.chat/models': minor +'@rocket.chat/meteor': minor +--- + +Added an endpoint to save SIP metadata on the media calls model diff --git a/apps/meteor/app/api/server/v1/media-calls.ts b/apps/meteor/app/api/server/v1/media-calls.ts index d526ec549ea1c..0c544a7070754 100644 --- a/apps/meteor/app/api/server/v1/media-calls.ts +++ b/apps/meteor/app/api/server/v1/media-calls.ts @@ -210,3 +210,68 @@ declare module '@rocket.chat/rest-typings' { // eslint-disable-next-line @typescript-eslint/naming-convention, @typescript-eslint/no-empty-interface interface Endpoints extends MediaCallsStateEndpoints {} } + +type MediaCallsInfoParams = { + callId: string; +}; + +const MediaCallsInfoSchema: JSONSchemaType = { + type: 'object', + properties: { + callId: { + type: 'string', + }, + }, + required: ['callId'], + additionalProperties: false, +}; + +export const isMediaCallsInfoProps = ajv.compile(MediaCallsInfoSchema); + +const mediaCallsInfoEndpoints = API.v1.get( + 'media-calls.info', + { + response: { + 200: ajv.compile<{ + call: IMediaCall; + }>({ + additionalProperties: false, + type: 'object', + properties: { + call: { + type: 'object', + $ref: '#/components/schemas/IMediaCall', + }, + success: { + type: 'boolean', + description: 'Indicates the request was successful.', + }, + }, + required: ['call', 'success'], + }), + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 404: validateNotFoundErrorResponse, + }, + query: isMediaCallsInfoProps, + authRequired: true, + }, + async function action() { + const call = await MediaCalls.findOneById(this.queryParams.callId); + + if (!call?.uids.includes(this.userId)) { + return API.v1.notFound(); + } + + return API.v1.success({ + call, + }); + }, +); + +type MediaCallsInfoEndpoints = ExtractRoutesFromAPI; + +declare module '@rocket.chat/rest-typings' { + // eslint-disable-next-line @typescript-eslint/naming-convention, @typescript-eslint/no-empty-interface + interface Endpoints extends MediaCallsInfoEndpoints {} +} diff --git a/apps/meteor/server/startup/callHistoryTestData.ts b/apps/meteor/server/startup/callHistoryTestData.ts index eb2099cc18056..3c6e8a03f48cf 100644 --- a/apps/meteor/server/startup/callHistoryTestData.ts +++ b/apps/meteor/server/startup/callHistoryTestData.ts @@ -200,6 +200,7 @@ export async function addCallHistoryTestData(uid: string, extraUid: string): Pro activatedAt: new Date(), uids: [uid], features: ['audio'], + sipCallId: 'sipCallId3', }, { _id: callId4, @@ -233,6 +234,7 @@ export async function addCallHistoryTestData(uid: string, extraUid: string): Pro activatedAt: new Date(), uids: [uid], features: ['audio'], + sipCallId: 'sipCallId4', }, ]); } diff --git a/apps/meteor/tests/end-to-end/api/media-calls.ts b/apps/meteor/tests/end-to-end/api/media-calls.ts new file mode 100644 index 0000000000000..f4252a83bcc51 --- /dev/null +++ b/apps/meteor/tests/end-to-end/api/media-calls.ts @@ -0,0 +1,114 @@ +import type { Credentials } from '@rocket.chat/api-client'; +import type { IUser } from '@rocket.chat/core-typings'; +import { expect } from 'chai'; +import { after, before, describe, it } from 'mocha'; +import type { Response } from 'supertest'; + +import { getCredentials, api, request, credentials } from '../../data/api-data'; +import { password } from '../../data/user'; +import { createUser, deleteUser, login } from '../../data/users.helper'; + +describe('[Media Calls]', () => { + let user2: IUser; + let userCredentials: Credentials; + + before((done) => getCredentials(done)); + + before(async () => { + user2 = await createUser(); + userCredentials = await login(user2.username, password); + }); + + after(() => deleteUser(user2)); + + describe('[/media-calls.info]', () => { + it('should return valid internal call information', async () => { + await request + .get(api('media-calls.info')) + .set(credentials) + .query({ + callId: 'rocketchat.internal.call.test', + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res: Response) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('call').that.is.an('object'); + + const { call } = res.body; + expect(call).to.have.property('_id', 'rocketchat.internal.call.test'); + 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('hangupReason', 'normal'); + 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('endedBy').that.is.an('object'); + expect(call.endedBy).to.have.property('type', 'user'); + expect(call).to.have.property('uids').that.is.an('array'); + expect(call.uids).to.have.lengthOf(2); + }); + }); + + it('should return valid external call information', async () => { + await request + .get(api('media-calls.info')) + .set(credentials) + .query({ + callId: 'rocketchat.external.call.test.outbound', + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res: Response) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('call').that.is.an('object'); + + const { call } = res.body; + expect(call).to.have.property('_id', 'rocketchat.external.call.test.outbound'); + 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('hangupReason', 'normal'); + expect(call).to.have.property('createdBy').that.is.an('object'); + expect(call).to.have.property('sipCallId', 'sipCallId3'); + 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', 'sip'); + expect(call).to.have.property('endedBy').that.is.an('object'); + expect(call.endedBy).to.have.property('type', 'user'); + expect(call).to.have.property('uids').that.is.an('array'); + expect(call.uids).to.have.lengthOf(1); + }); + }); + + it('should not return invalid calls', async () => { + await request + .get(api('media-calls.info')) + .set(credentials) + .query({ + callId: 'invalid.call', + }) + .expect('Content-Type', 'application/json') + .expect(404); + }); + + it('should not return calls from other users', async () => { + await request + .get(api('media-calls.info')) + .set(userCredentials) + .query({ + callId: 'rocketchat.internal.call.test', + }) + .expect('Content-Type', 'application/json') + .expect(404); + }); + }); +}); diff --git a/ee/packages/media-calls/src/server/CallDirector.ts b/ee/packages/media-calls/src/server/CallDirector.ts index 6387a373e2bce..4ccb501f04f35 100644 --- a/ee/packages/media-calls/src/server/CallDirector.ts +++ b/ee/packages/media-calls/src/server/CallDirector.ts @@ -24,6 +24,7 @@ const EXPIRATION_CHECK_TIMEOUT = EXPIRATION_TIME + 1000; export type CreateCallParams = InternalCallParams & { callerAgent: IMediaCallAgent; calleeAgent: IMediaCallAgent; + sipCallId?: string; }; // expiration checks by call id @@ -61,7 +62,12 @@ class MediaCallDirector { public async acceptCall( call: MediaCallHeader, calleeAgent: IMediaCallAgent, - data: { calleeContractId: string; webrtcAnswer?: RTCSessionDescriptionInit; supportedFeatures: CallFeature[] }, + data: { + calleeContractId: string; + webrtcAnswer?: RTCSessionDescriptionInit; + supportedFeatures: CallFeature[]; + sipCallId?: string; + }, ): Promise { logger.debug({ msg: 'MediaCallDirector.acceptCall' }); @@ -233,6 +239,7 @@ class MediaCallDirector { ...(parentCallId && { parentCallId }), features: allowedFeatures, + ...(params.sipCallId && { sipCallId: params.sipCallId }), }; logger.debug({ msg: 'creating call', call }); diff --git a/ee/packages/media-calls/src/sip/providers/IncomingSipCall.ts b/ee/packages/media-calls/src/sip/providers/IncomingSipCall.ts index 762e66ee5185e..d1bdbff3c81cd 100644 --- a/ee/packages/media-calls/src/sip/providers/IncomingSipCall.ts +++ b/ee/packages/media-calls/src/sip/providers/IncomingSipCall.ts @@ -105,6 +105,7 @@ export class IncomingSipCall extends BaseSipCall { callerAgent, calleeAgent, features: SIP_CALL_FEATURES, + sipCallId: req.get('Call-ID'), }); const negotiationId = await mediaCallDirector.startNewNegotiation(call, 'caller', webrtcOffer); diff --git a/ee/packages/media-calls/src/sip/providers/OutgoingSipCall.ts b/ee/packages/media-calls/src/sip/providers/OutgoingSipCall.ts index 15fbd27d40910..294b3ef1f1d21 100644 --- a/ee/packages/media-calls/src/sip/providers/OutgoingSipCall.ts +++ b/ee/packages/media-calls/src/sip/providers/OutgoingSipCall.ts @@ -269,6 +269,7 @@ export class OutgoingSipCall extends BaseSipCall { calleeContractId: this.session.sessionId, webrtcAnswer: { type: 'answer', sdp: this.sipDialog.remote.sdp }, supportedFeatures: SIP_CALL_FEATURES, + sipCallId: this.sipDialog.sip?.callId, }); } diff --git a/packages/core-typings/src/mediaCalls/IMediaCall.ts b/packages/core-typings/src/mediaCalls/IMediaCall.ts index b726495500543..9d0d0037512a7 100644 --- a/packages/core-typings/src/mediaCalls/IMediaCall.ts +++ b/packages/core-typings/src/mediaCalls/IMediaCall.ts @@ -68,4 +68,6 @@ export interface IMediaCall extends IRocketChatRecord { /** The list of features that may be used in this call. Values are final once the call is accepted. */ features: string[]; + + sipCallId?: string; } diff --git a/packages/model-typings/src/models/IMediaCallsModel.ts b/packages/model-typings/src/models/IMediaCallsModel.ts index 2221a5335d226..487a5be84aed6 100644 --- a/packages/model-typings/src/models/IMediaCallsModel.ts +++ b/packages/model-typings/src/models/IMediaCallsModel.ts @@ -22,7 +22,11 @@ export interface IMediaCallsModel extends IBaseModel { options?: FindOptions, ): Promise; startRingingById(callId: string, expiresAt: Date): Promise; - acceptCallById(callId: string, data: { calleeContractId: string; supportedFeatures: string[] }, expiresAt: Date): Promise; + acceptCallById( + callId: string, + data: { calleeContractId: string; supportedFeatures: string[]; sipCallId?: string }, + expiresAt: Date, + ): Promise; activateCallById(callId: string, expiresAt: Date): Promise; setExpiresAtById(callId: string, expiresAt: Date): Promise; hangupCallById(callId: string, params: { endedBy?: IMediaCall['endedBy']; reason?: string } | undefined): Promise; diff --git a/packages/models/src/models/MediaCalls.ts b/packages/models/src/models/MediaCalls.ts index 858ea9a9680ec..46b768d8f379b 100644 --- a/packages/models/src/models/MediaCalls.ts +++ b/packages/models/src/models/MediaCalls.ts @@ -89,10 +89,10 @@ export class MediaCallsRaw extends BaseRaw implements IMediaCallsMod public async acceptCallById( callId: string, - data: { calleeContractId: string; supportedFeatures: string[] }, + data: { calleeContractId: string; supportedFeatures: string[]; sipCallId?: string }, expiresAt: Date, ): Promise { - const { calleeContractId } = data; + const { calleeContractId, sipCallId } = data; return this.updateOne( { @@ -105,6 +105,7 @@ export class MediaCallsRaw extends BaseRaw implements IMediaCallsMod 'callee.contractId': calleeContractId, 'acceptedAt': new Date(), expiresAt, + ...(sipCallId && { sipCallId }), }, $pull: { features: {