Skip to content

Commit 3745234

Browse files
committed
feat: presence sync for voice and video
1 parent 8d11686 commit 3745234

6 files changed

Lines changed: 124 additions & 16 deletions

File tree

apps/meteor/server/services/media-call/service.ts

Lines changed: 36 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { api, ServiceClassInternal, type IMediaCallService, Authorization } from '@rocket.chat/core-services';
1+
import { api, Presence, ServiceClassInternal, type IMediaCallService, Authorization } from '@rocket.chat/core-services';
22
import type {
33
IMediaCall,
44
IUser,
@@ -7,6 +7,7 @@ import type {
77
CallHistoryItemState,
88
IExternalMediaCallHistoryItem,
99
} from '@rocket.chat/core-typings';
10+
import { UserStatus } from '@rocket.chat/core-typings';
1011
import { callServer, type IMediaCallServerSettings, getSignalsForExistingCall } from '@rocket.chat/media-calls';
1112
import type {
1213
CallFeature,
@@ -34,6 +35,8 @@ export class MediaCallService extends ServiceClassInternal implements IMediaCall
3435
super();
3536
callServer.emitter.on('signalRequest', ({ toUid, signal }) => this.sendSignal(toUid, signal));
3637
callServer.emitter.on('callUpdated', (params) => api.broadcast('media-call.updated', params));
38+
callServer.emitter.on('callActivated', ({ callId, uids }) => this.setPresenceForUsers(uids, callId));
39+
callServer.emitter.on('callEnded', ({ callId, uids }) => this.clearPresenceForUsers(uids, callId));
3740
callServer.emitter.on('historyUpdate', ({ callId }) => setImmediate(() => this.saveCallToHistory(callId)));
3841
callServer.emitter.on('pushNotificationRequest', ({ callId, event }) => sendVoipPushNotification(callId, event));
3942
this.onEvent('media-call.updated', (params) => callServer.receiveCallUpdate(params));
@@ -258,8 +261,8 @@ export class MediaCallService extends ServiceClassInternal implements IMediaCall
258261
}
259262
}
260263

261-
private getLanguageForUser(user: IUser): string {
262-
return user.language || settings.get('Language') || 'en';
264+
private getLanguageForUser(language?: string): string {
265+
return language || settings.get('Language') || 'en';
263266
}
264267

265268
private async sendHistoryMessage(call: IMediaCall, room: IRoom): Promise<void> {
@@ -274,7 +277,7 @@ export class MediaCallService extends ServiceClassInternal implements IMediaCall
274277
const skipNotifications = state !== 'not-answered' || call.hangupReason === 'rejected';
275278
const i18nKey = callStateToTranslationKey(state).i18n?.key;
276279

277-
const msg = i18nKey ? i18n.t(i18nKey, { lng: this.getLanguageForUser(user) }) : '';
280+
const msg = i18nKey ? i18n.t(i18nKey, { lng: this.getLanguageForUser(user.language) }) : '';
278281
const duration = this.getCallDuration(call);
279282

280283
const record = getHistoryMessagePayload(state, duration, call._id, msg);
@@ -366,6 +369,35 @@ export class MediaCallService extends ServiceClassInternal implements IMediaCall
366369
};
367370
}
368371

372+
private async setPresenceForUsers(uids: IUser['_id'][], callId: IMediaCall['_id']): Promise<void> {
373+
await Promise.all(
374+
uids.map(async (uid) => {
375+
try {
376+
const user = await Users.findOneById(uid, { projection: { language: 1 } });
377+
await Presence.setActiveState(uid, {
378+
statusDefault: UserStatus.BUSY,
379+
statusText: i18n.t('Presence_status_on_a_call', { lng: this.getLanguageForUser(user?.language) }),
380+
statusSource: 'internal',
381+
statusId: callId,
382+
});
383+
} catch (err) {
384+
logger.error({ msg: 'Failed to set presence for user on call', uid, err });
385+
}
386+
}),
387+
);
388+
}
389+
390+
private async clearPresenceForUsers(uids: IUser['_id'][], callId: IMediaCall['_id']): Promise<void> {
391+
// pass callId so only this call's claim is cleared, never another claim that took over
392+
await Promise.all(
393+
uids.map((uid) =>
394+
Presence.endActiveState(uid, callId).catch((err) =>
395+
logger.error({ msg: 'Failed to clear presence for user after call', uid, err }),
396+
),
397+
),
398+
);
399+
}
400+
369401
private async sendSignal(toUid: IUser['_id'], signal: ServerMediaSignal): Promise<void> {
370402
void api.broadcast('user.media-signal', { userId: toUid, signal });
371403
}

