Skip to content

Commit 423cdbe

Browse files
committed
feat: presence sync engine
1 parent fe65e8f commit 423cdbe

18 files changed

Lines changed: 893 additions & 380 deletions

File tree

.changeset/full-results-share.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
'@rocket.chat/presence-service': patch
3+
'@rocket.chat/core-services': patch
4+
'@rocket.chat/model-typings': patch
5+
'@rocket.chat/core-typings': patch
6+
'@rocket.chat/presence': patch
7+
'@rocket.chat/models': patch
8+
---
9+
10+
Adds the backend foundation for a unified presence engine with a priority-based claim system (internal > manual > external), status expiration, and previous state restore.

apps/meteor/app/api/server/v1/users.ts

Lines changed: 22 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { MeteorError, Team, api, Calendar } from '@rocket.chat/core-services';
1+
import { MeteorError, Presence, Team, Calendar } from '@rocket.chat/core-services';
22
import type { IExportOperation, ILoginToken, IPersonalAccessToken, IUser, UserStatus } from '@rocket.chat/core-typings';
33
import { Users, Subscriptions, Sessions } from '@rocket.chat/models';
44
import {
@@ -1986,47 +1986,36 @@ API.v1
19861986
return API.v1.forbidden();
19871987
}
19881988

1989-
const { _id, username, roles, name } = user;
1990-
let { statusText, status } = user;
1991-
1992-
if (this.bodyParams.message || this.bodyParams.message === '') {
1993-
await setStatusText(user, this.bodyParams.message, { emit: false });
1994-
statusText = this.bodyParams.message;
1995-
}
1996-
19971989
if (this.bodyParams.status) {
19981990
const validStatus = ['online', 'away', 'offline', 'busy'];
1999-
if (validStatus.includes(this.bodyParams.status)) {
2000-
status = this.bodyParams.status;
1991+
if (!validStatus.includes(this.bodyParams.status)) {
1992+
throw new Meteor.Error('error-invalid-status', 'Valid status types include online, away, offline, and busy.', {
1993+
method: 'users.setStatus',
1994+
});
1995+
}
20011996

2002-
if (status === 'offline' && !settings.get('Accounts_AllowInvisibleStatusOption')) {
2003-
throw new Meteor.Error('error-status-not-allowed', 'Invisible status is disabled', {
2004-
method: 'users.setStatus',
2005-
});
2006-
}
1997+
if (this.bodyParams.status === 'offline' && !settings.get('Accounts_AllowInvisibleStatusOption')) {
1998+
throw new Meteor.Error('error-status-not-allowed', 'Invisible status is disabled', {
1999+
method: 'users.setStatus',
2000+
});
2001+
}
20072002

2008-
await Users.updateOne(
2009-
{ _id: user._id },
2010-
{
2011-
$set: {
2012-
status,
2013-
statusDefault: status,
2014-
},
2015-
},
2016-
);
2003+
const { status } = this.bodyParams;
20172004

2018-
void wrapExceptions(() => Calendar.cancelUpcomingStatusChanges(user._id)).suppress();
2005+
if (status === 'online' && !this.bodyParams.message) {
2006+
await Presence.clearActiveState(user._id);
20192007
} else {
2020-
throw new Meteor.Error('error-invalid-status', 'Valid status types include online, away, offline, and busy.', {
2021-
method: 'users.setStatus',
2008+
await Presence.setActiveState(user._id, {
2009+
statusDefault: status,
2010+
statusSource: 'manual',
2011+
...(this.bodyParams.message != null && { statusText: this.bodyParams.message }),
20222012
});
20232013
}
2024-
}
20252014

2026-
void api.broadcast('presence.status', {
2027-
user: { status, _id, username, statusText, roles, name },
2028-
previousStatus: user.status,
2029-
});
2015+
void wrapExceptions(() => Calendar.cancelUpcomingStatusChanges(user._id)).suppress();
2016+
} else if (this.bodyParams.message != null) {
2017+
await setStatusText(user, this.bodyParams.message);
2018+
}
20302019

20312020
return API.v1.success();
20322021
},

apps/meteor/app/user-status/server/methods/setUserStatus.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,17 @@ export const setUserStatusMethod = async (
2626
method: 'setUserStatus',
2727
});
2828
}
29-
await Presence.setStatus(user._id, statusType);
29+
30+
if (statusType === 'online' && !statusText) {
31+
await Presence.clearActiveState(user._id);
32+
} else {
33+
await Presence.setActiveState(user._id, {
34+
statusDefault: statusType,
35+
statusSource: 'manual',
36+
...(statusText != null && { statusText }),
37+
});
38+
}
39+
return;
3040
}
3141

