Files
upstream-rocketchat/apps/meteor/server/services/media-call/service.ts
upstream-mirror 30810eecd7
Some checks failed
Create issues from TODOs / todos (push) Failing after 3s
Close inactive issues / close-issues (push) Has been cancelled
Deploy GitHub Pages / deploy-preview (push) Has been cancelled
CI / ⚙️ Variables Setup (push) Has been cancelled
CI / ⚙️ Test Guard (push) Has been cancelled
CI / 🚀 Notify external services - draft (push) Has been cancelled
CI / 📦 Build Packages (push) Has been cancelled
CI / 📦 Meteor Build (coverage) (push) Has been cancelled
CI / 📦 Meteor Build (production) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], fips) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Publish Docker Images (ghcr.io) (push) Has been cancelled
CI / 📦 Track Image Sizes (push) Has been cancelled
CI / 🔎 Code Check - Action Lint (push) Has been cancelled
CI / 🔎 Code Check (push) Has been cancelled
CI / 🔨 Test Storybook (push) Has been cancelled
CI / 🔨 Test Unit (push) Has been cancelled
CI / 🔨 Test API (CE) (push) Has been cancelled
CI / 🔨 Test API Livechat (CE) (push) Has been cancelled
CI / 🔨 Test UI (CE) (push) Has been cancelled
CI / 🔨 Test API (EE) (push) Has been cancelled
CI / 🔨 Test API Livechat (EE) (push) Has been cancelled
CI / 🔨 Test API Apps (deno-runtime - EE) (push) Has been cancelled
CI / 🔨 Test UI (EE) (push) Has been cancelled
CI / 🔨 Test API (FIPS) (push) Has been cancelled
CI / 🔨 Test API Livechat (FIPS) (push) Has been cancelled
CI / 🔨 Test UI (FIPS) (push) Has been cancelled
CI / 🔨 Test Federation Matrix (push) Has been cancelled
CI / 📊 Report Coverage (push) Has been cancelled
CI / ✅ Tests Done (push) Has been cancelled
CI / 🚀 Publish build assets (push) Has been cancelled
CI / 🚀 Publish Docker Images (DockerHub) (push) Has been cancelled
CI / 🚀 Notify external services (push) Has been cancelled
CI / Update Version Durability (push) Has been cancelled
Release / Release (push) Has been cancelled
Auto-close duplicate issues / auto-close-duplicates (push) Failing after 1s
Code scanning - action / CodeQL-Build (push) Failing after 1s
snapshot of https://github.com/RocketChat/Rocket.Chat.git (develop, 2026-09-16; blobs > 131072B omitted)
2026-09-16 01:21:02 +05:00

474 lines
15 KiB
TypeScript