apps/meteor/server/services/video-conference/service.ts

Lines changed: 44 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { Apps } from '@rocket.chat/apps';
22
import type { AppVideoConfProviderManager } from '@rocket.chat/apps/dist/server/managers/AppVideoConfProviderManager';
33
import type { VideoConfData, VideoConfDataExtended } from '@rocket.chat/apps-engine/definition/videoConfProviders';
44
import type { IVideoConfService, VideoConferenceJoinOptions } from '@rocket.chat/core-services';
5-
import { api, ServiceClassInternal, Room } from '@rocket.chat/core-services';
5+
import { api, Presence, ServiceClassInternal, Room } from '@rocket.chat/core-services';
66
import type {
77
IDirectVideoConference,
88
ILivechatVideoConference,
@@ -25,6 +25,7 @@ import type {
2525
IVoIPVideoConference,
2626
} from '@rocket.chat/core-typings';
2727
import {
28+
UserStatus,
2829
VideoConferenceStatus,
2930
isDirectVideoConference,
3031
isGroupVideoConference,
@@ -322,8 +323,8 @@ export class VideoConfService extends ServiceClassInternal implements IVideoConf
322323
throw new Error('Invalid User');
323324
}
324325

325-
const user = await Users.findOneById<Required<Pick<IUser, '_id' | 'username' | 'name' | 'avatarETag'>>>(userId, {
326-
projection: { username: 1, name: 1, avatarETag: 1 },
326+
const user = await Users.findOneById<Required<Pick<IUser, '_id' | 'username' | 'name' | 'avatarETag' | 'language'>>>(userId, {
327+
projection: { username: 1, name: 1, avatarETag: 1, language: 1 },
327328
});
328329
if (!user) {
329330
throw new Error('Invalid User');
@@ -334,6 +335,7 @@ export class VideoConfService extends ServiceClassInternal implements IVideoConf
334335
username: user.username,
335336
name: user.name,
336337
avatarETag: user.avatarETag,
338+
language: user.language,
337339
ts: ts || new Date(),
338340
});
339341
}
@@ -502,6 +504,9 @@ export class VideoConfService extends ServiceClassInternal implements IVideoConf
502504
}
503505

504506
await VideoConferenceModel.setDataById(call._id, { endedAt: new Date(), status: VideoConferenceStatus.ENDED });
507+
508+
await this.clearPresenceForCall(call);
509+
505510
await this.runVideoConferenceChangedEvent(call._id);
506511
this.notifyVideoConfUpdate(call.rid, call._id);
507512

@@ -511,12 +516,28 @@ export class VideoConfService extends ServiceClassInternal implements IVideoConf
511516
}
512517

513518
private async expireCall(callId: VideoConference['_id']): Promise<void> {
514-
const call = await VideoConferenceModel.findOneById<Pick<VideoConference, '_id' | 'messages'>>(callId, { projection: { messages: 1 } });
519+
const call = await VideoConferenceModel.findOneById<Pick<VideoConference, '_id' | 'messages' | 'users'>>(callId, {
520+
projection: { messages: 1, users: 1 },
521+
});
515522
if (!call) {
516523
return;
517524
}
518525

519526
await VideoConferenceModel.setDataById(call._id, { endedAt: new Date(), status: VideoConferenceStatus.EXPIRED });
527+
528+
await this.clearPresenceForCall(call);
529+
}
530+
531+
// clears the busy claim this conference set for each participant (id-scoped, so it never touches
532+
// a manual/calendar status or another active call a participant may hold)
533+
private async clearPresenceForCall(call: Pick<VideoConference, '_id' | 'users'>): Promise<void> {
534+
await Promise.all(
535+
call.users.map((user) =>
536+
Presence.endActiveState(user._id, call._id).catch((err) =>
537+
logger.error({ msg: 'Failed to clear presence for user after video conference', uid: user._id, err }),
538+
),
539+
),
540+
);
520541
}
521542