3242
if (statusText || statusText === '') {

apps/meteor/tests/end-to-end/apps/update-status-text.ts

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -67,9 +67,7 @@ const APP_USERNAME = 'update-status-test.bot';
6767
expect(appUser.statusText).to.be.equal(statusText);
6868
});
6969

70-
it('should update status without changing statusText', async () => {
71-
const userBefore = await getUserByUsername(APP_USERNAME);
72-
70+
it('should clear statusText when status is updated without providing statusText', async () => {
7371
await request
7472
.post(apps(`/public/${app.id}/update-status`))
7573
.set(credentials)
@@ -78,10 +76,9 @@ const APP_USERNAME = 'update-status-test.bot';
7876

7977
const appUser = await getUserByUsername(APP_USERNAME);
8078

81-
// We can't test the status value because the Presence service will override it with OFFLINE
82-
// when the user doesn't have an active session/connection
83-
// expect(appUser.status).to.equal(status);
84-
expect(appUser.statusText).to.be.equal(userBefore.statusText);
79+
// The test app defaults statusText to '' when not provided,
80+
// so the presence engine correctly clears it
81+
expect(appUser.statusText).to.be.equal('');
8582
});
8683
});
8784
});

ee/apps/presence-service/Dockerfile

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,9 @@ COPY ./ee/packages/presence/dist ee/packages/presence/dist
1313
COPY ./packages/agenda/package.json packages/agenda/package.json
1414
COPY ./packages/agenda/dist packages/agenda/dist
1515

16+
COPY ./packages/cron/package.json packages/cron/package.json
17+
COPY ./packages/cron/dist packages/cron/dist
18+
1619
COPY ./packages/core-services/package.json packages/core-services/package.json
1720
COPY ./packages/core-services/dist packages/core-services/dist
1821

