Chore: Move presence to package (#25541)

This commit is contained in:
Diego Sampaio 2022-09-15 17:34:52 -03:00 committed by GitHub
parent 8dc8817d9e
commit 6d3b20d81c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
75 changed files with 2553 additions and 690 deletions

View File

@ -39,9 +39,9 @@ import { resetUserE2EEncriptionKey } from '../../../../server/lib/resetUserE2EKe
import { resetTOTP } from '../../../2fa/server/functions/resetTOTP';
import { Team } from '../../../../server/sdk';
import { isValidQuery } from '../lib/isValidQuery';
import { setUserStatus } from '../../../../imports/users-presence/server/activeUsers';
import { getURL } from '../../../utils/server';
import { getUploadFormData } from '../lib/getUploadFormData';
import { api } from '../../../../server/sdk/api';
API.v1.addRoute(
'users.getAvatar',
@ -1031,7 +1031,11 @@ API.v1.addRoute(
},
});
setUserStatus(user, status);
const { _id, username, statusText, roles, name } = user;
api.broadcast('presence.status', {
user: { status, _id, username, statusText, roles, name },
previousStatus: user.status,
});
} else {
throw new Meteor.Error('error-invalid-status', 'Valid status types include online, away, offline, and busy.', {
method: 'users.setStatus',

View File

@ -1,5 +1,4 @@
import { Random } from 'meteor/random';
import { UserPresence } from 'meteor/konecty:user-presence';
import { UserBridge } from '@rocket.chat/apps-engine/server/bridges/UserBridge';
import type { IUserCreationOptions, IUser } from '@rocket.chat/apps-engine/definition/users';
import { Subscriptions, Users as UsersRaw } from '@rocket.chat/models';
@ -84,7 +83,11 @@ export class AppUserBridge extends UserBridge {
return true;
}
protected async update(user: IUser & { id: string }, fields: Partial<IUser>, appId: string): Promise<boolean> {
protected async update(
user: IUser & { id: string },
fields: Partial<IUser> & { statusDefault: string },
appId: string,
): Promise<boolean> {
this.orch.debugLog(`The App ${appId} is updating a user`);
if (!user) {
@ -98,12 +101,12 @@ export class AppUserBridge extends UserBridge {
const { status } = fields;
delete fields.status;
await UsersRaw.update({ _id: user.id }, { $set: fields as any });
if (status) {
UserPresence.setDefaultStatus(user.id, status);
fields.statusDefault = status;
}
await UsersRaw.updateOne({ _id: user.id }, { $set: fields as any });
return true;
}

View File

@ -1,4 +1,3 @@
import './cron';
import './status.ts';
export { Apps, AppEvents } from './orchestrator';

View File

@ -1,16 +0,0 @@
import { UserPresenceMonitor } from 'meteor/konecty:user-presence';
import { AppEvents, Apps } from './orchestrator';
UserPresenceMonitor.onSetUserStatus((...args: any) => {
const [user, status] = args;
// App IPostUserStatusChanged event hook
Promise.await(
Apps.triggerEvent(AppEvents.IPostUserStatusChanged, {
user,
currentStatus: status,
previousStatus: user.status,
}),
);
});

View File

@ -26,7 +26,7 @@ export { saveUserIdentity } from './saveUserIdentity';
export { sendMessage } from './sendMessage';
export { setEmail } from './setEmail';
export { setRealName, _setRealName } from './setRealName';
export { setStatusText, _setStatusText, _setStatusTextPromise } from './setStatusText';
export { setStatusText } from './setStatusText';
export { getStatusText } from './getStatusText';
export { setUserAvatar } from './setUserAvatar';
export { _setUsername, setUsername } from './setUsername';

View File

@ -1,21 +1,22 @@
import { Meteor } from 'meteor/meteor';
import s from 'underscore.string';
import type { IUser } from '@rocket.chat/core-typings';
import { Users as UsersRaw } from '@rocket.chat/models';
import { Users } from '@rocket.chat/models';
import { Users } from '../../../models/server';
import { hasPermission } from '../../../authorization/server';
import { RateLimiter } from '../lib';
import { api } from '../../../../server/sdk/api';
export const _setStatusTextPromise = async function (userId: string, statusText: string): Promise<boolean> {
async function _setStatusTextPromise(userId: string, statusText: string): Promise<boolean> {
if (!userId) {
return false;
}
statusText = s.trim(statusText).substr(0, 120);
const user = await UsersRaw.findOneById(userId);
const user = await Users.findOneById<Pick<IUser, '_id' | 'username' | 'name' | 'status' | 'roles' | 'statusText'>>(userId, {
projection: { username: 1, name: 1, status: 1, roles: 1, statusText: 1 },
});
if (!user) {
return false;
@ -25,44 +26,20 @@ export const _setStatusTextPromise = async function (userId: string, statusText:
return true;
}
await UsersRaw.updateStatusText(user._id, statusText);
await Users.updateStatusText(user._id, statusText);
const { _id, username, status } = user;
const { _id, username, status, name, roles } = user;
api.broadcast('presence.status', {
user: { _id, username, status, statusText },
user: { _id, username, status, statusText, name, roles },
previousStatus: status,
});
return true;
};
}
export const _setStatusText = function (userId: any, statusText: string): IUser | boolean {
statusText = s.trim(statusText);
if (statusText.length > 120) {
statusText = statusText.substr(0, 120);
}
if (!userId) {
return false;
}
const user = Users.findOneById(userId);
// User already has desired statusText, return
if (user.statusText === statusText) {
return user;
}
// Set new statusText
Users.updateStatusText(user._id, statusText);
user.statusText = statusText;
const { _id, username, status } = user;
api.broadcast('presence.status', {
user: { _id, username, status, statusText },
});
return true;
};
function _setStatusText(userId: any, statusText: string): boolean {
return Promise.await(_setStatusTextPromise(userId, statusText));
}
export const setStatusText = RateLimiter.limitFunction(_setStatusText, 5, 60000, {
0() {

View File

@ -1,10 +0,0 @@
import { UserPresenceMonitor } from 'meteor/konecty:user-presence';
import { Livechat } from './lib/Livechat';
import { hasAnyRole } from '../../authorization/server/functions/hasRole';
UserPresenceMonitor.onSetUserStatus((user, status) => {
if (hasAnyRole(user._id, ['livechat-manager', 'livechat-monitor', 'livechat-agent'])) {
Livechat.notifyAgentStatusChanged(user._id, status);
}
});

View File

@ -1,8 +1,6 @@
import './livechat';
import './config';
import './startup';
import './visitorStatus';
import './agentStatus';
import '../lib/messageTypes';
import './hooks/beforeCloseRoom';
import './hooks/beforeDelegateAgent';

View File

@ -1,12 +0,0 @@
import { Meteor } from 'meteor/meteor';
import { UserPresenceEvents } from 'meteor/konecty:user-presence';
import { Livechat } from './lib/Livechat';
Meteor.startup(() => {
UserPresenceEvents.on('setStatus', (session, status, metadata) => {
if (metadata && metadata.visitor) {
Livechat.notifyGuestStatusChanged(metadata.visitor, status);
}
});
});

View File

@ -1082,16 +1082,6 @@ export class Users extends Base {
return this.update(_id, update);
}
updateStatusById(_id, status) {
const update = {
$set: {
status,
},
};
return this.update(_id, update);
}
addPasswordToHistory(_id, password) {
const update = {
$push: {

View File

@ -1,17 +1,29 @@
import { settings } from '../../settings/server';
import { Voip } from '../../../server/sdk';
settings.watch('VoIP_Enabled', (value: boolean) => {
return value ? Voip.init() : Voip.stop();
settings.watch('VoIP_Enabled', async function (value: boolean) {
try {
if (value) {
await Voip.init();
} else {
await Voip.stop();
}
} catch (e) {
// do nothing
}
});
settings.changeMultiple(
['VoIP_Management_Server_Host', 'VoIP_Management_Server_Port', 'VoIP_Management_Server_Username', 'VoIP_Management_Server_Password'],
(_values) => {
async function (_values) {
// Here, if 4 settings are changed at once, we're getting 4 diff callbacks. The good part is that all callbacks are fired almost instantly
// So to avoid stopping/starting voip too often, we debounce the call and restart 1 second after the last setting has reached us.
if (settings.get('VoIP_Enabled')) {
Voip.refresh();
try {
await Voip.refresh();
} catch (e) {
// do nothing
}
}
},
);

View File

@ -2,7 +2,7 @@ import type { ServiceBroker, Context, ServiceSchema } from 'moleculer';
import { asyncLocalStorage } from '../../server/sdk';
import type { IBroker, IBrokerNode, IServiceMetrics } from '../../server/sdk/types/IBroker';
import type { ServiceClass } from '../../server/sdk/types/ServiceClass';
import type { IServiceClass } from '../../server/sdk/types/ServiceClass';
import type { EventSignatures } from '../../server/sdk/lib/Events';
const events: { [k: string]: string } = {
@ -35,6 +35,7 @@ export class NetworkBroker implements IBroker {
this.metrics = broker.metrics;
// TODO move this to a proper startup method?
this.started = this.broker.start();
}
@ -73,18 +74,19 @@ export class NetworkBroker implements IBroker {
return this.broker.call(method, data);
}
destroyService(instance: ServiceClass): void {
destroyService(instance: IServiceClass): void {
this.broker.destroyService(instance.getName());
}
createService(instance: ServiceClass): void {
createService(instance: IServiceClass): void {
const methods = (
instance.constructor?.name === 'Object'
? Object.getOwnPropertyNames(instance)
: Object.getOwnPropertyNames(Object.getPrototypeOf(instance))
).filter((name) => name !== 'constructor');
if (!instance.getEvents() || !methods.length) {
const instanceEvents = instance.getEvents();
if (!instanceEvents && !methods.length) {
return;
}
@ -98,7 +100,7 @@ export class NetworkBroker implements IBroker {
this.broker.logger.debug({ msg: 'Not shutting down, different node.', nodeID: this.broker.nodeID });
return;
}
this.broker.logger.info({ msg: 'Received shutdown event, destroying service.', nodeID: this.broker.nodeID });
this.broker.logger.warn({ msg: 'Received shutdown event, destroying service.', nodeID: this.broker.nodeID });
this.destroyService(instance);
});
}
@ -109,7 +111,7 @@ export class NetworkBroker implements IBroker {
name,
actions: {},
...dependencies,
events: instance.getEvents().reduce<Record<string, (ctx: Context) => void>>((map, eventName) => {
events: instanceEvents.reduce<Record<string, (ctx: Context) => void>>((map, eventName) => {
map[eventName] = /^\$/.test(eventName)
? (ctx: Context): void => {
// internal events params are not an array

View File

@ -6,18 +6,18 @@ module.exports = {
name: 'authorization',
watch: [...watch, '../../../server/services/authorization'],
},
{
name: 'presence',
},
// {
// name: 'presence',
// },
{
name: 'account',
},
{
name: 'stream-hub',
},
{
name: 'ddp-streamer',
},
// {
// name: 'ddp-streamer',
// },
].map((app) =>
Object.assign(app, {
script: app.script || `ts-node --files ${app.name}/service.ts`,

View File

@ -7,9 +7,9 @@
"scripts": {
"dev": "pm2 start ecosystem.config.js",
"pm2": "pm2",
"ms": "MONGO_URL=${MONGO_URL:-mongodb://localhost:3001/meteor} run-p start:account start:authorization start:stream-hub",
"start:account": "ts-node --files ./account/service.ts",
"start:authorization": "ts-node --files ./authorization/service.ts",
"start:presence": "ts-node --files ./presence/service.ts",
"start:stream-hub": "ts-node --files ./stream-hub/service.ts",
"typecheck": "tsc --noEmit --skipLibCheck",
"build": "tsc",
@ -62,9 +62,10 @@
"@types/fibers": "^3.1.1",
"@types/node": "^14.18.21",
"@types/ws": "^8.5.3",
"npm-run-all": "^4.1.5",
"pino-pretty": "^7.6.1",
"pm2": "^5.2.0",
"ts-node": "^10.8.1",
"ts-node": "^10.9.1",
"typescript": "~4.5.5"
},
"volta": {

View File

@ -1,56 +0,0 @@
import type { UserStatus } from '@rocket.chat/core-typings';
import { newConnection } from './actions/newConnection';
import { removeConnection } from './actions/removeConnection';
import { removeLostConnections } from './actions/removeLostConnections';
import { setStatus, setConnectionStatus } from './actions/setStatus';
import { updateUserPresence } from './actions/updateUserPresence';
import { ServiceClass } from '../../../../server/sdk/types/ServiceClass';
import type { IPresence } from '../../../../server/sdk/types/IPresence';
import type { IBrokerNode } from '../../../../server/sdk/types/IBroker';
export class Presence extends ServiceClass implements IPresence {
protected name = 'presence';
async onNodeDisconnected({ node }: { node: IBrokerNode }): Promise<void> {
const affectedUsers = await this.removeLostConnections(node.id);
return affectedUsers.forEach((uid) => this.updateUserPresence(uid));
}
async started(): Promise<void> {
setTimeout(async () => {
const affectedUsers = await this.removeLostConnections();
return affectedUsers.forEach((uid) => this.updateUserPresence(uid));
}, 100);
}
async newConnection(uid: string, session: string): Promise<{ uid: string; connectionId: string } | undefined> {
const result = await newConnection(uid, session, this.context);
await updateUserPresence(uid);
return result;
}
async removeConnection(uid: string, session: string): Promise<{ uid: string; session: string }> {
const result = await removeConnection(uid, session);
await updateUserPresence(uid);
return result;
}
async removeLostConnections(nodeID?: string): Promise<string[]> {
return removeLostConnections(nodeID, this.context);
}
async setStatus(uid: string, status: UserStatus, statusText?: string): Promise<boolean> {
return setStatus(uid, status, statusText);
}
async setConnectionStatus(uid: string, status: UserStatus, session: string): Promise<boolean> {
const result = await setConnectionStatus(uid, status, session);
await updateUserPresence(uid);
return result;
}
async updateUserPresence(uid: string): Promise<void> {
return updateUserPresence(uid);
}
}

View File

@ -1,49 +0,0 @@
import { getCollection, Collections } from '../../mongo';
import type { IServiceContext } from '../../../../../server/sdk/types/ServiceClass';
const status = 'online';
export async function newConnection(
uid: string,
session: string,
context?: IServiceContext,
): Promise<{ uid: string; connectionId: string } | undefined> {
const instanceId = context?.nodeID;
if (!instanceId) {
return;
}
const query = {
_id: uid,
};
const now = new Date();
const update = {
$push: {
connections: {
id: session,
instanceId,
status,
_createdAt: now,
_updatedAt: now,
},
},
};
// if (metadata) {
// update.$set = {
// metadata: metadata
// };
// connection.metadata = metadata;
// }
const UserSession = await getCollection(Collections.UserSession);
await UserSession.updateOne(query, update, { upsert: true });
return {
uid,
connectionId: session,
};
}

View File

@ -1,23 +0,0 @@
import { getCollection, Collections } from '../../mongo';
export async function removeConnection(uid: string, session: string): Promise<{ uid: string; session: string }> {
const query = {
'connections.id': session,
};
const update = {
$pull: {
connections: {
id: session,
},
},
};
const UserSession = await getCollection(Collections.UserSession);
await UserSession.updateMany(query, update);
return {
uid,
session,
};
}

View File

@ -1,69 +0,0 @@
import type { Collection } from 'mongodb';
import type { IUserSession } from '@rocket.chat/core-typings';
import { getCollection, Collections } from '../../mongo';
import type { IServiceContext } from '../../../../../server/sdk/types/ServiceClass';
async function getAffectedUsers(model: Collection<IUserSession>, query: object): Promise<string[]> {
const list = await model.find<{ _id: string }>(query, { projection: { _id: 1 } }).toArray();
return list.map(({ _id }) => _id);
}
// TODO: Change this to use find and modify
export async function removeLostConnections(nodeID?: string, context?: IServiceContext): Promise<string[]> {
const UserSession = await getCollection<IUserSession>(Collections.UserSession);
if (nodeID) {
const query = {
'connections.instanceId': nodeID,
};
const update = {
$pull: {
connections: {
instanceId: nodeID,
},
},
};
const affectedUsers = await getAffectedUsers(UserSession, query);
const { modifiedCount } = await UserSession.updateMany(query, update);
if (modifiedCount === 0) {
return [];
}
return affectedUsers;
}
if (!context) {
return [];
}
const nodes = await context.broker.nodeList();
const ids = nodes.filter((node) => node.available).map(({ id }) => id);
const affectedUsers = await getAffectedUsers(UserSession, {
'connections.instanceId': {
$exists: true,
$nin: ids,
},
});
const update = {
$pull: {
connections: {
instanceId: {
$nin: ids,
},
},
},
};
const { modifiedCount } = await UserSession.updateMany({}, update);
if (modifiedCount === 0) {
return [];
}
return affectedUsers;
}

View File

@ -1,63 +0,0 @@
import type { IUser, IUserSession, UserStatus } from '@rocket.chat/core-typings';
import { getCollection, Collections } from '../../mongo';
import { processPresenceAndStatus } from '../lib/processConnectionStatus';
import { api } from '../../../../../server/sdk/api';
export async function setStatus(uid: string, statusDefault: UserStatus, statusText?: string): Promise<boolean> {
const query = { _id: uid };
const UserSession = await getCollection<IUserSession>(Collections.UserSession);
const userSessions = (await UserSession.findOne(query)) || { connections: [] };
const { status, statusConnection } = processPresenceAndStatus(userSessions.connections, statusDefault);
const update = {
statusDefault,
status,
statusConnection,
...(typeof statusText !== 'undefined'
? {
// TODO logic duplicated from Rocket.Chat core
statusText: String(statusText || '')
.trim()
.substr(0, 120),
}
: {}),
};
const User = await getCollection(Collections.User);
const result = await User.updateOne(query, {
$set: update,
});
if (result.modifiedCount > 0) {
const user = await User.findOne<IUser>(query, { projection: { username: 1 } });
api.broadcast('presence.status', {
user: { _id: uid, username: user?.username, status, statusText },
});
}
return !!result.modifiedCount;
}
export async function setConnectionStatus(uid: string, status: UserStatus, session: string): Promise<boolean> {
const query = {
'_id': uid,
'connections.id': session,
};
const now = new Date();
const update = {
$set: {
'connections.$.status': status,
'connections.$._updatedAt': now,
},
};
const UserSession = await getCollection(Collections.UserSession);
const result = await UserSession.updateOne(query, update);
return !!result.modifiedCount;
}

View File

@ -1,42 +0,0 @@
// import { afterAll } from '../hooks';
import type { IUserSession, IUser } from '@rocket.chat/core-typings';
import { processPresenceAndStatus } from '../lib/processConnectionStatus';
import { getCollection, Collections } from '../../mongo';
import { api } from '../../../../../server/sdk/api';
const projection = {
projection: {
username: 1,
statusDefault: 1,
statusText: 1,
},
};
export async function updateUserPresence(uid: string): Promise<void> {
const query = { _id: uid };
const UserSession = await getCollection<IUserSession>(Collections.UserSession);
const User = await getCollection<IUser>(Collections.User);
const user = await User.findOne<IUser>(query, projection);
if (!user) {
return;
}
const userSessions = (await UserSession.findOne(query)) || { connections: [] };
const { statusDefault } = user;
const { status, statusConnection } = processPresenceAndStatus(userSessions.connections, statusDefault);
const result = await User.updateOne(query, {
$set: { status, statusConnection },
});
if (result.modifiedCount > 0) {
api.broadcast('presence.status', {
user: { _id: uid, username: user.username, status, statusText: user.statusText },
});
}
}

View File

@ -1,6 +0,0 @@
import '../../startup/broker';
import { api } from '../../../../server/sdk/api';
import { Presence } from './Presence';
api.registerService(new Presence());

View File

@ -1,3 +1,2 @@
import '../../message-read-receipt/server';
import '../../personal-access-tokens/server';
import '../../users-presence/server';

View File

@ -1,36 +0,0 @@
import { UserStatus } from '@rocket.chat/core-typings';
import { UserPresenceEvents } from 'meteor/konecty:user-presence';
import { settings } from '../../../app/settings/server';
import { api } from '../../../server/sdk/api';
export const STATUS_MAP = {
[UserStatus.OFFLINE]: 0,
[UserStatus.ONLINE]: 1,
[UserStatus.AWAY]: 2,
[UserStatus.BUSY]: 3,
};
export const setUserStatus = (user, status /* , statusConnection*/) => {
const { _id, username, statusText } = user;
// since this callback can be called by only one instance in the cluster
// we need to broadcast the change to all instances
api.broadcast('presence.status', {
user: { status, _id, username, statusText }, // TODO remove username
});
};
let TroubleshootDisablePresenceBroadcast;
settings.watch('Troubleshoot_Disable_Presence_Broadcast', (value) => {
if (TroubleshootDisablePresenceBroadcast === value) {
return;
}
TroubleshootDisablePresenceBroadcast = value;
if (value) {
return UserPresenceEvents.removeListener('setUserStatus', setUserStatus);
}
UserPresenceEvents.on('setUserStatus', setUserStatus);
});

View File

@ -1 +0,0 @@
import './activeUsers';

View File

@ -16,7 +16,7 @@
"start": "meteor",
"build:ci": "METEOR_DISABLE_OPTIMISTIC_CACHING=1 meteor build --server-only",
"dev": "meteor --exclude-archs \"web.browser.legacy, web.cordova\"",
"dsv": "meteor --exclude-archs \"web.browser.legacy, web.cordova\"",
"dsv": "meteor npm run dev",
"ha": "meteor npm run ha:start",
"ha:start": "ts-node .scripts/run-ha.ts main",
"ha:add": "ts-node .scripts/run-ha.ts instance",
@ -183,7 +183,7 @@
"stylelint-order": "^5.0.0",
"supertest": "^6.2.3",
"template-file": "^6.0.1",
"ts-node": "^10.8.1",
"ts-node": "^10.9.1",
"typescript": "~4.5.5",
"webpack": "^4.46.0"
},
@ -223,6 +223,7 @@
"@rocket.chat/mp3-encoder": "0.24.0",
"@rocket.chat/onboarding-ui": "next",
"@rocket.chat/poplib": "workspace:^",
"@rocket.chat/presence": "workspace:^",
"@rocket.chat/rest-typings": "workspace:^",
"@rocket.chat/string-helpers": "next",
"@rocket.chat/ui-client": "workspace:^",

View File

@ -6,7 +6,6 @@ import {
LivechatInquiry,
LivechatDepartmentAgents,
Rooms,
UsersSessions,
Roles,
LoginServiceConfiguration,
InstanceStatus,
@ -23,7 +22,6 @@ export const watchCollections = [
Subscriptions.getCollectionName(),
LivechatInquiry.getCollectionName(),
LivechatDepartmentAgents.getCollectionName(),
UsersSessions.getCollectionName(),
Permissions.getCollectionName(),
Roles.getCollectionName(),
Rooms.getCollectionName(),

View File

@ -66,6 +66,7 @@ import './methods/setUserActiveStatus';
import './methods/setUserPassword';
import './methods/toogleFavorite';
import './methods/unmuteUserInRoom';
import './methods/userPresence';
import './methods/userSetUtcOffset';
import './publications/messages';
import './publications/room';

View File

@ -0,0 +1,28 @@
import { UserStatus } from '@rocket.chat/core-typings';
import { Meteor } from 'meteor/meteor';
import { Presence } from '../sdk';
Meteor.methods({
'UserPresence:setDefaultStatus'(status): Promise<boolean> | undefined {
const { userId } = this;
if (!userId) {
return;
}
return Presence.setStatus(userId, status);
},
'UserPresence:online'(): Promise<boolean> | undefined {
const { userId, connection } = this;
if (!userId || !connection) {
return;
}
return Presence.setConnectionStatus(userId, UserStatus.ONLINE, connection.id);
},
'UserPresence:away'(): Promise<boolean> | undefined {
const { userId, connection } = this;
if (!userId || !connection) {
return;
}
return Presence.setConnectionStatus(userId, UserStatus.AWAY, connection.id);
},
});

View File

@ -736,6 +736,35 @@ export class UsersRaw extends BaseRaw {
return this.update(query, update, { multi: true });
}
/**
* @param {string} userId
* @param {object} status
* @param {string} status.status
* @param {string} status.statusConnection
* @param {string} [status.statusDefault]
* @param {string} [status.statusText]
*/
updateStatusById(userId, { statusDefault, status, statusConnection, statusText }) {
const query = {
_id: userId,
};
const update = {
$set: {
status,
statusConnection,
...(statusDefault && { statusDefault }),
...(statusText && {
statusText: String(statusText).trim().substr(0, 120),
}),
},
};
// We don't want to update the _updatedAt field on this operation,
// so we can check if the status update triggered a change
return this.col.updateOne(query, update);
}
openAgentsBusinessHoursByBusinessHourId(businessHourIds) {
const query = {
roles: 'livechat-agent',

View File

@ -1,6 +1,6 @@
import type { IUserSession, RocketChatRecordDeleted } from '@rocket.chat/core-typings';
import type { IUserSession, IUserSessionConnection, RocketChatRecordDeleted } from '@rocket.chat/core-typings';
import type { IUsersSessionsModel } from '@rocket.chat/model-typings';
import type { Collection, Db } from 'mongodb';
import type { FindCursor, Collection, Db, FindOptions } from 'mongodb';
import { BaseRaw } from './BaseRaw';
@ -28,4 +28,104 @@ export class UsersSessionsRaw extends BaseRaw<IUserSession> implements IUsersSes
},
);
}
updateConnectionStatusById(uid: string, connectionId: string, status: string): ReturnType<BaseRaw<IUserSession>['updateOne']> {
const query = {
'_id': uid,
'connections.id': connectionId,
};
const update = {
$set: {
'connections.$.status': status,
'connections.$._updatedAt': new Date(),
},
};
return this.updateOne(query, update);
}
async removeConnectionsFromInstanceId(instanceId: string): ReturnType<BaseRaw<IUserSession>['updateMany']> {
return this.updateMany(
{
'connections.instanceId': instanceId,
},
{
$pull: {
connections: {
instanceId,
},
},
},
);
}
removeConnectionsFromOtherInstanceIds(instanceIds: string[]): ReturnType<BaseRaw<IUserSession>['updateMany']> {
return this.updateMany(
{},
{
$pull: {
connections: {
instanceId: {
$nin: instanceIds,
},
},
},
},
);
}
async removeConnectionByConnectionId(connectionId: string): ReturnType<BaseRaw<IUserSession>['updateMany']> {
return this.updateMany(
{
'connections.id': connectionId,
},
{
$pull: {
connections: {
id: connectionId,
},
},
},
);
}
findByInstanceId(instanceId: string): FindCursor<IUserSession> {
return this.find({
'connections.instanceId': instanceId,
});
}
addConnectionById(
userId: string,
{ id, instanceId, status }: Pick<IUserSessionConnection, 'id' | 'instanceId' | 'status'>,
): ReturnType<BaseRaw<IUserSession>['updateOne']> {
const now = new Date();
const update = {
$push: {
connections: {
id,
instanceId,
status,
_createdAt: now,
_updatedAt: now,
},
},
};
return this.updateOne({ _id: userId }, update, { upsert: true });
}
findByOtherInstanceIds(instanceIds: string[], options?: FindOptions<IUserSession>): FindCursor<IUserSession> {
return this.find(
{
'connections.instanceId': {
$exists: true,
$nin: instanceIds,
},
},
options,
);
}
}

View File

@ -16,7 +16,6 @@ import type {
SettingValue,
ILivechatInquiryRecord,
IRole,
IUserSession,
} from '@rocket.chat/core-typings';
import {
Subscriptions,
@ -24,7 +23,6 @@ import {
Users,
Settings,
Roles,
UsersSessions,
LivechatInquiry,
LivechatDepartmentAgents,
Rooms,
@ -39,7 +37,6 @@ import {
import { subscriptionFields, roomFields } from './publishFields';
import type { EventSignatures } from '../../sdk/lib/Events';
import { isPresenceMonitorEnabled } from '../../lib/isPresenceMonitorEnabled';
import type { DatabaseWatcher } from '../../database/DatabaseWatcher';
type BroadcastCallback = <T extends keyof EventSignatures>(event: T, ...args: Parameters<EventSignatures[T]>) => Promise<void>;
@ -160,25 +157,6 @@ export function initWatchers(watcher: DatabaseWatcher, broadcast: BroadcastCallb
});
});
if (isPresenceMonitorEnabled()) {
watcher.on<IUserSession>(UsersSessions.getCollectionName(), async ({ clientAction, id, data: eventData }) => {
switch (clientAction) {
case 'inserted':
case 'updated':
const data = eventData ?? (await UsersSessions.findOneById(id));
if (!data) {
return;
}
broadcast('watch.userSessions', { clientAction, userSession: data });
break;
case 'removed':
broadcast('watch.userSessions', { clientAction, userSession: { _id: id } });
break;
}
});
}
watcher.on<ILivechatInquiryRecord>(LivechatInquiry.getCollectionName(), async ({ clientAction, id, data, diff }) => {
switch (clientAction) {
case 'inserted':

View File

@ -1,3 +1,9 @@
import { isRunningMs } from '../lib/isRunningMs';
import { Api } from './lib/Api';
import { LocalBroker } from './lib/LocalBroker';
export const api = new Api();
if (!isRunningMs()) {
api.setBroker(new LocalBroker());
}

View File

@ -1,13 +1,12 @@
// import { BaseBroker } from './BaseBroker';
import type { IBroker } from '../types/IBroker';
import type { ServiceClass } from '../types/ServiceClass';
import type { IApiService } from '../types/IApiService';
import type { IBroker, IBrokerNode } from '../types/IBroker';
import type { IServiceClass } from '../types/ServiceClass';
import type { EventSignatures } from './Events';
import { LocalBroker } from './LocalBroker';
export class Api {
private services = new Set<ServiceClass>();
export class Api implements IApiService {
private services: Set<IServiceClass> = new Set<IServiceClass>();
private broker: IBroker = new LocalBroker();
private broker: IBroker;
// set a broker for the API and registers all services in the broker
setBroker(broker: IBroker): void {
@ -16,7 +15,7 @@ export class Api {
this.services.forEach((service) => this.broker.createService(service));
}
destroyService(instance: ServiceClass): void {
destroyService(instance: IServiceClass): void {
if (!this.services.has(instance)) {
return;
}
@ -28,9 +27,11 @@ export class Api {
this.services.delete(instance);
}
registerService(instance: ServiceClass): void {
registerService(instance: IServiceClass): void {
this.services.add(instance);
instance.setApi(this);
if (this.broker) {
this.broker.createService(instance);
}
@ -59,4 +60,8 @@ export class Api {
async broadcastLocal<T extends keyof EventSignatures>(event: T, ...args: Parameters<EventSignatures[T]>): Promise<void> {
return this.broker.broadcastLocal(event, ...args);
}
nodeList(): Promise<IBrokerNode[]> {
return this.broker.nodeList();
}
}

View File

@ -17,12 +17,12 @@ import type {
ISocketConnection,
ISubscription,
IUser,
IUserSession,
IUserStatus,
IInvite,
IWebdavAccount,
ICustomSound,
VoipEventDataSignature,
UserStatus,
} from '@rocket.chat/core-typings';
import type { AutoUpdateRecord } from '../types/IMeteor';
@ -88,12 +88,14 @@ export type EventSignatures = {
'user.nameChanged'(user: Partial<IUser>): void;
'user.roleUpdate'(update: Record<string, any>): void;
'user.updateCustomStatus'(userStatus: IUserStatus): void;
'presence.status'(data: { user: Partial<IUser> }): void;
'presence.status'(data: {
user: Pick<IUser, '_id' | 'username' | 'status' | 'statusText' | 'name' | 'roles'>;
previousStatus: UserStatus | undefined;
}): void;
'watch.messages'(data: { clientAction: ClientAction; message: Partial<IMessage> }): void;
'watch.roles'(data: { clientAction: ClientAction; role: Partial<IRole> }): void;
'watch.rooms'(data: { clientAction: ClientAction; room: Pick<IRoom, '_id'> & Partial<IRoom> }): void;
'watch.subscriptions'(data: { clientAction: ClientAction; subscription: Partial<ISubscription> }): void;
'watch.userSessions'(data: { clientAction: ClientAction; userSession: Partial<IUserSession> }): void;
'watch.inquiries'(data: { clientAction: ClientAction; inquiry: IInquiry; diff?: undefined | Record<string, any> }): void;
'watch.settings'(data: { clientAction: ClientAction; setting: ISetting }): void;
'watch.users'(data: {

View File

@ -1,7 +1,9 @@
import { EventEmitter } from 'events';
import { InstanceStatus } from '@rocket.chat/models';
import type { IBroker, IBrokerNode } from '../types/IBroker';
import type { ServiceClass } from '../types/ServiceClass';
import type { ServiceClass, IServiceClass } from '../types/ServiceClass';
import { asyncLocalStorage } from '..';
import type { EventSignatures } from './Events';
import { StreamerCentral } from '../../modules/streamer/streamer.module';
@ -49,9 +51,11 @@ export class LocalBroker implements IBroker {
}
}
createService(instance: ServiceClass): void {
createService(instance: IServiceClass): void {
const namespace = instance.getName();
instance.created();
instance.getEvents().forEach((eventName) => {
this.events.on(eventName, (...args) => {
instance.emit(eventName, ...(args as Parameters<EventSignatures[typeof eventName]>));
@ -70,6 +74,8 @@ export class LocalBroker implements IBroker {
this.methods.set(`${namespace}.${method}`, i[method].bind(i));
}
instance.started();
}
async broadcast<T extends keyof EventSignatures>(event: T, ...args: Parameters<EventSignatures[T]>): Promise<void> {
@ -91,6 +97,9 @@ export class LocalBroker implements IBroker {
}
async nodeList(): Promise<IBrokerNode[]> {
return [];
// TODO models should not be called form here. we should create an abstraction to an internal service to perform this query
const instances = await InstanceStatus.find({}, { projection: { _id: 1 } }).toArray();
return instances.map(({ _id }) => ({ id: _id, available: true }));
}
}

View File

@ -0,0 +1,27 @@
import type { IBroker, IBrokerNode } from './IBroker';
import type { IServiceClass } from './ServiceClass';
import type { EventSignatures } from '../lib/Events';
export interface IApiService {
setBroker(broker: IBroker): void;
destroyService(instance: IServiceClass): void;
registerService(instance: IServiceClass): void;
call(method: string, data?: unknown): Promise<any>;
waitAndCall(method: string, data: any): Promise<any>;
broadcast<T extends keyof EventSignatures>(event: T, ...args: Parameters<EventSignatures[T]>): Promise<void>;
broadcastToServices<T extends keyof EventSignatures>(
services: string[],
event: T,
...args: Parameters<EventSignatures[T]>
): Promise<void>;
broadcastLocal<T extends keyof EventSignatures>(event: T, ...args: Parameters<EventSignatures[T]>): Promise<void>;
nodeList(): Promise<IBrokerNode[]>;
}

View File

@ -0,0 +1,3 @@
import type { IServiceClass } from './ServiceClass';
export type IAppsEngineService = IServiceClass;

View File

@ -1,18 +1,18 @@
import type { ServiceClass } from './ServiceClass';
import type { IServiceClass } from './ServiceClass';
import type { EventSignatures } from '../lib/Events';
export interface IBrokerNode {
id: string;
instanceID: string;
instanceID?: string;
available: boolean;
local: boolean;
local?: boolean;
// lastHeartbeatTime: 16,
// config: {},
// client: { type: 'nodejs', version: '0.14.10', langVersion: 'v12.18.3' },
// metadata: {},
// ipList: [ '192.168.0.100', '192.168.1.25' ],
// port: 59989,
// hostname: 'RocketChats-MacBook-Pro-Rodrigo-Nascimento.local',
// hostname: 'service.local-1',
// udpAddress: null,
// cpu: 25,
// cpuSeq: 1,
@ -47,8 +47,8 @@ export interface IServiceMetrics {
export interface IBroker {
metrics?: IServiceMetrics;
destroyService(service: ServiceClass): void;
createService(service: ServiceClass): void;
destroyService(service: IServiceClass): void;
createService(service: IServiceClass): void;
call(method: string, data: any): Promise<any>;
waitAndCall(method: string, data: any): Promise<any>;
broadcastToServices<T extends keyof EventSignatures>(

View File

@ -0,0 +1,3 @@
import type { IServiceClass } from './ServiceClass';
export type IOmnichannelService = IServiceClass;

View File

@ -3,10 +3,19 @@ import type { UserStatus } from '@rocket.chat/core-typings';
import type { IServiceClass } from './ServiceClass';
export interface IPresence extends IServiceClass {
newConnection(uid: string, session: string): Promise<{ uid: string; connectionId: string } | undefined>;
removeConnection(uid: string, session: string): Promise<{ uid: string; session: string }>;
newConnection(
uid: string | undefined,
session: string | undefined,
nodeId: string,
): Promise<{ uid: string; connectionId: string } | undefined>;
removeConnection(
uid: string | undefined,
session: string | undefined,
nodeId: string,
): Promise<{ uid: string; session: string } | undefined>;
removeLostConnections(nodeID: string): Promise<string[]>;
setStatus(uid: string, status: UserStatus, statusText?: string): Promise<boolean>;
setConnectionStatus(uid: string, status: UserStatus, session: string): Promise<boolean>;
updateUserPresence(uid: string): Promise<void>;
toggleBroadcast(enabled: boolean): void;
}

View File

@ -3,6 +3,7 @@ import { EventEmitter } from 'events';
import { asyncLocalStorage } from '..';
import type { IBroker, IBrokerNode } from './IBroker';
import type { EventSignatures } from '../lib/Events';
import type { IApiService } from './IApiService';
export interface IServiceContext {
id: string; // Context ID
@ -29,12 +30,18 @@ export interface IServiceClass {
onNodeConnected?({ node, reconnected }: { node: IBrokerNode; reconnected: boolean }): void;
onNodeUpdated?({ node }: { node: IBrokerNode }): void;
onNodeDisconnected?({ node, unexpected }: { node: IBrokerNode; unexpected: boolean }): Promise<void>;
getEvents(): Array<keyof EventSignatures>;
setApi(api: IApiService): void;
onEvent<T extends keyof EventSignatures>(event: T, handler: EventSignatures[T]): void;
emit<T extends keyof EventSignatures>(event: T, ...args: Parameters<EventSignatures[T]>): void;
created?(): Promise<void>;
started?(): Promise<void>;
stopped?(): Promise<void>;
isInternal(): boolean;
created(): Promise<void>;
started(): Promise<void>;
stopped(): Promise<void>;
}
export abstract class ServiceClass implements IServiceClass {
@ -44,10 +51,16 @@ export abstract class ServiceClass implements IServiceClass {
protected internal = false;
protected api: IApiService;
constructor() {
this.emit = this.emit.bind(this);
}
setApi(api: IApiService): void {
this.api = api;
}
getEvents(): Array<keyof EventSignatures> {
return this.events.eventNames() as unknown as Array<keyof EventSignatures>;
}
@ -71,6 +84,18 @@ export abstract class ServiceClass implements IServiceClass {
public emit<T extends keyof EventSignatures>(event: T, ...args: Parameters<EventSignatures[T]>): void {
this.events.emit(event, ...args);
}
async created(): Promise<void> {
// noop
}
async started(): Promise<void> {
// noop
}
async stopped(): Promise<void> {
// noop
}
}
/**

View File

@ -0,0 +1,17 @@
import { ServiceClassInternal } from '../../sdk/types/ServiceClass';
import type { IAppsEngineService } from '../../sdk/types/IAppsEngineService';
import { Apps, AppEvents } from '../../../app/apps/server/orchestrator';
export class AppsEngineService extends ServiceClassInternal implements IAppsEngineService {
protected name = 'apps-engine';
async created() {
this.onEvent('presence.status', async ({ user, previousStatus }): Promise<void> => {
Apps.triggerEvent(AppEvents.IPostUserStatusChanged, {
user,
currentStatus: user.status,
previousStatus,
});
});
}
}

View File

@ -1,6 +1,5 @@
import { Meteor } from 'meteor/meteor';
import { ServiceConfiguration } from 'meteor/service-configuration';
import { UserPresenceMonitor, UserPresence } from 'meteor/konecty:user-presence';
import { MongoInternals } from 'meteor/mongo';
import type { IUser } from '@rocket.chat/core-typings';
import { Users } from '@rocket.chat/models';
@ -16,10 +15,9 @@ import { RoutingManager } from '../../../app/livechat/server/lib/RoutingManager'
import { onlineAgents, monitorAgents } from '../../../app/livechat/server/lib/stream/agentStatus';
import { matrixBroadCastActions } from '../../stream/streamBroadcast';
import { triggerHandler } from '../../../app/integrations/server/lib/triggerHandler';
import { ListenersModule, minimongoChangeMap } from '../../modules/listeners/listeners.module';
import { ListenersModule } from '../../modules/listeners/listeners.module';
import notifications from '../../../app/notifications/server/lib/Notifications';
import { configureEmailInboxes } from '../../features/EmailInbox/EmailInbox';
import { isPresenceMonitorEnabled } from '../../lib/isPresenceMonitorEnabled';
import { use } from '../../../app/settings/server/Middleware';
import type { IRoutingManagerConfig } from '../../../definition/IRoutingManagerConfig';
@ -148,38 +146,14 @@ export class MeteorService extends ServiceClassInternal implements IMeteor {
setValue(setting._id, undefined);
});
// TODO: May need to merge with https://github.com/RocketChat/Rocket.Chat/blob/0ddc2831baf8340cbbbc432f88fc2cb97be70e9b/ee/server/services/Presence/Presence.ts#L28
if (isPresenceMonitorEnabled()) {
this.onEvent('watch.userSessions', async ({ clientAction, userSession }): Promise<void> => {
if (clientAction === 'removed') {
UserPresenceMonitor.processUserSession(
{
_id: userSession._id,
connections: [
{
fake: true,
},
],
},
'removed',
);
}
UserPresenceMonitor.processUserSession(userSession, minimongoChangeMap[clientAction]);
});
}
this.onEvent('watch.instanceStatus', async ({ clientAction, id, data }): Promise<void> => {
if (clientAction === 'removed') {
UserPresence.removeConnectionsByInstanceId(id);
matrixBroadCastActions?.removed?.(id);
return;
}
if (clientAction === 'inserted') {
if (data?.extraInformation?.port) {
matrixBroadCastActions?.added?.(data);
}
if (clientAction === 'inserted' && data?.extraInformation?.port) {
matrixBroadCastActions?.added?.(data);
}
});

View File

@ -0,0 +1,20 @@
import { ServiceClassInternal } from '../../sdk/types/ServiceClass';
import type { IOmnichannelService } from '../../sdk/types/IOmnichannelService';
import { Livechat } from '../../../app/livechat/server';
export class OmnichannelService extends ServiceClassInternal implements IOmnichannelService {
protected name = 'omnichannel';
async created() {
this.onEvent('presence.status', async ({ user }): Promise<void> => {
if (!user?._id) {
return;
}
const hasRole = user.roles.some((role) => ['livechat-manager', 'livechat-monitor', 'livechat-agent'].includes(role));
if (hasRole) {
// TODO change `Livechat.notifyAgentStatusChanged` to a service call
Livechat.notifyAgentStatusChanged(user._id, user.status);
}
});
}
}

View File

@ -2,6 +2,7 @@ import { MongoInternals } from 'meteor/mongo';
import { AnalyticsService } from './analytics/service';
import { api } from '../sdk/api';
import { AppsEngineService } from './apps-engine/service';
import { AuthorizationLivechat } from '../../app/livechat/server/roomAccessValidator.internalService';
import { BannerService } from './banner/service';
import { LDAPService } from './ldap/service';
@ -12,6 +13,7 @@ import { RoomService } from './room/service';
import { SAUMonitorService } from './sauMonitor/service';
import { TeamService } from './team/service';
import { UiKitCoreApp } from './uikit-core-app/service';
import { OmnichannelService } from './omnichannel/service';
import { OmnichannelVoipService } from './omnichannel-voip/service';
import { VoipService } from './voip/service';
import { VideoConfService } from './video-conference/service';
@ -21,6 +23,7 @@ import { DeviceManagementService } from './device-management/service';
const { db } = MongoInternals.defaultRemoteCollectionDriver().mongo;
api.registerService(new AppsEngineService());
api.registerService(new AnalyticsService());
api.registerService(new AuthorizationLivechat());
api.registerService(new BannerService());
@ -31,6 +34,7 @@ api.registerService(new NPSService());
api.registerService(new RoomService());
api.registerService(new SAUMonitorService());
api.registerService(new VoipService(db));
api.registerService(new OmnichannelService());
api.registerService(new OmnichannelVoipService());
api.registerService(new TeamService());
api.registerService(new UiKitCoreApp());
@ -41,8 +45,11 @@ api.registerService(new VideoConfService());
// if the process is running in micro services mode we don't need to register services that will run separately
if (!isRunningMs()) {
(async (): Promise<void> => {
const { Presence } = await import('@rocket.chat/presence');
const { Authorization } = await import('./authorization/service');
api.registerService(new Presence());
api.registerService(new Authorization(db));
})();
}

View File

@ -1,12 +1,18 @@
import './migrations';
import './watchDb';
import './appcache';
import './callbacks';
import './cron';
import './initialData';
import './instance';
import './presence';
import './serverRunning';
import './coreApps';
import './presenceTroubleshoot';
import '../hooks';
import '../lib/rooms/roomTypes';
import { isRunningMs } from '../lib/isRunningMs';
// only starts network broker if running in micro services mode
if (!isRunningMs()) {
require('./watchDb');
require('./presence');
}

View File

@ -1,19 +0,0 @@
import { Meteor } from 'meteor/meteor';
import { UserPresence } from 'meteor/konecty:user-presence';
import { InstanceStatus, UsersSessions } from '@rocket.chat/models';
import { isPresenceMonitorEnabled } from '../lib/isPresenceMonitorEnabled';
Meteor.startup(function () {
UserPresence.start();
if (!isPresenceMonitorEnabled()) {
return;
}
// UserPresenceMonitor.start();
// Remove lost connections
const ids = Promise.await(InstanceStatus.find({}, { projection: { _id: 1 } }).toArray()).map((id) => id._id);
Promise.await(UsersSessions.clearConnectionsFromInstanceId(ids));
});

View File

@ -0,0 +1,35 @@
import { Accounts } from 'meteor/accounts-base';
import { Meteor } from 'meteor/meteor';
import { InstanceStatus } from 'meteor/konecty:multiple-instances-status';
import { Presence } from '../sdk';
Meteor.startup(function () {
const nodeId = InstanceStatus.id();
Meteor.onConnection(function (connection) {
const session = Meteor.server.sessions.get(connection.id);
connection.onClose(function () {
if (!session) {
return;
}
Presence.removeConnection(session.userId, connection.id, nodeId);
});
});
process.on('exit', function () {
Presence.removeLostConnections(nodeId);
});
Accounts.onLogin(function (login: any): void {
if (login.type !== 'resume') {
return;
}
Presence.newConnection(login.user._id, login.connection.id, nodeId);
});
Accounts.onLogout(function (login: any): void {
Presence.removeConnection(login.user._id, login.connection.id, nodeId);
});
});

View File

@ -0,0 +1,11 @@
import { settings } from '../../app/settings/server';
import { Presence } from '../sdk';
// maybe this setting should disable the listener to 'presence.status' event on listerners.module.ts
settings.watch('Troubleshoot_Disable_Presence_Broadcast', async function (value) {
try {
await Presence.toggleBroadcast(!value);
} catch (e) {
// do nothing
}
});

View File

@ -2,17 +2,14 @@ import { MongoInternals } from 'meteor/mongo';
import { DatabaseWatcher } from '../database/DatabaseWatcher';
import { db } from '../database/utils';
import { isRunningMs } from '../lib/isRunningMs';
import { initWatchers } from '../modules/watchers/watchers.module';
import { api } from '../sdk/api';
import { metrics } from '../../app/metrics/server/lib/metrics';
if (!isRunningMs()) {
const { mongo } = MongoInternals.defaultRemoteCollectionDriver();
const { mongo } = MongoInternals.defaultRemoteCollectionDriver();
const watcher = new DatabaseWatcher({ db, _oplogHandle: (mongo as any)._oplogHandle, metrics });
const watcher = new DatabaseWatcher({ db, _oplogHandle: (mongo as any)._oplogHandle, metrics });
initWatchers(watcher, api.broadcastLocal.bind(api));
initWatchers(watcher, api.broadcastLocal.bind(api));
watcher.watch();
}
watcher.watch();

View File

@ -1,5 +1,4 @@
import { Meteor } from 'meteor/meteor';
import { UserPresence } from 'meteor/konecty:user-presence';
import { InstanceStatus } from 'meteor/konecty:multiple-instances-status';
import { check } from 'meteor/check';
import { DDP } from 'meteor/ddp';
@ -10,9 +9,7 @@ import { Logger } from '../lib/logger/Logger';
import { hasPermission } from '../../app/authorization/server';
import { settings } from '../../app/settings/server';
import { isDocker, getURL } from '../../app/utils/server';
import { Users } from '../../app/models/server';
import { StreamerCentral } from '../modules/streamer/streamer.module';
import { isPresenceMonitorEnabled } from '../lib/isPresenceMonitorEnabled';
process.env.PORT = String(process.env.PORT).trim();
process.env.INSTANCE_IP = String(process.env.INSTANCE_IP).trim();
@ -57,13 +54,8 @@ function authorizeConnection(instance) {
}
const cache = new Map();
const originalSetDefaultStatus = UserPresence.setDefaultStatus;
export let matrixBroadCastActions;
function startMatrixBroadcast() {
if (!isPresenceMonitorEnabled()) {
UserPresence.setDefaultStatus = originalSetDefaultStatus;
}
matrixBroadCastActions = {
added: Meteor.bindEnvironment((record) => {
cache.set(record._id, record);
@ -156,12 +148,6 @@ function startStreamCastBroadcast(value) {
connLogger.info({ msg: 'connecting in', instance, value });
if (!isPresenceMonitorEnabled()) {
UserPresence.setDefaultStatus = (id, status) => {
Users.updateDefaultStatus(id, status);
};
}
const connection = DDP.connect(value, {
_dontPrintErrors: settings.get('Log_Level') !== '2',
});

View File

@ -56,9 +56,9 @@ services:
presence-service:
build:
dockerfile: apps/meteor/ee/server/services/Dockerfile
dockerfile: ee/apps/presence-service/Dockerfile
args:
SERVICE: presence
SERVICE: presence-service
image: ghcr.io/${LOWERCASE_REPOSITORY}/presence-service:${DOCKER_TAG}
environment:
- MONGO_URL=${MONGO_URL}

View File

@ -5,6 +5,7 @@
"description": "Rocket.Chat DDP-Streamer service",
"scripts": {
"build": "tsc -p tsconfig.json",
"ms": "MONGO_URL=${MONGO_URL:-mongodb://localhost:3001/meteor} ts-node --files src/service.ts",
"test": "echo \"Error: no test specified\" && exit 1",
"lint": "eslint src",
"typecheck": "tsc --noEmit --skipLibCheck -p tsconfig.json"
@ -47,6 +48,7 @@
"@types/ws": "^8.5.3",
"eslint": "^8.21.0",
"pino-pretty": "^7.6.1",
"ts-node": "^10.9.1",
"typescript": "~4.5.5"
},
"main": "./dist/service.js",

View File

@ -6,8 +6,9 @@ import WebSocket from 'ws';
import { ListenersModule } from '../../../../apps/meteor/server/modules/listeners/listeners.module';
import { StreamerCentral } from '../../../../apps/meteor/server/modules/streamer/streamer.module';
import { MeteorService } from '../../../../apps/meteor/server/sdk';
import { MeteorService, Presence } from '../../../../apps/meteor/server/sdk';
import { ServiceClass } from '../../../../apps/meteor/server/sdk/types/ServiceClass';
import { api } from '../../../../apps/meteor/server/sdk/api';
import { Client } from './Client';
import { events, server } from './configureServer';
import { DDP_EVENTS } from './constants';
@ -104,7 +105,7 @@ export class DDPStreamer extends ServiceClass {
}
const { broker, nodeID } = this.context;
if (!broker) {
if (!broker || !nodeID) {
return;
}
@ -141,5 +142,38 @@ export class DDPStreamer extends ServiceClass {
metrics.decrement('users_logged', { nodeID }, 1);
}
});
server.on(DDP_EVENTS.LOGGED, (info) => {
const { userId, connection } = info;
Presence.newConnection(userId, connection.id, nodeID);
api.broadcast('accounts.login', { userId, connection });
});
server.on(DDP_EVENTS.LOGGEDOUT, (info) => {
const { userId, connection } = info;
api.broadcast('accounts.logout', { userId, connection });
if (!userId) {
return;
}
Presence.removeConnection(userId, connection.id, nodeID);
});
server.on(DDP_EVENTS.DISCONNECTED, (info) => {
const { userId, connection } = info;
api.broadcast('socket.disconnected', connection);
if (!userId) {
return;
}
Presence.removeConnection(userId, connection.id, nodeID);
});
server.on(DDP_EVENTS.CONNECTED, ({ connection }) => {
api.broadcast('socket.connected', connection);
});
}
}

View File

@ -5,7 +5,6 @@ import { UserStatus } from '@rocket.chat/core-typings';
import { DDP_EVENTS, WS_ERRORS } from './constants';
import { Account, Presence, MeteorService } from '../../../../apps/meteor/server/sdk';
import { Server } from './Server';
import { api } from '../../../../apps/meteor/server/sdk/api';
import { MeteorError } from '../../../../apps/meteor/server/sdk/errors';
import { Autoupdate } from './lib/Autoupdate';
@ -74,6 +73,7 @@ server.methods({
this.userId = result.uid;
this.userToken = result.hashedToken;
this.connection.loginToken = result.hashedToken;
this.emit(DDP_EVENTS.LOGGED);
@ -152,36 +152,3 @@ server.methods({
}
},
});
server.on(DDP_EVENTS.LOGGED, (info) => {
const { userId, connection } = info;
Presence.newConnection(userId, connection.id);
api.broadcast('accounts.login', { userId, connection });
});
server.on(DDP_EVENTS.LOGGEDOUT, (info) => {
const { userId, connection } = info;
api.broadcast('accounts.logout', { userId, connection });
if (!userId) {
return;
}
Presence.removeConnection(userId, connection.id);
});
server.on(DDP_EVENTS.DISCONNECTED, (info) => {
const { userId, connection } = info;
api.broadcast('socket.disconnected', connection);
if (!userId) {
return;
}
Presence.removeConnection(userId, connection.id);
});
server.on(DDP_EVENTS.CONNECTED, ({ connection }) => {
api.broadcast('socket.connected', connection);
});

View File

@ -0,0 +1,16 @@
{
"extends": ["@rocket.chat/eslint-config"],
"overrides": [
{
"files": ["**/*.spec.js", "**/*.spec.jsx"],
"env": {
"jest": true
}
}
],
"ignorePatterns": ["**/dist"],
"plugins": ["jest"],
"env": {
"jest/globals": true
}
}

View File

@ -0,0 +1,38 @@
FROM node:14.19.3-alpine
ARG SERVICE
WORKDIR /app
COPY ./packages/presence/package.json packages/presence/package.json
COPY ./packages/presence/dist packages/presence/dist
COPY ./packages/core-typings/package.json packages/core-typings/package.json
COPY ./packages/core-typings/dist packages/core-typings/dist
COPY ./packages/rest-typings/package.json packages/rest-typings/package.json
COPY ./packages/rest-typings/dist packages/rest-typings/dist
COPY ./packages/model-typings/package.json packages/model-typings/package.json
COPY ./packages/model-typings/dist packages/model-typings/dist
COPY ./packages/models/package.json packages/models/package.json
COPY ./packages/models/dist packages/models/dist
COPY ./packages/ui-contexts/package.json packages/ui-contexts/package.json
COPY ./packages/ui-contexts/dist packages/ui-contexts/dist
COPY ./ee/apps/${SERVICE}/dist .
COPY ./package.json .
COPY ./yarn.lock .
COPY ./.yarnrc.yml .
COPY ./.yarn/plugins .yarn/plugins
COPY ./.yarn/releases .yarn/releases
COPY ./ee/apps/${SERVICE}/package.json ee/apps/${SERVICE}/package.json
ENV NODE_ENV=production \
PORT=3000
WORKDIR /app/ee/apps/${SERVICE}
RUN yarn workspaces focus --production
EXPOSE 3000 9458
CMD ["node", "src/service.js"]

View File

@ -0,0 +1,43 @@
{
"name": "@rocket.chat/presence-service",
"private": true,
"version": "0.1.0",
"description": "Rocket.Chat Presence service",
"scripts": {
"build": "tsc -p tsconfig.json",
"ms": "MONGO_URL=${MONGO_URL:-mongodb://localhost:3001/meteor} ts-node --files src/service.ts",
"test": "echo \"Error: no test specified\" && exit 1",
"lint": "eslint src",
"typecheck": "tsc --noEmit --skipLibCheck -p tsconfig.json"
},
"keywords": [
"rocketchat"
],
"author": "Rocket.Chat",
"dependencies": {
"@rocket.chat/emitter": "next",
"@rocket.chat/presence": "workspace:^",
"@rocket.chat/string-helpers": "next",
"@types/node": "^14.18.21",
"ejson": "^2.2.2",
"eventemitter3": "^4.0.7",
"fibers": "^5.0.1",
"moleculer": "^0.14.21",
"mongodb": "^4.3.1",
"nats": "^2.4.0",
"pino": "^8.4.2",
"polka": "^0.5.2"
},
"devDependencies": {
"@rocket.chat/eslint-config": "workspace:^",
"@types/eslint": "^8",
"@types/polka": "^0.5.4",
"eslint": "^8.21.0",
"ts-node": "^10.9.1",
"typescript": "~4.5.5"
},
"main": "./dist/presence/src/Presence.js",
"files": [
"/dist"
]
}

View File

@ -0,0 +1,28 @@
import type { Document } from 'mongodb';
import polka from 'polka';
import '../../../../apps/meteor/ee/server/startup/broker';
import { api } from '../../../../apps/meteor/server/sdk/api';
import { Collections, getCollection, getConnection } from '../../../../apps/meteor/ee/server/services/mongo';
import { registerServiceModels } from '../../../../apps/meteor/ee/server/lib/registerServiceModels';
const PORT = process.env.PORT || 3031;
getConnection().then(async (db) => {
const trash = await getCollection<Document>(Collections.Trash);
registerServiceModels(db, trash);
// need to import Presence service after models are registered
const { Presence } = await import('@rocket.chat/presence');
api.registerService(new Presence());
polka()
.get('/health', async function (_req, res) {
await api.nodeList();
res.end('ok');
})
.listen(PORT);
});

View File

@ -0,0 +1,31 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"target": "es2018",
"lib": ["esnext", "dom"],
"allowJs": true,
"checkJs": false,
"incremental": true,
/* Strict Type-Checking Options */
"noImplicitAny": true,
"strictNullChecks": true,
"strictPropertyInitialization": false,
"strictFunctionTypes": false,
/* Additional Checks */
"noUnusedLocals": true,
"noUnusedParameters": true,
"noImplicitReturns": false,
"noFallthroughCasesInSwitch": false,
/* Module Resolution Options */
"outDir": "./dist",
"importsNotUsedAsValues": "preserve",
"declaration": false,
"declarationMap": false
},
"files": ["./src/service.ts"],
"include": ["../../../apps/meteor/definition"],
"exclude": ["./dist"]
}

View File

@ -3,6 +3,7 @@ import type { IncomingHttpHeaders } from 'http';
export interface ISocketConnection {
id: string;
instanceId: string;
loginToken?: string;
livechatToken?: string;
onClose(fn: (...args: any[]) => void): void;
clientAddress: string | undefined;

View File

@ -1,5 +1,5 @@
import type { Document, UpdateResult, FindCursor, FindOptions } from 'mongodb';
import type { IUser, IRole, IRoom, ILivechatAgent } from '@rocket.chat/core-typings';
import type { IUser, IRole, IRoom, ILivechatAgent, UserStatus } from '@rocket.chat/core-typings';
import type { FindPaginated, IBaseModel } from './IBaseModel';
@ -153,4 +153,14 @@ export interface IUsersModel extends IBaseModel<IUser> {
removeRoomByRoomId(rid: any): any;
findOneByResetToken(token: string, options: FindOptions<IUser>): Promise<IUser | null>;
updateStatusById(
userId: string,
{
statusDefault,
status,
statusConnection,
statusText,
}: { statusDefault?: string; status: UserStatus; statusConnection: UserStatus; statusText?: string },
): Promise<UpdateResult>;
}

View File

@ -1,7 +1,18 @@
import type { IUserSession } from '@rocket.chat/core-typings';
import type { FindCursor, FindOptions } from 'mongodb';
import type { IUserSession, IUserSessionConnection } from '@rocket.chat/core-typings';
import type { IBaseModel } from './IBaseModel';
export interface IUsersSessionsModel extends IBaseModel<IUserSession> {
clearConnectionsFromInstanceId(instanceId: string[]): ReturnType<IBaseModel<IUserSession>['updateMany']>;
updateConnectionStatusById(uid: string, connectionId: string, status: string): ReturnType<IBaseModel<IUserSession>['updateOne']>;
removeConnectionsFromInstanceId(instanceId: string): ReturnType<IBaseModel<IUserSession>['updateMany']>;
removeConnectionByConnectionId(connectionId: string): ReturnType<IBaseModel<IUserSession>['updateMany']>;
findByInstanceId(instanceId: string): FindCursor<IUserSession>;
addConnectionById(
userId: string,
{ id, instanceId, status }: Pick<IUserSessionConnection, 'id' | 'instanceId' | 'status'>,
): ReturnType<IBaseModel<IUserSession>['updateOne']>;
findByOtherInstanceIds(instanceIds: string[], options?: FindOptions<IUserSession>): FindCursor<IUserSession>;
removeConnectionsFromOtherInstanceIds(instanceIds: string[]): ReturnType<IBaseModel<IUserSession>['updateMany']>;
}

View File

@ -7,7 +7,10 @@ function handler<T extends object>(namespace: string): ProxyHandler<T> {
return {
get: (_target: T, prop: string): any => {
if (!models.has(namespace) && lazyModels.has(namespace)) {
models.set(namespace, (lazyModels.get(namespace) as () => IBaseModel<any>)());
const getModel = lazyModels.get(namespace);
if (getModel) {
models.set(namespace, getModel());
}
}
// @ts-ignore

View File

@ -0,0 +1,12 @@
{
"extends": ["@rocket.chat/eslint-config"],
"overrides": [
{
"files": ["**/*.spec.js", "**/*.spec.jsx"],
"env": {
"jest": true
}
}
],
"ignorePatterns": ["**/dist"]
}

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

@ -0,0 +1 @@
.nyc_output

View File

@ -0,0 +1,3 @@
module.exports = {
presets: [['@babel/preset-env', { targets: { node: 'current' } }], '@babel/preset-typescript'],
};

View File

@ -0,0 +1,37 @@
{
"name": "@rocket.chat/presence",
"version": "0.0.1",
"private": true,
"devDependencies": {
"@babel/core": "^7.19.1",
"@babel/preset-env": "^7.19.1",
"@babel/preset-typescript": "^7.18.6",
"@rocket.chat/apps-engine": "^1.32.0",
"@rocket.chat/eslint-config": "workspace:^",
"@rocket.chat/rest-typings": "workspace:^",
"@rocket.chat/ui-contexts": "workspace:^",
"@types/node": "^14.18.21",
"babel-jest": "^29.0.3",
"eslint": "^8.21.0",
"jest": "^29.0.3",
"typescript": "~4.5.5"
},
"scripts": {
"lint": "eslint src",
"lint:fix": "eslint src --fix",
"jest": "jest",
"build": "tsc -p tsconfig.json",
"testunit": "jest tests/**/*.test.ts",
"typecheck": "tsc --noEmit --skipLibCheck -p tsconfig.json"
},
"main": "./dist/packages/presence/src/Presence.js",
"typings": "./dist/packages/presence/src/Presence.d.ts",
"files": [
"/dist"
],
"dependencies": {
"@rocket.chat/core-typings": "workspace:^",
"@rocket.chat/models": "workspace:^",
"mongodb": "^4.3.1"
}
}

189
packages/presence/src/Presence.ts Executable file
View File

@ -0,0 +1,189 @@
import type { IUser } from '@rocket.chat/core-typings';
import { UserStatus } from '@rocket.chat/core-typings';
import { Users, UsersSessions } from '@rocket.chat/models';
import { processPresenceAndStatus } from './lib/processConnectionStatus';
import type { IPresence } from '../../../apps/meteor/server/sdk/types/IPresence';
import type { IBrokerNode } from '../../../apps/meteor/server/sdk/types/IBroker';
import { ServiceClass } from '../../../apps/meteor/server/sdk/types/ServiceClass';
export class Presence extends ServiceClass implements IPresence {
protected name = 'presence';
private broadcastEnabled = true;
private lostConTimeout?: NodeJS.Timeout;
async onNodeDisconnected({ node }: { node: IBrokerNode }): Promise<void> {
const affectedUsers = await this.removeLostConnections(node.id);
return affectedUsers.forEach((uid) => this.updateUserPresence(uid));
}
async created(): Promise<void> {
this.onEvent('watch.instanceStatus', async ({ clientAction, id }): Promise<void> => {
if (clientAction !== 'removed') {
return;
}
const affectedUsers = await this.removeLostConnections(id);
return affectedUsers.forEach((uid) => this.updateUserPresence(uid));
});
}
async started(): Promise<void> {
this.lostConTimeout = setTimeout(async () => {
const affectedUsers = await this.removeLostConnections();
return affectedUsers.forEach((uid) => this.updateUserPresence(uid));
}, 10000);
}
async stopped(): Promise<void> {
if (!this.lostConTimeout) {
return;
}
clearTimeout(this.lostConTimeout);
}
toggleBroadcast(enabled: boolean): void {
this.broadcastEnabled = enabled;
}
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 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);
}
async setStatus(uid: string, statusDefault: UserStatus, statusText?: string): Promise<boolean> {
const userSessions = (await UsersSessions.findOneById(uid)) || { connections: [] };
const user = await Users.findOneById<Pick<IUser, 'username' | 'roles' | 'status'>>(uid, {
projection: { username: 1, roles: 1, status: 1 },
});
const { status, statusConnection } = processPresenceAndStatus(userSessions.connections, statusDefault);
const result = await Users.updateStatusById(uid, {
statusDefault,
status,
statusConnection,
statusText,
});
if (result.modifiedCount > 0) {
this.broadcast({ _id: uid, username: user?.username, status, statusText, roles: user?.roles || [] }, user?.status);
}
return !!result.modifiedCount;
}
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;
}
async updateUserPresence(uid: string): Promise<void> {
const user = await Users.findOneById<Pick<IUser, 'username' | 'statusDefault' | 'statusText' | 'roles' | 'status'>>(uid, {
projection: {
username: 1,
statusDefault: 1,
statusText: 1,
roles: 1,
status: 1,
},
});
if (!user) {
return;
}
const userSessions = (await UsersSessions.findOneById(uid)) || { connections: [] };
const { statusDefault } = user;
const { status, statusConnection } = processPresenceAndStatus(userSessions.connections, statusDefault);
const result = await Users.updateStatusById(uid, {
status,
statusConnection,
});
if (result.modifiedCount > 0) {
this.broadcast({ _id: uid, username: user.username, status, statusText: user.statusText, roles: user.roles }, user.status);
}
}
private broadcast(
user: Pick<IUser, '_id' | 'username' | 'status' | 'statusText' | 'roles'>,
previousStatus: UserStatus | undefined,
): void {
if (!this.broadcastEnabled) {
return;
}
this.api.broadcast('presence.status', {
user,
previousStatus,
});
}
}

View File

@ -1,51 +1,47 @@
import { expect } from 'chai';
import { describe, expect, test } from '@jest/globals';
import { UserStatus } from '@rocket.chat/core-typings';
import {
processConnectionStatus,
processStatus,
processPresenceAndStatus,
} from '../../../../../../server/services/presence/lib/processConnectionStatus';
import { processConnectionStatus, processStatus, processPresenceAndStatus } from '../../src/lib/processConnectionStatus';
describe('Presence micro service', () => {
it('should return connection as online when there is a connection online', () => {
expect(processConnectionStatus(UserStatus.OFFLINE, UserStatus.ONLINE)).to.equal(UserStatus.ONLINE);
expect(processConnectionStatus(UserStatus.ONLINE, UserStatus.ONLINE)).to.equal(UserStatus.ONLINE);
expect(processConnectionStatus(UserStatus.BUSY, UserStatus.ONLINE)).to.equal(UserStatus.ONLINE);
expect(processConnectionStatus(UserStatus.AWAY, UserStatus.ONLINE)).to.equal(UserStatus.ONLINE);
test('should return connection as online when there is a connection online', () => {
expect(processConnectionStatus(UserStatus.OFFLINE, UserStatus.ONLINE)).toBe(UserStatus.ONLINE);
expect(processConnectionStatus(UserStatus.ONLINE, UserStatus.ONLINE)).toBe(UserStatus.ONLINE);
expect(processConnectionStatus(UserStatus.BUSY, UserStatus.ONLINE)).toBe(UserStatus.ONLINE);
expect(processConnectionStatus(UserStatus.AWAY, UserStatus.ONLINE)).toBe(UserStatus.ONLINE);
});
it('should return the connections status if the other connection is offline', () => {
expect(processConnectionStatus(UserStatus.OFFLINE, UserStatus.OFFLINE)).to.equal(UserStatus.OFFLINE);
expect(processConnectionStatus(UserStatus.ONLINE, UserStatus.OFFLINE)).to.equal(UserStatus.ONLINE);
expect(processConnectionStatus(UserStatus.AWAY, UserStatus.OFFLINE)).to.equal(UserStatus.AWAY);
test('should return the connections status if the other connection is offline', () => {
expect(processConnectionStatus(UserStatus.OFFLINE, UserStatus.OFFLINE)).toBe(UserStatus.OFFLINE);
expect(processConnectionStatus(UserStatus.ONLINE, UserStatus.OFFLINE)).toBe(UserStatus.ONLINE);
expect(processConnectionStatus(UserStatus.AWAY, UserStatus.OFFLINE)).toBe(UserStatus.AWAY);
});
it('should return the connection status when the default status is online', () => {
expect(processStatus(UserStatus.ONLINE, UserStatus.ONLINE)).to.equal(UserStatus.ONLINE);
expect(processStatus(UserStatus.AWAY, UserStatus.ONLINE)).to.equal(UserStatus.AWAY);
expect(processStatus(UserStatus.OFFLINE, UserStatus.ONLINE)).to.equal(UserStatus.OFFLINE);
test('should return the connection status when the default status is online', () => {
expect(processStatus(UserStatus.ONLINE, UserStatus.ONLINE)).toBe(UserStatus.ONLINE);
expect(processStatus(UserStatus.AWAY, UserStatus.ONLINE)).toBe(UserStatus.AWAY);
expect(processStatus(UserStatus.OFFLINE, UserStatus.ONLINE)).toBe(UserStatus.OFFLINE);
});
it('should return status busy when the default status is busy', () => {
expect(processStatus(UserStatus.ONLINE, UserStatus.BUSY)).to.equal(UserStatus.BUSY);
expect(processStatus(UserStatus.AWAY, UserStatus.BUSY)).to.equal(UserStatus.BUSY);
expect(processStatus(UserStatus.OFFLINE, UserStatus.BUSY)).to.equal(UserStatus.OFFLINE);
test('should return status busy when the default status is busy', () => {
expect(processStatus(UserStatus.ONLINE, UserStatus.BUSY)).toBe(UserStatus.BUSY);
expect(processStatus(UserStatus.AWAY, UserStatus.BUSY)).toBe(UserStatus.BUSY);
expect(processStatus(UserStatus.OFFLINE, UserStatus.BUSY)).toBe(UserStatus.OFFLINE);
});
it('should return status away when the default status is away', () => {
expect(processStatus(UserStatus.ONLINE, UserStatus.AWAY)).to.equal(UserStatus.AWAY);
expect(processStatus(UserStatus.AWAY, UserStatus.AWAY)).to.equal(UserStatus.AWAY);
expect(processStatus(UserStatus.OFFLINE, UserStatus.AWAY)).to.equal(UserStatus.OFFLINE);
test('should return status away when the default status is away', () => {
expect(processStatus(UserStatus.ONLINE, UserStatus.AWAY)).toBe(UserStatus.AWAY);
expect(processStatus(UserStatus.AWAY, UserStatus.AWAY)).toBe(UserStatus.AWAY);
expect(processStatus(UserStatus.OFFLINE, UserStatus.AWAY)).toBe(UserStatus.OFFLINE);
});
it('should return status offline when the default status is offline', () => {
expect(processStatus(UserStatus.ONLINE, UserStatus.OFFLINE)).to.equal(UserStatus.OFFLINE);
expect(processStatus(UserStatus.AWAY, UserStatus.OFFLINE)).to.equal(UserStatus.OFFLINE);
expect(processStatus(UserStatus.OFFLINE, UserStatus.OFFLINE)).to.equal(UserStatus.OFFLINE);
test('should return status offline when the default status is offline', () => {
expect(processStatus(UserStatus.ONLINE, UserStatus.OFFLINE)).toBe(UserStatus.OFFLINE);
expect(processStatus(UserStatus.AWAY, UserStatus.OFFLINE)).toBe(UserStatus.OFFLINE);
expect(processStatus(UserStatus.OFFLINE, UserStatus.OFFLINE)).toBe(UserStatus.OFFLINE);
});
it('should return correct status and statusConnection when connected once', () => {
test('should return correct status and statusConnection when connected once', () => {
expect(
processPresenceAndStatus(
[
@ -59,7 +55,7 @@ describe('Presence micro service', () => {
],
UserStatus.ONLINE,
),
).to.deep.equal({ status: UserStatus.ONLINE, statusConnection: UserStatus.ONLINE });
).toStrictEqual({ status: UserStatus.ONLINE, statusConnection: UserStatus.ONLINE });
expect(
processPresenceAndStatus(
@ -74,7 +70,7 @@ describe('Presence micro service', () => {
],
UserStatus.ONLINE,
),
).to.deep.equal({ status: UserStatus.AWAY, statusConnection: UserStatus.AWAY });
).toStrictEqual({ status: UserStatus.AWAY, statusConnection: UserStatus.AWAY });
expect(
processPresenceAndStatus(
@ -89,7 +85,7 @@ describe('Presence micro service', () => {
],
UserStatus.BUSY,
),
).to.deep.equal({ status: UserStatus.BUSY, statusConnection: UserStatus.ONLINE });
).toStrictEqual({ status: UserStatus.BUSY, statusConnection: UserStatus.ONLINE });
expect(
processPresenceAndStatus(
@ -104,7 +100,7 @@ describe('Presence micro service', () => {
],
UserStatus.AWAY,
),
).to.deep.equal({ status: UserStatus.AWAY, statusConnection: UserStatus.ONLINE });
).toStrictEqual({ status: UserStatus.AWAY, statusConnection: UserStatus.ONLINE });
expect(
processPresenceAndStatus(
@ -119,7 +115,7 @@ describe('Presence micro service', () => {
],
UserStatus.BUSY,
),
).to.deep.equal({ status: UserStatus.BUSY, statusConnection: UserStatus.AWAY });
).toStrictEqual({ status: UserStatus.BUSY, statusConnection: UserStatus.AWAY });
expect(
processPresenceAndStatus(
@ -134,7 +130,7 @@ describe('Presence micro service', () => {
],
UserStatus.OFFLINE,
),
).to.deep.equal({ status: UserStatus.OFFLINE, statusConnection: UserStatus.ONLINE });
).toStrictEqual({ status: UserStatus.OFFLINE, statusConnection: UserStatus.ONLINE });
expect(
processPresenceAndStatus(
@ -149,10 +145,10 @@ describe('Presence micro service', () => {
],
UserStatus.OFFLINE,
),
).to.deep.equal({ status: UserStatus.OFFLINE, statusConnection: UserStatus.AWAY });
).toStrictEqual({ status: UserStatus.OFFLINE, statusConnection: UserStatus.AWAY });
});
it('should return correct status and statusConnection when connected twice', () => {
test('should return correct status and statusConnection when connected twice', () => {
expect(
processPresenceAndStatus(
[
@ -173,7 +169,7 @@ describe('Presence micro service', () => {
],
UserStatus.ONLINE,
),
).to.deep.equal({ status: UserStatus.ONLINE, statusConnection: UserStatus.ONLINE });
).toStrictEqual({ status: UserStatus.ONLINE, statusConnection: UserStatus.ONLINE });
expect(
processPresenceAndStatus(
@ -195,7 +191,7 @@ describe('Presence micro service', () => {
],
UserStatus.ONLINE,
),
).to.deep.equal({ status: UserStatus.ONLINE, statusConnection: UserStatus.ONLINE });
).toStrictEqual({ status: UserStatus.ONLINE, statusConnection: UserStatus.ONLINE });
expect(
processPresenceAndStatus(
@ -217,26 +213,26 @@ describe('Presence micro service', () => {
],
UserStatus.ONLINE,
),
).to.deep.equal({ status: UserStatus.AWAY, statusConnection: UserStatus.AWAY });
).toStrictEqual({ status: UserStatus.AWAY, statusConnection: UserStatus.AWAY });
});
it('should return correct status and statusConnection when not connected', () => {
expect(processPresenceAndStatus([], UserStatus.ONLINE)).to.deep.equal({
test('should return correct status and statusConnection when not connected', () => {
expect(processPresenceAndStatus([], UserStatus.ONLINE)).toStrictEqual({
status: UserStatus.OFFLINE,
statusConnection: UserStatus.OFFLINE,
});
expect(processPresenceAndStatus([], UserStatus.BUSY)).to.deep.equal({
expect(processPresenceAndStatus([], UserStatus.BUSY)).toStrictEqual({
status: UserStatus.OFFLINE,
statusConnection: UserStatus.OFFLINE,
});
expect(processPresenceAndStatus([], UserStatus.AWAY)).to.deep.equal({
expect(processPresenceAndStatus([], UserStatus.AWAY)).toStrictEqual({
status: UserStatus.OFFLINE,
statusConnection: UserStatus.OFFLINE,
});
expect(processPresenceAndStatus([], UserStatus.OFFLINE)).to.deep.equal({
expect(processPresenceAndStatus([], UserStatus.OFFLINE)).toStrictEqual({
status: UserStatus.OFFLINE,
statusConnection: UserStatus.OFFLINE,
});

View File

@ -0,0 +1,30 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"target": "es2018",
"lib": ["esnext", "dom"],
"allowJs": true,
"checkJs": false,
"incremental": true,
/* Strict Type-Checking Options */
"noImplicitAny": true,
"strictNullChecks": true,
"strictPropertyInitialization": false,
/* Additional Checks */
"noUnusedLocals": true,
"noUnusedParameters": true,
"noImplicitReturns": false,
"noFallthroughCasesInSwitch": false,
/* Module Resolution Options */
"outDir": "./dist",
"importsNotUsedAsValues": "preserve",
// "declaration": false,
"declarationMap": false
},
"include": ["../../apps/meteor/definition/externals/meteor/rocketchat-streamer.d.ts"],
"exclude": ["./dist"],
"files": ["./src/Presence.ts"]
}

View File

@ -1,4 +1,4 @@
import type { IExportOperation, ISubscription, ITeam, IUser, IPersonalAccessToken } from '@rocket.chat/core-typings';
import type { IExportOperation, ISubscription, ITeam, IUser, IPersonalAccessToken, UserStatus } from '@rocket.chat/core-typings';
import Ajv from 'ajv';
import type { UserCreateParamsPOST } from './users/UserCreateParamsPOST';
@ -268,7 +268,7 @@ export type UsersEndpoints = {
};
'/v1/users.setStatus': {
POST: (params: { message?: string; status?: 'online' | 'offline' | 'away' | 'busy' }) => void;
POST: (params: { message?: string; status?: UserStatus }) => void;
};
'/v1/users.getStatus': {

View File

@ -36,6 +36,9 @@
"dependsOn": ["build"],
"cache": false
},
"ms": {
"dependsOn": ["^build"]
},
"@rocket.chat/ui-contexts#build": {
"dependsOn": ["^build"],
"cache": false

1578
yarn.lock

File diff suppressed because it is too large Load Diff