522543
private async endDirectCall(call: IDirectVideoConference): Promise<void> {
@@ -576,6 +597,10 @@ export class VideoConfService extends ServiceClassInternal implements IVideoConf
576597
return message._id;
577598
}
578599

600+
private getLanguageForUser(language?: string): string {
601+
return language || settings.get('Language') || 'en';
602+
}
603+
579604
private async validateProvider(providerName: string): Promise<void> {
580605
const manager = await this.getProviderManager();
581606
const configured = await manager.isFullyConfigured(providerName).catch(() => false);
@@ -1067,7 +1092,14 @@ export class VideoConfService extends ServiceClassInternal implements IVideoConf
10671092

10681093
private async addUserToCall(
10691094
call: Optional<VideoConference, 'providerData'>,
1070-
{ _id, username, name, avatarETag, ts }: AtLeast<Required<IUser>, '_id' | 'username' | 'name' | 'avatarETag'> & { ts?: Date },
1095+
{
1096+
_id,
1097+
username,
1098+
name,
1099+
avatarETag,
1100+
language,
1101+
ts,
1102+
}: AtLeast<Required<IUser>, '_id' | 'username' | 'name' | 'avatarETag'> & { language?: string; ts?: Date },
10711103
): Promise<void> {
10721104
// If the call has a discussion, ensure the user is subscribed to it;
10731105
// This is done even if the user has already joined the call before, so they can be added back if they had left the discussion.
@@ -1081,6 +1113,13 @@ export class VideoConfService extends ServiceClassInternal implements IVideoConf
10811113

10821114
await VideoConferenceModel.addUserById(call._id, { _id, username, name, avatarETag, ts });
10831115

1116+
await Presence.setActiveState(_id, {
1117+
statusDefault: UserStatus.BUSY,
1118+
statusText: i18n.t('Presence_status_in_a_meeting', { lng: this.getLanguageForUser(language) }),
1119+
statusSource: 'internal',
1120+
statusId: call._id,
1121+
}).catch((err) => logger.error({ msg: 'Failed to set presence for user joining video conference', uid: _id, err }));
1122+
10841123
if (call.type === 'direct') {
10851124
return this.updateDirectCall(call as IDirectVideoConference, _id);
10861125
}

apps/meteor/tests/end-to-end/apps/presence-state.ts

Lines changed: 35 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import type { App } from '@rocket.chat/core-typings';
22
import { expect } from 'chai';
3-
import { after, before, describe, it } from 'mocha';
3+
import { after, afterEach, before, describe, it } from 'mocha';
44

55
import { getCredentials, request, credentials } from '../../data/api-data';
66
import { appPresenceStateTest } from '../../data/apps/app-packages';
@@ -22,6 +22,15 @@ import { IS_EE } from '../../e2e/config/constants';
2222

2323
after(() => cleanupApps());
2424

25+
afterEach(async () => {
26+
const user = await getUserByUsername(adminUsername);
27+
await request
28+
.post(apps(`/public/${app.id}/end-active-state`))
29+
.set(credentials)
30+
.send({ userId: user._id })
31+
.expect(200);
32+
});
33+
2534
describe('[setActiveState]', () => {
2635
it('should set the user presence with status text and source', async () => {
2736
const user = await getUserByUsername(adminUsername);
@@ -44,10 +53,20 @@ import { IS_EE } from '../../e2e/config/constants';
4453
});
4554

4655
describe('[endActiveState]', () => {
47-
it('should restore the user presence to previous state', async () => {
56+
it('should restore the displaced same-priority state and reset once the queue is exhausted', async () => {
4857
const user = await getUserByUsername(adminUsername);
4958

50-
// First set an active state
59+
await request
60+
.post(apps(`/public/${app.id}/set-active-state`))
61+
.set(credentials)
62+
.send({
63+
userId: user._id,
64+
statusDefault: 'busy',
65+
statusText: 'In a meeting',
66+
statusSource: 'internal',
67+
})
68+
.expect(200);
69+
5170
await request
5271
.post(apps(`/public/${app.id}/set-active-state`))
5372
.set(credentials)
@@ -59,16 +78,25 @@ import { IS_EE } from '../../e2e/config/constants';
5978
})
6079
.expect(200);
6180

62-
// Then end it
6381
await request
6482
.post(apps(`/public/${app.id}/end-active-state`))
6583
.set(credentials)
6684
.send({ userId: user._id })
6785
.expect(200);
6886

69-
const updatedUser = await getUserByUsername(adminUsername);
70-
expect(updatedUser.statusText).to.not.equal('On a call');
71-
expect(updatedUser.statusSource).to.not.equal('internal');
87+
const restoredUser = await getUserByUsername(adminUsername);
88+
expect(restoredUser.statusText).to.equal('In a meeting');
89+
expect(restoredUser.statusSource).to.equal('internal');
90+
91+
await request
92+
.post(apps(`/public/${app.id}/end-active-state`))
93+
.set(credentials)
94+
.send({ userId: user._id })
95+
.expect(200);
96+
97+
const clearedUser = await getUserByUsername(adminUsername);
98+
expect(clearedUser.statusText).to.equal('');
99+
expect(clearedUser.statusSource).to.be.undefined;
72100
});
73101
});
74102
});

ee/packages/media-calls/src/definition/IMediaCallServer.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ export type VoipPushNotificationEventType = 'new' | 'answer' | 'end';
99

1010
export type MediaCallServerEvents = {
1111
callUpdated: { callId: string; dtmf?: ClientMediaSignalBody<'dtmf'> };
12+
callActivated: { callId: string; uids: IUser['_id'][] };
13+
callEnded: { callId: string; uids: IUser['_id'][] };
1214
signalRequest: { toUid: IUser['_id']; signal: ServerMediaSignal };
1315
historyUpdate: { callId: string };
1416
pushNotificationRequest: { callId: string; event: VoipPushNotificationEventType };

ee/packages/media-calls/src/server/CallDirector.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ class MediaCallDirector {
5555

5656
logger.info({ msg: 'Call was flagged as active', callId: call._id });
5757
this.scheduleExpirationCheckByCallId(call._id);
58+
getMediaCallServer().emitter.emit('callActivated', { callId: call._id, uids: call.uids });
5859
return actorAgent.oppositeAgent?.onCallActive(call._id);
5960
}
6061

@@ -400,6 +401,10 @@ class MediaCallDirector {
400401
if (ended) {
401402
logger.info({ msg: 'Call was flagged as ended', callId, reason: params?.reason });
402403
getMediaCallServer().updateCallHistory({ callId });
404+
const call = await MediaCalls.findOneById<Pick<IMediaCall, '_id' | 'uids'>>(callId, { projection: { uids: 1 } });
405+
if (call) {
406+
getMediaCallServer().emitter.emit('callEnded', { callId, uids: call.uids });
407+
}
403408
}
404409

405410
return ended;

packages/i18n/src/locales/en.i18n.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4264,6 +4264,8 @@
42644264
"Presence_broadcast_disabled_Description": "This shows if the presence broadcast has been disabled automatically. This can happen if you don't have an Premium License and have more than 200 concurrent connections.",
42654265
"Presence_service": "Presence service",
42664266
"Presence_service_cap": "Presence service cap",
4267+
"Presence_status_on_a_call": "On a call",
4268+
"Presence_status_in_a_meeting": "In a meeting",
42674269
"Preview": "Preview",
42684270
"Previous_image": "Previous image",
42694271
"Previous_month": "Previous Month",

0 commit comments

Comments
 (0)