Skip to content

Commit 7d4cd03

Browse files
committed
feat: presence sync engine integrations (#40557)
1 parent efe52d4 commit 7d4cd03

36 files changed

Lines changed: 751 additions & 972 deletions

File tree

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

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { MeteorError, Presence, Team, Calendar } from '@rocket.chat/core-services';
1+
import { MeteorError, Presence, Team } from '@rocket.chat/core-services';
22
import type { IExportOperation, ILoginToken, IPersonalAccessToken, IUser, UserStatus } from '@rocket.chat/core-typings';
33
import { Users, Subscriptions, Sessions, OAuthAccessTokens, OAuthRefreshTokens, OAuthAuthCodes } from '@rocket.chat/models';
44
import {
@@ -29,7 +29,7 @@ import {
2929
validateForbiddenErrorResponse,
3030
} from '@rocket.chat/rest-typings';
3131
import { escapeRegExp } from '@rocket.chat/string-helpers';
32-
import { getLoginExpirationInMs, wrapExceptions } from '@rocket.chat/tools';
32+
import { getLoginExpirationInMs } from '@rocket.chat/tools';
3333
import { Accounts } from 'meteor/accounts-base';
3434
import { Match, check } from 'meteor/check';
3535
import { Meteor } from 'meteor/meteor';
@@ -2028,10 +2028,6 @@ API.v1
20282028

20292029
await Presence.setStatus(user._id, effectiveStatus, message, statusExpiresAt);
20302030

2031-
if (status) {
2032-
void wrapExceptions(() => Calendar.cancelUpcomingStatusChanges(user._id)).suppress();
2033-
}
2034-
20352031
return API.v1.success();
20362032
},
20372033
)

apps/meteor/app/apps/server/bridges/users.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -181,7 +181,7 @@ export class AppUserBridge extends UserBridge {
181181

182182
protected async setActiveState(
183183
userId: IUser['id'],
184-
state: Pick<IUser, 'statusDefault' | 'statusSource' | 'statusText' | 'statusExpiresAt'>,
184+
state: Pick<IUser, 'statusDefault' | 'statusSource' | 'statusText' | 'statusExpiresAt' | 'statusId'>,
185185
appId: string,
186186
): Promise<void> {
187187
this.orch.debugLog(`The App ${appId} is setting active state for user ${userId}`);
@@ -191,13 +191,14 @@ export class AppUserBridge extends UserBridge {
191191
statusText: state.statusText,
192192
statusSource: state.statusSource as PresenceSource,
193193
...(state.statusExpiresAt && { statusExpiresAt: state.statusExpiresAt }),
194+
...(state.statusId && { statusId: state.statusId }),
194195
});
195196
}
196197

