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

This commit is contained in:
upstream-mirror
2026-09-16 01:21:02 +05:00
commit 30810eecd7
10161 changed files with 987110 additions and 0 deletions

1
ee/packages/presence/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
.nyc_output

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,6 @@
import server from '@rocket.chat/jest-presets/server';
import type { Config } from 'jest';
export default {
preset: server.preset,
} satisfies Config;

View File

@@ -0,0 +1,37 @@
{
"name": "@rocket.chat/presence",
"version": "0.3.2",
"private": true,
"main": "./dist/Presence.js",
"typings": "./dist/Presence.d.ts",
"files": [
"/dist"
],
"scripts": {
"build": "tsc",
"lint": "eslint .",
"lint:fix": "eslint --fix .",
"test": "jest",
"testunit": "jest",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@rocket.chat/core-services": "workspace:^",
"@rocket.chat/core-typings": "workspace:^",
"@rocket.chat/cron": "workspace:^",
"@rocket.chat/logger": "workspace:^",
"@rocket.chat/models": "workspace:^",
"mongodb": "6.16.0"
},
"devDependencies": {
"@rocket.chat/apps-engine": "workspace:^",
"@rocket.chat/rest-typings": "workspace:^",
"@types/node": "~24.13.2",
"eslint": "~9.39.5",
"jest": "~30.2.0",
"typescript": "~5.9.3"
},
"volta": {
"extends": "../../../package.json"
}
}

View File

@@ -0,0 +1,276 @@
import type { IUser } from '@rocket.chat/core-typings';
import { UserStatus } from '@rocket.chat/core-typings';
import { registerModel } from '@rocket.chat/models';
import { Presence } from './Presence';
const findUserMock = jest.fn();
const updatePresenceMock = jest.fn();
const findSessionMock = jest.fn();
registerModel('IUsersModel', {
findOneById: findUserMock,
updatePresenceAndStatus: updatePresenceMock,
findExpiredStatuses: jest.fn(),
findNextStatusExpiration: jest.fn().mockResolvedValue(null),
} as any);
registerModel('IUsersSessionsModel', {
findOneById: findSessionMock,
addConnectionById: jest.fn(),
removeConnectionByConnectionId: jest.fn(),
updateConnectionStatusById: jest.fn(),
} as any);
const user = (o: Partial<IUser> = {}): IUser =>
({
_id: 'u1',
username: 'test',
roles: ['user'],
status: UserStatus.ONLINE,
statusDefault: UserStatus.ONLINE,
statusConnection: UserStatus.ONLINE,
statusText: '',
...o,
}) as IUser;
const withOnlineSession = () =>
findSessionMock.mockResolvedValue({ connections: [{ id: 's1', instanceId: 'i1', status: UserStatus.ONLINE }] });
const withNoSessions = () => findSessionMock.mockResolvedValue(null);
describe('Presence class', () => {
let presence: Presence;
beforeEach(() => {
jest.clearAllMocks();
presence = new Presence();
(presence as any).broadcastEnabled = true;
(presence as any).api = { broadcast: jest.fn(), nodeList: jest.fn().mockResolvedValue([]) };
updatePresenceMock.mockResolvedValue(user());
});
describe('setActiveState', () => {
it('should apply claim and write combined result when user is online', async () => {
findUserMock.mockResolvedValue(user());
withOnlineSession();
await presence.setActiveState('u1', {
statusDefault: UserStatus.BUSY,
statusSource: 'manual',
statusText: 'Focus',
});
expect(updatePresenceMock).toHaveBeenCalledWith(
'u1',
expect.objectContaining({ statusDefault: UserStatus.BUSY, statusSource: 'manual', status: UserStatus.BUSY }),
expect.any(Array),
undefined,
);
});
it('should not write when claim is rejected (offline + external)', async () => {
findUserMock.mockResolvedValue(user({ statusDefault: UserStatus.OFFLINE }));
withNoSessions();
await presence.setActiveState('u1', {
statusDefault: UserStatus.BUSY,
statusSource: 'external',
});
expect(updatePresenceMock).not.toHaveBeenCalled();
});
it('should store claim but show offline status when user has no sessions', async () => {
findUserMock.mockResolvedValue(user({ statusDefault: UserStatus.ONLINE }));
withNoSessions();
await presence.setActiveState('u1', {
statusDefault: UserStatus.BUSY,
statusSource: 'manual',
statusText: 'Working',
});
expect(updatePresenceMock).toHaveBeenCalledWith(
'u1',
expect.objectContaining({ status: UserStatus.OFFLINE, statusConnection: UserStatus.OFFLINE, statusDefault: UserStatus.BUSY }),
expect.any(Array),
undefined,
);
});
it('should pass expiresAt when provided', async () => {
const expiresAt = new Date(Date.now() + 3600_000);
findUserMock.mockResolvedValue(user());
withOnlineSession();
await presence.setActiveState('u1', {
statusDefault: UserStatus.BUSY,
statusSource: 'manual',
statusText: 'Focus',
statusExpiresAt: expiresAt,
});
expect(updatePresenceMock).toHaveBeenCalledWith(
'u1',
expect.objectContaining({ statusExpiresAt: expiresAt }),
expect.arrayContaining(['previousState']),
undefined,
);
});
it('should reject a past statusExpiresAt', async () => {
await expect(
presence.setActiveState('u1', {
statusDefault: UserStatus.BUSY,
statusSource: 'manual',
statusText: 'Focus',
statusExpiresAt: new Date(Date.now() - 3600_000),
}),
).rejects.toThrow('statusExpiresAt must be a future date');
expect(updatePresenceMock).not.toHaveBeenCalled();
});
it('should reject an invalid statusExpiresAt', async () => {
await expect(
presence.setActiveState('u1', {
statusDefault: UserStatus.BUSY,
statusSource: 'manual',
statusText: 'Focus',
statusExpiresAt: new Date('not a date'),
}),
).rejects.toThrow('statusExpiresAt must be a future date');
expect(updatePresenceMock).not.toHaveBeenCalled();
});
});
describe('endActiveState', () => {
it('should restore previous state and write', async () => {
findUserMock.mockResolvedValue(
user({
statusSource: 'manual',
statusDefault: UserStatus.BUSY,
previousState: { statusDefault: UserStatus.BUSY, statusText: 'Meeting', statusSource: 'external' },
}),
);
withOnlineSession();
await presence.endActiveState('u1');
expect(updatePresenceMock).toHaveBeenCalledWith(
'u1',
expect.objectContaining({ statusSource: 'external', statusText: 'Meeting' }),
expect.arrayContaining(['previousState']),
undefined,
);
});
});
describe('clearActiveState', () => {
it('should reset to online and write', async () => {
findUserMock.mockResolvedValue(user({ statusDefault: UserStatus.BUSY, statusSource: 'manual' }));
withOnlineSession();
await presence.clearActiveState('u1');
expect(updatePresenceMock).toHaveBeenCalledWith(
'u1',
expect.objectContaining({ statusDefault: UserStatus.ONLINE, statusText: '', status: UserStatus.ONLINE }),
expect.arrayContaining(['statusSource', 'previousState']),
undefined,
);
});
});
describe('setStatus', () => {
it('should apply a manual claim when status changes', async () => {
findUserMock.mockResolvedValue(user());
withOnlineSession();
await presence.setStatus('u1', UserStatus.BUSY, 'Working');
expect(updatePresenceMock).toHaveBeenCalledWith(
'u1',
expect.objectContaining({ statusDefault: UserStatus.BUSY, statusSource: 'manual' }),
expect.any(Array),
undefined,
);
});
it('should trigger clearActive when status is ONLINE with no text', async () => {
findUserMock.mockResolvedValue(user({ statusDefault: UserStatus.BUSY, statusSource: 'manual' }));
withOnlineSession();
await presence.setStatus('u1', UserStatus.ONLINE);
expect(updatePresenceMock).toHaveBeenCalledWith(
'u1',
expect.objectContaining({ statusDefault: UserStatus.ONLINE }),
expect.arrayContaining(['statusSource', 'previousState']),
undefined,
);
});
it('should trigger clearActive when status is ONLINE with empty string text', async () => {
findUserMock.mockResolvedValue(user({ statusDefault: UserStatus.BUSY, statusSource: 'manual' }));
withOnlineSession();
await presence.setStatus('u1', UserStatus.ONLINE, '');
expect(updatePresenceMock).toHaveBeenCalledWith(
'u1',
expect.objectContaining({ statusDefault: UserStatus.ONLINE }),
expect.arrayContaining(['statusSource', 'previousState']),
undefined,
);
});
it('should trigger setActive when status is ONLINE with text', async () => {
findUserMock.mockResolvedValue(user());
withOnlineSession();
await presence.setStatus('u1', UserStatus.ONLINE, 'brb');
expect(updatePresenceMock).toHaveBeenCalledWith(
'u1',
expect.objectContaining({ statusDefault: UserStatus.ONLINE, statusSource: 'manual', statusText: 'brb' }),
expect.any(Array),
undefined,
);
});
it('should write empty string statusText when explicitly provided', async () => {
findUserMock.mockResolvedValue(user({ statusText: 'Old text' }));
withOnlineSession();
await presence.setStatus('u1', UserStatus.BUSY, '');
expect(updatePresenceMock).toHaveBeenCalledWith(
'u1',
expect.objectContaining({ statusDefault: UserStatus.BUSY, statusText: '' }),
expect.any(Array),
undefined,
);
});
it('should not include statusText when undefined', async () => {
findUserMock.mockResolvedValue(user({ statusText: 'Old text' }));
withOnlineSession();
await presence.setStatus('u1', UserStatus.BUSY);
const updateArg = updatePresenceMock.mock.calls[0][1];
expect(updateArg).not.toHaveProperty('statusText');
});
it('should not write when user is not found', async () => {
findUserMock.mockResolvedValue(null);
await presence.setStatus('u1', UserStatus.BUSY);
expect(updatePresenceMock).not.toHaveBeenCalled();
});
});
});

