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
2 changes: 2 additions & 0 deletions apps/meteor/server/models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
BannersDismissRaw,
BannersRaw,
CalendarEventRaw,
CallHistoryRaw,
CredentialTokensRaw,
CronHistoryRaw,
CustomSoundsRaw,
Expand Down Expand Up @@ -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));
Expand Down
124 changes: 122 additions & 2 deletions apps/meteor/server/services/media-call/service.ts
Original file line number Diff line number Diff line change
@@ -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');

Expand All @@ -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<void> => {
Expand Down Expand Up @@ -62,6 +65,123 @@ export class MediaCallService extends ServiceClassInternal implements IMediaCall
}
}

private async saveCallToHistory(callId: IMediaCall['_id']): Promise<void> {
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<void> {
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<InsertionModel<IInternalMediaCallHistoryItem>, 'uid' | 'direction' | 'contactId'> = {
ts: call.createdAt,
callId: call._id,
state,
type: 'media-call',
duration,
endedAt: call.endedAt || new Date(),
external: false,
...(rid && { rid }),
};
Comment on lines +95 to +108
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Do not swallow DM resolution errors

getRoomIdForInternalCall throwing is expected when we cannot resolve/create the DM, but this catch drops the error, so we quietly skip DM creation and lose the rid, contradicting VGA‑53’s requirement. Please at least log and propagate the failure so upstream knows the DM could not be created.

-		const rid = await this.getRoomIdForInternalCall(call).catch(() => undefined);
+		let rid: IRoom['_id'] | undefined;
+		try {
+			rid = await this.getRoomIdForInternalCall(call);
+		} catch (error) {
+			logger.error({ msg: 'Failed to resolve DM room for internal call history', callId: call._id, error });
+			throw error;
+		}
🤖 Prompt for AI Agents
In apps/meteor/server/services/media-call/service.ts around lines 95 to 108, the
call to getRoomIdForInternalCall currently catches and swallows any thrown error
causing silent DM resolution failures and loss of rid; change this to surface
the failure by either removing the .catch or replacing it with a catch that logs
the error with context (including call._id) and then rethrows the error so
upstream can handle it; keep existing behavior for the successful path
(assigning rid when present) but ensure failures are not silently ignored.


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,
}),
]);
Comment on lines +110 to +123
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Surface CallHistory insertion failures

Promise.allSettled lets the two inserts fail silently. If either write is rejected we lose call history without any signal, violating VGA‑50’s persistence goal. Please inspect the settled results and log or rethrow on rejection so failures are visible.

-		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,
-			}),
-		]);
+		const results = 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,
+			}),
+		]);
+
+		results.forEach((result) => {
+			if (result.status === 'rejected') {
+				logger.error({ msg: 'Failed to insert call history entry', callId: call._id, error: result.reason });
+				throw result.reason;
+			}
+		});
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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,
}),
]);
const results = 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,
}),
]);
results.forEach((result) => {
if (result.status === 'rejected') {
logger.error({ msg: 'Failed to insert call history entry', callId: call._id, error: result.reason });
throw result.reason;
}
});
🤖 Prompt for AI Agents
In apps/meteor/server/services/media-call/service.ts around lines 110 to 123,
the Promise.allSettled call lets CallHistory.insertOne failures be ignored;
inspect the resulting array of settled results, check each result.status, and
for any "rejected" entry log the error (with contextual data like call id and
which uid/direction) and/or throw an AggregateError so the failure is visible to
callers; ensure successful inserts remain unaffected and keep the operation
asynchronous but surface failures per VGA-50.


// 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<IRoom['_id']> {
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> {
void api.broadcast('user.media-signal', { userId: toUid, signal });
}
Expand Down
2 changes: 2 additions & 0 deletions ee/packages/media-calls/src/definition/IMediaCallServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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;
Expand Down
10 changes: 8 additions & 2 deletions ee/packages/media-calls/src/server/CallDirector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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(
Expand Down
6 changes: 6 additions & 0 deletions ee/packages/media-calls/src/server/MediaCallServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> {
try {
const fullParams = await this.parseCallContacts(params);
Expand Down
46 changes: 46 additions & 0 deletions packages/core-typings/src/ICallHistoryItem.ts
Original file line number Diff line number Diff line change
@@ -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;
1 change: 1 addition & 0 deletions packages/core-typings/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
1 change: 1 addition & 0 deletions packages/model-typings/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,3 +86,4 @@ export * from './models/IMediaCallChannelsModel';
export * from './models/IMediaCallNegotiationsModel';
export * from './updater';
export * from './models/IWorkspaceCredentialsModel';
export * from './models/ICallHistoryModel';
5 changes: 5 additions & 0 deletions packages/model-typings/src/models/ICallHistoryModel.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import type { CallHistoryItem } from '@rocket.chat/core-typings';

import type { IBaseModel } from './IBaseModel';

export type ICallHistoryModel = IBaseModel<CallHistoryItem>;
2 changes: 2 additions & 0 deletions packages/models/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@ import type {
IMediaCallsModel,
IMediaCallChannelsModel,
IMediaCallNegotiationsModel,
ICallHistoryModel,
} from '@rocket.chat/model-typings';
import type { Collection, Db } from 'mongodb';

Expand Down Expand Up @@ -145,6 +146,7 @@ export const Analytics = proxify<IAnalyticsModel>('IAnalyticsModel');
export const Avatars = proxify<IAvatarsModel>('IAvatarsModel');
export const BannersDismiss = proxify<IBannersDismissModel>('IBannersDismissModel');
export const Banners = proxify<IBannersModel>('IBannersModel');
export const CallHistory = proxify<ICallHistoryModel>('ICallHistoryModel');
export const CannedResponse = proxify<ICannedResponseModel>('ICannedResponseModel');
export const CredentialTokens = proxify<ICredentialTokensModel>('ICredentialTokensModel');
export const CustomSounds = proxify<ICustomSoundsModel>('ICustomSoundsModel');
Expand Down
1 change: 1 addition & 0 deletions packages/models/src/modelClasses.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,3 +78,4 @@ export * from './models/MediaCallChannels';
export * from './models/MediaCallNegotiations';
export * from './models/WorkspaceCredentials';
export * from './models/Trash';
export * from './models/CallHistory';
18 changes: 18 additions & 0 deletions packages/models/src/models/CallHistory.ts
Original file line number Diff line number Diff line change
@@ -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<CallHistoryItem> 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 },
];
}
}
Loading