197-
protected async endActiveState(userId: IUser['id'], appId: string): Promise<void> {
198+
protected async endActiveState(userId: IUser['id'], appId: string, statusId?: string): Promise<void> {
198199
this.orch.debugLog(`The App ${appId} is ending active state for user ${userId}`);
199200

200-
await Presence.endActiveState(userId);
201+
await Presence.endActiveState(userId, statusId);
201202
}
202203

203204
protected async getActiveUserCount(): Promise<number> {

apps/meteor/server/services/calendar/service.ts

Lines changed: 66 additions & 100 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import type { ICalendarService } from '@rocket.chat/core-services';
2-
import { ServiceClassInternal, api } from '@rocket.chat/core-services';
2+
import { Presence, ServiceClassInternal, api } from '@rocket.chat/core-services';
33
import type { IUser, ICalendarEvent } from '@rocket.chat/core-typings';
44
import { UserStatus } from '@rocket.chat/core-typings';
55
import { cronJobs } from '@rocket.chat/cron';
@@ -8,12 +8,10 @@ import type { InsertionModel } from '@rocket.chat/model-typings';
88
import { CalendarEvent, Users } from '@rocket.chat/models';
99
import type { UpdateResult, DeleteResult } from 'mongodb';
1010

11-
import { applyStatusChange } from './statusEvents/applyStatusChange';
12-
import { cancelUpcomingStatusChanges } from './statusEvents/cancelUpcomingStatusChanges';
13-
import { removeCronJobs } from './statusEvents/removeCronJobs';
1411
import { getShiftedTime } from './utils/getShiftedTime';
1512
import { settings } from '../../../app/settings/server';
1613
import { getUserPreference } from '../../../app/utils/server/lib/getUserPreference';
14+
import { i18n } from '../../lib/i18n';
1715

1816
const logger = new Logger('Calendar');
1917

@@ -44,6 +42,7 @@ export class CalendarService extends ServiceClassInternal implements ICalendarSe
4442
await this.setupNextNotification();
4543
if (busy !== false) {
4644
await this.setupNextStatusChange();
45+
await this.reconcileInProgressEvent(insertResult.insertedId);
4746
}
4847

4948
return insertResult.insertedId;
@@ -83,6 +82,7 @@ export class CalendarService extends ServiceClassInternal implements ICalendarSe
8382
await this.setupNextNotification();
8483
if (busy !== false) {
8584
await this.setupNextStatusChange();
85+
await this.reconcileInProgressEvent(insertResult.insertedId);
8686
}
8787

8888
return insertResult.insertedId;
@@ -93,6 +93,7 @@ export class CalendarService extends ServiceClassInternal implements ICalendarSe
9393
await this.setupNextNotification();
9494
if (busy !== false) {
9595
await this.setupNextStatusChange();
96+
await this.reconcileInProgressEvent(event._id);
9697
}
9798
}
9899

@@ -135,10 +136,10 @@ export class CalendarService extends ServiceClassInternal implements ICalendarSe
135136
await this.setupNextNotification();
136137

137138
if (startTime || endTime) {
138-
await removeCronJobs(eventId, event.uid);
139139
const isBusy = busy !== undefined ? busy : event.busy !== false;
140140
if (isBusy) {
141141
await this.setupNextStatusChange();
142+
await this.reconcileInProgressEvent(eventId);
142143
}
143144
}
144145
}
@@ -148,16 +149,20 @@ export class CalendarService extends ServiceClassInternal implements ICalendarSe
148149

149150
public async delete(eventId: ICalendarEvent['_id']): Promise<DeleteResult> {
150151
const event = await this.get(eventId);
151-
if (event) {
152-
await removeCronJobs(eventId, event.uid);
153-
}
152+
const now = new Date();
153+
const wasInProgress = Boolean(event && event.busy !== false && event.startTime <= now && (!event.endTime || event.endTime > now));
154154

155155
const result = await CalendarEvent.deleteOne({
156156
_id: eventId,
157157
});
158158

159159
if (result.deletedCount > 0) {
160160
await this.setupNextStatusChange();
161+
162+
// deleting an in-progress busy event: recompute the claim from the events still active
163+
if (event && wasInProgress) {
164+
await this.syncBusyPresence(event.uid, { excludeEventId: eventId, now });
165+
}
161166
}
162167

163168
return result;
@@ -171,10 +176,6 @@ export class CalendarService extends ServiceClassInternal implements ICalendarSe
171176
return this.doSetupNextStatusChange();
172177
}
173178