View File

@@ -0,0 +1,456 @@
import { setTimeout, clearTimeout } from 'node:timers';
import type { IPresence, IBrokerNode } from '@rocket.chat/core-services';
import { License, ServiceClass, Settings } from '@rocket.chat/core-services';
import type { IUser } from '@rocket.chat/core-typings';
import { UserStatus } from '@rocket.chat/core-typings';
import { cronJobs } from '@rocket.chat/cron';
import { Logger } from '@rocket.chat/logger';
import { Users, UsersSessions } from '@rocket.chat/models';
import { PresenceReaper } from './lib/PresenceReaper';
import { normalizeStatusText } from './lib/normalizeStatusText';
import { type ClaimUpdate, processPresence } from './lib/presenceEngine';
const logger = new Logger('Presence');
const MAX_CONNECTIONS = 200;
const STATUS_EXPIRATION_JOB = 'presence-status-expiration';
type PresenceUser = Pick<
IUser,
| '_id'
| 'username'
| 'type'
| 'roles'
| 'status'
| 'statusDefault'
| 'statusSource'
| 'statusText'
| 'statusExpiresAt'
| 'statusConnection'
| 'statusId'
| 'previousState'
>;
export class Presence extends ServiceClass implements IPresence {
protected name = 'presence';
private broadcastEnabled = true;
private hasPresenceLicense = false;
private hasScalabilityLicense = false;
private hasLicense = false;
private lostConTimeout?: NodeJS.Timeout;
private connsPerInstance = new Map<string, number>();
private peakConnections = 0;
private reaper: PresenceReaper;
private expirationScheduleToken?: symbol;
constructor() {
super();
this.reaper = new PresenceReaper({
batchSize: 500,
staleThresholdMs: 5 * 60 * 1000, // 5 minutes
onUpdate: (userIds) => this.handleReaperUpdates(userIds),
});
this.onEvent('watch.instanceStatus', async ({ clientAction, id, diff }): Promise<void> => {
if (clientAction === 'removed') {
this.connsPerInstance.delete(id);
const affectedUsers = await this.removeLostConnections(id);
affectedUsers.forEach((uid) => this.updateUserPresence(uid));
return;
}
// always store the number of connections per instance so we can show correct in the UI
if (diff && Object.hasOwn(diff, 'extraInformation.conns')) {
this.connsPerInstance.set(id, diff['extraInformation.conns']);
this.peakConnections = Math.max(this.peakConnections, this.getTotalConnections());
void this.validateAvailability();
}
});
this.onEvent('license.module', async ({ module, valid }) => {
switch (module) {
case 'unlimited-presence':
this.hasPresenceLicense = valid;
break;
case 'scalability':
this.hasScalabilityLicense = valid;
break;
default:
return;
}
// The scalability module is also accepted as a way to enable the presence service for backwards compatibility
this.hasLicense = this.hasPresenceLicense || this.hasScalabilityLicense;
// broadcast should always be enabled if license is active (unless the troubleshoot setting is on)
if (!this.broadcastEnabled && this.hasLicense) {
await this.toggleBroadcast(true);
}
});
}
async onNodeDisconnected({ node }: { node: IBrokerNode }): Promise<void> {
const affectedUsers = await this.removeLostConnections(node.id);
return affectedUsers.forEach((uid) => this.updateUserPresence(uid));
}
override async started(): Promise<void> {
this.reaper.start();
this.lostConTimeout = setTimeout(async () => {
const affectedUsers = await this.removeLostConnections();
return affectedUsers.forEach((uid) => this.updateUserPresence(uid));
}, 10000);
try {
await Settings.set('Presence_broadcast_disabled', false);
this.hasScalabilityLicense = await License.hasModule('scalability');
this.hasPresenceLicense = await License.hasModule('unlimited-presence');
this.hasLicense = this.hasPresenceLicense || this.hasScalabilityLicense;
} catch (e: unknown) {
// ignore
}
await this.handleExpirationJob();
}
private async processExpiredStatuses(): Promise<void> {
const expiredCursor = Users.findExpiredStatuses();
for await (const user of expiredCursor) {
await this.updateUserPresence(user, { type: 'endActive' });
}
}
private async setupNextExpiration(): Promise<void> {
const token = Symbol();
this.expirationScheduleToken = token;
const next = await Users.findNextStatusExpiration();
// A newer reschedule superseded this token during the lookup; let it win.
if (this.expirationScheduleToken !== token) {
return;
}
await cronJobs.remove(STATUS_EXPIRATION_JOB);
if (!next?.statusExpiresAt) {
return;
}
await cronJobs.addAtTimestamp(STATUS_EXPIRATION_JOB, next.statusExpiresAt, () => this.handleExpirationJob());
}
private async handleExpirationJob(): Promise<void> {
await this.processExpiredStatuses();
await this.setupNextExpiration();
}
private async handleReaperUpdates(userIds: string[]): Promise<void> {
const results = await Promise.allSettled(userIds.map((uid) => this.updateUserPresence(uid)));
const fulfilled = results.filter((result) => result.status === 'fulfilled');
const rejected = results.filter((result) => result.status === 'rejected');
if (fulfilled.length > 0) {
logger.debug({ msg: 'Successfully updated presence for users', count: fulfilled.length });
}
if (rejected.length > 0) {
logger.error({
msg: 'Failed to update presence for users',
count: rejected.length,
reasons: rejected.map(({ reason }): unknown => reason),
});
}
}
override async stopped(): Promise<void> {
this.reaper.stop();
this.expirationScheduleToken = undefined;
await cronJobs.remove(STATUS_EXPIRATION_JOB);
clearTimeout(this.lostConTimeout);
}
async toggleBroadcast(enabled: boolean): Promise<void> {
if (!this.hasLicense && this.getTotalConnections() > MAX_CONNECTIONS) {
throw new Error('Cannot enable broadcast when there are more than 200 connections');
}
this.broadcastEnabled = enabled;
// update the setting only to turn it on, because it may have been disabled via the troubleshooting setting, which doesn't affect the setting
if (enabled) {
await Settings.set('Presence_broadcast_disabled', false);
}
}
getConnectionCount(): { current: number; max: number } {
return {
current: this.getTotalConnections(),
max: MAX_CONNECTIONS,
};
}
async newConnection(
uid: string | undefined,
session: string | undefined,
nodeId: string,
): Promise<{ uid: string; connectionId: string } | undefined> {
if (!uid || !session) {
return;
}
await UsersSessions.addConnectionById(uid, {
id: session,
instanceId: nodeId,
status: UserStatus.ONLINE,
});
await this.updateUserPresence(uid);
return {
uid,
connectionId: session,
};
}
async updateConnection(uid: string, connectionId: string): Promise<{ uid: string; connectionId: string } | undefined> {
const query = {
'_id': uid,
'connections.id': connectionId,
};
const update = {
$set: {
'connections.$._updatedAt': new Date(),
},
};
const result = await UsersSessions.updateOne(query, update);
if (result.modifiedCount === 0) {
return;
}
await this.updateUserPresence(uid);
return { uid, connectionId };
}
async removeConnection(uid: string | undefined, session: string | undefined): Promise<{ uid: string; session: string } | undefined> {
if (!uid || !session) {
return;
}
await UsersSessions.removeConnectionByConnectionId(session);
await this.updateUserPresence(uid);
return {
uid,
session,
};
}
async removeLostConnections(nodeID?: string): Promise<string[]> {
if (nodeID) {
const affectedUsers = await UsersSessions.findByInstanceId(nodeID).toArray();
const { modifiedCount } = await UsersSessions.removeConnectionsFromInstanceId(nodeID);
if (modifiedCount === 0) {
return [];
}
return affectedUsers.map(({ _id }) => _id);
}
const nodes = (await this.api?.nodeList()) || [];
const ids = nodes.filter((node) => node.available).map(({ id }) => id);
if (ids.length === 0) {
return [];
}
const affectedUsers = await UsersSessions.findByOtherInstanceIds(ids, { projection: { _id: 1 } }).toArray();
const { modifiedCount } = await UsersSessions.removeConnectionsFromOtherInstanceIds(ids);
if (modifiedCount === 0) {
return [];
}
return affectedUsers.map(({ _id }) => _id);
}
/**
* Updates presence and reschedules the expiration job.
* All public methods should use this instead of calling updateUserPresence directly.
*/
private async updatePresenceAndReschedule(uid: string, claimUpdate: ClaimUpdate): Promise<boolean> {
const result = await this.updateUserPresence(uid, claimUpdate);
await this.setupNextExpiration();
return result;
}
async setStatus(userId: string, statusDefault: UserStatus, statusText?: string, statusExpiresAt?: Date): Promise<boolean> {
// Selecting 'online' without a status message clears any manual claim
// and reverts to connection-driven presence.
if (statusDefault === UserStatus.ONLINE && !statusText) {
return this.clearActiveState(userId);
}
return this.setActiveState(userId, {
statusDefault,
statusSource: 'manual',
...(statusText != null && { statusText: normalizeStatusText(statusText) }),
...(statusExpiresAt && { statusExpiresAt }),
});
}
/**
* Applies a presence claim from a source (manual, external, internal).
*/
async setActiveState(
userId: string,
newState: Pick<IUser, 'statusDefault' | 'statusSource' | 'statusText' | 'statusExpiresAt' | 'statusId'>,
): Promise<boolean> {
if (newState.statusExpiresAt) {
const expiresAt = new Date(newState.statusExpiresAt).getTime();
if (Number.isNaN(expiresAt) || expiresAt <= Date.now()) {
throw new Error('statusExpiresAt must be a future date');
}
}
return this.updatePresenceAndReschedule(userId, {
type: 'setActive',
newState: {
...newState,
...(newState.statusText != null && { statusText: normalizeStatusText(newState.statusText) }),
},
});
}
/**
* Ends a presence claim. With `statusId`, only that claim is affected (so concurrent voice/video
* claims end in either order); without it, the displaced claim is restored.
*/
async endActiveState(userId: string, statusId?: string): Promise<boolean> {
return this.updatePresenceAndReschedule(userId, { type: 'endActive', ...(statusId && { statusId }) });
}
/**
* Removes all presence claims and resets to "Online" with no text.
*/
async clearActiveState(userId: string): Promise<boolean> {
return this.updatePresenceAndReschedule(userId, { type: 'clearActive' });
}
async setConnectionStatus(uid: string, status: UserStatus, session: string): Promise<boolean> {
const result = await UsersSessions.updateConnectionStatusById(uid, session, status);
await this.updateUserPresence(uid);
return !!result.modifiedCount;
}
/**
* Low-level presence update. Does not reschedule the expiration job.
* Prefer {@link updatePresenceAndReschedule} for public-facing methods.
*/
private async updateUserPresence(uidOrUser: string | PresenceUser, claimUpdate?: ClaimUpdate): Promise<boolean> {
const user =
typeof uidOrUser === 'string'
? await Users.findOneById<PresenceUser>(uidOrUser, {
projection: {
username: 1,
type: 1,
roles: 1,
status: 1,
statusDefault: 1,
statusSource: 1,
statusText: 1,
statusExpiresAt: 1,
statusConnection: 1,
statusId: 1,
previousState: 1,
},
})
: uidOrUser;
if (!user) {
return false;
}
const userSessions = await UsersSessions.findOneById(user._id);
const sessions = userSessions?.connections ?? [];
const result = processPresence(user, sessions, claimUpdate);
if (Object.keys(result.values).length === 0) {
return false;
}
// Only apply this update if statusExpiresAt and previousState haven't changed since we read them.
// Prevents two presence instances from processing the same expiration and overwriting each other.
const guard =
claimUpdate?.type === 'endActive' && user.statusExpiresAt
? {
statusExpiresAt: user.statusExpiresAt,
...(user.previousState ? { previousState: user.previousState } : { previousState: { $exists: false } }),
}
: undefined;
const updatedUser = await Users.updatePresenceAndStatus(user._id, result.values, result.clear, guard);
if (updatedUser) {
this.broadcast(updatedUser, user.status);
}
return !!updatedUser;
}
private broadcast(
user: Pick<IUser, '_id' | 'username' | 'status' | 'statusText' | 'statusSource' | 'statusExpiresAt' | 'roles'>,
previousStatus: UserStatus | undefined,
): void {
if (!this.broadcastEnabled) {
return;
}
void this.api?.broadcast('presence.status', {
user,
previousStatus,
});
}
private async validateAvailability(): Promise<void> {
if (this.hasLicense) {
return;
}
if (this.getTotalConnections() > MAX_CONNECTIONS) {
this.broadcastEnabled = false;
await Settings.set('Presence_broadcast_disabled', true);
}
}
private getTotalConnections(): number {
return Array.from(this.connsPerInstance.values()).reduce((acc, conns) => acc + conns, 0);
}
getPeakConnections(reset = false): number {
const peak = this.peakConnections;
if (reset) {
this.resetPeakConnections();
}
return peak;
}
resetPeakConnections(): void {
this.peakConnections = 0;
}
}

