diff --git a/.changeset/five-frogs-kneel.md b/.changeset/five-frogs-kneel.md new file mode 100644 index 0000000000000..6ad2a9411fbba --- /dev/null +++ b/.changeset/five-frogs-kneel.md @@ -0,0 +1,10 @@ +--- +"@rocket.chat/meteor": minor +"@rocket.chat/core-typings": minor +"@rocket.chat/i18n": minor +"@rocket.chat/model-typings": minor +"@rocket.chat/models": minor +"@rocket.chat/media-calls": minor +--- + +Introduces in-chat messages for when a voice call ends diff --git a/apps/meteor/jest.config.ts b/apps/meteor/jest.config.ts index 92cc6f4706569..bb6cb59d6b282 100644 --- a/apps/meteor/jest.config.ts +++ b/apps/meteor/jest.config.ts @@ -43,6 +43,7 @@ export default { '/app/api/server/**.spec.ts', '/app/api/server/helpers/**.spec.ts', '/app/api/server/middlewares/**.spec.ts', + '/server/services/media-call/**.spec.ts', ], coveragePathIgnorePatterns: ['/node_modules/'], }, 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/getHistoryMessagePayload.spec.ts b/apps/meteor/server/services/media-call/getHistoryMessagePayload.spec.ts new file mode 100644 index 0000000000000..1184e5b535612 --- /dev/null +++ b/apps/meteor/server/services/media-call/getHistoryMessagePayload.spec.ts @@ -0,0 +1,278 @@ +import { callStateToTranslationKey, callStateToIcon, getFormattedCallDuration, getHistoryMessagePayload } from './getHistoryMessagePayload'; + +const appId = 'media-call-core'; +describe('callStateToTranslationKey', () => { + it('should return correct translation key for "ended" state', () => { + const result = callStateToTranslationKey('ended'); + expect(result).toEqual({ type: 'mrkdwn', i18n: { key: 'Call_ended_bold' }, text: 'Call ended' }); + }); + + it('should return correct translation key for "not-answered" state', () => { + const result = callStateToTranslationKey('not-answered'); + expect(result).toEqual({ type: 'mrkdwn', i18n: { key: 'Call_not_answered_bold' }, text: 'Call not answered' }); + }); + + it('should return correct translation key for "failed" state', () => { + const result = callStateToTranslationKey('failed'); + expect(result).toEqual({ type: 'mrkdwn', i18n: { key: 'Call_failed_bold' }, text: 'Call failed' }); + }); + + it('should return correct translation key for "error" state', () => { + const result = callStateToTranslationKey('error'); + expect(result).toEqual({ type: 'mrkdwn', i18n: { key: 'Call_failed_bold' }, text: 'Call failed' }); + }); + + it('should return correct translation key for "transferred" state', () => { + const result = callStateToTranslationKey('transferred'); + expect(result).toEqual({ type: 'mrkdwn', i18n: { key: 'Call_transferred_bold' }, text: 'Call transferred' }); + }); +}); + +describe('callStateToIcon', () => { + it('should return correct icon for "ended" state', () => { + const result = callStateToIcon('ended'); + expect(result).toEqual({ type: 'icon', icon: 'phone-off', variant: 'secondary' }); + }); + + it('should return correct icon for "not-answered" state', () => { + const result = callStateToIcon('not-answered'); + expect(result).toEqual({ type: 'icon', icon: 'clock', variant: 'danger' }); + }); + + it('should return correct icon for "failed" state', () => { + const result = callStateToIcon('failed'); + expect(result).toEqual({ type: 'icon', icon: 'phone-issue', variant: 'danger' }); + }); + + it('should return correct icon for "error" state', () => { + const result = callStateToIcon('error'); + expect(result).toEqual({ type: 'icon', icon: 'phone-issue', variant: 'danger' }); + }); + + it('should return correct icon for "transferred" state', () => { + const result = callStateToIcon('transferred'); + expect(result).toEqual({ type: 'icon', icon: 'arrow-forward', variant: 'secondary' }); + }); +}); + +describe('getFormattedCallDuration', () => { + it('should return undefined when callDuration is undefined', () => { + const result = getFormattedCallDuration(undefined); + expect(result).toBeUndefined(); + }); + + it('should return undefined when callDuration is 0', () => { + const result = getFormattedCallDuration(0); + expect(result).toBeUndefined(); + }); + + it('should format duration correctly for seconds only (less than 60 seconds)', () => { + const result = getFormattedCallDuration(30); + expect(result).toEqual({ type: 'mrkdwn', text: '*00:30*' }); + }); + + it('should format duration correctly for minutes and seconds (less than 1 hour)', () => { + const result = getFormattedCallDuration(125); // 2 minutes 5 seconds + expect(result).toEqual({ type: 'mrkdwn', text: '*02:05*' }); + }); + + it('should format duration correctly for exactly 1 minute', () => { + const result = getFormattedCallDuration(60); + expect(result).toEqual({ type: 'mrkdwn', text: '*01:00*' }); + }); + + it('should format duration correctly for hours, minutes, and seconds', () => { + const result = getFormattedCallDuration(3665); // 1 hour 1 minute 5 seconds + expect(result).toEqual({ type: 'mrkdwn', text: '*01:01:05*' }); + }); + + it('should format duration correctly for multiple hours', () => { + const result = getFormattedCallDuration(7325); // 2 hours 2 minutes 5 seconds + expect(result).toEqual({ type: 'mrkdwn', text: '*02:02:05*' }); + }); + + it('should pad single digit values with zeros', () => { + const result = getFormattedCallDuration(61); // 1 minute 1 second + expect(result).toEqual({ type: 'mrkdwn', text: '*01:01*' }); + }); + + it('should handle large durations correctly', () => { + const result = getFormattedCallDuration(36661); // 10 hours 11 minutes 1 second + expect(result).toEqual({ type: 'mrkdwn', text: '*10:11:01*' }); + }); +}); + +describe('getHistoryMessagePayload', () => { + it('should return correct payload for "ended" state without duration', () => { + const result = getHistoryMessagePayload('ended', undefined); + expect(result).toEqual({ + msg: '', + groupable: false, + blocks: [ + { + appId, + type: 'info_card', + rows: [ + { + background: 'default', + elements: [ + { type: 'icon', icon: 'phone-off', variant: 'secondary' }, + { type: 'mrkdwn', i18n: { key: 'Call_ended_bold' }, text: 'Call ended' }, + ], + }, + ], + }, + ], + }); + }); + + it('should return correct payload for "ended" state with duration', () => { + const result = getHistoryMessagePayload('ended', 125); + expect(result).toEqual({ + msg: '', + groupable: false, + blocks: [ + { + appId, + type: 'info_card', + rows: [ + { + background: 'default', + elements: [ + { type: 'icon', icon: 'phone-off', variant: 'secondary' }, + { type: 'mrkdwn', i18n: { key: 'Call_ended_bold' }, text: 'Call ended' }, + ], + }, + { + background: 'secondary', + elements: [{ type: 'mrkdwn', text: '*02:05*' }], + }, + ], + }, + ], + }); + }); + + it('should return correct payload for "not-answered" state', () => { + const result = getHistoryMessagePayload('not-answered', undefined); + expect(result).toEqual({ + msg: '', + groupable: false, + blocks: [ + { + appId, + type: 'info_card', + rows: [ + { + background: 'default', + elements: [ + { type: 'icon', icon: 'clock', variant: 'danger' }, + { type: 'mrkdwn', i18n: { key: 'Call_not_answered_bold' }, text: 'Call not answered' }, + ], + }, + ], + }, + ], + }); + }); + + it('should return correct payload for "failed" state', () => { + const result = getHistoryMessagePayload('failed', undefined); + expect(result).toEqual({ + msg: '', + groupable: false, + blocks: [ + { + appId, + type: 'info_card', + rows: [ + { + background: 'default', + elements: [ + { type: 'icon', icon: 'phone-issue', variant: 'danger' }, + { type: 'mrkdwn', i18n: { key: 'Call_failed_bold' }, text: 'Call failed' }, + ], + }, + ], + }, + ], + }); + }); + + it('should return correct payload for "error" state', () => { + const result = getHistoryMessagePayload('error', undefined); + expect(result).toEqual({ + msg: '', + groupable: false, + blocks: [ + { + appId, + type: 'info_card', + rows: [ + { + background: 'default', + elements: [ + { type: 'icon', icon: 'phone-issue', variant: 'danger' }, + { type: 'mrkdwn', i18n: { key: 'Call_failed_bold' }, text: 'Call failed' }, + ], + }, + ], + }, + ], + }); + }); + + it('should return correct payload for "transferred" state', () => { + const result = getHistoryMessagePayload('transferred', undefined); + expect(result).toEqual({ + msg: '', + groupable: false, + blocks: [ + { + appId, + type: 'info_card', + rows: [ + { + background: 'default', + elements: [ + { type: 'icon', icon: 'arrow-forward', variant: 'secondary' }, + { type: 'mrkdwn', i18n: { key: 'Call_transferred_bold' }, text: 'Call transferred' }, + ], + }, + ], + }, + ], + }); + }); + + it('should include duration row when duration is provided', () => { + const result = getHistoryMessagePayload('ended', 3665); + + expect(result.blocks[0].rows).toHaveLength(2); + expect(result.blocks[0].rows[1]).toEqual({ + background: 'secondary', + elements: [{ type: 'mrkdwn', text: '*01:01:05*' }], + }); + }); + + it('should not include duration row when duration is undefined', () => { + const result = getHistoryMessagePayload('ended', undefined); + expect(result.blocks[0].rows).toHaveLength(1); + }); + + it('should handle all call states with duration correctly', () => { + const states = ['ended', 'transferred', 'not-answered', 'failed', 'error'] as const; + const duration = 125; + + states.forEach((state) => { + const result = getHistoryMessagePayload(state, duration); + expect(result.msg).toBe(''); + expect(result.groupable).toBe(false); + expect(result.blocks).toHaveLength(1); + expect(result.blocks[0].type).toBe('info_card'); + expect(result.blocks[0].rows).toHaveLength(2); + expect(result.blocks[0].rows[1].background).toBe('secondary'); + expect(result.blocks[0].rows[1].elements[0].type).toBe('mrkdwn'); + }); + }); +}); diff --git a/apps/meteor/server/services/media-call/getHistoryMessagePayload.ts b/apps/meteor/server/services/media-call/getHistoryMessagePayload.ts new file mode 100644 index 0000000000000..27289904fcdd5 --- /dev/null +++ b/apps/meteor/server/services/media-call/getHistoryMessagePayload.ts @@ -0,0 +1,89 @@ +import type { CallHistoryItemState, IMessage } from '@rocket.chat/core-typings'; +import type { IconElement, InfoCardBlock, TextObject } from '@rocket.chat/ui-kit'; +import { intervalToDuration, secondsToMilliseconds } from 'date-fns'; + +const APP_ID = 'media-call-core'; + +export const callStateToTranslationKey = (callState: CallHistoryItemState): TextObject => { + switch (callState) { + case 'ended': + return { type: 'mrkdwn', i18n: { key: 'Call_ended_bold' }, text: 'Call ended' }; + case 'not-answered': + return { type: 'mrkdwn', i18n: { key: 'Call_not_answered_bold' }, text: 'Call not answered' }; + case 'failed': + case 'error': + return { type: 'mrkdwn', i18n: { key: 'Call_failed_bold' }, text: 'Call failed' }; + case 'transferred': + return { type: 'mrkdwn', i18n: { key: 'Call_transferred_bold' }, text: 'Call transferred' }; + } +}; + +export const callStateToIcon = (callState: CallHistoryItemState): IconElement => { + switch (callState) { + case 'ended': + return { type: 'icon', icon: 'phone-off', variant: 'secondary' }; + case 'not-answered': + return { type: 'icon', icon: 'clock', variant: 'danger' }; + case 'failed': + case 'error': + return { type: 'icon', icon: 'phone-issue', variant: 'danger' }; + case 'transferred': + return { type: 'icon', icon: 'arrow-forward', variant: 'secondary' }; + } +}; + +const buildDurationString = (...values: number[]): string => { + return values.map((value) => value.toString().padStart(2, '0')).join(':'); +}; + +export const getFormattedCallDuration = (callDuration: number | undefined): TextObject | undefined => { + if (!callDuration || typeof callDuration !== 'number') { + return undefined; + } + + const milliseconds = secondsToMilliseconds(callDuration); + const duration = { minutes: 0, seconds: 0, ...intervalToDuration({ start: 0, end: milliseconds }) }; + + if (duration.hours && duration.hours > 0) { + return { type: 'mrkdwn', text: `*${buildDurationString(duration.hours, duration.minutes, duration.seconds)}*` } as const; + } + + return { + type: 'mrkdwn', + text: `*${buildDurationString(duration.minutes, duration.seconds)}*`, + } as const; +}; + +export const getHistoryMessagePayload = ( + callState: CallHistoryItemState, + callDuration: number | undefined, +): Pick & { blocks: [InfoCardBlock] } => { + const callStateTranslationKey = callStateToTranslationKey(callState); + const icon = callStateToIcon(callState); + const callDurationFormatted = getFormattedCallDuration(callDuration); + + return { + msg: '', + groupable: false, + blocks: [ + { + appId: APP_ID, + type: 'info_card', + rows: [ + { + background: 'default', + elements: [icon, callStateTranslationKey], + }, + ...(callDurationFormatted + ? [ + { + background: 'secondary', + elements: [callDurationFormatted], + } as const, + ] + : []), + ], + }, + ], + }; +}; diff --git a/apps/meteor/server/services/media-call/service.ts b/apps/meteor/server/services/media-call/service.ts index f475cd6929dd3..fff0a13465d2e 100644 --- a/apps/meteor/server/services/media-call/service.ts +++ b/apps/meteor/server/services/media-call/service.ts @@ -1,11 +1,15 @@ 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 { getHistoryMessagePayload } from './getHistoryMessagePayload'; +import { sendMessage } from '../../../app/lib/server/functions/sendMessage'; import { settings } from '../../../app/settings/server'; +import { createDirectMessage } from '../../methods/createDirectMessage'; const logger = new Logger('media-call service'); @@ -16,6 +20,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 +67,165 @@ 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; + } + + 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 room = await this.getRoomIdForInternalCall(call).catch((error) => { + logger.error({ msg: 'Failed to determine room id for Internal Call', error }); + return undefined; + }); + const { _id: rid } = room || {}; + 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 }), + }; + + const outboundHistoryItem = { + ...sharedData, + uid: call.caller.id, + direction: 'outbound', + contactId: call.callee.id, + } as const; + + const inboundHistoryItem = { + ...sharedData, + uid: call.callee.id, + direction: 'inbound', + contactId: call.caller.id, + } as const; + + await CallHistory.insertMany([outboundHistoryItem, inboundHistoryItem]).catch((error: unknown) => + logger.error({ msg: 'Failed to insert items into Call History', error }), + ); + + if (room) { + return this.sendHistoryMessage(call, room); + } + } + + private async sendHistoryMessage(call: IMediaCall, room: IRoom): Promise { + const userId = call.caller.id || call.createdBy?.id; // I think this should always be the caller, since during a transfer the createdBy contact is the one that transferred the call + + const user = await Users.findOneById(userId); + if (!user) { + return; + } + + const state = this.getCallHistoryItemState(call); + const duration = this.getCallDuration(call); + + const record = getHistoryMessagePayload(state, duration); + + try { + const message = await sendMessage(user, record, room, false); + + if ('_id' in message) { + await CallHistory.updateMany({ callId: call._id }, { $set: { messageId: message._id } }); + return; + } + throw new Error('Failed to save message id in history'); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Failed to send history message'; + logger.error({ msg: errorMessage, error, callId: call._id }); + } + } + + 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); + if (room) { + return room; + } + + 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 } }) + .map((user) => user.username) + .toArray() + ).filter((username) => username); + + if (usernames.length !== 2) { + throw new Error('Invalid usernames for DM.'); + } + + const dmCreatorIsPartOfTheCall = call.uids.includes(dmCreatorId); + + const newRoom = await createDirectMessage(usernames, dmCreatorId, !dmCreatorIsPartOfTheCall); // If the dm creator is not part of the call, we need to exclude him from the new DM + return { + ...newRoom, + _id: 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..1c0c51ac26620 --- /dev/null +++ b/packages/core-typings/src/ICallHistoryItem.ts @@ -0,0 +1,48 @@ +import type { IMessage } from './IMessage/IMessage'; +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']; + messageId?: IMessage['_id']; // Id of the message that was sent after the call ended +} + +// 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 7e8bba71d6b02..fb6335e1c6bce 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/i18n/src/locales/en.i18n.json b/packages/i18n/src/locales/en.i18n.json index ac253ada65cc2..9636df6ef6a9e 100644 --- a/packages/i18n/src/locales/en.i18n.json +++ b/packages/i18n/src/locales/en.i18n.json @@ -916,6 +916,10 @@ "Call_back": "Call back", "Call_declined": "Call Declined!", "Call_ended": "Call ended", + "Call_ended_bold": "*Voice call ended*", + "Call_not_answered_bold": "*Voice call not answered*", + "Call_failed_bold": "*Voice call failed*", + "Call_transferred_bold": "*Voice call transferred*", "Call_history_provides_a_record_of_when_calls_took_place_and_who_joined": "Call history provides a record of when calls took place and who joined.", "Call_not_found": "Call not found", "Call_not_found_error": "This could happen when the call URL is not valid, or you're having connection issues. Please check with the source of the call URL and try again, or talk to your workspace administrator if the problem persists", 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..79f945c5fa1d4 --- /dev/null +++ b/packages/models/src/models/CallHistory.ts @@ -0,0 +1,15 @@ +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 } }]; + } +}