174-
public async cancelUpcomingStatusChanges(uid: IUser['_id'], endTime = new Date()): Promise<void> {
175-
return cancelUpcomingStatusChanges(uid, endTime);
176-
}
177-
178179
private async getMeetingUrl(eventData: Partial<ICalendarEvent>): Promise<string | undefined> {
179180
if (eventData.meetingUrl !== undefined) {
180181
return eventData.meetingUrl || undefined;
@@ -205,139 +206,104 @@ export class CalendarService extends ServiceClassInternal implements ICalendarSe
205206
}
206207

207208
private async doSetupNextStatusChange(): Promise<void> {
208-
// This method is called in the following moments:
209-
// 1. When a new busy event is created or imported
210-
// 2. When a busy event is updated (time/busy status changes)
211-
// 3. When a busy event is deleted
212-
// 4. When a status change job executes and completes
213-
// 5. When an event ends and the status is restored
214-
// 6. From Outlook Calendar integration (ee/server/configuration/outlookCalendar.ts)
209+
// Schedules only event starts; event end is handled by the presence engine's expiration cron.
210+
211+
const schedulerJobId = 'calendar-status-scheduler';
215212

216213
const busyStatusEnabled = settings.get<boolean>('Calendar_BusyStatus_Enabled');
217214
if (!busyStatusEnabled) {
218-
const schedulerJobId = 'calendar-status-scheduler';
219215
if (await cronJobs.has(schedulerJobId)) {
220216
await cronJobs.remove(schedulerJobId);
221217
}
222218
return;
223219
}
224220

225-
const schedulerJobId = 'calendar-status-scheduler';
226221
if (await cronJobs.has(schedulerJobId)) {
227222
await cronJobs.remove(schedulerJobId);
228223
}
229224

230-
const now = new Date();
231-
const nextStartEvent = await CalendarEvent.findNextFutureEvent(now);
232-
const inProgressEvents = await CalendarEvent.findInProgressEvents(now).toArray();
233-
const eventsWithEndTime = inProgressEvents.filter((event) => event.endTime && event.busy !== false);
234-
if (eventsWithEndTime.length === 0 && !nextStartEvent) {
235-
return;
236-
}
237-
238-
let nextEndTime: Date | null = null;
239-
if (eventsWithEndTime.length > 0 && eventsWithEndTime[0].endTime) {
240-
nextEndTime = eventsWithEndTime.reduce((earliest, event) => {
241-
if (!event.endTime) return earliest;
242-
return event.endTime.getTime() < earliest.getTime() ? event.endTime : earliest;
243-
}, eventsWithEndTime[0].endTime);
244-
}
245-
246-
let nextProcessTime: Date;
247-
if (nextStartEvent && nextEndTime) {
248-
nextProcessTime = nextStartEvent.startTime.getTime() < nextEndTime.getTime() ? nextStartEvent.startTime : nextEndTime;
249-
} else if (nextStartEvent) {
250-
nextProcessTime = nextStartEvent.startTime;
251-
} else if (nextEndTime) {
252-
nextProcessTime = nextEndTime;
253-
} else {
254-
// This should never happen due to the earlier check, but just in case
225+
const nextStartEvent = await CalendarEvent.findNextFutureEvent(new Date());
226+
if (!nextStartEvent) {
255227
return;
256228
}
257229

258-
await cronJobs.addAtTimestamp(schedulerJobId, nextProcessTime, async () => this.processStatusChangesAtTime());
230+
await cronJobs.addAtTimestamp(schedulerJobId, nextStartEvent.startTime, async () => this.processStatusChangesAtTime());
259231
}
260232

261233
private async processStatusChangesAtTime(): Promise<void> {
262234
const processTime = new Date();
263235

264-
const eventsStartingNow = await CalendarEvent.findEventsStartingNow({ now: processTime, offset: 5000 }).toArray();
265-
for await (const event of eventsStartingNow) {
266-
if (event.busy === false) {
267-
continue;
268-
}
269-
await this.processEventStart(event);
270-
}
271-
272-
const eventsEndingNow = await CalendarEvent.findEventsEndingNow({ now: processTime, offset: 5000 }).toArray();
273-
for await (const event of eventsEndingNow) {
274-
if (event.busy === false) {
275-
continue;
236+
try {
237+
const eventsStartingNow = await CalendarEvent.findEventsStartingNow({ now: processTime, offset: 5000 }).toArray();
238+
for await (const event of eventsStartingNow) {
239+
if (event.busy === false) {
240+
continue;
241+
}
242+
await this.processEventStart(event);
276243
}
277-
await this.processEventEnd(event);
244+
} catch (err) {
245+
logger.error({ msg: 'Failed to process calendar status changes', err });
278246
}
279247

280248
await this.doSetupNextStatusChange();
281249
}
282250

283251
private async processEventStart(event: ICalendarEvent): Promise<void> {
252+
// no endTime → no expiry to set, so the claim could never auto-clear
284253
if (!event.endTime) {
285254
return;
286255
}
287256

288-
const user = await Users.findOneById(event.uid, { projection: { statusDefault: 1 } });
289-
if (!user || user.statusDefault === UserStatus.OFFLINE) {
257+
await this.syncBusyPresence(event.uid, { excludeEventId: event._id, seedEndTime: event.endTime });
258+
}
259+
260+
// The start scheduler only fires at start times, so it misses an event imported already in progress.
261+
private async reconcileInProgressEvent(eventId: ICalendarEvent['_id']): Promise<void> {
262+
const event = await CalendarEvent.findOne({ _id: eventId });
263+
if (!event?.endTime || event.busy === false) {
290264
return;
291265
}
292266

293-
const overlappingEvents = await CalendarEvent.findOverlappingEvents(event._id, event.uid, event.startTime, event.endTime)
294-
.sort({ startTime: -1 })
295-
.toArray();
296-
const previousStatus = overlappingEvents.at(0)?.previousStatus ?? user.statusDefault;
297-
298-
if (previousStatus) {
299-
await CalendarEvent.updateEvent(event._id, { previousStatus });
267+
const now = new Date();
268+
if (event.startTime > now || event.endTime <= now) {
269+
return;
300270
}
301271

302-
await applyStatusChange({
303-
eventId: event._id,
304-
uid: event.uid,
305-
startTime: event.startTime,
306-
endTime: event.endTime,
307-
status: UserStatus.BUSY,
308-
});
272+
await this.syncBusyPresence(event.uid, { excludeEventId: event._id, seedEndTime: event.endTime, now });
309273
}
310274

311-
private async processEventEnd(event: ICalendarEvent): Promise<void> {
312-
if (!event.endTime) {
275+
// Derives "busy until the latest active meeting ends" from the events in progress now and applies
276+
// it as one calendar claim. `excludeEventId`/`seedEndTime` re-add the triggering event's own end.
277+
private async syncBusyPresence(
278+
uid: IUser['_id'],
279+
{ excludeEventId, seedEndTime, now = new Date() }: { excludeEventId?: ICalendarEvent['_id']; seedEndTime?: Date; now?: Date } = {},
280+
): Promise<void> {
281+
if (!settings.get<boolean>('Calendar_BusyStatus_Enabled')) {
313282
return;
314283
}
315284

316-
const user = await Users.findOneById(event.uid, { projection: { statusDefault: 1 } });
317-
if (!user) {
285+
const overlappingEvents = await CalendarEvent.findOverlappingEvents(excludeEventId ?? '', uid, now, now).toArray();
286+
const endTimes = [...(seedEndTime ? [seedEndTime] : []), ...overlappingEvents.map((event) => event.endTime)].filter(
287+
(date): date is Date => Boolean(date),
288+
);
289+
290+
// No busy event left → end the calendar claim (no-op if a higher-priority status took over).
291+
if (endTimes.length === 0) {
292+
await Presence.endActiveState(uid, this.name);
318293
return;
319294
}
320295

321-
// Only restore status if:
322-
// 1. The current statusDefault is BUSY (meaning it was set by our system, not manually changed by user)
323-
// 2. We have a previousStatus stored from before the event started
324-
325-
if (user.statusDefault === UserStatus.BUSY && event.previousStatus && event.previousStatus !== user.statusDefault) {
326-
await applyStatusChange({
327-
eventId: event._id,
328-
uid: event.uid,
329-
startTime: event.startTime,
330-
endTime: event.endTime,
331-
status: event.previousStatus,
332-
});
333-
} else {
334-
logger.debug({
335-
msg: 'Not restoring status for user',
336-
userId: event.uid,
337-
currentStatusDefault: user.statusDefault,
338-
previousStatus: event.previousStatus,
339-
});
340-
}
296+
const statusExpiresAt = endTimes.reduce((latest, date) => (date > latest ? date : latest));
297+
const user = await Users.findOneById<Pick<IUser, '_id' | 'language'>>(uid, { projection: { language: 1 } });
298+
const lng = user?.language || settings.get<string>('Language') || 'en';
299+
300+
await Presence.setActiveState(uid, {
301+
statusDefault: UserStatus.BUSY,
302+
statusText: i18n.t('Presence_status_in_a_meeting', { lng }),
303+
statusSource: 'external',
304+
statusExpiresAt,
305+
statusId: this.name,
306+
});
341307
}
342308

343309
private async sendCurrentNotifications(date: Date): Promise<void> {

0 commit comments

Comments
 (0)