View File

@@ -0,0 +1,154 @@
import { UserStatus, type IUserSession, type IUserSessionConnection } from '@rocket.chat/core-typings';
import { registerModel } from '@rocket.chat/models';
import type { FindCursor, WithId } from 'mongodb';
import { PresenceReaper } from './PresenceReaper';
let sessions = 0;
const createSession = (overrides: Partial<IUserSession> = {}): IUserSession => ({
_id: `user-${sessions++}`,
connections: [],
...overrides,
});
let connections = 0;
const createConnection = (overrides: Partial<IUserSessionConnection> = {}): IUserSessionConnection => ({
id: `conn-${connections++}`,
instanceId: `instanceId`,
status: UserStatus.ONLINE,
_createdAt: new Date(),
_updatedAt: new Date(),
...overrides,
});
const createDates = () => {
const now = new Date();
const stale = new Date(now.getTime() - 10 * 60 * 1000); // 10 mins ago
const active = new Date(now.getTime() - 1 * 60 * 1000); // 1 min ago
const cutoff = new Date(now.getTime() - 5 * 60 * 1000); // 5 mins ago
return { now, stale, active, cutoff };
};
const createCursor = (documents: WithId<IUserSession>[]): FindCursor<WithId<IUserSession>> => {
let index = 0;
return {
async *[Symbol.asyncIterator]() {
while (index < documents.length) {
yield documents[index++];
}
},
} as FindCursor<WithId<IUserSession>>;
};
describe('PresenceReaper', () => {
let reaper: PresenceReaper;
const bulkWriteMock = jest.fn();
const findMock = jest.fn();
const onUpdateMock = jest.fn();
registerModel('IUsersSessionsModel', {
find: findMock,
col: {
bulkWrite: bulkWriteMock,
},
} as any);
beforeEach(() => {
bulkWriteMock.mockClear();
findMock.mockClear();
onUpdateMock.mockClear();
reaper = new PresenceReaper({
onUpdate: onUpdateMock,
staleThresholdMs: 5 * 60 * 1000, // 5 minutes
batchSize: 2, // small batch size for testing
});
});
it('should not call onUpdate when there no connections', async () => {
findMock.mockReturnValue(createCursor([]));
await reaper.run();
expect(onUpdateMock).not.toHaveBeenCalled();
});
it('should process users with stale connections correctly', async () => {
const { stale } = createDates();
findMock.mockReturnValue(
createCursor([
createSession({
_id: 'user-789',
connections: [createConnection({ _updatedAt: stale })],
}),
]),
);
await reaper.run();
expect(onUpdateMock).toHaveBeenCalledTimes(1);
expect(onUpdateMock).toHaveBeenCalledWith(['user-789']);
});
it('should process multiple users and batch updates correctly', async () => {
const { stale } = createDates();
findMock.mockReturnValue(
createCursor([
createSession({
_id: 'user-1',
connections: [createConnection({ _updatedAt: stale })],
}),
createSession({
_id: 'user-2',
connections: [createConnection({ _updatedAt: stale })],
}),
createSession({
_id: 'user-3',
connections: [createConnection({ _updatedAt: stale })],
}),
]),
);
// Execute Run
await reaper.run();
// Verify 'users' Update called twice due to batch size of 2
expect(onUpdateMock).toHaveBeenCalledTimes(2);
expect(onUpdateMock).toHaveBeenNthCalledWith(1, ['user-1', 'user-2']);
expect(onUpdateMock).toHaveBeenNthCalledWith(2, ['user-3']);
});
it('should process users with mixed connection states correctly', async () => {
const { stale, active } = createDates();
findMock.mockReturnValue(
createCursor([
createSession({
_id: 'no-connections',
connections: [],
}),
createSession({
_id: 'all-active',
connections: [createConnection({ _updatedAt: active })],
}),
createSession({
_id: 'all-stale',
connections: [createConnection({ _updatedAt: stale })],
}),
createSession({
_id: 'mixed',
connections: [createConnection({ _updatedAt: stale }), createConnection({ _updatedAt: active })],
}),
]),
);
// Execute Run
await reaper.run();
// Verify 'users' Update called for both users
expect(onUpdateMock).toHaveBeenCalledTimes(1);
expect(onUpdateMock).toHaveBeenNthCalledWith(1, ['all-stale', 'mixed']);
});
});

