diff --git a/apps/meteor/server/models.ts b/apps/meteor/server/models.ts index c1cd1be02a649..e9e577a74730a 100644 --- a/apps/meteor/server/models.ts +++ b/apps/meteor/server/models.ts @@ -9,6 +9,7 @@ import { BannersDismissRaw, BannersRaw, CalendarEventRaw, + CallHistoryRaw, CredentialTokensRaw, CronHistoryRaw, CustomSoundsRaw, @@ -95,6 +96,7 @@ registerModel('IAvatarsModel', new AvatarsRaw(db)); registerModel('IBannersDismissModel', new BannersDismissRaw(db)); registerModel('IBannersModel', new BannersRaw(db)); registerModel('ICalendarEventModel', new CalendarEventRaw(db)); +registerModel('ICallHistoryModel', new CallHistoryRaw(db)); registerModel('ICredentialTokensModel', new CredentialTokensRaw(db)); registerModel('ICronHistoryModel', new CronHistoryRaw(db)); registerModel('ICustomSoundsModel', new CustomSoundsRaw(db)); diff --git a/apps/meteor/server/services/media-call/service.ts b/apps/meteor/server/services/media-call/service.ts index f475cd6929dd3..662a2960f8798 100644 --- a/apps/meteor/server/services/media-call/service.ts +++ b/apps/meteor/server/services/media-call/service.ts @@ -1,11 +1,13 @@ import { api, ServiceClassInternal, type IMediaCallService, Authorization } from '@rocket.chat/core-services'; -import type { IUser } from '@rocket.chat/core-typings'; +import type { IMediaCall, IUser, IRoom, IInternalMediaCallHistoryItem, CallHistoryItemState } from '@rocket.chat/core-typings'; import { Logger } from '@rocket.chat/logger'; import { callServer, type IMediaCallServerSettings } from '@rocket.chat/media-calls'; import { isClientMediaSignal, type ClientMediaSignal, type ServerMediaSignal } from '@rocket.chat/media-signaling'; -import { MediaCalls } from '@rocket.chat/models'; +import type { InsertionModel } from '@rocket.chat/model-typings'; +import { CallHistory, MediaCalls, Rooms, Users } from '@rocket.chat/models'; import { settings } from '../../../app/settings/server'; +import { createDirectMessage } from '../../methods/createDirectMessage'; const logger = new Logger('media-call service'); @@ -16,6 +18,7 @@ export class MediaCallService extends ServiceClassInternal implements IMediaCall super(); callServer.emitter.on('signalRequest', ({ toUid, signal }) => this.sendSignal(toUid, signal)); callServer.emitter.on('callUpdated', (params) => api.broadcast('media-call.updated', params)); + callServer.emitter.on('historyUpdate', ({ callId }) => setImmediate(() => this.saveCallToHistory(callId))); this.onEvent('media-call.updated', (params) => callServer.receiveCallUpdate(params)); this.onEvent('watch.settings', async ({ setting }): Promise => { @@ -62,6 +65,123 @@ export class MediaCallService extends ServiceClassInternal implements IMediaCall } } + private async saveCallToHistory(callId: IMediaCall['_id']): Promise { + logger.info({ msg: 'saving media call to history', callId }); + + const call = await MediaCalls.findOneById(callId); + if (!call) { + logger.warn({ msg: 'Attempt to save an invalid call to history', callId }); + return; + } + if (!call.ended) { + logger.warn({ msg: 'Attempt to save a pending call to history', callId }); + return; + } + + // TODO: save external media calls to history + if (call.uids.length !== 2) { + return; + } + + return this.saveInternalCallToHistory(call); + } + + private async saveInternalCallToHistory(call: IMediaCall): Promise { + if (call.caller.type !== 'user' || call.callee.type !== 'user') { + logger.warn({ msg: 'Attempt to save an internal call history with a call that is not internal', callId: call._id }); + return; + } + + const rid = await this.getRoomIdForInternalCall(call).catch(() => undefined); + const state = this.getCallHistoryItemState(call); + const duration = this.getCallDuration(call); + + const sharedData: Omit, 'uid' | 'direction' | 'contactId'> = { + ts: call.createdAt, + callId: call._id, + state, + type: 'media-call', + duration, + endedAt: call.endedAt || new Date(), + external: false, + ...(rid && { rid }), + }; + + await Promise.allSettled([ + CallHistory.insertOne({ + ...sharedData, + uid: call.caller.id, + direction: 'outbound', + contactId: call.callee.id, + }), + CallHistory.insertOne({ + ...sharedData, + uid: call.callee.id, + direction: 'inbound', + contactId: call.caller.id, + }), + ]); + + // TODO: If there's a `rid`, send a message in that room - planned for 7.13 + } + + private getCallDuration(call: IMediaCall): number { + const { activatedAt, endedAt = new Date() } = call; + if (!activatedAt) { + return 0; + } + + const diff = endedAt.valueOf() - activatedAt.valueOf(); + return Math.floor(diff / 1000); + } + + private getCallHistoryItemState(call: IMediaCall): CallHistoryItemState { + if (call.transferredBy) { + return 'transferred'; + } + + if (call.hangupReason?.includes('error')) { + if (!call.activatedAt) { + return 'failed'; + } + + return 'error'; + } + + if (!call.acceptedAt) { + return 'not-answered'; + } + + if (!call.activatedAt) { + return 'failed'; + } + + return 'ended'; + } + + private async getRoomIdForInternalCall(call: IMediaCall): Promise { + const room = await Rooms.findOneDirectRoomContainingAllUserIDs(call.uids, { projection: { _id: 1 } }); + if (room) { + return room._id; + } + + const requesterId = call.createdBy.type === 'user' && call.createdBy.id; + const callerId = call.caller.type === 'user' && call.caller.id; + + const dmCreatorId = requesterId || callerId || call.uids[0]; + + const usernames = (await Users.findByIds(call.uids, { projection: { username: 1 } }).toArray()) + .map(({ username }) => username) + .filter((username) => username); + + if (usernames.length !== 2) { + throw new Error('Invalid usernames for DM.'); + } + + const newRoom = await createDirectMessage(usernames, dmCreatorId, true); + return newRoom.rid; + } + private async sendSignal(toUid: IUser['_id'], signal: ServerMediaSignal): Promise { void api.broadcast('user.media-signal', { userId: toUid, signal }); } diff --git a/ee/packages/media-calls/src/definition/IMediaCallServer.ts b/ee/packages/media-calls/src/definition/IMediaCallServer.ts index b4f4fb52dbfd8..28ec88d7610ce 100644 --- a/ee/packages/media-calls/src/definition/IMediaCallServer.ts +++ b/ee/packages/media-calls/src/definition/IMediaCallServer.ts @@ -7,6 +7,7 @@ import type { InternalCallParams } from './common'; export type MediaCallServerEvents = { callUpdated: { callId: string; dtmf?: ClientMediaSignalBody<'dtmf'> }; signalRequest: { toUid: IUser['_id']; signal: ServerMediaSignal }; + historyUpdate: { callId: string }; }; export interface IMediaCallServerSettings { @@ -39,6 +40,7 @@ export interface IMediaCallServer { // functions that trigger events sendSignal(toUid: IUser['_id'], signal: ServerMediaSignal): void; reportCallUpdate(params: { callId: string; dtmf?: ClientMediaSignalBody<'dtmf'> }): void; + updateCallHistory(params: { callId: string }): void; // functions that are run on events receiveSignal(fromUid: IUser['_id'], signal: ClientMediaSignal): void; diff --git a/ee/packages/media-calls/src/server/CallDirector.ts b/ee/packages/media-calls/src/server/CallDirector.ts index 435b049cf519b..4ed1cb60eff74 100644 --- a/ee/packages/media-calls/src/server/CallDirector.ts +++ b/ee/packages/media-calls/src/server/CallDirector.ts @@ -3,7 +3,7 @@ import type { CallHangupReason, CallRole } from '@rocket.chat/media-signaling'; import type { InsertionModel } from '@rocket.chat/model-typings'; import { MediaCallNegotiations, MediaCalls } from '@rocket.chat/models'; -import { getCastDirector } from './injection'; +import { getCastDirector, getMediaCallServer } from './injection'; import type { IMediaCallAgent } from '../definition/IMediaCallAgent'; import type { IMediaCallCastDirector } from '../definition/IMediaCallCastDirector'; import type { InternalCallParams, MediaCallHeader } from '../definition/common'; @@ -368,7 +368,13 @@ class MediaCallDirector { }); throw error; }); - return Boolean(result.modifiedCount); + + const ended = Boolean(result.modifiedCount); + if (ended) { + getMediaCallServer().updateCallHistory({ callId }); + } + + return ended; } public async hangupCallByIdAndNotifyAgents( diff --git a/ee/packages/media-calls/src/server/MediaCallServer.ts b/ee/packages/media-calls/src/server/MediaCallServer.ts index c57b790b9624e..00cd4f5ac8cbb 100644 --- a/ee/packages/media-calls/src/server/MediaCallServer.ts +++ b/ee/packages/media-calls/src/server/MediaCallServer.ts @@ -65,6 +65,12 @@ export class MediaCallServer implements IMediaCallServer { this.emitter.emit('callUpdated', params); } + public updateCallHistory(params: { callId: string }): void { + logger.debug({ msg: 'MediaCallServer.updateCallHistory', params }); + + this.emitter.emit('historyUpdate', params); + } + public async requestCall(params: InternalCallParams): Promise { try { const fullParams = await this.parseCallContacts(params); diff --git a/packages/core-typings/src/ICallHistoryItem.ts b/packages/core-typings/src/ICallHistoryItem.ts new file mode 100644 index 0000000000000..3d7c72017a53a --- /dev/null +++ b/packages/core-typings/src/ICallHistoryItem.ts @@ -0,0 +1,46 @@ +import type { IRocketChatRecord } from './IRocketChatRecord'; +import type { IRoom } from './IRoom'; +import type { IUser } from './IUser'; + +export type CallHistoryItemState = + /** One of the users ended the call */ + | 'ended' + /** Call was not answered */ + | 'not-answered' + /** The call could not be established */ + | 'failed' + /** The call was established, but it ended due to an error */ + | 'error' + /** The call ended due to a transfer */ + | 'transferred'; + +interface ICallHistoryItem extends IRocketChatRecord { + uid: IUser['_id']; + ts: Date; + + callId: string; + + direction: 'inbound' | 'outbound'; + state: CallHistoryItemState; +} + +interface IMediaCallHistoryItem extends ICallHistoryItem { + type: 'media-call'; + external: boolean; + + /* The call's duration, in seconds */ + duration: number; + endedAt: Date; +} + +export interface IInternalMediaCallHistoryItem extends IMediaCallHistoryItem { + external: false; + contactId: IUser['_id']; + + rid?: IRoom['_id']; +} + +// TODO: IExternalMediaCallHistoryItem, planned for 8.0 +// TODO: IVideoConfHistoryItem, expected in the future but not yet on the roadmap + +export type CallHistoryItem = IInternalMediaCallHistoryItem; diff --git a/packages/core-typings/src/index.ts b/packages/core-typings/src/index.ts index 4157b3702a281..2ae11a46a9664 100644 --- a/packages/core-typings/src/index.ts +++ b/packages/core-typings/src/index.ts @@ -147,5 +147,6 @@ export * from './RoomRouteData'; export * as Cloud from './cloud'; export * from './themes'; export * from './mediaCalls'; +export * from './ICallHistoryItem'; export { schemas } from './Ajv'; diff --git a/packages/model-typings/src/index.ts b/packages/model-typings/src/index.ts index 1404419554a8c..612b471b10b54 100644 --- a/packages/model-typings/src/index.ts +++ b/packages/model-typings/src/index.ts @@ -86,3 +86,4 @@ export * from './models/IMediaCallChannelsModel'; export * from './models/IMediaCallNegotiationsModel'; export * from './updater'; export * from './models/IWorkspaceCredentialsModel'; +export * from './models/ICallHistoryModel'; diff --git a/packages/model-typings/src/models/ICallHistoryModel.ts b/packages/model-typings/src/models/ICallHistoryModel.ts new file mode 100644 index 0000000000000..b700a5054baf1 --- /dev/null +++ b/packages/model-typings/src/models/ICallHistoryModel.ts @@ -0,0 +1,5 @@ +import type { CallHistoryItem } from '@rocket.chat/core-typings'; + +import type { IBaseModel } from './IBaseModel'; + +export type ICallHistoryModel = IBaseModel; diff --git a/packages/models/src/index.ts b/packages/models/src/index.ts index cdb17caa026e1..dc77a653dcdce 100644 --- a/packages/models/src/index.ts +++ b/packages/models/src/index.ts @@ -91,6 +91,7 @@ import type { IMediaCallsModel, IMediaCallChannelsModel, IMediaCallNegotiationsModel, + ICallHistoryModel, } from '@rocket.chat/model-typings'; import type { Collection, Db } from 'mongodb'; @@ -145,6 +146,7 @@ export const Analytics = proxify('IAnalyticsModel'); export const Avatars = proxify('IAvatarsModel'); export const BannersDismiss = proxify('IBannersDismissModel'); export const Banners = proxify('IBannersModel'); +export const CallHistory = proxify('ICallHistoryModel'); export const CannedResponse = proxify('ICannedResponseModel'); export const CredentialTokens = proxify('ICredentialTokensModel'); export const CustomSounds = proxify('ICustomSoundsModel'); diff --git a/packages/models/src/modelClasses.ts b/packages/models/src/modelClasses.ts index 12b4b6ea68e0c..bb7c60fd209ac 100644 --- a/packages/models/src/modelClasses.ts +++ b/packages/models/src/modelClasses.ts @@ -78,3 +78,4 @@ export * from './models/MediaCallChannels'; export * from './models/MediaCallNegotiations'; export * from './models/WorkspaceCredentials'; export * from './models/Trash'; +export * from './models/CallHistory'; diff --git a/packages/models/src/models/CallHistory.ts b/packages/models/src/models/CallHistory.ts new file mode 100644 index 0000000000000..81a36513955a6 --- /dev/null +++ b/packages/models/src/models/CallHistory.ts @@ -0,0 +1,18 @@ +import type { CallHistoryItem } from '@rocket.chat/core-typings'; +import type { ICallHistoryModel } from '@rocket.chat/model-typings'; +import type { Db, IndexDescription } from 'mongodb'; + +import { BaseRaw } from './BaseRaw'; + +export class CallHistoryRaw extends BaseRaw implements ICallHistoryModel { + constructor(db: Db) { + super(db, 'call_history'); + } + + protected modelIndexes(): IndexDescription[] { + return [ + { key: { uid: 1, callId: 1 }, unique: true }, + { key: { uid: 1, ts: -1 }, unique: false }, + ]; + } +}