ee/apps/presence-service/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
"dependencies": {
2222
"@rocket.chat/core-services": "workspace:^",
2323
"@rocket.chat/core-typings": "workspace:^",
24+
"@rocket.chat/cron": "workspace:^",
2425
"@rocket.chat/emitter": "^0.32.0",
2526
"@rocket.chat/logger": "workspace:^",
2627
"@rocket.chat/model-typings": "workspace:^",

ee/packages/presence/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
"dependencies": {
1919
"@rocket.chat/core-services": "workspace:^",
2020
"@rocket.chat/core-typings": "workspace:^",
21+
"@rocket.chat/cron": "workspace:^",
2122
"@rocket.chat/models": "workspace:^",
2223
"mongodb": "6.16.0"
2324
},
Lines changed: 236 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,236 @@
1+
import type { IUser } from '@rocket.chat/core-typings';
2+
import { UserStatus } from '@rocket.chat/core-typings';
3+
import { registerModel } from '@rocket.chat/models';
4+
5+
import { Presence } from './Presence';
6+
7+
const findUserMock = jest.fn();
8+
const updatePresenceMock = jest.fn();
9+
const findSessionMock = jest.fn();
10+
11+
registerModel('IUsersModel', {
12+
findOneById: findUserMock,
13+
updatePresenceAndStatus: updatePresenceMock,
14+
findExpiredStatuses: jest.fn(),
15+
} as any);
16+
17+
registerModel('IUsersSessionsModel', {
18+
findOneById: findSessionMock,
19+
addConnectionById: jest.fn(),
20+
removeConnectionByConnectionId: jest.fn(),
21+
updateConnectionStatusById: jest.fn(),
22+
} as any);
23+
24+
const user = (o: Partial<IUser> = {}): IUser =>
25+
({
26+
_id: 'u1',
27+
username: 'test',
28+
roles: ['user'],
29+
status: UserStatus.ONLINE,
30+
statusDefault: UserStatus.ONLINE,
31+
statusConnection: UserStatus.ONLINE,
32+
statusText: '',
33+
...o,
34+
}) as IUser;
35+
36+
const withOnlineSession = () =>
37+
findSessionMock.mockResolvedValue({ connections: [{ id: 's1', instanceId: 'i1', status: UserStatus.ONLINE }] });
38+
39+
const withNoSessions = () => findSessionMock.mockResolvedValue(null);
40+
41+
describe('Presence class', () => {
42+
let presence: Presence;
43+
44+
beforeEach(() => {
45+
jest.clearAllMocks();
46+
presence = new Presence();
47+
(presence as any).broadcastEnabled = true;
48+
(presence as any).api = { broadcast: jest.fn(), nodeList: jest.fn().mockResolvedValue([]) };
49+
updatePresenceMock.mockResolvedValue(user());
50+
});
51+
52+
describe('setActiveState', () => {
53+
it('should apply claim and write combined result when user is online', async () => {
54+
findUserMock.mockResolvedValue(user());
55+
withOnlineSession();
56+
57+
await presence.setActiveState('u1', {
58+
statusDefault: UserStatus.BUSY,
59+
statusSource: 'manual',
60+
statusText: 'Focus',
61+
});
62+
63+
expect(updatePresenceMock).toHaveBeenCalledWith(
64+
'u1',
65+
expect.objectContaining({ statusDefault: UserStatus.BUSY, statusSource: 'manual', status: UserStatus.BUSY }),
66+
expect.any(Array),
67+
);
68+
});
69+
70+
it('should not write when claim is rejected (offline + external)', async () => {
71+
findUserMock.mockResolvedValue(user({ statusDefault: UserStatus.OFFLINE }));
72+
withNoSessions();
73+
74+
await presence.setActiveState('u1', {
75+
statusDefault: UserStatus.BUSY,
76+
statusSource: 'external',
77+
});
78+
79+
expect(updatePresenceMock).not.toHaveBeenCalled();
80+
});
81+
82+
it('should store claim but show offline status when user has no sessions', async () => {
83+
findUserMock.mockResolvedValue(user({ statusDefault: UserStatus.ONLINE }));
84+
withNoSessions();
85+
86+
await presence.setActiveState('u1', {
87+
statusDefault: UserStatus.BUSY,
88+
statusSource: 'manual',
89+
statusText: 'Working',
90+
});
91+
92+
expect(updatePresenceMock).toHaveBeenCalledWith(
93+
'u1',
94+
expect.objectContaining({ status: UserStatus.OFFLINE, statusConnection: UserStatus.OFFLINE, statusDefault: UserStatus.BUSY }),
95+
expect.any(Array),
96+
);
97+
});
98+
99+
it('should pass expiresAt when provided', async () => {
100+
const expiresAt = new Date(Date.now() + 3600_000);
101+
findUserMock.mockResolvedValue(user());
102+
withOnlineSession();
103+
104+
await presence.setActiveState('u1', {
105+
statusDefault: UserStatus.BUSY,
106+
statusSource: 'manual',
107+
statusText: 'Focus',
108+
statusExpiresAt: expiresAt,
109+
});
110+
111+
expect(updatePresenceMock).toHaveBeenCalledWith('u1', expect.objectContaining({ statusExpiresAt: expiresAt }), undefined);
112+
});
113+
});
114+
115+
describe('endActiveState', () => {
116+
it('should restore previous state and write', async () => {
117+
findUserMock.mockResolvedValue(
118+
user({
119+
statusSource: 'manual',
120+
statusDefault: UserStatus.BUSY,
121+
previousState: { statusDefault: UserStatus.BUSY, statusText: 'Meeting', statusSource: 'external' },
122+
}),
123+
);
124+
withOnlineSession();
125+
126+
await presence.endActiveState('u1');
127+
128+
expect(updatePresenceMock).toHaveBeenCalledWith(
129+
'u1',
130+
expect.objectContaining({ statusSource: 'external', statusText: 'Meeting' }),
131+
expect.arrayContaining(['previousState']),
132+
);
133+
});
134+
});
135+
136+
describe('clearActiveState', () => {
137+
it('should reset to online and write', async () => {
138+
findUserMock.mockResolvedValue(user({ statusDefault: UserStatus.BUSY, statusSource: 'manual' }));
139+
withOnlineSession();
140+
141+
await presence.clearActiveState('u1');
142+
143+
expect(updatePresenceMock).toHaveBeenCalledWith(
144+
'u1',
145+
expect.objectContaining({ statusDefault: UserStatus.ONLINE, statusText: '', status: UserStatus.ONLINE }),
146+
expect.arrayContaining(['statusSource', 'previousState']),
147+
);
148+
});
149+
});
150+
151+
describe('setStatus', () => {
152+
it('should return true when status changed', async () => {
153+
findUserMock.mockResolvedValue(user());
154+
withOnlineSession();
155+
156+
const result = await presence.setStatus('u1', UserStatus.BUSY, 'Working');
157+
158+
expect(result).toBe(true);
159+
expect(updatePresenceMock).toHaveBeenCalledWith(
160+
'u1',
161+
expect.objectContaining({ statusDefault: UserStatus.BUSY, statusSource: 'manual' }),
162+
expect.any(Array),
163+
);
164+
});
165+
166+
it('should trigger clearActive when status is ONLINE with no text', async () => {
167+
findUserMock.mockResolvedValue(user({ statusDefault: UserStatus.BUSY, statusSource: 'manual' }));
168+
withOnlineSession();
169+
170+
await presence.setStatus('u1', UserStatus.ONLINE);
171+
172+
expect(updatePresenceMock).toHaveBeenCalledWith(
173+
'u1',
174+
expect.objectContaining({ statusDefault: UserStatus.ONLINE }),
175+
expect.arrayContaining(['statusSource', 'previousState']),
176+
);
177+
});
178+
179+
it('should trigger clearActive when status is ONLINE with empty string text', async () => {
180+
findUserMock.mockResolvedValue(user({ statusDefault: UserStatus.BUSY, statusSource: 'manual' }));
181+
withOnlineSession();
182+
183+
await presence.setStatus('u1', UserStatus.ONLINE, '');
184+
185+
expect(updatePresenceMock).toHaveBeenCalledWith(
186+
'u1',
187+
expect.objectContaining({ statusDefault: UserStatus.ONLINE }),
188+
expect.arrayContaining(['statusSource', 'previousState']),
189+
);
190+
});
191+
192+
it('should trigger setActive when status is ONLINE with text', async () => {
193+
findUserMock.mockResolvedValue(user());
194+
withOnlineSession();
195+
196+
await presence.setStatus('u1', UserStatus.ONLINE, 'brb');
197+
198+
expect(updatePresenceMock).toHaveBeenCalledWith(
199+
'u1',
200+
expect.objectContaining({ statusDefault: UserStatus.ONLINE, statusSource: 'manual', statusText: 'brb' }),
201+
expect.any(Array),
202+
);
203+
});
204+
205+
it('should write empty string statusText when explicitly provided', async () => {
206+
findUserMock.mockResolvedValue(user({ statusText: 'Old text' }));
207+
withOnlineSession();
208+
209+
await presence.setStatus('u1', UserStatus.BUSY, '');
210+
211+
expect(updatePresenceMock).toHaveBeenCalledWith(
212+
'u1',
213+
expect.objectContaining({ statusDefault: UserStatus.BUSY, statusText: '' }),
214+
expect.any(Array),
215+
);
216+
});
217+
218+
it('should not include statusText when undefined', async () => {
219+
findUserMock.mockResolvedValue(user({ statusText: 'Old text' }));
220+
withOnlineSession();
221+
222+
await presence.setStatus('u1', UserStatus.BUSY);
223+
224+
const updateArg = updatePresenceMock.mock.calls[0][1];
225+
expect(updateArg).not.toHaveProperty('statusText');
226+
});
227+
228+
it('should return false when user is not found', async () => {
229+
findUserMock.mockResolvedValue(null);
230+
231+
const result = await presence.setStatus('u1', UserStatus.BUSY);
232+
233+
expect(result).toBe(false);
234+
});
235+
});
236+
});

0 commit comments

Comments
 (0)