import { api, Presence, ServiceClassInternal, type IMediaCallService, Authorization } from '@rocket.chat/core-services';
import type {
IMediaCall,
IUser,
IRoom,
IInternalMediaCallHistoryItem,
CallHistoryItemState,
IExternalMediaCallHistoryItem,
} from '@rocket.chat/core-typings';
import { UserStatus } from '@rocket.chat/core-typings';
import { callServer, type IMediaCallServerSettings, getSignalsForExistingCall } from '@rocket.chat/media-calls';
import type {
CallFeature,
ClientMediaSignal,
ServerMediaSignal,
ServerMediaCallSignal,
ClientMediaSignalAnswer,
} from '@rocket.chat/media-signaling';
import { isClientMediaSignal } from '@rocket.chat/media-signaling';
import type { InsertionModel } from '@rocket.chat/model-typings';
import { CallHistory, MediaCalls, Rooms, Users } from '@rocket.chat/models';
import { callStateToTranslationKey, getHistoryMessagePayload } from '@rocket.chat/ui-voip/dist/ui-kit/getHistoryMessagePayload';
import { logger } from './logger';
import { sendVoipPushNotification } from './push/sendVoipPushNotification';
import { i18n } from '../../lib/i18n';
import { sendMessage } from '../../lib/messages/sendMessage';
import { createDirectMessage } from '../../meteor-methods/messages/createDirectMessage';
import { settings } from '../../settings';
export class MediaCallService extends ServiceClassInternal implements IMediaCallService {
protected name = 'media-call';
constructor() {
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('callActivated', ({ callId, uids }) => this.setPresenceForUsers(uids, callId));
callServer.emitter.on('callEnded', ({ callId, uids }) => this.clearPresenceForUsers(uids, callId));
callServer.emitter.on('historyUpdate', ({ callId }) => setImmediate(() => this.saveCallToHistory(callId)));
callServer.emitter.on('pushNotificationRequest', ({ callId, event }) => sendVoipPushNotification(callId, event));
this.onEvent('media-call.updated', (params) => callServer.receiveCallUpdate(params));
this.onEvent('watch.settings', async ({ setting }): Promise<void> => {
if (setting._id.startsWith('VoIP_TeamCollab_')) {
setImmediate(() => this.configureMediaCallServer());
}
});
this.configureMediaCallServer();
}
public async answerCall(uid: IUser['_id'], params: Omit<ClientMediaSignalAnswer, 'type'>): Promise<IMediaCall> {
const { callId, answer } = params;
const call = await MediaCalls.findOneByIdAndCallee<Pick<IMediaCall, '_id'>>(
callId,
{ type: 'user', id: uid },
{ projection: { _id: 1 } },
);
if (!call) {
throw new Error('not-found');
}
const signal: ClientMediaSignalAnswer = {
type: 'answer',
...params,
};
await callServer.receiveSignal(uid, signal, { throwIfSkipped: true });
const updatedCall = await MediaCalls.findOneById(callId);
if (!updatedCall) {
throw new Error('internal-error');
}
switch (answer) {
case 'ack':
if (updatedCall.acceptedAt || updatedCall.ended) {
throw new Error('invalid-call-state');
}
break;
case 'reject':
if (!updatedCall.ended || updatedCall.endedBy?.id !== uid) {
throw new Error('invalid-call-state');
}
break;
case 'accept':
if (updatedCall.callee.contractId !== signal.contractId) {
if (updatedCall.callee.contractId) {
throw new Error('invalid-call-state');
}
throw new Error('internal-error');
}
break;
}
return updatedCall;
}
public async processSignal(uid: IUser['_id'], signal: ClientMediaSignal): Promise<void> {
try {
await callServer.receiveSignal(uid, signal);
} catch (err) {
logger.error({ msg: 'failed to process client signal', err, signal, uid });
}
}
public async processSerializedSignal(uid: IUser['_id'], signal: string): Promise<void> {
let signalType: string | null = null;
try {
const deserialized = await this.deserializeClientSignal(signal);
signalType = deserialized.type;
await callServer.receiveSignal(uid, deserialized);
} catch (err) {
logger.error({ msg: 'failed to process client signal', err, uid, type: signalType });
}
}
public async hangupExpiredCalls(): Promise<void> {
await callServer.hangupExpiredCalls().catch((err) => {
logger.error({ msg: 'Media Call Server failed to hangup expired calls', err });
});
try {
if (await MediaCalls.hasUnfinishedCalls()) {
callServer.scheduleExpirationCheck();
}
} catch (err) {
logger.error({ msg: 'Media Call Server failed to check if there are expired calls', err });
}
}
public async getUserStateSignals(uid: IUser['_id'], contractId: string): Promise<ServerMediaCallSignal[]> {
const calls = await MediaCalls.findAllNotOverByUid(uid).toArray();
const signals: ServerMediaCallSignal[] = [];
for (const call of calls) {
const callSignals = await getSignalsForExistingCall(call, uid, contractId);
signals.push(...callSignals);
}
return signals;
}
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;
}
if (call.uids.length !== 2) {
return this.saveExternalCallToHistory(call);
}
return this.saveInternalCallToHistory(call);
}
private async saveExternalCallToHistory(call: IMediaCall): Promise<void> {
const callerIsInternal = call.caller.type === 'user';
const calleeIsInternal = call.callee.type === 'user';
if (callerIsInternal && calleeIsInternal) {
logger.warn({ msg: 'Attempt to save an external call history with a call that is not external', callId: call._id });
return;
}
if (!callerIsInternal && !calleeIsInternal) {
logger.warn({ msg: 'Attempt to save an external call history with an invalid call', callId: call._id });
return;
}
const state = this.getCallHistoryItemState(call);
const duration = this.getCallDuration(call);
const direction = callerIsInternal ? 'outbound' : 'inbound';
const uid = callerIsInternal ? call.caller.id : call.callee.id;
const contact = callerIsInternal ? call.callee : call.caller;
const contactExtension = contact.sipExtension || contact.id;
const historyItem: InsertionModel<IExternalMediaCallHistoryItem> = {
uid,
ts: call.createdAt,
callId: call._id,
state,
type: 'media-call',
duration,
endedAt: call.endedAt || new Date(),
external: true,
direction,
contactExtension,
};
await CallHistory.insertOne(historyItem).catch((err: unknown) => logger.error({ msg: 'Failed to insert item into Call History', err }));
}
private getContactDataForInternalHistory(
contact: IMediaCall['caller'] | IMediaCall['callee'],
): Pick<IInternalMediaCallHistoryItem, 'contactId' | 'contactName' | 'contactUsername'> {
return {
contactId: contact.id,
contactName: contact.displayName,
contactUsername: contact.username,
};
}
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 room = await this.getRoomIdForInternalCall(call).catch((err) => {
logger.error({ msg: 'Failed to determine room id for Internal Call', err });
return undefined;
});
const { _id: rid } = room || {};
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 }),
};
const outboundHistoryItem = {
...sharedData,
uid: call.caller.id,
direction: 'outbound',
...this.getContactDataForInternalHistory(call.callee),
} as const;
const inboundHistoryItem = {
...sharedData,
uid: call.callee.id,
direction: 'inbound',
...this.getContactDataForInternalHistory(call.caller),
} as const;
await CallHistory.insertMany([outboundHistoryItem, inboundHistoryItem]).catch((err: unknown) =>
logger.error({ msg: 'Failed to insert items into Call History', err }),
);
if (room) {
return this.sendHistoryMessage(call, room);
}
}
private getLanguageForUser(language?: string): string {
return language || settings.get('Language') || 'en';
}
private async sendHistoryMessage(call: IMediaCall, room: IRoom): Promise<void> {
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 skipNotifications = state !== 'not-answered' || call.hangupReason === 'rejected';
const i18nKey = callStateToTranslationKey(state).i18n?.key;
const msg = i18nKey ? i18n.t(i18nKey, { lng: this.getLanguageForUser(user.language) }) : '';
const duration = this.getCallDuration(call);
const record = getHistoryMessagePayload(state, duration, call._id, msg);
try {
const message = await sendMessage(user, record, room, { skipNotifications });
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 (err) {
logger.error({ msg: 'Failed to send history message', err, 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 === 'not-answered') {
return 'not-answered';
}
if (call.hangupReason?.startsWith('timeout')) {
return 'failed';
}
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> {
const uniqueUids = [...new Set(call.uids)];
const room = await Rooms.findOneDirectRoomContainingAllUserIDs(uniqueUids);
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 setPresenceForUsers(uids: IUser['_id'][], callId: IMediaCall['_id']): Promise<void> {
const users = await Users.findByIds<Pick<IUser, '_id' | 'language'>>(uids, { projection: { language: 1 } }).toArray();
const languageByUid = new Map(users.map((user) => [user._id, user.language]));
await Promise.all(
uids.map(async (uid) => {
try {
await Presence.setActiveState(uid, {
statusDefault: UserStatus.BUSY,
statusText: i18n.t('Presence_status_on_a_call', { lng: this.getLanguageForUser(languageByUid.get(uid)) }),
statusSource: 'internal',
statusId: callId,
});
} catch (err) {
logger.error({ msg: 'Failed to set presence for user on call', uid, err });
}
}),
);
}
private async clearPresenceForUsers(uids: IUser['_id'][], callId: IMediaCall['_id']): Promise<void> {
// pass callId so only this call's claim is cleared, never another claim that took over
await Promise.all(
uids.map((uid) =>
Presence.endActiveState(uid, callId).catch((err) =>
logger.error({ msg: 'Failed to clear presence for user after call', uid, err }),
),
),
);
}
private async sendSignal(toUid: IUser['_id'], signal: ServerMediaSignal): Promise<void> {
void api.broadcast('user.media-signal', { userId: toUid, signal });
}
private configureMediaCallServer(): void {
callServer.configure(this.getMediaServerSettings());
}
private getMediaServerSettings(): IMediaCallServerSettings {
const sipEnabled = settings.get<boolean>('VoIP_TeamCollab_SIP_Integration_Enabled') ?? false;
const mobileRinging = settings.get<boolean>('VoIP_TeamCollab_Mobile_Ringing_Enabled') ?? false;
const forceSip = sipEnabled && (settings.get<boolean>('VoIP_TeamCollab_SIP_Integration_For_Internal_Calls') ?? false);
return {
internalCalls: {
requireExtensions: forceSip,
routeExternally: forceSip ? 'always' : 'never',
},
sip: {
enabled: sipEnabled,
drachtio: {
host: settings.get<string>('VoIP_TeamCollab_Drachtio_Host') ?? '',
port: settings.get<number>('VoIP_TeamCollab_Drachtio_Port') ?? 9022,
secret: settings.get<string>('VoIP_TeamCollab_Drachtio_Password') ?? '',
},
sipServer: {
host: settings.get<string>('VoIP_TeamCollab_SIP_Server_Host') ?? '',
port: settings.get<number>('VoIP_TeamCollab_SIP_Server_Port') ?? 5060,
},
},
mobileRinging,
permissionCheck: (uid, callType) => this.userHasMediaCallPermission(uid, callType),
isFeatureAvailableForUser: (uid, feature) => this.userHasFeaturePermission(uid, feature),
};
}
private userHasFeaturePermission(_uid: IUser['_id'], feature: CallFeature): boolean {
if (feature === 'audio') {
return true;
}
if (feature === 'screen-share') {
return settings.get<boolean>('VoIP_TeamCollab_Screen_Sharing_Enabled') ?? false;
}
return true;
}
private async userHasMediaCallPermission(uid: IUser['_id'], callType: 'internal' | 'external' | 'any'): Promise<boolean> {
if (callType === 'any') {
return Authorization.hasAtLeastOnePermission(uid, ['allow-internal-voice-calls', 'allow-external-voice-calls']);
}
const permissionId = `allow-${callType}-voice-calls`;
return Authorization.hasPermission(uid, permissionId);
}
private async deserializeClientSignal(serialized: string): Promise<ClientMediaSignal> {
try {
const signal = JSON.parse(serialized);
if (!isClientMediaSignal(signal)) {
throw new Error('signal-format-invalid');
}
return signal;
} catch (err) {
logger.error({ msg: 'Failed to parse client signal', err });
throw err;
}
}
}