View File

@@ -0,0 +1,135 @@
import { setInterval } from 'node:timers';
import type { IUserSession } from '@rocket.chat/core-typings';
import { Logger } from '@rocket.chat/logger';
import { UsersSessions } from '@rocket.chat/models';
import type { AnyBulkWriteOperation } from 'mongodb';
const logger = new Logger('PresenceReaper');
type ReaperPlan = {
userId: string;
removeIds: NonEmptyArray<string>;
cutoffDate: Date;
};
type NonEmptyArray<T> = Omit<[T, ...T[]], 'map'> & {
map<U>(callbackfn: (value: T, index: number, array: T[]) => U): NonEmptyArray<U>;
};
const isNonEmptyArray = <T>(arr: T[]): arr is NonEmptyArray<T> => arr.length > 0;
type ReaperCallback = (userIds: NonEmptyArray<string>) => Promise<void>;
type ReaperOptions = {
onUpdate: ReaperCallback;
staleThresholdMs: number;
batchSize: number;
};
export class PresenceReaper {
private staleThresholdMs: number;
private batchSize: number;
private running: boolean;
private onUpdate: ReaperCallback;
private intervalId?: NodeJS.Timeout;
constructor(options: ReaperOptions) {
this.onUpdate = options.onUpdate;
this.staleThresholdMs = options.staleThresholdMs;
this.batchSize = options.batchSize;
this.running = false;
}
public start() {
if (this.running) return;
this.running = true;
// Run every 1 minute
this.intervalId = setInterval(() => {
this.run().catch((err) => logger.error({ msg: 'Error running presence reaper', err }));
}, 60 * 1000);
}
public stop() {
if (!this.running) return;
this.running = false;
if (this.intervalId) {
clearInterval(this.intervalId);
this.intervalId = undefined;
}
}
public async run(): Promise<void> {
const cutoffDate = new Date(Date.now() - this.staleThresholdMs);
// 1. Find users with potentially stale connections
const cursor = UsersSessions.find(
{ 'connections._updatedAt': { $lte: cutoffDate } },
{
projection: { _id: 1, connections: 1 },
},
);
const userChangeSet = new Map<string, ReaperPlan>();
for await (const sessionDoc of cursor) {
this.processDocument(sessionDoc, cutoffDate, userChangeSet);
if (userChangeSet.size >= this.batchSize) {
await this.flushBatch(userChangeSet);
userChangeSet.clear();
}
}
if (userChangeSet.size > 0) {
await this.flushBatch(userChangeSet);
}
}
private processDocument(sessionDoc: IUserSession, cutoffDate: Date, changeMap: Map<string, ReaperPlan>): void {
const userId = sessionDoc._id;
const allConnections = sessionDoc.connections || [];
// Filter connections based on the cutoff
const staleConnections = allConnections.filter((c) => c._updatedAt <= cutoffDate);
if (isNonEmptyArray(staleConnections)) {
changeMap.set(userId, {
userId,
removeIds: staleConnections.map((c) => c.id),
cutoffDate, // Keep reference for race-condition check
});
}
}
private async flushBatch(changeMap: Map<string, ReaperPlan>): Promise<void> {
const operations = [];
for (const plan of changeMap.values()) {
operations.push({
updateOne: {
filter: { _id: plan.userId },
update: {
$pull: {
connections: {
id: { $in: plan.removeIds },
_updatedAt: { $lte: plan.cutoffDate },
},
},
},
},
} satisfies AnyBulkWriteOperation<IUserSession>);
}
if (isNonEmptyArray(operations)) {
await UsersSessions.col.bulkWrite(operations);
await this.onUpdate(operations.map((op) => op.updateOne.filter._id));
}
}
}

