Skip to content

Commit 80c7f46

Browse files
committed
feat: presence sync for voice and video calls
1 parent eb52ac7 commit 80c7f46

12 files changed

Lines changed: 279 additions & 53 deletions

File tree

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

Lines changed: 32 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', ({ uids }) => this.setPresenceForUsers(uids));
39+
callServer.emitter.on('callEnded', ({ uids }) => this.clearPresenceForUsers(uids));
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,31 @@ export class MediaCallService extends ServiceClassInternal implements IMediaCall
366369
};
367370
}
368371

372+
private async setPresenceForUsers(uids: IUser['_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+
});
382+
} catch (err) {
383+
logger.error({ msg: 'Failed to set presence for user on call', uid, err });
384+
}
385+
}),
386+
);
387+
}
388+
389+
private async clearPresenceForUsers(uids: IUser['_id'][]): Promise<void> {
390+
await Promise.all(
391+
uids.map((uid) =>
392+
Presence.endActiveState(uid).catch((err) => logger.error({ msg: 'Failed to clear presence for user after call', uid, err })),
393+
),
394+
);
395+
}
396+
369397
private async sendSignal(toUid: IUser['_id'], signal: ServerMediaSignal): Promise<void> {
370398
void api.broadcast('user.media-signal', { userId: toUid, signal });
371399
}

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

Lines changed: 32 additions & 4 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,15 @@ export class VideoConfService extends ServiceClassInternal implements IVideoConf
502504
}
503505

504506
await VideoConferenceModel.setDataById(call._id, { endedAt: new Date(), status: VideoConferenceStatus.ENDED });
507+
508+
await Promise.all(
509+
call.users.map((user) =>
510+
Presence.endActiveState(user._id).catch((err) =>
511+
logger.error({ msg: 'Failed to clear presence for user after video conference', uid: user._id, err }),
512+
),
513+
),
514+
);
515+
505516
await this.runVideoConferenceChangedEvent(call._id);
506517
this.notifyVideoConfUpdate(call.rid, call._id);
507518

@@ -576,6 +587,10 @@ export class VideoConfService extends ServiceClassInternal implements IVideoConf
576587
return message._id;
577588
}
578589

590+
private getLanguageForUser(language?: string): string {
591+
return language || settings.get('Language') || 'en';
592+
}
593+
579594
private async validateProvider(providerName: string): Promise<void> {
580595
const manager = await this.getProviderManager();
581596
const configured = await manager.isFullyConfigured(providerName).catch(() => false);
@@ -1067,7 +1082,14 @@ export class VideoConfService extends ServiceClassInternal implements IVideoConf
10671082

10681083
private async addUserToCall(
10691084
call: Optional<VideoConference, 'providerData'>,
1070-
{ _id, username, name, avatarETag, ts }: AtLeast<Required<IUser>, '_id' | 'username' | 'name' | 'avatarETag'> & { ts?: Date },
1085+
{
1086+
_id,
1087+
username,
1088+
name,
1089+
avatarETag,
1090+
language,
1091+
ts,
1092+
}: AtLeast<Required<IUser>, '_id' | 'username' | 'name' | 'avatarETag'> & { language?: string; ts?: Date },
10711093
): Promise<void> {
10721094
// If the call has a discussion, ensure the user is subscribed to it;
10731095
// 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 +1103,12 @@ export class VideoConfService extends ServiceClassInternal implements IVideoConf
10811103

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

1106+
await Presence.setActiveState(_id, {
1107+
statusDefault: UserStatus.BUSY,
1108+
statusText: i18n.t('Presence_status_in_a_meeting', { lng: this.getLanguageForUser(language) }),
1109+
statusSource: 'internal',
1110+
}).catch((err) => logger.error({ msg: 'Failed to set presence for user joining video conference', uid: _id, err }));
1111+
10841112
if (call.type === 'direct') {
10851113
return this.updateDirectCall(call as IDirectVideoConference, _id);
10861114
}

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: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ class MediaCallDirector {
3838
if (modified) {
3939
await actorAgent.onCallEnded(call._id);
4040
await actorAgent.oppositeAgent?.onCallEnded(call._id);
41+
getMediaCallServer().emitter.emit('callEnded', { callId: call._id, uids: call.uids });
4142
}
4243
}
4344

@@ -55,6 +56,7 @@ class MediaCallDirector {
5556

5657
logger.info({ msg: 'Call was flagged as active', callId: call._id });
5758
this.scheduleExpirationCheckByCallId(call._id);
59+
getMediaCallServer().emitter.emit('callActivated', { callId: call._id, uids: call.uids });
5860
return actorAgent.oppositeAgent?.onCallActive(call._id);
5961
}
6062

ee/packages/presence/src/Presence.ts

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ type PresenceUser = Pick<
1717
IUser,
1818
| '_id'
1919
| 'username'
20+
| 'type'
2021
| 'roles'
2122
| 'status'
2223
| 'statusDefault'
@@ -326,11 +327,12 @@ export class Presence extends ServiceClass implements IPresence {
326327
}
327328

328329
/**
329-
* Ends the current active claim. Restores previous if valid, otherwise
330-
* falls back to system-managed.
330+
* Ends the current active claim. Restores previous if valid, otherwise falls back to
331+
* system-managed. When `expectedSource` is given, only ends the claim if that source still
332+
* owns the display (so a caller can clear its own claim without clobbering one that took over).
331333
*/
332-
async endActiveState(userId: string): Promise<boolean> {
333-
return this.updatePresenceAndReschedule(userId, { type: 'endActive' });
334+
async endActiveState(userId: string, expectedSource?: IUser['statusSource']): Promise<boolean> {
335+
return this.updatePresenceAndReschedule(userId, { type: 'endActive', ...(expectedSource && { expectedSource }) });
334336
}
335337

336338
/**
@@ -358,6 +360,7 @@ export class Presence extends ServiceClass implements IPresence {
358360
? await Users.findOneById<PresenceUser>(uidOrUser, {
359361
projection: {
360362
username: 1,
363+
type: 1,
361364
roles: 1,
362365
status: 1,
363366
statusDefault: 1,

0 commit comments

Comments
 (0)