snapshot of https://github.com/RocketChat/Rocket.Chat.git (develop, 2026-09-16; blobs > 131072B omitted)
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
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
This commit is contained in:
3
apps/meteor/server/services/media-call/logger.ts
Normal file
3
apps/meteor/server/services/media-call/logger.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { Logger } from '@rocket.chat/logger';
|
||||
|
||||
export const logger = new Logger('MediaCall');
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { IMediaCall } from '@rocket.chat/core-typings';
|
||||
import type { VoipPushNotificationType } from '@rocket.chat/media-calls';
|
||||
|
||||
export function getPushNotificationType(call: IMediaCall): VoipPushNotificationType {
|
||||
if (call.acceptedAt) {
|
||||
return 'answeredElsewhere';
|
||||
}
|
||||
|
||||
if (call.endedBy?.id === call.callee.id || call.hangupReason === 'rejected') {
|
||||
return 'declinedElsewhere';
|
||||
}
|
||||
|
||||
if (call.endedBy?.id === call.caller.id) {
|
||||
return 'remoteEnded';
|
||||
}
|
||||
|
||||
if (call.ended) {
|
||||
return 'unanswered';
|
||||
}
|
||||
|
||||
return 'incoming_call';
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import type { IMediaCall, IUser, MediaCallContact, MediaCallActorType } from '@rocket.chat/core-typings';
|
||||
import type { VoipPushNotificationEventType } from '@rocket.chat/media-calls';
|
||||
import { MediaCalls, Users } from '@rocket.chat/models';
|
||||
import { Meteor } from 'meteor/meteor';
|
||||
|
||||
import { getPushNotificationType } from './getPushNotificationType';
|
||||
import { metrics } from '../../../lib/metrics/lib/metrics';
|
||||
import { Push } from '../../../lib/notifications/push/push';
|
||||
import PushNotification from '../../../lib/notifications/push-config/lib/PushNotification';
|
||||
import { getUserAvatarURL } from '../../../lib/utils/getUserAvatarURL';
|
||||
import { getUserPreference } from '../../../lib/utils/lib/getUserPreference';
|
||||
import { settings } from '../../../settings';
|
||||
import { logger } from '../logger';
|
||||
|
||||
async function getActorUser(actor: MediaCallContact): Promise<Pick<IUser, '_id' | 'name' | 'username' | 'freeSwitchExtension'> | null> {
|
||||
const options = { projection: { name: 1, username: 1, freeSwitchExtension: 1 } };
|
||||
|
||||
switch (actor.type) {
|
||||
case 'user':
|
||||
return Users.findOneById(actor.id, options);
|
||||
case 'sip':
|
||||
return Users.findOneByFreeSwitchExtension(actor.id, options);
|
||||
}
|
||||
}
|
||||
|
||||
async function getActorUserData(
|
||||
actor: MediaCallContact,
|
||||
): Promise<{ type: MediaCallActorType; id: string; name: string; avatarUrl?: string; username?: string }> {
|
||||
const actorUsername = actor.type === 'user' ? actor.username : undefined;
|
||||
const actorExtension = actor.sipExtension || (actor.type === 'sip' ? actor.id : undefined);
|
||||
|
||||
const data = {
|
||||
type: actor.type,
|
||||
id: actor.id,
|
||||
name: actor.displayName || actorUsername || actorExtension || '',
|
||||
} as const;
|
||||
|
||||
const user = await getActorUser(actor);
|
||||
|
||||
if (user) {
|
||||
const username = user.username || actorUsername;
|
||||
|
||||
return {
|
||||
...data,
|
||||
name: user.name || user.username || user.freeSwitchExtension || data.name,
|
||||
...(username && { username, avatarUrl: getUserAvatarURL(username) }),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...data,
|
||||
...(actorUsername && { username: actorUsername, avatarUrl: getUserAvatarURL(actorUsername) }),
|
||||
};
|
||||
}
|
||||
|
||||
async function sendVoipPushNotificationAsync(callId: IMediaCall['_id'], event: VoipPushNotificationEventType): Promise<void> {
|
||||
if (settings.get('Push_enable') !== true || settings.get('VoIP_TeamCollab_Mobile_Ringing_Enabled') !== true) {
|
||||
return;
|
||||
}
|
||||
|
||||
const call = await MediaCalls.findOneById(callId);
|
||||
if (!call) {
|
||||
logger.error({ msg: 'Failed to send push notification: Media Call not found', callId });
|
||||
return;
|
||||
}
|
||||
|
||||
if (call.callee.type !== 'user') {
|
||||
logger.error({ msg: 'Failed to send push notification: Invalid Callee Type', callId });
|
||||
return;
|
||||
}
|
||||
|
||||
const {
|
||||
kind,
|
||||
callee: { id: userId },
|
||||
} = call;
|
||||
|
||||
// If the call was accepted, we don't need to notify when it ends
|
||||
if (call.acceptedAt && event !== 'answer') {
|
||||
return;
|
||||
}
|
||||
|
||||
const type = getPushNotificationType(call);
|
||||
// If the state changed before we had a chance to send the incoming call, skip it altogether
|
||||
if (event === 'new' && type !== 'incoming_call') {
|
||||
return;
|
||||
}
|
||||
if (type === 'incoming_call' && event !== 'new') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!(await getUserPreference(userId, 'enableMobileRinging'))) {
|
||||
return;
|
||||
}
|
||||
|
||||
const caller = await getActorUserData(call.caller);
|
||||
|
||||
metrics.notificationsSent.inc({ notification_type: 'mobile' });
|
||||
const useVoipToken = type === 'incoming_call';
|
||||
|
||||
await Push.send({
|
||||
useVoipToken,
|
||||
priority: 10,
|
||||
payload: {
|
||||
host: Meteor.absoluteUrl(),
|
||||
hostName: settings.get<string>('Site_Name'),
|
||||
notificationType: 'voip',
|
||||
type,
|
||||
kind,
|
||||
callId: call._id,
|
||||
caller,
|
||||
createdAt: call.createdAt.toISOString(),
|
||||
},
|
||||
...(useVoipToken && { apn: { expirationSeconds: 60 } }),
|
||||
userId,
|
||||
notId: PushNotification.getNotificationId(call._id),
|
||||
// We should not send state change notifications to the device where the call was accepted/rejected
|
||||
...(call.callee.contractId && { skipTokenId: call.callee.contractId }),
|
||||
});
|
||||
}
|
||||
|
||||
export function sendVoipPushNotification(callId: IMediaCall['_id'], event: VoipPushNotificationEventType): void {
|
||||
void sendVoipPushNotificationAsync(callId, event).catch((err) => {
|
||||
logger.error({ msg: 'Failed to send VoIP push notification', err, callId, event });
|
||||
});
|
||||
}
|
||||
473
apps/meteor/server/services/media-call/service.ts
Normal file
473
apps/meteor/server/services/media-call/service.ts
Normal file
@@ -0,0 +1,473 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user