View File

@@ -0,0 +1,5 @@
export const STATUS_TEXT_MAX_LENGTH = 120;
export function normalizeStatusText(text: string): string {
return text.trim().substring(0, STATUS_TEXT_MAX_LENGTH);
}

View File

@@ -0,0 +1,537 @@
import type { IUser, IUserSessionConnection } from '@rocket.chat/core-typings';
import { UserStatus } from '@rocket.chat/core-typings';
import { processPresence } from './presenceEngine';
const ONE_HOUR = 3600_000;
type PresenceUser = Pick<
IUser,
'type' | 'roles' | 'statusDefault' | 'statusSource' | 'statusText' | 'statusExpiresAt' | 'statusId' | 'previousState'
>;
const user = (data: Partial<PresenceUser> = {}): PresenceUser => ({
statusDefault: UserStatus.ONLINE,
statusText: '',
...data,
});
const bot = (data: Partial<PresenceUser> = {}): PresenceUser => user({ type: 'bot', ...data });
const session = (status: UserStatus = UserStatus.ONLINE): IUserSessionConnection => ({
id: 'random',
instanceId: 'random',
status,
_createdAt: new Date(),
_updatedAt: new Date(),
});
describe('processPresence', () => {
describe('baseline (no claim)', () => {
test('should resolve to ONLINE when user has an active session', () => {
const result = processPresence(user(), [session(UserStatus.ONLINE)]);
expect(result.values).toMatchObject({ status: UserStatus.ONLINE, statusConnection: UserStatus.ONLINE });
});
test('should display BUSY when statusDefault is BUSY even if connection is ONLINE', () => {
const result = processPresence(user({ statusDefault: UserStatus.BUSY }), [session(UserStatus.ONLINE)]);
expect(result.values).toMatchObject({ status: UserStatus.BUSY, statusConnection: UserStatus.ONLINE });
});
test('should resolve to AWAY when user is idle', () => {
const result = processPresence(user(), [session(UserStatus.AWAY)]);
expect(result.values).toMatchObject({ status: UserStatus.AWAY, statusConnection: UserStatus.AWAY });
});
test('should resolve to OFFLINE when user has no sessions and no active claim', () => {
const result = processPresence(user({ statusDefault: UserStatus.BUSY, statusText: 'Focusing' }), []);
expect(result.values).toMatchObject({ status: UserStatus.OFFLINE, statusConnection: UserStatus.OFFLINE });
});
test('should not clear a custom status when going OFFLINE (vacation text survives disconnect)', () => {
const result = processPresence(
user({
statusDefault: UserStatus.AWAY,
statusSource: 'manual',
statusText: 'On vacation',
statusExpiresAt: new Date(Date.now() + ONE_HOUR),
}),
[],
);
expect(result.values).toEqual({ status: UserStatus.OFFLINE, statusConnection: UserStatus.OFFLINE });
expect(result.values).not.toHaveProperty('statusText');
expect(result.clear).toBeUndefined();
});
test('should stay OFFLINE when user is invisible (OFFLINE statusDefault with sessions)', () => {
const result = processPresence(user({ statusDefault: UserStatus.OFFLINE }), [session(UserStatus.ONLINE)]);
expect(result.values).toMatchObject({ status: UserStatus.OFFLINE, statusConnection: UserStatus.ONLINE });
});
});
describe('session reduction', () => {
test('should resolve to ONLINE when at least one session is active', () => {
const result = processPresence(user(), [session(UserStatus.ONLINE), session(UserStatus.AWAY)]);
expect(result.values).toMatchObject({ status: UserStatus.ONLINE, statusConnection: UserStatus.ONLINE });
});
test('should resolve to AWAY when all sessions are idle', () => {
const result = processPresence(user(), [session(UserStatus.AWAY), session(UserStatus.AWAY)]);
expect(result.values).toMatchObject({ status: UserStatus.AWAY, statusConnection: UserStatus.AWAY });
});
});
describe('auto-away over an existing claim (claimless recompute)', () => {
test('a manual online claim still goes AWAY on idle, without touching the claim or statusText', () => {
const result = processPresence(user({ statusSource: 'manual', statusDefault: UserStatus.ONLINE, statusText: 'Working' }), [
session(UserStatus.AWAY),
]);
expect(result.values).toMatchObject({ status: UserStatus.AWAY, statusConnection: UserStatus.AWAY });
expect(result.values).not.toHaveProperty('statusText');
expect(result.values).not.toHaveProperty('statusSource');
expect(result.values).not.toHaveProperty('statusDefault');
expect(result.clear).toBeUndefined();
});
test('a manual busy claim stays BUSY on idle (auto-away suppressed), claim preserved', () => {
const result = processPresence(user({ statusSource: 'manual', statusDefault: UserStatus.BUSY, statusText: 'Focusing' }), [
session(UserStatus.AWAY),
]);
expect(result.values).toMatchObject({ status: UserStatus.BUSY, statusConnection: UserStatus.AWAY });
expect(result.values).not.toHaveProperty('statusText');
expect(result.values).not.toHaveProperty('statusSource');
expect(result.clear).toBeUndefined();
});
test('when the user becomes active again the manual online claim resolves back to ONLINE', () => {
const result = processPresence(user({ statusSource: 'manual', statusDefault: UserStatus.ONLINE, statusText: 'Working' }), [
session(UserStatus.ONLINE),
]);
expect(result.values).toMatchObject({ status: UserStatus.ONLINE, statusConnection: UserStatus.ONLINE });
expect(result.values).not.toHaveProperty('statusText');
});
});
describe('setActive', () => {
test('should apply manual claim when user is online', () => {
const result = processPresence(user(), [session()], {
type: 'setActive',
newState: { statusDefault: UserStatus.BUSY, statusSource: 'manual' },
});
expect(result.values.status).toBe(UserStatus.BUSY);
expect(result.values.statusSource).toBe('manual');
});
test('should pass expiresAt when provided', () => {
const exp = new Date(Date.now() + ONE_HOUR);
const result = processPresence(user(), [session()], {
type: 'setActive',
newState: {
statusDefault: UserStatus.BUSY,
statusText: 'Out of office',
statusSource: 'manual',
statusExpiresAt: exp,
},
});
expect(result.values.statusExpiresAt).toEqual(exp);
});
test('should save current as previousState when higher priority claim arrives', () => {
const result = processPresence(
user({ statusSource: 'manual', statusDefault: UserStatus.BUSY, statusText: 'Focusing' }),
[session()],
{ type: 'setActive', newState: { statusDefault: UserStatus.BUSY, statusText: 'On a call', statusSource: 'internal' } },
);
expect(result.values.statusSource).toBe('internal');
expect(result.values.previousState).toMatchObject({ statusSource: 'manual', statusText: 'Focusing' });
});
test('should not save previousState when no existing claim', () => {
const result = processPresence(user(), [session()], {
type: 'setActive',
newState: { statusDefault: UserStatus.BUSY, statusSource: 'manual' },
});
expect(result.values.previousState).toBeUndefined();
});
test('should display the new claim and save the displaced one as previousState on same priority', () => {
const result = processPresence(
user({ statusSource: 'internal', statusDefault: UserStatus.BUSY, statusText: 'On a call' }),
[session()],
{ type: 'setActive', newState: { statusDefault: UserStatus.BUSY, statusText: 'In a meeting', statusSource: 'internal' } },
);
expect(result.values.statusText).toBe('In a meeting');
expect(result.values.statusSource).toBe('internal');
expect(result.values.previousState).toMatchObject({ statusSource: 'internal', statusText: 'On a call' });
});
test('should queue lower priority claim as previousState', () => {
const result = processPresence(
user({ statusSource: 'internal', statusDefault: UserStatus.BUSY, statusText: 'On a call' }),
[session()],
{ type: 'setActive', newState: { statusDefault: UserStatus.AWAY, statusText: 'Lunch', statusSource: 'manual' } },
);
expect(result.values.previousState).toMatchObject({ statusDefault: UserStatus.AWAY, statusText: 'Lunch', statusSource: 'manual' });
expect(result.values.statusSource).toBeUndefined();
});
test('should replace expired previousState with lower priority claim', () => {
const result = processPresence(
user({
statusSource: 'internal',
statusDefault: UserStatus.BUSY,
previousState: {
statusDefault: UserStatus.BUSY,
statusText: 'Old',
statusSource: 'external',
statusExpiresAt: new Date(Date.now() - ONE_HOUR),
},
}),
[session()],
{ type: 'setActive', newState: { statusDefault: UserStatus.BUSY, statusText: 'Deep work', statusSource: 'manual' } },
);
expect(result.values.previousState).toMatchObject({
statusDefault: UserStatus.BUSY,
statusText: 'Deep work',
statusSource: 'manual',
});
});
test('should discard lower priority claim when previousState has higher priority', () => {
const result = processPresence(
user({
statusSource: 'internal',
statusDefault: UserStatus.BUSY,
previousState: {
statusDefault: UserStatus.BUSY,
statusText: 'Deep work',
statusSource: 'manual',
statusExpiresAt: new Date(Date.now() + ONE_HOUR),
},
}),
[session()],
{ type: 'setActive', newState: { statusDefault: UserStatus.BUSY, statusText: 'Standup', statusSource: 'external' } },
);
expect(result.values).toStrictEqual({});
});
test('should apply manual over external without stashing the displaced claim', () => {
const result = processPresence(
user({ statusSource: 'external', statusDefault: UserStatus.BUSY, statusText: 'In a meeting' }),
[session()],
{ type: 'setActive', newState: { statusDefault: UserStatus.BUSY, statusText: 'Focusing', statusSource: 'manual' } },
);
expect(result.values.statusSource).toBe('manual');
expect(result.values.statusText).toBe('Focusing');
expect(result.values.previousState).toBeUndefined();
expect(result.clear).toContain('previousState');
});
test('should drop a queued previousState when a manual claim wins', () => {
const result = processPresence(
user({
statusSource: 'external',
statusDefault: UserStatus.BUSY,
statusText: 'In a meeting',
previousState: { statusDefault: UserStatus.AWAY, statusText: 'Lunch', statusSource: 'manual' },
}),
[session()],
{ type: 'setActive', newState: { statusDefault: UserStatus.BUSY, statusText: 'Focusing', statusSource: 'manual' } },
);
expect(result.values.statusSource).toBe('manual');
expect(result.values.previousState).toBeUndefined();
expect(result.clear).toContain('previousState');
});
test('should clear a queued previousState when overwriting a same-source manual claim', () => {
const result = processPresence(
user({
statusSource: 'manual',
statusDefault: UserStatus.BUSY,
statusText: 'Focusing',
previousState: { statusDefault: UserStatus.AWAY, statusText: 'Lunch', statusSource: 'external' },
}),
[session()],
{ type: 'setActive', newState: { statusDefault: UserStatus.AWAY, statusText: 'Heads down', statusSource: 'manual' } },
);
expect(result.values.statusText).toBe('Heads down');
expect(result.clear).toContain('previousState');
});
test('should update a same-source external claim in place without stashing it (calendar re-apply)', () => {
const oldExp = new Date(Date.now() + ONE_HOUR);
const newExp = new Date(Date.now() + 2 * ONE_HOUR);
const result = processPresence(
user({ statusSource: 'external', statusDefault: UserStatus.BUSY, statusText: 'In a meeting', statusExpiresAt: oldExp }),
[session()],
{
type: 'setActive',
newState: { statusDefault: UserStatus.BUSY, statusText: 'In a meeting', statusSource: 'external', statusExpiresAt: newExp },
},
);
expect(result.values.statusExpiresAt).toEqual(newExp);
expect(result.values.previousState).toBeUndefined();
});
test('should still stash a same-source internal claim (voice + video stack)', () => {
const result = processPresence(
user({ statusSource: 'internal', statusDefault: UserStatus.BUSY, statusText: 'On a call' }),
[session()],
{ type: 'setActive', newState: { statusDefault: UserStatus.BUSY, statusText: 'In a meeting', statusSource: 'internal' } },
);
expect(result.values.previousState).toMatchObject({ statusSource: 'internal', statusText: 'On a call' });
});
});
describe('endActive', () => {
test('should reset to ONLINE when no previousState exists', () => {
const result = processPresence(
user({ statusSource: 'manual', statusDefault: UserStatus.BUSY, statusText: 'Design work' }),
[session()],
{ type: 'endActive' },
);
expect(result.values).toMatchObject({ statusDefault: UserStatus.ONLINE });
expect(result.clear).toEqual(expect.arrayContaining(['statusSource', 'statusExpiresAt', 'previousState']));
});
test('should restore previousState when valid', () => {
const result = processPresence(
user({
statusSource: 'internal',
statusDefault: UserStatus.BUSY,
previousState: { statusDefault: UserStatus.BUSY, statusText: 'Focusing', statusSource: 'manual' },
}),
[session()],
{ type: 'endActive' },
);
expect(result.values).toMatchObject({ statusSource: 'manual', statusText: 'Focusing' });
expect(result.clear).toContain('previousState');
});
test('should reset to ONLINE when previousState is expired', () => {
const result = processPresence(
user({
statusSource: 'internal',
statusDefault: UserStatus.BUSY,
previousState: {
statusDefault: UserStatus.BUSY,
statusText: 'Focus',
statusSource: 'manual',
statusExpiresAt: new Date(Date.now() - 600_000),
},
}),
[session()],
{ type: 'endActive' },
);
expect(result.values).toMatchObject({ statusDefault: UserStatus.ONLINE });
expect(result.clear).toContain('previousState');
});
test('should restore previousState with expiresAt', () => {
const exp = new Date(Date.now() + ONE_HOUR);
const result = processPresence(
user({
statusSource: 'internal',
statusDefault: UserStatus.BUSY,
previousState: {
statusDefault: UserStatus.BUSY,
statusText: 'Standup',
statusSource: 'external',
statusExpiresAt: exp,
},
}),
[session()],
{ type: 'endActive' },
);
expect(result.values).toMatchObject({ statusSource: 'external', statusExpiresAt: exp });
expect(result.clear).toContain('previousState');
});
});
describe('endActive with statusId (claim identity)', () => {
test('should drop the stash and keep displaying when the stashed claim ends (older-first)', () => {
const result = processPresence(
user({
statusSource: 'internal',
statusDefault: UserStatus.BUSY,
statusText: 'In a meeting',
statusId: 'confB',
previousState: { statusDefault: UserStatus.BUSY, statusText: 'On a call', statusSource: 'internal', statusId: 'callA' },
}),
[session()],
{ type: 'endActive', statusId: 'callA' },
);
expect(result.values.statusText).toBeUndefined();
expect(result.values.statusSource).toBeUndefined();
expect(result.values.status).toBe(UserStatus.BUSY);
expect(result.clear).toContain('previousState');
});
test('should restore the stashed claim with its statusId when the active claim ends (newest-first)', () => {
const result = processPresence(
user({
statusSource: 'internal',
statusDefault: UserStatus.BUSY,
statusText: 'In a meeting',
statusId: 'confB',
previousState: { statusDefault: UserStatus.BUSY, statusText: 'On a call', statusSource: 'internal', statusId: 'callA' },
}),
[session()],
{ type: 'endActive', statusId: 'confB' },
);
expect(result.values).toMatchObject({ statusSource: 'internal', statusText: 'On a call', statusId: 'callA' });
expect(result.clear).toContain('previousState');
});
test('should no-op when the statusId matches neither the active nor the stashed claim', () => {
const result = processPresence(
user({
statusSource: 'internal',
statusDefault: UserStatus.BUSY,
statusText: 'In a meeting',
statusId: 'confB',
previousState: { statusDefault: UserStatus.BUSY, statusText: 'On a call', statusSource: 'internal', statusId: 'callA' },
}),
[session()],
{ type: 'endActive', statusId: 'unknown' },
);
expect(result.values).toStrictEqual({});
});
test('should reset to ONLINE when the active claim ends and there is no stash', () => {
const result = processPresence(
user({ statusSource: 'internal', statusDefault: UserStatus.BUSY, statusText: 'On a call', statusId: 'callA' }),
[session()],
{ type: 'endActive', statusId: 'callA' },
);
expect(result.values).toMatchObject({ statusDefault: UserStatus.ONLINE });
expect(result.clear).toEqual(expect.arrayContaining(['statusSource', 'statusExpiresAt', 'previousState', 'statusId']));
});
});
describe('setActive with statusId (claim identity)', () => {
test('should store the statusId on a new claim', () => {
const result = processPresence(user(), [session()], {
type: 'setActive',
newState: { statusDefault: UserStatus.BUSY, statusText: 'On a call', statusSource: 'internal', statusId: 'callA' },
});
expect(result.values).toMatchObject({ statusSource: 'internal', statusId: 'callA' });
});
test('should stash the displaced claim with its statusId and store the new id (voice + video)', () => {
const result = processPresence(
user({ statusSource: 'internal', statusDefault: UserStatus.BUSY, statusText: 'On a call', statusId: 'callA' }),
[session()],
{
type: 'setActive',
newState: { statusDefault: UserStatus.BUSY, statusText: 'In a meeting', statusSource: 'internal', statusId: 'confB' },
},
);
expect(result.values).toMatchObject({ statusText: 'In a meeting', statusId: 'confB' });
expect(result.values.previousState).toMatchObject({ statusText: 'On a call', statusId: 'callA' });
});
});
describe('clearActive', () => {
test('should reset to ONLINE and clear all claim fields', () => {
const result = processPresence(user({ statusSource: 'manual', statusDefault: UserStatus.BUSY }), [session()], {
type: 'clearActive',
});
expect(result.values).toMatchObject({ statusDefault: UserStatus.ONLINE, statusText: '', status: UserStatus.ONLINE });
expect(result.clear).toEqual(expect.arrayContaining(['statusSource', 'statusExpiresAt', 'previousState']));
});
test('should defer to connection when statusDefault resets to ONLINE', () => {
const result = processPresence(user({ statusDefault: UserStatus.BUSY, statusSource: 'manual' }), [session(UserStatus.AWAY)], {
type: 'clearActive',
});
expect(result.values.status).toBe(UserStatus.AWAY);
expect(result.values.statusDefault).toBe(UserStatus.ONLINE);
});
});
describe('offline users', () => {
test('should reject non-manual claim when user is offline', () => {
const result = processPresence(user({ statusDefault: UserStatus.OFFLINE }), [], {
type: 'setActive',
newState: { statusDefault: UserStatus.BUSY, statusSource: 'external' },
});
expect(result.values).toStrictEqual({});
});
test('should accept manual claim when user is offline', () => {
const result = processPresence(user({ statusDefault: UserStatus.OFFLINE }), [], {
type: 'setActive',
newState: { statusDefault: UserStatus.BUSY, statusSource: 'manual', statusText: 'Working' },
});
expect(result.values.statusSource).toBe('manual');
// display status is OFFLINE because there is no active connection;
// statusDefault (BUSY) is persisted and will take effect on reconnect
expect(result.values.status).toBe(UserStatus.OFFLINE);
expect(result.values.statusConnection).toBe(UserStatus.OFFLINE);
});
});
describe('fieldsToUnset', () => {
test('should unset expiresAt when absent from new claim', () => {
const result = processPresence(user({ statusExpiresAt: new Date() }), [session()], {
type: 'setActive',
newState: { statusDefault: UserStatus.BUSY, statusText: 'Focus', statusSource: 'manual' },
});
expect(result.clear).toContain('statusExpiresAt');
});
});
describe('computeStatus', () => {
test('should resolve to OFFLINE when session is OFFLINE regardless of claim', () => {
const result = processPresence(user(), [session(UserStatus.OFFLINE)], {
type: 'setActive',
newState: { statusDefault: UserStatus.BUSY, statusSource: 'manual' },
});
expect(result.values.status).toBe(UserStatus.OFFLINE);
});
});
describe('no connection - humans (connection-bound)', () => {
test('clearActive: explicit "set online" is honored (stays online)', () => {
const result = processPresence(user({ statusSource: 'manual', statusDefault: UserStatus.BUSY }), [], { type: 'clearActive' });
expect(result.values.status).toBe(UserStatus.ONLINE);
});
test('setActive: busy claim is persisted but display is OFFLINE', () => {
const result = processPresence(user(), [], {
type: 'setActive',
newState: { statusDefault: UserStatus.BUSY, statusSource: 'manual', statusText: 'Focus' },
});
expect(result.values.status).toBe(UserStatus.OFFLINE);
expect(result.values.statusDefault).toBe(UserStatus.BUSY); // claim persisted for reconnect
});
test('endActive: ending a claim reverts to connection reality (OFFLINE), not statusDefault', () => {
const result = processPresence(user({ statusSource: 'manual', statusDefault: UserStatus.BUSY }), [], { type: 'endActive' });
expect(result.values.status).toBe(UserStatus.OFFLINE);
});
});
describe('no connection - service users (bots/apps) hold their declared status', () => {
test.each([
['type "bot"', bot(), UserStatus.BUSY, 'manual' as const],
['type "app"', user({ type: 'app' }), UserStatus.AWAY, 'external' as const],
['role "bot" (type "user")', user({ type: 'user', roles: ['bot'] }), UserStatus.BUSY, 'manual' as const],
])('setActive: a service user (%s) displays its declared status, not OFFLINE', (_label, serviceUser, statusDefault, statusSource) => {
const result = processPresence(serviceUser, [], {
type: 'setActive',
newState: { statusDefault, statusSource },
});
expect(result.values.status).toBe(statusDefault);
});
test('endActive: a bot whose claim is released reverts to OFFLINE (re-assert to restore)', () => {
const result = processPresence(bot({ statusSource: 'manual', statusDefault: UserStatus.BUSY }), [], { type: 'endActive' });
expect(result.values.status).toBe(UserStatus.OFFLINE);
});
});
});

View File

@@ -0,0 +1,226 @@
import type { IUser, IUserSessionConnection } from '@rocket.chat/core-typings';
import { UserStatus } from '@rocket.chat/core-typings';
export type ClaimUpdate =
| { type: 'setActive'; newState: Pick<IUser, 'statusDefault' | 'statusSource' | 'statusText' | 'statusExpiresAt' | 'statusId'> }
| { type: 'endActive'; statusId?: string }
| { type: 'clearActive' };
// Priority: internal > manual > external (lower number = higher priority).
// System states (like auto-away) are not listed here and have the lowest priority.
const PRIORITY = { internal: 1, manual: 2, external: 3 };
const NO_PRIORITY = 4;
const RESET_TO_ONLINE = {
set: { statusDefault: UserStatus.ONLINE, statusText: '' },
unset: ['statusSource', 'statusExpiresAt', 'previousState', 'statusId'],
};
function isExpired(expiresAt?: Date): boolean {
return expiresAt != null && expiresAt.getTime() < Date.now();
}
function fieldsToUnset(state: Pick<IUser, 'statusExpiresAt'>, extra?: string[]): string[] {
const fields = new Set(extra);
if (!state.statusExpiresAt) {
fields.add('statusExpiresAt');
}
return [...fields];
}
/** Reduces multiple DDP sessions into one status: ONLINE wins,
* then first non-OFFLINE. */
function reduceConnections(current: UserStatus, status: UserStatus): UserStatus {
if (current === UserStatus.ONLINE) {
return UserStatus.ONLINE;
}
if (status !== UserStatus.OFFLINE) {
return status;
}
return current;
}
/**
* Resolves final display status: OFFLINE connection always wins,
* explicit claims (busy/away) override connection, ONLINE defers to connection.
*/
function computeStatus(statusConnection: UserStatus, statusDefault: UserStatus): UserStatus {
if (statusConnection === UserStatus.OFFLINE) {
return UserStatus.OFFLINE;
}
if (statusDefault === UserStatus.ONLINE) {
return statusConnection;
}
return statusDefault;
}
function disconnectedStatus(claimType: ClaimUpdate['type'], isServiceUser: boolean, statusDefault: UserStatus): UserStatus {
switch (claimType) {
case 'clearActive':
return statusDefault;
case 'setActive':
return isServiceUser ? statusDefault : UserStatus.OFFLINE;
case 'endActive':
return UserStatus.OFFLINE;
}
}
/**
* Resolves a claim update against the user's current state using the priority system.
* Returns the DB fields to set/unset, or null if the claim is rejected.
*/
function resolveIntent(
user: Pick<IUser, 'statusDefault' | 'statusSource' | 'statusText' | 'statusExpiresAt' | 'statusId' | 'previousState'>,
claimUpdate: ClaimUpdate,
): { set: Record<string, unknown> & { statusDefault?: UserStatus }; unset: string[] } | null {
const currentStatusDefault = user.statusDefault ?? UserStatus.ONLINE;
if (claimUpdate.type === 'clearActive') {
return RESET_TO_ONLINE;
}
if (claimUpdate.type === 'endActive') {
const { statusId } = claimUpdate;
if (statusId) {
const matchesActive = user.statusId === statusId;
const matchesStash = user.previousState?.statusId === statusId;
// stashed claim ended: drop it, keep displaying the still-active claim
if (matchesStash && !matchesActive) {
return { set: {}, unset: ['previousState'] };
}
if (!matchesActive && !matchesStash) {
return null;
}
}
if (user.previousState && !isExpired(user.previousState.statusExpiresAt)) {
const prev = user.previousState;
return {
set: {
statusDefault: prev.statusDefault,
statusSource: prev.statusSource,
...(prev.statusText != null && { statusText: prev.statusText }),
...(prev.statusExpiresAt && { statusExpiresAt: prev.statusExpiresAt }),
...(prev.statusId && { statusId: prev.statusId }),
},
unset: fieldsToUnset(prev, prev.statusId ? ['previousState'] : ['previousState', 'statusId']),
};
}
return RESET_TO_ONLINE;
}
// type === 'setActive'
const { newState } = claimUpdate;
const { statusId } = newState;
// offline users can only have their status changed by manual sources
if (currentStatusDefault === UserStatus.OFFLINE && newState.statusSource !== 'manual') {
return null;
}
const currentPriority = user.statusSource ? PRIORITY[user.statusSource] : NO_PRIORITY;
const newPriority = newState.statusSource ? PRIORITY[newState.statusSource] : NO_PRIORITY;
// manual is the user's explicit intent: it never stashes and clears any queued claim
const isManual = newState.statusSource === 'manual';
// higher or equal priority -> apply new; equal-priority internals (voice + video) stash so statusId
// can restore either order. Single slot: a 3rd concurrent internal claim evicts the oldest.
if (newPriority <= currentPriority) {
// re-applying the same non-stacking source (calendar) updates in place; only internal stacks
const sameSourceReapply = user.statusSource === newState.statusSource;
const sourceStacks = newState.statusSource === 'internal';
const shouldStash = !isManual && Boolean(user.statusSource) && (!sameSourceReapply || sourceStacks);
const previousState = shouldStash
? {
statusDefault: currentStatusDefault,
statusText: user.statusText ?? '',
statusSource: user.statusSource,
statusExpiresAt: user.statusExpiresAt,
...(user.statusId && { statusId: user.statusId }),
}
: undefined;
return {
set: {
statusDefault: newState.statusDefault,
statusSource: newState.statusSource,
...(newState.statusText != null && { statusText: newState.statusText }),
...(newState.statusExpiresAt && { statusExpiresAt: newState.statusExpiresAt }),
...(statusId && { statusId }),
...(previousState && { previousState }),
},
unset: fieldsToUnset(newState, [...(isManual ? ['previousState'] : []), ...(statusId ? [] : ['statusId'])]),
};
}
const previousState = {
statusDefault: newState.statusDefault,
statusSource: newState.statusSource,
...(newState.statusText != null && { statusText: newState.statusText }),
...(newState.statusExpiresAt && { statusExpiresAt: newState.statusExpiresAt }),
...(statusId && { statusId }),
};
// lower priority -> save as previous if slot available
if (!user.previousState || isExpired(user.previousState.statusExpiresAt)) {
return {
set: { previousState },
unset: [],
};
}
if (
newState.statusSource &&
user.previousState.statusSource &&
PRIORITY[newState.statusSource] <= PRIORITY[user.previousState.statusSource]
) {
return {
set: { previousState },
unset: [],
};
}
// rejected - can't store
return null;
}
/**
* Computes the final presence state for a user by combining claim intent with connection reality.
* Returns the DB fields to $set and optionally $unset.
*/
export function processPresence(
user: Pick<IUser, 'type' | 'roles' | 'statusDefault' | 'statusSource' | 'statusText' | 'statusExpiresAt' | 'statusId' | 'previousState'>,
sessions: IUserSessionConnection[],
claimUpdate?: ClaimUpdate,
): { values: Record<string, unknown>; clear?: string[] } {
// no claim - status is determined entirely by DDP sessions
if (!claimUpdate) {
if (!sessions.length) {
return { values: { status: UserStatus.OFFLINE, statusConnection: UserStatus.OFFLINE } };
}
const statusDefault = user.statusDefault ?? UserStatus.ONLINE;
const statusConnection = sessions.map((s) => s.status).reduce(reduceConnections, UserStatus.OFFLINE);
return { values: { status: computeStatus(statusConnection, statusDefault), statusConnection } };
}
const intent = resolveIntent(user, claimUpdate);
if (!intent) {
return { values: {} };
}
const { set, unset } = intent;
const statusDefault = set.statusDefault ?? user.statusDefault ?? UserStatus.ONLINE;
const clear = unset.length ? unset : undefined;
// no live connection: humans fall back to offline; bots/apps keep their declared status
if (!sessions.length) {
const isServiceUser = user.type === 'bot' || user.type === 'app' || (user.roles?.includes('bot') ?? false);
const status = disconnectedStatus(claimUpdate.type, isServiceUser, statusDefault);
return { values: { ...set, status, statusConnection: UserStatus.OFFLINE }, clear };
}
const statusConnection = sessions.map((s) => s.status).reduce(reduceConnections, UserStatus.OFFLINE);
const status = computeStatus(statusConnection, statusDefault);
return { values: { ...set, status, statusConnection }, clear };
}

View File

@@ -0,0 +1,9 @@
{
"extends": "@rocket.chat/tsconfig/server.json",
"compilerOptions": {
"declaration": true,
"outDir": "./dist",
"rootDir": "./src",
},
"files": ["./src/Presence.ts"]
}