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
Code scanning - action / CodeQL-Build (push) Failing after 1s
Auto-close duplicate issues / auto-close-duplicates (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

View File

@@ -0,0 +1,16 @@
import { Permissions } from '@rocket.chat/models';
export const createPermissions = async () => {
const permissions = [
{ _id: 'abac-management', roles: ['admin'] },
{ _id: 'manage-abac-admin-settings', roles: ['admin'] },
{ _id: 'manage-abac-admin-room-attributes', roles: ['admin'] },
{ _id: 'manage-abac-admin-rooms', roles: ['admin'] },
{ _id: 'view-abac-admin-audit', roles: ['admin'] },
{ _id: 'bypass-abac-store-validation', roles: [] },
];
for (const permission of permissions) {
void Permissions.create(permission._id, permission.roles);
}
};

View File

@@ -0,0 +1,46 @@
import { AppStatus } from '@rocket.chat/apps-engine/definition/AppStatus';
import type { LicenseImp } from '@rocket.chat/license';
import { i18n } from '../../../../server/lib/i18n';
import { sendMessagesToAdmins } from '../../../../server/lib/sendMessagesToAdmins';
import { Apps } from '../../apps';
type OnModuleCallbackParameter = Parameters<Parameters<LicenseImp['onModule']>[0]>[0];
export async function _disableAppsWithAddonsCallback(
deps: { Apps: typeof Apps; sendMessagesToAdmins: typeof sendMessagesToAdmins },
{ module, external, valid }: OnModuleCallbackParameter,
) {
if (!external || valid) return;
const enabledApps = await deps.Apps.installedApps({ enabled: true });
if (!enabledApps) return;
const affectedApps: string[] = [];
await Promise.all(
enabledApps.map(async (app) => {
if (app.getInfo().addon !== module) return;
affectedApps.push(app.getName());
return deps.Apps.getManager()?.disable(app.getID(), AppStatus.DISABLED, false);
}),
);
if (!affectedApps.length) return;
await deps.sendMessagesToAdmins({
msgs: async ({ adminUser }) => ({
msg: i18n.t('App_has_been_disabled_addon_message', {
lng: adminUser.language || 'en',
count: affectedApps.length,
appNames: affectedApps,
}),
}),
});
}
export const disableAppsWithAddonsCallback = (ctx: OnModuleCallbackParameter) =>
_disableAppsWithAddonsCallback({ Apps, sendMessagesToAdmins }, ctx);

View File

@@ -0,0 +1,235 @@
import type { ILivechatAgent, ILivechatVisitor, IMessage, IRoom, IUser, IAuditLog } from '@rocket.chat/core-typings';
import { LivechatRooms, Messages, Rooms, Users, AuditLog } from '@rocket.chat/models';
import { escapeRegExp, isTruthy } from '@rocket.chat/tools';
import { Meteor } from 'meteor/meteor';
import type { Filter } from 'mongodb';
import { hasPermissionAsync } from '../../../../server/lib/authorization/hasPermission';
import { callbacks } from '../../../../server/lib/callbacks';
import { i18n } from '../../../../server/lib/i18n';
import { updateCounter } from '../../../../server/lib/statistics';
const getValue = (room: IRoom | null) => room && { rids: [room._id], name: room.name };
const getUsersIdFromUserName = async (usernames: IUser['username'][]) => {
const users = usernames ? await Users.findByUsernames(usernames.filter(isTruthy)).toArray() : undefined;
return users?.filter(isTruthy).map((userId) => userId._id);
};
const getRoomInfoByAuditParams = async ({
type,
roomId: rid,
users: usernames,
visitor,
agent,
userId,
}: {
type: string;
roomId: IRoom['_id'];
users: NonNullable<IUser['username']>[];
visitor: ILivechatVisitor['_id'];
agent: ILivechatAgent['_id'];
userId: string;
}) => {
if (rid) {
return getValue(await Rooms.findOne({ _id: rid, abacAttributes: { $exists: false } }));
}
if (type === 'd') {
return getValue(await Rooms.findDirectRoomContainingAllUsernames(usernames));
}
if (type === 'l') {
const extraQuery = await callbacks.run('livechat.applyRoomRestrictions', {}, { userId });
const rooms = await LivechatRooms.findByVisitorIdAndAgentId(
visitor,
agent,
{
projection: { _id: 1 },
},
extraQuery,
).toArray();
return rooms?.length ? { rids: rooms.map(({ _id }) => _id), name: i18n.t('Omnichannel') } : undefined;
}
};
const requireAuditor = async (userId: string | null): Promise<IUser> => {
if (!userId) {
throw new Meteor.Error('Not allowed');
}
const user = await Users.findOneById(userId);
if (!user || !(await hasPermissionAsync(user._id, 'can-audit'))) {
throw new Meteor.Error('Not allowed');
}
return user;
};
type AuditMessagesParams = {
rid?: IRoom['_id'];
startDate: Date;
endDate: Date;
users: NonNullable<IUser['username']>[];
msg: IMessage['msg'];
type: string;
visitor?: ILivechatVisitor['_id'];
agent?: ILivechatAgent['_id'];
};
export const auditGetMessagesMethod = async (userId: string | null, params: AuditMessagesParams): Promise<IMessage[]> => {
const { rid, startDate, endDate, users: usernames, msg, type, visitor, agent } = params;
const user = await requireAuditor(userId);
const userFields = {
_id: user._id,
username: user.username,
...(user.name && { name: user.name }),
...(user.avatarETag && { avatarETag: user.avatarETag }),
};
let rids;
let name;
const query: Filter<IMessage> = {
ts: {
$gt: startDate,
$lt: endDate,
},
};
if (type === 'u') {
const usersId = await getUsersIdFromUserName(usernames);
query['u._id'] = { $in: usersId };
const abacRooms = await Rooms.findAllPrivateRoomsWithAbacAttributes({ projection: { _id: 1 } })
.map((doc) => doc._id)
.toArray();
query.rid = { $nin: abacRooms };
} else {
const roomInfo = await getRoomInfoByAuditParams({
type,
roomId: rid ?? '',
users: usernames,
visitor: visitor ?? '',
agent: agent ?? '',
userId: user._id,
});
if (!roomInfo) {
throw new Meteor.Error(`Room doesn't exist`);
}
rids = roomInfo.rids;
name = roomInfo.name;
query.rid = { $in: rids };
}
if (msg) {
const regex = new RegExp(escapeRegExp(msg).trim(), 'i');
query.msg = regex;
}
const messages = await Messages.find(query).toArray();
await AuditLog.insertOne(
{
ts: new Date(),
results: messages.length,
u: userFields,
fields: { msg, users: usernames, rids, room: name, startDate, endDate, type, visitor, agent },
},
{ ignoreUndefined: true },
);
updateCounter({ settingsId: 'Message_Auditing_Panel_Load_Count' });
return messages;
};
type AuditOmnichannelMessagesParams = {
startDate: Date;
endDate: Date;
users: NonNullable<IUser['username']>[];
msg: IMessage['msg'];
type: string;
visitor?: ILivechatVisitor['_id'];
agent?: ILivechatAgent['_id'];
};
export const auditGetOmnichannelMessagesMethod = async (
userId: string | null,
params: AuditOmnichannelMessagesParams,
): Promise<IMessage[]> => {
const { startDate, endDate, users: usernames, msg, type, visitor, agent } = params;
const user = await requireAuditor(userId);
const userFields = {
_id: user._id,
username: user.username,
...(user.name && { name: user.name }),
...(user.avatarETag && { avatarETag: user.avatarETag }),
};
// No livechat.applyRoomRestrictions here (unlike the type 'l' path in getRoomInfoByAuditParams): this
// mirrors the original DDP auditGetOmnichannelMessages. Access is gated by the can-audit permission and
// audit is intended to span all omnichannel rooms, so unit/visibility restrictions are not applied.
const rooms = await LivechatRooms.findByVisitorIdAndAgentId(visitor, agent, {
projection: { _id: 1 },
}).toArray();
// keep rids an array — `$in: undefined` throws "$in needs an array" when no rooms match
const rids = rooms.map(({ _id }) => _id);
const name = i18n.t('Omnichannel');
const query: Filter<IMessage> = {
rid: { $in: rids },
ts: {
$gt: startDate,
$lt: endDate,
},
};
if (msg) {
const regex = new RegExp(escapeRegExp(msg).trim(), 'i');
query.msg = regex;
}
const messages = await Messages.find(query).toArray();
await AuditLog.insertOne(
{
ts: new Date(),
results: messages.length,
u: userFields,
fields: { msg, users: usernames, rids, room: name, startDate, endDate, type, visitor, agent },
},
{ ignoreUndefined: true },
);
return messages;
};
export const auditGetAuditionsMethod = async (userId: string | null, startDate: Date, endDate: Date): Promise<IAuditLog[]> => {
if (!userId || !(await hasPermissionAsync(userId, 'can-audit-log'))) {
throw new Meteor.Error('Not allowed');
}
return AuditLog.find(
{
ts: {
$gt: startDate,
$lt: endDate,
},
},
{
projection: {
'u.services': 0,
'u.roles': 0,
'u.lastLogin': 0,
'u.statusConnection': 0,
'u.emails': 0,
},
},
).toArray();
};

View File

@@ -0,0 +1,79 @@
import type { ILivechatAgent, ILivechatVisitor, IMessage, IRoom, IUser, IAuditLog } from '@rocket.chat/core-typings';
import type { ServerMethods } from '@rocket.chat/ddp-client';
import { check } from 'meteor/check';
import { DDPRateLimiter } from 'meteor/ddp-rate-limiter';
import { Meteor } from 'meteor/meteor';
import { auditGetAuditionsMethod, auditGetMessagesMethod, auditGetOmnichannelMessagesMethod } from './functions';
import { methodDeprecationLogger } from '../../../../server/lib/deprecationWarningLogger';
declare module '@rocket.chat/ddp-client' {
// eslint-disable-next-line @typescript-eslint/naming-convention
interface ServerMethods {
auditGetAuditions: (params: { startDate: Date; endDate: Date }) => IAuditLog[];
auditGetMessages: (params: {
rid: IRoom['_id'];
startDate: Date;
endDate: Date;
users: NonNullable<IUser['username']>[];
msg: IMessage['msg'];
type: string;
visitor: ILivechatVisitor['_id'];
agent: ILivechatAgent['_id'];
}) => IMessage[];
auditGetOmnichannelMessages: (params: {
startDate: Date;
endDate: Date;
users: NonNullable<IUser['username']>[];
msg: IMessage['msg'];
type: string;
visitor?: ILivechatVisitor['_id'];
agent?: ILivechatAgent['_id'];
}) => IMessage[];
}
}
Meteor.methods<ServerMethods>({
async auditGetOmnichannelMessages(params) {
methodDeprecationLogger.method('auditGetOmnichannelMessages', '9.0.0', '/v1/audit.omnichannelMessages');
check(params.startDate, Date);
check(params.endDate, Date);
return auditGetOmnichannelMessagesMethod(Meteor.userId(), params);
},
async auditGetMessages(params) {
methodDeprecationLogger.method('auditGetMessages', '9.0.0', '/v1/audit.messages');
check(params.startDate, Date);
check(params.endDate, Date);
return auditGetMessagesMethod(Meteor.userId(), params);
},
async auditGetAuditions({ startDate, endDate }) {
methodDeprecationLogger.method('auditGetAuditions', '9.0.0', '/v1/audit.auditions');
check(startDate, Date);
check(endDate, Date);
return auditGetAuditionsMethod(Meteor.userId(), startDate, endDate);
},
});
DDPRateLimiter.addRule(
{
type: 'method',
name: 'auditGetAuditions',
userId(/* userId*/) {
return true;
},
},
10,
60000,
);
DDPRateLimiter.addRule(
{
type: 'method',
name: 'auditGetMessages',
userId(/* userId*/) {
return true;
},
},
10,
60000,
);

View File

@@ -0,0 +1,22 @@
import { Permissions } from '@rocket.chat/models';
import { createOrUpdateProtectedRoleAsync } from '../../../../server/lib/roles/createOrUpdateProtectedRole';
export const createPermissions = async () => {
const permissions = [
{ _id: 'can-audit', roles: ['admin', 'auditor'] },
{ _id: 'can-audit-log', roles: ['admin', 'auditor-log'] },
{ _id: 'view-members-list-all-rooms', roles: ['admin', 'auditor'] },
];
const defaultRoles = [
{ name: 'auditor', scope: 'Users' },
{ name: 'auditor-log', scope: 'Users' },
] as const;
for (const permission of permissions) {
void Permissions.create(permission._id, permission.roles);
}
return Promise.all(defaultRoles.map((role) => createOrUpdateProtectedRoleAsync(role.name, role)));
};

View File

@@ -0,0 +1,2 @@
// This list is currently duplicated on the client code as there's no available API to load it from the server
export const guestPermissions = ['view-d-room', 'view-joined-room', 'view-p-room', 'start-discussion', 'mobile-upload-file'];

View File

@@ -0,0 +1,13 @@
import { Permissions } from '@rocket.chat/models';
import { guestPermissions } from './guestPermissions';
export const resetEnterprisePermissions = async function (): Promise<void> {
await Permissions.updateMany(
{
_id: { $nin: guestPermissions },
roles: 'guest',
},
{ $pull: { roles: 'guest' } },
);
};

View File

@@ -0,0 +1,262 @@
import { MeteorError } from '@rocket.chat/core-services';
import { License, MockedLicenseBuilder } from '@rocket.chat/license';
import { validateUserRoles } from './validateUserRoles';
beforeEach(async () => {
const license = new MockedLicenseBuilder();
await License.setWorkspaceUrl('http://localhost:3000');
License.setLicenseLimitCounter('activeUsers', () => 0);
License.setLicenseLimitCounter('guestUsers', () => 0);
License.setLicenseLimitCounter('roomsPerGuest', async () => 0);
License.setLicenseLimitCounter('privateApps', () => 0);
License.setLicenseLimitCounter('marketplaceApps', () => 0);
License.setLicenseLimitCounter('monthlyActiveContacts', async () => 0);
License.setLicenseLimitCounter('activeUsers', () => 1);
license
.withLimits('activeUsers', [
{
max: 1,
behavior: 'prevent_action',
},
])
.withLimits('guestUsers', [
{
max: 1,
behavior: 'prevent_action',
},
]);
await License.setLicense(await license.sign());
});
describe('Operating after activeUsers Limits', () => {
beforeEach(async () => {
License.setLicenseLimitCounter('activeUsers', () => 1);
});
describe('Adding a new user', () => {
it('should throw error when user is active as undefined', async () => {
const user = {
roles: ['user'],
};
await expect(validateUserRoles(user)).rejects.toThrow(MeteorError);
});
it('should not throw error when user is not active', async () => {
const user = {
active: false,
type: 'user',
};
await expect(validateUserRoles(user)).resolves.not.toThrow();
});
it('should not throw error when user is an app', async () => {
const user = {
active: true,
type: 'app',
};
await expect(validateUserRoles(user)).resolves.not.toThrow();
});
it('should not throw error when user is a bot', async () => {
const user = {
active: true,
type: 'bot',
};
await expect(validateUserRoles(user)).resolves.not.toThrow();
});
it('should not throw error when user is a guest', async () => {
const user = {
active: true,
type: 'user',
roles: ['guest'],
};
await expect(validateUserRoles(user)).resolves.not.toThrow();
});
it('should throw error when user is active', async () => {
const user = {
active: true,
type: 'user',
};
await expect(validateUserRoles(user)).rejects.toThrow(MeteorError);
});
});
describe('Editing an existing user', () => {
beforeEach(async () => {
License.setLicenseLimitCounter('guestUsers', () => 1);
});
afterEach(async () => {
License.setLicenseLimitCounter('guestUsers', () => 0);
});
it('should throw an error when we try to activate a user', async () => {
const user = {
active: true,
type: 'user',
};
const currentUser = {
active: false,
type: 'user',
};
await expect(validateUserRoles(user, currentUser)).rejects.toThrow(MeteorError);
});
it('should not throw an error when we try to deactivate a user', async () => {
const user = {
active: false,
type: 'user',
};
const currentUser = {
active: true,
type: 'user',
};
await expect(validateUserRoles(user, currentUser)).resolves.not.toThrow();
});
it('should not throw an error when we try to change an active user', async () => {
const user = {
active: true,
type: 'user',
};
const currentUser = {
active: true,
type: 'user',
};
await expect(validateUserRoles(user, currentUser)).resolves.not.toThrow();
});
it('should throw an error when we try to convert a bot to a user', async () => {
const user = {
active: true,
type: 'user',
};
const currentUser = {
active: true,
type: 'bot',
};
await expect(validateUserRoles(user, currentUser)).rejects.toThrow(MeteorError);
});
});
});
describe('Operating after guestUsers Limits', () => {
beforeEach(async () => {
License.setLicenseLimitCounter('guestUsers', () => 1);
});
it('should throw an error when we try to convert an user to guest', async () => {
const user = {
active: true,
type: 'user',
roles: ['guest'],
};
const currentUser = {
active: true,
type: 'user',
};
await expect(validateUserRoles(user, currentUser)).rejects.toThrow(MeteorError);
});
it('should throw an error when we try to convert app to guest', async () => {
const user = {
active: true,
type: 'user',
roles: ['guest'],
};
const currentUser = {
active: true,
type: 'app',
};
await expect(validateUserRoles(user, currentUser)).rejects.toThrow(MeteorError);
});
it('should not throw an error when we try to edit a guest', async () => {
const user = {
active: true,
type: 'user',
roles: ['guest'],
};
const currentUser = {
active: true,
type: 'user',
roles: ['guest'],
};
await expect(validateUserRoles(user, currentUser)).resolves.not.toThrow();
});
});
describe('Operating under activeUsers Limits', () => {
beforeEach(async () => {
License.setLicenseLimitCounter('activeUsers', () => 0);
});
describe('Adding a new user', () => {
it('should not throw an error validating a regular user', async () => {
const user = {
active: true,
type: 'user',
};
await expect(validateUserRoles(user)).resolves.not.toThrow();
});
});
describe('Editing an existing user', () => {
it('should not throw an error when we try to activate a user', async () => {
const user = {
active: true,
type: 'user',
};
const currentUser = {
active: false,
type: 'user',
};
await expect(validateUserRoles(user, currentUser)).resolves.not.toThrow();
});
it('should not throw an error when we try to convert a guest to a user', async () => {
const user = {
active: true,
type: 'user',
};
const currentUser = {
active: true,
type: 'user',
roles: ['guest'],
};
await expect(validateUserRoles(user, currentUser)).resolves.not.toThrow();
});
it('should not throw an error when we try to convert a bot to a user', async () => {
const user = {
active: true,
type: 'user',
};
const currentUser = {
active: true,
type: 'bot',
};
await expect(validateUserRoles(user, currentUser)).resolves.not.toThrow();
});
it('should not throw an error when we try to convert an app to a user', async () => {
const user = {
active: true,
type: 'user',
};
const currentUser = {
active: true,
type: 'app',
};
await expect(validateUserRoles(user, currentUser)).resolves.not.toThrow();
});
});
});

View File

@@ -0,0 +1,52 @@
import { MeteorError } from '@rocket.chat/core-services';
import type { IUser } from '@rocket.chat/core-typings';
import { License } from '@rocket.chat/license';
import { i18n } from '../../../../server/lib/i18n';
export async function validateUserRoles(userData: Partial<IUser>, currentUserData?: Partial<IUser>) {
const isApp = Boolean(userData.type === 'app');
const wasApp = Boolean(currentUserData?.type === 'app');
const isBot = Boolean(userData.type === 'bot');
const wasBot = Boolean(currentUserData?.type === 'bot');
const isGuest = Boolean(userData.roles?.includes('guest') && userData.roles.length === 1);
const wasGuest = Boolean(currentUserData?.roles?.includes('guest') && currentUserData.roles.length === 1);
const isSpecialType = isApp || isBot;
const hasGuestToChanged = isGuest && !wasGuest;
if (isSpecialType) {
return;
}
if (hasGuestToChanged && (await License.shouldPreventAction('guestUsers'))) {
throw new MeteorError('error-max-guests-number-reached', 'Maximum number of guests reached.', {
method: 'insertOrUpdateUser',
field: 'Assign_role',
});
}
if (isGuest) {
return;
}
const isActive = Boolean(userData.active !== false);
const wasActive = currentUserData && currentUserData?.active !== false;
const hasRemovedSpecialType = (wasApp && !isApp) || (wasBot && !isBot);
if (!isActive) {
return;
}
if (!hasRemovedSpecialType && wasActive) {
return;
}
if (await License.shouldPreventAction('activeUsers')) {
throw new MeteorError('error-license-user-limit-reached', i18n.t('error-license-user-limit-reached'));
}
}

View File

@@ -0,0 +1,13 @@
import { License } from '@rocket.chat/license';
await License.onLicense('canned-responses', async () => {
const { createSettings } = await import('./settings');
await import('./permissions');
await import('../../hooks/canned-responses/onRemoveAgentDepartment');
await import('../../hooks/canned-responses/onSaveAgentDepartment');
await import('../../hooks/canned-responses/cannedResponses');
await import('../../meteor-methods/saveCannedResponse');
await import('../../meteor-methods/removeCannedResponse');
await createSettings();
});

View File

@@ -0,0 +1,12 @@
import { Permissions } from '@rocket.chat/models';
import { Meteor } from 'meteor/meteor';
Meteor.startup(async () => {
await Permissions.create('view-canned-responses', ['livechat-agent', 'livechat-monitor', 'livechat-manager', 'admin']);
await Permissions.create('view-all-canned-responses', ['livechat-manager', 'admin']);
await Permissions.create('view-agent-canned-responses', ['livechat-agent']);
await Permissions.create('save-canned-responses', ['livechat-agent', 'livechat-monitor', 'livechat-manager', 'admin']);
await Permissions.create('save-all-canned-responses', ['livechat-manager', 'admin']);
await Permissions.create('save-department-canned-responses', ['livechat-monitor']);
await Permissions.create('remove-canned-responses', ['livechat-agent', 'livechat-monitor', 'livechat-manager', 'admin']);
});

View File

@@ -0,0 +1,16 @@
import { settingsRegistry } from '../../../../server/settings';
const omnichannelEnabledQuery = { _id: 'Livechat_enabled', value: true };
export const createSettings = async (): Promise<void> => {
await settingsRegistry.add('Canned_Responses_Enable', true, {
group: 'Omnichannel',
section: 'Canned_Responses',
type: 'boolean',
public: true,
enterprise: true,
invalidValue: false,
modules: ['canned-responses'],
enableQuery: omnichannelEnabledQuery,
});
};

View File

@@ -0,0 +1 @@
export const MAX_ROOM_SIZE_CHECK_INDIVIDUAL_READ_RECEIPTS = 50;

View File

@@ -0,0 +1,119 @@
import type { ISocketConnection } from '@rocket.chat/core-typings';
import { Users } from '@rocket.chat/models';
import { Accounts } from 'meteor/accounts-base';
import { Meteor } from 'meteor/meteor';
import moment from 'moment';
import { UAParser } from 'ua-parser-js';
import { t } from '../../../../app/utils/lib/i18n';
import * as Mailer from '../../../../server/lib/notifications/email/api';
import { UAParserDesktop, UAParserMobile } from '../../../../server/lib/statistics/lib/UAParserCustom';
import { getUserPreference } from '../../../../server/lib/utils/lib/getUserPreference';
import { deviceManagementEvents } from '../../../../server/services/device-management/events';
import { settings } from '../../../../server/settings';
let mailTemplates: string;
Meteor.startup(() => {
Mailer.getTemplate('Device_Management_Email_Body', (template) => {
mailTemplates = template;
});
});
const uaParser = async (
uaString: ISocketConnection['httpHeaders']['user-agent'],
): Promise<UAParser.IResult & { app?: { name: string; version: string; bundle: string } }> => {
const ua = new UAParser(uaString);
return {
...ua.getResult(),
...(uaString && UAParserMobile.isMobileApp(uaString) && UAParserMobile.uaObject(uaString)),
...(uaString && UAParserDesktop.isDesktopApp(uaString) && UAParserDesktop.uaObject(uaString)),
};
};
export const listenSessionLogin = () => {
return deviceManagementEvents.on('device-login', async ({ userId, userAgent, clientAddress }) => {
const deviceEnabled = settings.get('Device_Management_Enable_Login_Emails');
if (!deviceEnabled) {
return;
}
const user = await Users.findOneByIdWithEmailAddress(userId, {
projection: { 'name': 1, 'username': 1, 'emails': 1, 'settings.preferences.receiveLoginDetectionEmail': 1 },
});
if (!user?.emails?.length) {
return;
}
const userReceiveLoginEmailPreference =
!settings.get('Device_Management_Allow_Login_Email_preference') ||
(await getUserPreference(userId, 'receiveLoginDetectionEmail', true));
if (!userReceiveLoginEmailPreference) {
return;
}
const dateFormat = settings.get('Message_TimeAndDateFormat');
const {
name,
username,
emails: [{ address: email }],
} = user;
const { browser, os, device, cpu, app } = await uaParser(userAgent);
const mailData = {
name,
username,
browserInfo: `${browser.name} ${browser.version}`,
osInfo: `${os.name}`,
deviceInfo: `${device.type || t('Device_Management_Device_Unknown')} ${device.vendor || ''} ${device.model || ''} ${
cpu.architecture || ''
}`,
ipInfo: clientAddress,
userAgent: '',
date: moment().format(String(dateFormat)),
};
switch (device.type) {
case 'mobile':
case 'tablet':
case 'smarttv':
mailData.browserInfo = `${browser.name} ${browser.version}`;
mailData.osInfo = `${os.name}`;
mailData.deviceInfo = `${device.type} ${device.vendor || ''} ${device.model || ''} ${cpu.architecture || ''}`;
break;
case 'mobile-app':
mailData.browserInfo = `Rocket.Chat App ${app?.bundle || app?.version}`;
mailData.osInfo = `${os.name}`;
mailData.deviceInfo = 'Mobile App';
break;
case 'desktop-app':
mailData.browserInfo = `Rocket.Chat ${app?.name || browser.name} ${app?.bundle || app?.version || browser.version}`;
mailData.osInfo = `${os.name}`;
mailData.deviceInfo = `Desktop App ${cpu.architecture || ''}`;
break;
default:
mailData.userAgent = userAgent || '';
break;
}
try {
await Mailer.send({
to: `${name} <${email}>`,
from: Accounts.emailTemplates.from,
subject: settings.get('Device_Management_Email_Subject'),
html: mailTemplates,
data: mailData,
});
} catch ({ message }: any) {
throw new Meteor.Error('error-email-send-failed', `Error trying to send email: ${message}`, {
method: 'listenSessionLogin',
message,
});
}
});
};

View File

@@ -0,0 +1,32 @@
import { Permissions } from '@rocket.chat/models';
import { settingsRegistry } from '../../../../server/settings';
export const createPermissions = async (): Promise<void> => {
await Promise.all([
Permissions.create('logout-device-management', ['admin']),
Permissions.create('block-ip-device-management', ['admin']),
]);
};
export const createEmailTemplates = async (): Promise<void> => {
await settingsRegistry.addGroup('Email', async function () {
await this.section('Device Management - Login Detected', async function () {
await this.add('Device_Management_Email_Subject', '{Device_Management_Email_Subject}', {
type: 'string',
i18nLabel: 'Subject',
});
await this.add(
'Device_Management_Email_Body',
'<h2 class="rc-color">{Login_Detected}</h2><p><strong>[name] ([username]) {Logged_In_Via}</strong></p><p><strong>{Device_Management_Client}:</strong> [browserInfo]<br><strong>{Device_Management_OS}:</strong> [osInfo]<br><strong>{Device_Management_Device}:</strong> [deviceInfo]<br><strong>{Device_Management_IP}:</strong>[ipInfo]<br><strong>{Date}:</strong> [date]</p><p><small>[userAgent]</small></p><a class="btn" href="[Site_URL]">{Access_Your_Account}</a><p>{Or_Copy_And_Paste_This_URL_Into_A_Tab_Of_Your_Browser}<br><a href="[Site_URL]">[SITE_URL]</a></p><p>{Thank_You_For_Choosing_RocketChat}</p>',
{
type: 'code',
code: 'text/html',
multiline: true,
i18nLabel: 'Body',
i18nDescription: 'Device_Management_Email_Body',
},
);
});
});
};

View File

@@ -0,0 +1,59 @@
import type { IDirectMessageRoom, IRoom } from '@rocket.chat/core-typings';
import { Analytics } from '@rocket.chat/models';
import moment from 'moment';
import { convertDateToInt, diffBetweenDaysInclusive } from './date';
import { roomCoordinator } from '../../../../server/lib/rooms/roomCoordinator';
export const findChannelsWithNumberOfMessages = async ({
start,
end,
options = {},
}: {
start: Date;
end: Date;
options: {
offset?: number;
count?: number;
};
}): Promise<{
channels: {
room: {
_id: IRoom['_id'];
name: IRoom['name'] | IRoom['fname'];
ts: IRoom['ts'];
t: IRoom['t'];
_updatedAt: IRoom['_updatedAt'];
usernames?: IDirectMessageRoom['usernames'];
};
messages: number;
lastWeekMessages: number;
diffFromLastWeek: number;
}[];
total: number;
}> => {
const daysBetweenDates = diffBetweenDaysInclusive(end, start);
const endOfLastWeek = moment(start).subtract(1, 'days').toDate();
const startOfLastWeek = moment(endOfLastWeek).subtract(daysBetweenDates, 'days').toDate();
const roomTypes = roomCoordinator.getTypesToShowOnDashboard() as Array<IRoom['t']>;
const aggregationResult = await Analytics.findRoomsByTypesWithNumberOfMessagesBetweenDate({
types: roomTypes,
start: convertDateToInt(start),
end: convertDateToInt(end),
startOfLastWeek: convertDateToInt(startOfLastWeek),
endOfLastWeek: convertDateToInt(endOfLastWeek),
options,
}).toArray();
// The aggregation result may be undefined if there are no matching analytics or corresponding rooms in the period
if (!aggregationResult.length) {
return { channels: [], total: 0 };
}
const [{ channels, total }] = aggregationResult;
return {
channels,
total,
};
};

View File

@@ -0,0 +1,40 @@
import mem from 'mem';
import moment from 'moment';
export const isDateISOString = mem(
(input: string): input is string => {
const timestamp = Date.parse(input);
return !Number.isNaN(timestamp) && new Date(timestamp).toISOString() === input;
},
{ maxAge: 10000 },
);
export const mapDateForAPI = (input: string): Date => {
if (!isDateISOString(input)) {
throw new Error('invalid ISO 8601 date');
}
return new Date(Date.parse(input));
};
export const convertDateToInt = (date: Date): number => parseInt(moment(date).clone().format('YYYYMMDD'), 10);
export const convertIntToDate = (intValue: number): Date => moment(intValue, 'YYYYMMDD').clone().toDate();
const diffBetweenDays = (start: string | number | Date, end: string | number | Date): number =>
moment(new Date(start)).clone().diff(new Date(end), 'days');
export const diffBetweenDaysInclusive = (start: string | number | Date, end: string | number | Date): number =>
diffBetweenDays(start, end) + 1;
export const getTotalOfWeekItems = <T extends Record<string, number>>(weekItems: T[], property: keyof T): number =>
weekItems.reduce((acc, item) => {
acc += item[property];
return acc;
}, 0);
export function transformDatesForAPI(start: string): { start: Date; end: undefined };
export function transformDatesForAPI(start: string, end: string): { start: Date; end: Date };
export function transformDatesForAPI(start: string, end?: string): { start: Date; end: Date | undefined } {
return {
start: mapDateForAPI(start),
end: end ? mapDateForAPI(end) : undefined,
};
}

View File

@@ -0,0 +1,156 @@
import type { IDirectMessageRoom, IRoom, IMessage } from '@rocket.chat/core-typings';
import { Messages, Analytics } from '@rocket.chat/models';
import moment from 'moment';
import { convertDateToInt, diffBetweenDaysInclusive, convertIntToDate, getTotalOfWeekItems } from './date';
import { roomCoordinator } from '../../../../server/lib/rooms/roomCoordinator';
export const handleMessagesSent = async (message: IMessage, { room }: { room?: IRoom }): Promise<IMessage> => {
const roomTypesToShow = roomCoordinator.getTypesToShowOnDashboard();
if (!room || !roomTypesToShow.includes(room.t)) {
return message;
}
await Analytics.saveMessageSent({
date: convertDateToInt(message.ts),
room,
});
return message;
};
export const handleMessagesDeleted = async (message: IMessage, { room }: { room: IRoom }): Promise<IMessage> => {
const roomTypesToShow = roomCoordinator.getTypesToShowOnDashboard();
if (!room || !roomTypesToShow.includes(room.t)) {
return message;
}
await Analytics.saveMessageDeleted({
date: convertDateToInt(message.ts),
room,
});
return message;
};
export const fillFirstDaysOfMessagesIfNeeded = async (date: Date): Promise<void> => {
const messagesFromAnalytics = await Analytics.findOneByTypeBeforeDate({
type: 'messages',
date: convertDateToInt(date),
});
if (!messagesFromAnalytics) {
const startOfPeriod = moment(date).subtract(90, 'days').toDate();
const messages = await Messages.getTotalOfMessagesSentByDate({
start: startOfPeriod,
end: date,
});
await Promise.all(
messages.map((message) =>
Analytics.insertOne({
...message,
date: parseInt(message.date),
}),
),
);
}
};
export const findWeeklyMessagesSentData = async ({
start,
end,
}: {
start: Date;
end: Date;
}): Promise<{
days: { day: Date; messages: number }[];
period: {
count: number;
variation: number;
};
yesterday: {
count: number;
variation: number;
};
}> => {
const daysBetweenDates = diffBetweenDaysInclusive(end, start);
const endOfLastWeek = moment(start).clone().subtract(1, 'days').toDate();
const startOfLastWeek = moment(endOfLastWeek).clone().subtract(daysBetweenDates, 'days').toDate();
const today = convertDateToInt(end);
const yesterday = convertDateToInt(moment(end).clone().subtract(1, 'days').toDate());
const currentPeriodMessages = await Analytics.getMessagesSentTotalByDate({
start: convertDateToInt(start),
end: convertDateToInt(end),
options: { count: daysBetweenDates, sort: { _id: -1 } },
}).toArray();
const lastPeriodMessages = await Analytics.getMessagesSentTotalByDate({
start: convertDateToInt(startOfLastWeek),
end: convertDateToInt(endOfLastWeek),
options: { count: daysBetweenDates, sort: { _id: -1 } },
}).toArray();
const yesterdayMessages = currentPeriodMessages.find((item) => item._id === yesterday)?.messages || 0;
const todayMessages = currentPeriodMessages.find((item) => item._id === today)?.messages || 0;
const currentPeriodTotalOfMessages = getTotalOfWeekItems(currentPeriodMessages, 'messages');
const lastPeriodTotalOfMessages = getTotalOfWeekItems(lastPeriodMessages, 'messages');
return {
days: currentPeriodMessages.map((day) => ({
day: convertIntToDate(day._id),
messages: day.messages,
})),
period: {
count: currentPeriodTotalOfMessages,
variation: currentPeriodTotalOfMessages - lastPeriodTotalOfMessages,
},
yesterday: {
count: yesterdayMessages,
variation: todayMessages - yesterdayMessages,
},
};
};
export const findMessagesSentOrigin = async ({
start,
end,
}: {
start: Date;
end: Date;
}): Promise<{
origins: {
t: IRoom['t'];
messages: number;
}[];
}> => {
const origins = await Analytics.getMessagesOrigin({
start: convertDateToInt(start),
end: convertDateToInt(end),
}).toArray();
const roomTypesToShow: IRoom['t'][] = roomCoordinator.getTypesToShowOnDashboard() as IRoom['t'][];
const responseTypes = origins.map((origin) => origin.t);
const missingTypes = roomTypesToShow.filter((type): type is IRoom['t'] => !responseTypes.includes(type));
if (missingTypes.length) {
missingTypes.forEach((type) => origins.push({ messages: 0, t: type }));
}
return { origins };
};
export const findTopFivePopularChannelsByMessageSentQuantity = async ({
start,
end,
}: {
start: Date;
end: Date;
}): Promise<{
channels: {
t: IRoom['t'];
messages: number;
name: IRoom['name'] | IRoom['fname'];
usernames?: IDirectMessageRoom['usernames'];
}[];
}> => {
const channels = await Analytics.getMostPopularChannelsByMessagesSentQuantity({
start: convertDateToInt(start),
end: convertDateToInt(end),
options: { count: 5, sort: { messages: -1 } },
}).toArray();
return { channels };
};

View File

@@ -0,0 +1,25 @@
import { fillFirstDaysOfMessagesIfNeeded, handleMessagesDeleted, handleMessagesSent } from './messages';
import { fillFirstDaysOfUsersIfNeeded, handleUserCreated } from './users';
import { callbacks } from '../../../../server/lib/callbacks';
export const attachCallbacks = (): void => {
callbacks.add(
'afterSaveMessage',
(message, { room }) => handleMessagesSent(message, { room }),
callbacks.priority.MEDIUM,
'engagementDashboard.afterSaveMessage',
);
callbacks.add('afterDeleteMessage', handleMessagesDeleted, callbacks.priority.MEDIUM, 'engagementDashboard.afterDeleteMessage');
callbacks.add('afterCreateUser', handleUserCreated, callbacks.priority.MEDIUM, 'engagementDashboard.afterCreateUser');
};
export const detachCallbacks = (): void => {
callbacks.remove('afterSaveMessage', 'engagementDashboard.afterSaveMessage');
callbacks.remove('afterDeleteMessage', 'engagementDashboard.afterDeleteMessage');
callbacks.remove('afterCreateUser', 'engagementDashboard.afterCreateUser');
};
export const prepareAnalytics = async (): Promise<void> => {
const now = new Date();
await Promise.all([fillFirstDaysOfUsersIfNeeded(now), fillFirstDaysOfMessagesIfNeeded(now)]);
};

View File

@@ -0,0 +1,179 @@
import type { IUser } from '@rocket.chat/core-typings';
import { Users, Analytics, Sessions } from '@rocket.chat/models';
import moment from 'moment';
import { convertDateToInt, diffBetweenDaysInclusive, getTotalOfWeekItems, convertIntToDate } from './date';
export const handleUserCreated = async (user: IUser): Promise<IUser> => {
if (user.roles?.includes('anonymous')) {
return user;
}
await Analytics.saveUserData({
date: convertDateToInt(user.createdAt),
});
return user;
};
export const fillFirstDaysOfUsersIfNeeded = async (date: Date): Promise<void> => {
const usersFromAnalytics = await Analytics.findOneByTypeBeforeDate({
type: 'users',
date: convertDateToInt(date),
});
if (!usersFromAnalytics) {
const startOfPeriod = moment(date).subtract(90, 'days').toDate();
const users = await Users.getTotalOfRegisteredUsersByDate({
start: startOfPeriod,
end: date,
});
users.forEach((user) =>
Analytics.insertOne({
...user,
date: parseInt(user.date),
}),
);
}
};
export const findWeeklyUsersRegisteredData = async ({
start,
end,
}: {
start: Date;
end: Date;
}): Promise<{
days: { day: Date; users: number }[];
period: {
count: number;
variation: number;
};
yesterday: {
count: number;
variation: number;
};
}> => {
const daysBetweenDates = diffBetweenDaysInclusive(end, start);
const endOfLastWeek = moment(start).clone().subtract(1, 'days').toDate();
const startOfLastWeek = moment(endOfLastWeek).clone().subtract(daysBetweenDates, 'days').toDate();
const today = convertDateToInt(end);
const yesterday = convertDateToInt(moment(end).clone().subtract(1, 'days').toDate());
const currentPeriodUsers = await Analytics.getTotalOfRegisteredUsersByDate({
start: convertDateToInt(start),
end: convertDateToInt(end),
options: { count: daysBetweenDates, sort: { _id: -1 } },
}).toArray();
const lastPeriodUsers = await Analytics.getTotalOfRegisteredUsersByDate({
start: convertDateToInt(startOfLastWeek),
end: convertDateToInt(endOfLastWeek),
options: { count: daysBetweenDates, sort: { _id: -1 } },
}).toArray();
const yesterdayUsers = currentPeriodUsers.find((item) => item._id === yesterday)?.users || 0;
const todayUsers = currentPeriodUsers.find((item) => item._id === today)?.users || 0;
const currentPeriodTotalUsers = getTotalOfWeekItems(currentPeriodUsers, 'users');
const lastPeriodTotalUsers = getTotalOfWeekItems(lastPeriodUsers, 'users');
return {
days: currentPeriodUsers.map((day) => ({ day: convertIntToDate(day._id), users: day.users })),
period: {
count: currentPeriodTotalUsers,
variation: currentPeriodTotalUsers - lastPeriodTotalUsers,
},
yesterday: {
count: yesterdayUsers,
variation: todayUsers - yesterdayUsers,
},
};
};
const createDestructuredDate = (
input: moment.MomentInput,
): {
year: number;
month: number;
day: number;
} => {
const date = moment(input);
return {
year: date.year(),
month: date.month() + 1,
day: date.date(),
};
};
export const findActiveUsersMonthlyData = async ({
start,
end,
}: {
start: Date;
end: Date;
}): Promise<{
month: {
day: number;
month: number;
year: number;
usersList: IUser['_id'][];
users: number;
}[];
}> => ({
month: await Sessions.getActiveUsersOfPeriodByDayBetweenDates({
start: createDestructuredDate(start),
end: createDestructuredDate(end),
}),
});
export const findBusiestsChatsInADayByHours = async ({
start,
}: {
start: Date;
}): Promise<{
hours: {
hour: number;
users: number;
}[];
}> => ({
hours: await Sessions.getBusiestTimeWithinHoursPeriod({
start: moment(start).subtract(24, 'hours').toDate(),
end: start,
groupSize: 2,
}),
});
export const findBusiestsChatsWithinAWeek = async ({
start,
}: {
start: Date;
}): Promise<{
month: {
day: number;
month: number;
year: number;
users: number;
}[];
}> => ({
month: await Sessions.getTotalOfSessionsByDayBetweenDates({
start: createDestructuredDate(moment(start).subtract(7, 'days')),
end: createDestructuredDate(start),
}),
});
export const findUserSessionsByHourWithinAWeek = async ({
start,
end,
}: {
start: Date;
end: Date;
}): Promise<{
week: {
hour: number;
day: number;
month: number;
year: number;
users: number;
}[];
}> => ({
week: await Sessions.getTotalOfSessionByHourAndDayBetweenDates({
start,
end,
}),
});

View File

@@ -0,0 +1,110 @@
import { expect } from 'chai';
import { beforeEach, describe, it } from 'mocha';
import proxyquire from 'proxyquire';
import sinon from 'sinon';
const settingsStub = { get: sinon.stub() };
const roomsStub = { findOneByNonValidatedName: sinon.stub() };
const subscriptionsStub = { findOneByRoomIdAndUserId: sinon.stub() };
const addUserToRoomStub = sinon.stub();
const removeUserFromRoomStub = sinon.stub();
const loggerStub = { debug: sinon.stub(), error: sinon.stub(), info: sinon.stub(), warn: sinon.stub() };
const { LDAPEEManager } = proxyquire.noCallThru().load('./Manager', {
'@rocket.chat/core-services': { Abac: {}, Team: {} },
'@rocket.chat/license': { License: { hasModule: () => false } },
'@rocket.chat/models': { Users: {}, Roles: {}, Subscriptions: subscriptionsStub, Rooms: roomsStub },
'../../../../server/lib/import/definitions/IConversionCallbacks': {},
'../../../../server/settings': { settings: settingsStub },
'../../../../server/lib/utils/lib/getValidRoomName': { getValidRoomName: (name: string) => Promise.resolve(name) },
'../../../../lib/utils/arrayUtils': { ensureArray: (value: unknown) => (Array.isArray(value) ? value : [value]) },
'../../../../server/lib/ldap/Connection': { LDAPConnection: class {} },
'../../../../server/lib/ldap/Logger': { logger: loggerStub, searchLogger: loggerStub, mapLogger: loggerStub },
'../../../../server/lib/ldap/Manager': { LDAPManager: class {} },
'../../../../server/lib/ldap/UserConverter': { LDAPUserConverter: class {} },
'../../../../server/lib/rooms/addUserToRoom': { addUserToRoom: addUserToRoomStub },
'../../../../server/lib/rooms/createRoom': { createRoom: sinon.stub() },
'../../../../server/lib/rooms/removeUserFromRoom': { removeUserFromRoom: removeUserFromRoomStub },
'../../../../server/lib/users/setUserActiveStatus': { setUserActiveStatus: sinon.stub() },
'../syncUserRoles': { syncUserRoles: sinon.stub() },
'./copyCustomFieldsLDAP': { copyCustomFieldsLDAP: sinon.stub() },
});
const user = { _id: 'userId', username: 'john' };
const channelsMap = {
groupA: 'channel-a',
deadGroup: 'missing-channel',
groupB: 'channel-b',
};
const setupSettings = (map: Record<string, string>) => {
settingsStub.get.reset();
settingsStub.get.withArgs('LDAP_Sync_User_Data_Channels').returns(true);
settingsStub.get.withArgs('LDAP_Sync_User_Data_Channels_Enforce_AutoChannels').returns(true);
settingsStub.get.withArgs('LDAP_Sync_User_Data_ChannelsMap').returns(JSON.stringify(map));
settingsStub.get.withArgs('LDAP_Sync_User_Data_Channels_Filter').returns('');
settingsStub.get.withArgs('LDAP_Sync_User_Data_Channels_BaseDN').returns('');
settingsStub.get.withArgs('LDAP_Sync_User_Data_Channels_GroupMembershipValidationStrategy').returns('once');
settingsStub.get.withArgs('ABAC_Enabled').returns(false);
};
const syncUserChannels = (ldapUserGroups: string[]) => {
const groupsStub = sinon.stub(LDAPEEManager as any, 'getLdapGroupsByUsername').resolves(ldapUserGroups);
return (LDAPEEManager as any).syncUserChannels({}, user, 'dn').finally(() => groupsStub.restore());
};
describe('LDAPEEManager syncUserChannels', () => {
beforeEach(() => {
roomsStub.findOneByNonValidatedName.reset();
subscriptionsStub.findOneByRoomIdAndUserId.reset();
addUserToRoomStub.reset();
removeUserFromRoomStub.reset();
setupSettings(channelsMap);
roomsStub.findOneByNonValidatedName.withArgs('channel-a').resolves({ _id: 'ridA' });
roomsStub.findOneByNonValidatedName.withArgs('channel-b').resolves({ _id: 'ridB' });
roomsStub.findOneByNonValidatedName.withArgs('missing-channel').resolves(null);
subscriptionsStub.findOneByRoomIdAndUserId.resolves({ _id: 'subId' });
});
it('should remove the user from remaining channels when an earlier mapped channel cannot be resolved', async () => {
await syncUserChannels(['groupA']);
expect(addUserToRoomStub.calledWith('ridA', user)).to.be.true;
expect(removeUserFromRoomStub.calledWith('ridB', user)).to.be.true;
});
it('should skip Team channels on removal without aborting the rest of the pass', async () => {
roomsStub.findOneByNonValidatedName.withArgs('missing-channel').resolves({ _id: 'ridTeam', teamMain: true });
await syncUserChannels(['groupA']);
expect(removeUserFromRoomStub.calledWith('ridTeam', user)).to.be.false;
expect(removeUserFromRoomStub.calledWith('ridB', user)).to.be.true;
});
it('should keep a channel that was added in the same sync when another group maps it for removal, and still remove the rest', async () => {
setupSettings({ groupA: 'channel-a', groupB: 'channel-a', groupC: 'channel-c' });
roomsStub.findOneByNonValidatedName.withArgs('channel-c').resolves({ _id: 'ridC' });
await syncUserChannels(['groupA']);
expect(addUserToRoomStub.calledWith('ridA', user)).to.be.true;
expect(removeUserFromRoomStub.calledWith('ridA', user)).to.be.false;
expect(removeUserFromRoomStub.calledWith('ridC', user)).to.be.true;
});
it('should keep adding and removing channels when an unresolved channel fails to be created on the add pass', async () => {
sinon.stub(LDAPEEManager as any, 'createRoomForSync').resolves(undefined);
try {
await syncUserChannels(['deadGroup', 'groupA']);
} finally {
((LDAPEEManager as any).createRoomForSync as sinon.SinonStub).restore();
}
expect(addUserToRoomStub.calledWith('ridA', user)).to.be.true;
expect(removeUserFromRoomStub.calledWith('ridB', user)).to.be.true;
});
});

View File

@@ -0,0 +1,825 @@
import { Abac, Team } from '@rocket.chat/core-services';
import type { ILDAPEntry, IUser, IRoom, IRole, IImportUser, IImportRecord } from '@rocket.chat/core-typings';
import { License } from '@rocket.chat/license';
import { Users, Roles, Subscriptions as SubscriptionsRaw, Rooms } from '@rocket.chat/models';
import type ldapjs from 'ldapjs';
import type { FindCursor } from 'mongodb';
import { copyCustomFieldsLDAP } from './copyCustomFieldsLDAP';
import { ensureArray } from '../../../../lib/utils/arrayUtils';
import type {
ImporterAfterImportCallback,
ImporterBeforeImportCallback,
} from '../../../../server/lib/import/definitions/IConversionCallbacks';
import { LDAPConnection } from '../../../../server/lib/ldap/Connection';
import { logger, searchLogger, mapLogger } from '../../../../server/lib/ldap/Logger';
import { LDAPManager } from '../../../../server/lib/ldap/Manager';
import { LDAPUserConverter } from '../../../../server/lib/ldap/UserConverter';
import { addUserToRoom } from '../../../../server/lib/rooms/addUserToRoom';
import { createRoom } from '../../../../server/lib/rooms/createRoom';
import { removeUserFromRoom } from '../../../../server/lib/rooms/removeUserFromRoom';
import { setUserActiveStatus } from '../../../../server/lib/users/setUserActiveStatus';
import { getValidRoomName } from '../../../../server/lib/utils/lib/getValidRoomName';
import { settings } from '../../../../server/settings';
import { syncUserRoles } from '../syncUserRoles';
export class LDAPEEManager extends LDAPManager {
public static async sync(): Promise<void> {
if (settings.get('LDAP_Enable') !== true || settings.get('LDAP_Background_Sync') !== true) {
return;
}
const createNewUsers = settings.get<boolean>('LDAP_Background_Sync_Import_New_Users') ?? true;
const updateExistingUsers = settings.get<boolean>('LDAP_Background_Sync_Keep_Existant_Users_Updated') ?? true;
let disableMissingUsers = updateExistingUsers && (settings.get<boolean>('LDAP_Background_Sync_Disable_Missing_Users') ?? false);
const mergeExistingUsers = settings.get<boolean>('LDAP_Background_Sync_Merge_Existent_Users') ?? false;
const options = this.getConverterOptions();
options.skipExistingUsers = !updateExistingUsers;
options.skipNewUsers = !createNewUsers;
const ldap = new LDAPConnection();
const converter = new LDAPUserConverter(options);
const touchedUsers = new Set<IUser['_id']>();
try {
await ldap.connect();
if (createNewUsers || mergeExistingUsers) {
await this.importNewUsers(ldap, converter);
} else if (updateExistingUsers) {
await this.updateExistingUsers(ldap, converter, disableMissingUsers);
// Missing users will have been disabled automatically by the update operation, so no need to do a separate query for them
disableMissingUsers = false;
}
const membersOfGroupFilter = await ldap.searchMembersOfGroupFilter();
await converter.convertData({
beforeImportFn: (async ({ options }: IImportRecord): Promise<boolean> => {
if (!ldap.options.groupFilterEnabled || !ldap.options.groupFilterGroupMemberFormat) {
return true;
}
const memberFormat = ldap.options.groupFilterGroupMemberFormat
?.replace(/#{username}/g, options?.username || '#{username}')
.replace(/#{userdn}/g, options?.dn || '#{userdn}');
return membersOfGroupFilter.includes(memberFormat);
}) as ImporterBeforeImportCallback,
afterImportFn: (async ({ data }, isNewRecord: boolean): Promise<void> => {
if (data._id) {
touchedUsers.add(data._id);
}
await this.advancedSync(ldap, data as IImportUser, converter, isNewRecord);
}) as ImporterAfterImportCallback,
});
if (disableMissingUsers) {
await this.disableMissingUsers([...touchedUsers]);
}
} catch (err) {
logger.error({ err });
}
ldap.disconnect();
}
public static async syncAvatars(): Promise<void> {
if (settings.get('LDAP_Enable') !== true || settings.get('LDAP_Background_Sync_Avatars') !== true) {
return;
}
try {
const ldap = new LDAPConnection();
await ldap.connect();
try {
await this.updateUserAvatars(ldap);
} finally {
ldap.disconnect();
}
} catch (err) {
logger.error({ err });
}
}
public static async syncAbacAttributes(): Promise<void> {
if (
!settings.get('LDAP_Enable') ||
!settings.get('LDAP_Background_Sync_ABAC_Attributes') ||
!License.hasModule('abac') ||
!settings.get('ABAC_Enabled') ||
settings.get('ABAC_PDP_Type') === 'virtru'
) {
return;
}
try {
const ldap = new LDAPConnection();
await ldap.connect();
try {
await this.updateUserAbacAttributes(ldap);
} finally {
ldap.disconnect();
}
} catch (err) {
logger.error({ err });
}
}
public static async syncUsersAbacAttributes(users: FindCursor<IUser>): Promise<void> {
if (
!settings.get('LDAP_Enable') ||
!License.hasModule('abac') ||
!settings.get('ABAC_Enabled') ||
settings.get('ABAC_PDP_Type') === 'virtru'
) {
return;
}
try {
const ldap = new LDAPConnection();
await ldap.connect();
try {
logger.debug({ msg: 'Starting ABAC attributes sync for LDAP users' });
for await (const user of users) {
await this.syncUserAbacAttribute(ldap, user);
}
} finally {
ldap.disconnect();
}
} catch (err) {
logger.error({ err });
}
}
public static validateLDAPTeamsMappingChanges(json: string): void {
if (!json) {
return;
}
const mustBeAnArrayOfStrings = (array: Array<string>): boolean =>
Boolean(Array.isArray(array) && array.length && array.every((item) => typeof item === 'string'));
const mappedTeams = this.parseJson(json);
if (!mappedTeams) {
return;
}
const mappedRocketChatTeams = Object.values(mappedTeams);
const validStructureMapping = mappedRocketChatTeams.every(mustBeAnArrayOfStrings);
if (!validStructureMapping) {
throw new Error(
'Please verify your mapping for LDAP X RocketChat Teams. The structure is invalid, the structure should be an object like: {key: LdapTeam, value: [An array of rocket.chat teams]}',
);
}
}
public static validateLDAPABACAttributeMap(json: string): void {
if (!json) {
return;
}
const mappedAttributes = this.parseJson(json);
// attributes are { key: value } with key being the ldap attribute and value being the abac attribute in rocketchat
// both strings
// There's no need for the attribute to exist in rocketchat, we just add whatever the admin wants to map
if (!mappedAttributes || Object.keys(mappedAttributes).length === 0) {
return;
}
const validStructureMapping = Object.entries(mappedAttributes).every(
([key, value]) => typeof key === 'string' && typeof value === 'string',
);
if (!validStructureMapping) {
throw new Error(
'Please verify your mapping for LDAP X RocketChat ABAC Attributes. The structure is invalid, the structure should be an object like: {key: LdapAttribute, value: RocketChatAbacAttribute}',
);
}
}
public static async syncAvatarAndAbacAttributes(): Promise<void> {
const syncAvatars = settings.get('LDAP_Background_Sync_Avatars');
const syncAbac = settings.get('LDAP_Background_Sync_ABAC_Attributes') && License.hasModule('abac') && settings.get('ABAC_Enabled');
const abacMapping = syncAbac && this.parseJson(settings.get('LDAP_ABAC_AttributeMap'));
if (!syncAvatars && !syncAbac) {
return;
}
try {
const ldap = new LDAPConnection();
await ldap.connect();
try {
const users = Users.findLDAPUsers();
for await (const user of users) {
const ldapUser = await this.findLDAPUser(ldap, user);
if (!ldapUser) {
continue;
}
if (syncAvatars) {
await LDAPManager.syncUserAvatar(user, ldapUser);
}
if (syncAbac && abacMapping) {
await Abac.addSubjectAttributes(user, ldapUser, abacMapping, undefined);
}
}
} finally {
ldap.disconnect();
}
} catch (err) {
logger.error({ err });
}
}
public static async syncLogout(): Promise<void> {
if (settings.get('LDAP_Enable') !== true || settings.get('LDAP_Sync_AutoLogout_Enabled') !== true) {
return;
}
try {
const ldap = new LDAPConnection();
await ldap.connect();
try {
await this.logoutDeactivatedUsers(ldap);
} finally {
ldap.disconnect();
}
} catch (err) {
logger.error({ err });
}
}
public static async advancedSyncForUser(ldap: LDAPConnection, user: IUser, isNewRecord: boolean, dn: string): Promise<void> {
try {
await this.syncUserRoles(ldap, user, dn);
await this.syncUserChannels(ldap, user, dn);
await this.syncUserTeams(ldap, user, dn, isNewRecord);
} catch (err) {
logger.error({ msg: 'Advanced Sync failed for user', dn, err });
}
}
private static async advancedSync(
ldap: LDAPConnection,
importUser: IImportUser,
converter: LDAPUserConverter,
isNewRecord: boolean,
): Promise<void> {
const user = await converter.findExistingUser(importUser);
if (!user?.username) {
return;
}
const dn = importUser.importIds[0];
return this.advancedSyncForUser(ldap, user, isNewRecord, dn);
}
private static async isUserInGroup(
ldap: LDAPConnection,
baseDN: string,
filter: string,
{ dn, username }: { dn: string; username: string },
groupName: string,
): Promise<boolean> {
if (!filter || !baseDN) {
logger.error('Please setup LDAP Group Filter and LDAP Group BaseDN in LDAP Settings.');
return false;
}
const searchOptions: ldapjs.SearchOptions = {
filter: filter
.replace(/#{username}/g, username)
.replace(/#{groupName}/g, groupName)
.replace(/#{userdn}/g, dn.replace(/\\/g, '\\5c')),
scope: 'sub',
};
const result = await ldap.searchRaw(baseDN, searchOptions);
if (!Array.isArray(result) || result.length === 0) {
logger.debug({ msg: 'User is not in group', username, groupName });
} else {
logger.debug({ msg: 'User is in group', username, groupName });
return true;
}
return false;
}
private static parseJson(json: string): Record<string, any> | undefined {
try {
return JSON.parse(json);
} catch (err) {
logger.error({ msg: 'Unexpected error', err });
}
}
private static async syncUserRoles(ldap: LDAPConnection, user: IUser, dn: string): Promise<void> {
const { username } = user;
if (!username) {
logger.debug('User has no username');
return;
}
const shouldSyncUserRoles = settings.get<boolean>('LDAP_Sync_User_Data_Roles') ?? false;
const syncUserRolesAutoRemove = settings.get<boolean>('LDAP_Sync_User_Data_Roles_AutoRemove') ?? false;
const syncUserRolesFieldMap = (settings.get<string>('LDAP_Sync_User_Data_RolesMap') ?? '').trim();
const syncUserRolesFilter = (settings.get<string>('LDAP_Sync_User_Data_Roles_Filter') ?? '').trim();
const syncUserRolesBaseDN = (settings.get<string>('LDAP_Sync_User_Data_Roles_BaseDN') ?? '').trim();
const searchStrategy = settings.get<'once' | 'each_group'>('LDAP_Sync_User_Data_Roles_GroupMembershipValidationStrategy');
if (!shouldSyncUserRoles || !syncUserRolesFieldMap) {
logger.debug('not syncing user roles');
return;
}
const roles = await Roles.find(
{},
{
projection: {
_id: 1,
name: 1,
},
},
).toArray();
if (!roles) {
return;
}
const groupsToRolesMap = this.parseJson(syncUserRolesFieldMap);
if (!groupsToRolesMap) {
logger.debug('missing group role mapping');
return;
}
const ldapGroups = Object.keys(groupsToRolesMap);
const roleList: Array<IRole['_id']> = [];
const roleIdsList: Array<IRole['_id']> = [];
const allowedRoles: Array<IRole['_id']> = this.getDataMappedByLdapGroups(groupsToRolesMap, ldapGroups)
.map((role) => role.split(/\.(.+)/)[0])
.reduce((allowedRolesIds: string[], roleIdOrName: string) => {
const role = roles.find((role) => role._id === roleIdOrName) ?? roles.find((role) => role.name === roleIdOrName);
if (role) {
allowedRolesIds.push(role._id);
}
return allowedRolesIds;
}, []);
if (searchStrategy === 'once') {
const ldapUserGroups = await this.getLdapGroupsByUsername(ldap, username, dn, syncUserRolesBaseDN, syncUserRolesFilter);
roleList.push(...this.getDataMappedByLdapGroups(groupsToRolesMap, ldapUserGroups));
} else if (searchStrategy === 'each_group') {
for await (const ldapGroup of ldapGroups) {
if (await this.isUserInGroup(ldap, syncUserRolesBaseDN, syncUserRolesFilter, { dn, username }, ldapGroup)) {
roleList.push(...ensureArray<string>(groupsToRolesMap[ldapGroup]));
}
}
}
for await (const nonValidatedRole of roleList) {
const [roleIdOrName] = nonValidatedRole.split(/\.(.+)/);
const role = roles.find((role) => role._id === roleIdOrName) ?? roles.find((role) => role.name === roleIdOrName);
if (role) {
roleIdsList.push(role._id);
}
}
await syncUserRoles(user._id, roleIdsList, {
allowedRoles,
skipRemovingRoles: !syncUserRolesAutoRemove,
});
}
private static async createRoomForSync(channel: string): Promise<IRoom | undefined> {
logger.debug({ msg: "Channel doesn't exist, creating it", channel });
const roomOwner = settings.get<string>('LDAP_Sync_User_Data_Channels_Admin') || '';
const user = await Users.findOneByUsernameIgnoringCase(roomOwner);
if (!user) {
logger.error({ msg: 'Unable to find user to own channel', roomOwner, channel });
return;
}
const room = await createRoom('c', channel, user, [], false, false, {
customFields: { ldap: true },
});
if (!room?.rid) {
logger.error({ msg: 'Unable to auto-create channel during LDAP sync', channel });
return;
}
room._id = room.rid;
return room;
}
private static async syncUserChannels(ldap: LDAPConnection, user: IUser, dn: string): Promise<void> {
const syncUserChannels = settings.get<boolean>('LDAP_Sync_User_Data_Channels') ?? false;
const syncUserChannelsRemove = settings.get<boolean>('LDAP_Sync_User_Data_Channels_Enforce_AutoChannels') ?? false;
const syncUserChannelsFieldMap = (settings.get<string>('LDAP_Sync_User_Data_ChannelsMap') ?? '').trim();
const syncUserChannelsFilter = (settings.get<string>('LDAP_Sync_User_Data_Channels_Filter') ?? '').trim();
const syncUserChannelsBaseDN = (settings.get<string>('LDAP_Sync_User_Data_Channels_BaseDN') ?? '').trim();
const searchStrategy = settings.get<'once' | 'each_group'>('LDAP_Sync_User_Data_Channels_GroupMembershipValidationStrategy');
if (!syncUserChannels || !syncUserChannelsFieldMap) {
logger.debug('not syncing groups to channels');
return;
}
const groupsToRoomsMap = this.parseJson(syncUserChannelsFieldMap);
if (!groupsToRoomsMap) {
logger.debug('missing group channel mapping');
return;
}
const { username } = user;
if (!username) {
return;
}
logger.debug('syncing user channels');
const ldapGroups = Object.keys(groupsToRoomsMap);
const ldapUserGroups: string[] = [];
const channelsToAdd = new Set<string>();
const userChannelsNames: string[] = [];
if (searchStrategy === 'once') {
ldapUserGroups.push(...(await this.getLdapGroupsByUsername(ldap, username, dn, syncUserChannelsBaseDN, syncUserChannelsFilter)));
userChannelsNames.push(...this.getDataMappedByLdapGroups(groupsToRoomsMap, ldapUserGroups));
} else if (searchStrategy === 'each_group') {
for await (const ldapGroup of ldapGroups) {
if (await this.isUserInGroup(ldap, syncUserChannelsBaseDN, syncUserChannelsFilter, { dn, username }, ldapGroup)) {
userChannelsNames.push(...ensureArray<string>(groupsToRoomsMap[ldapGroup]));
ldapUserGroups.push(ldapGroup);
}
}
}
for await (const userChannelName of userChannelsNames) {
try {
const name = await getValidRoomName(userChannelName.trim(), undefined, { allowDuplicates: true });
const room = (await Rooms.findOneByNonValidatedName(name)) || (await this.createRoomForSync(userChannelName));
if (!room) {
logger.debug({ msg: 'Unable to resolve mapped channel for sync', userChannelName, username });
continue;
}
if (settings.get('ABAC_Enabled') && room?.abacAttributes?.length) {
logger.error({ msg: 'Cannot add user to channel. Channel is ABAC managed', userChannelName });
continue;
}
if (room.teamMain) {
logger.error({ msg: "Can't add user to channel because it is a team", userChannelName });
} else {
channelsToAdd.add(room._id);
await addUserToRoom(room._id, user);
logger.debug({ msg: 'Synced user channel from LDAP', roomId: room._id, username });
}
} catch (err) {
logger.debug({ msg: 'Failed to sync user room', username, userChannelName });
logger.error({ err });
}
}
if (syncUserChannelsRemove) {
const notInUserGroups = ldapGroups.filter((ldapGroup) => !ldapUserGroups.includes(ldapGroup));
const notMappedRooms = this.getDataMappedByLdapGroups(groupsToRoomsMap, notInUserGroups);
const roomsToRemove = new Set<string>(notMappedRooms);
for await (const roomName of roomsToRemove) {
const name = await getValidRoomName(roomName.trim(), undefined, { allowDuplicates: true });
const room = await Rooms.findOneByNonValidatedName(name);
if (!room || room.teamMain || channelsToAdd.has(room._id)) {
logger.debug({ msg: 'Skipping channel on removal sync', roomName, username });
continue;
}
const subscription = await SubscriptionsRaw.findOneByRoomIdAndUserId(room._id, user._id);
if (subscription) {
await removeUserFromRoom(room._id, user);
logger.debug({ msg: 'Removed user from channel', username, roomId: room._id });
}
}
}
}
private static async syncUserTeams(ldap: LDAPConnection, user: IUser, dn: string, isNewRecord: boolean): Promise<void> {
if (!user.username) {
return;
}
const mapTeams =
settings.get<boolean>('LDAP_Enable_LDAP_Groups_To_RC_Teams') &&
(isNewRecord || settings.get<boolean>('LDAP_Validate_Teams_For_Each_Login'));
if (!mapTeams) {
return;
}
const baseDN = (settings.get<string>('LDAP_Teams_BaseDN') ?? '').trim() || ldap.options.baseDN;
const filter = settings.get<string>('LDAP_Query_To_Get_User_Teams');
const groupAttributeName = settings.get<string>('LDAP_Teams_Name_Field');
const ldapUserTeams = await this.getLdapGroupsByUsername(ldap, user.username, dn, baseDN, filter, groupAttributeName);
const mapJson = settings.get<string>('LDAP_Groups_To_Rocket_Chat_Teams');
if (!mapJson) {
return;
}
const map = this.parseJson(mapJson) as Record<string, string>;
if (!map) {
return;
}
const teamNames = this.getDataMappedByLdapGroups(map, ldapUserTeams);
const allTeamNames = [...new Set(Object.values(map).flat())];
const allTeams = await Team.listByNames(allTeamNames, { projection: { _id: 1, name: 1 } });
const inTeamIds = allTeams.filter(({ name }) => teamNames.includes(name)).map(({ _id }) => _id);
const notInTeamIds = allTeams.filter(({ name }) => !teamNames.includes(name)).map(({ _id }) => _id);
const currentTeams = await Team.listTeamsBySubscriberUserId(user._id, {
projection: { teamId: 1 },
});
const currentTeamIds = currentTeams?.map(({ teamId }) => teamId);
const teamsToRemove = currentTeamIds?.filter((teamId) => notInTeamIds.includes(teamId));
let teamsToAdd = inTeamIds.filter((teamId) => !currentTeamIds?.includes(teamId));
if (settings.get('ABAC_Enabled')) {
const roomsWithAbacAttributes = await Rooms.findPrivateRoomsByIdsWithAbacAttributes(
allTeams.filter((t) => teamsToAdd.includes(t._id)).map((t) => t.roomId),
{ projection: { teamId: 1 } },
)
.map((r) => r.teamId)
.toArray();
logger.debug({ msg: 'Some teams will be ignored from sync because they are abac managed', roomsWithAbacAttributes });
teamsToAdd = teamsToAdd.filter((teamId) => !roomsWithAbacAttributes.includes(teamId));
if (!teamsToAdd.length) {
return;
}
}
await Team.insertMemberOnTeams(user._id, teamsToAdd);
if (teamsToRemove) {
await Team.removeMemberFromTeams(user._id, teamsToRemove);
}
}
private static getDataMappedByLdapGroups(map: Record<string, string>, ldapGroups: Array<string>): Array<string> {
const mappedLdapGroups = Object.keys(map);
const filteredMappedLdapGroups = ldapGroups.filter((ldapGroup) => mappedLdapGroups.includes(ldapGroup));
if (filteredMappedLdapGroups.length < ldapGroups.length) {
const unmappedLdapGroups = ldapGroups.filter((ldapGroup) => !mappedLdapGroups.includes(ldapGroup));
logger.error({ msg: 'The following LDAP groups are not mapped in Rocket.Chat', unmappedLdapGroups });
}
if (!filteredMappedLdapGroups.length) {
return [];
}
return [...new Set(filteredMappedLdapGroups.map((ldapGroup) => map[ldapGroup]).flat())];
}
private static async getLdapGroupsByUsername(
ldap: LDAPConnection,
username: string,
userDN: string,
baseDN: string,
filter: string,
groupAttributeName?: string,
): Promise<Array<string>> {
if (!filter) {
return [];
}
const searchOptions = {
filter: filter.replace(/#{username}/g, username).replace(/#{userdn}/g, userDN.replace(/\\/g, '\\5c')),
scope: ldap.options.userSearchScope || 'sub',
sizeLimit: ldap.options.searchSizeLimit,
};
const attributeNames = groupAttributeName ? groupAttributeName.split(',').map((attributeName) => attributeName.trim()) : ['ou', 'cn'];
const ldapUserGroups = await ldap.searchRaw(baseDN, searchOptions);
if (!Array.isArray(ldapUserGroups)) {
return [];
}
return ldapUserGroups
.map((entry) => {
if (!entry?.raw) {
return undefined;
}
for (const attributeName of attributeNames) {
if (entry.raw[attributeName]) {
return ldap.extractLdapAttribute(entry.raw[attributeName]) as string;
}
}
return undefined;
})
.filter((entry): entry is string => Boolean(entry))
.flat();
}
private static isUserDeactivated(ldapUser: ILDAPEntry): boolean {
// EDirectory: Account manually disabled by an admin
if (ldapUser.loginDisabled) {
mapLogger.debug('User account was manually disabled by an admin (attribute loginDisabled)');
return true;
}
// Oracle: Account must not be allowed to authenticate
if (ldapUser.orclIsEnabled && ldapUser.orclIsEnabled !== 'ENABLED') {
mapLogger.debug('User must not be allowed to authenticate (attribute orclIsEnabled)');
return true;
}
// Active Directory - Account disabled by an Admin
if (ldapUser.userAccountControl && (ldapUser.userAccountControl & 2) === 2) {
mapLogger.debug('User account disabled by an admin (attribute userAccountControl)');
return true;
}
return false;
}
public static copyActiveState(ldapUser: ILDAPEntry, userData: IImportUser): void {
if (!ldapUser) {
return;
}
const syncUserState = settings.get('LDAP_Sync_User_Active_State');
if (syncUserState === 'none') {
return;
}
const deleted = this.isUserDeactivated(ldapUser);
if (deleted === userData.deleted) {
return;
}
if (syncUserState === 'disable' && !deleted) {
return;
}
if (syncUserState === 'enable' && deleted) {
return;
}
userData.deleted = deleted;
logger.info({ msg: 'Switching user status', name: userData.name, username: userData.username, active: !deleted });
}
public static copyCustomFields(ldapUser: ILDAPEntry, userData: IImportUser): void {
return copyCustomFieldsLDAP(
{
ldapUser,
userData,
customFieldsSettings: settings.get<string>('Accounts_CustomFields'),
customFieldsMap: settings.get<string>('LDAP_CustomFieldMap'),
syncCustomFields: settings.get<boolean>('LDAP_Sync_Custom_Fields'),
},
logger,
);
}
private static async importNewUsers(ldap: LDAPConnection, converter: LDAPUserConverter): Promise<void> {
return new Promise((resolve, reject) => {
let count = 0;
ldap
.searchAllUsers<IImportUser>({
entryCallback: (entry: ldapjs.SearchEntry): IImportUser | undefined => {
const data = ldap.extractLdapEntryData(entry);
count++;
const userData = this.mapUserData(data);
converter.addObjectToMemory(userData, { dn: data.dn, username: this.getLdapUsername(data) });
return userData;
},
endCallback: (err: any): void => {
if (err) {
logger.error({ err });
reject(err);
return;
}
logger.info({ msg: 'LDAP finished loading users. Users added to importer', count });
resolve();
},
})
.catch(reject);
});
}
private static async updateExistingUsers(ldap: LDAPConnection, converter: LDAPUserConverter, disableMissingUsers = false): Promise<void> {
const users = await Users.findLDAPUsers().toArray();
for await (const user of users) {
const ldapUser = await this.findLDAPUser(ldap, user);
if (ldapUser) {
const userData = this.mapUserData(ldapUser, user.username);
converter.addObjectToMemory(userData, { dn: ldapUser.dn, username: this.getLdapUsername(ldapUser) });
} else if (disableMissingUsers && user.active) {
await setUserActiveStatus(user._id, false, true);
}
}
}
private static async disableMissingUsers(foundUsers: IUser['_id'][]): Promise<void> {
const userIds = (await Users.findActiveLDAPUsersExceptIds(foundUsers, { projection: { _id: 1 } }).toArray()).map(({ _id }) => _id);
await Promise.allSettled(userIds.map((id) => setUserActiveStatus(id, false, true)));
}
private static async updateUserAvatars(ldap: LDAPConnection): Promise<void> {
const users = await Users.findLDAPUsers().toArray();
for await (const user of users) {
const ldapUser = await this.findLDAPUser(ldap, user);
if (!ldapUser) {
continue;
}
await LDAPManager.syncUserAvatar(user, ldapUser);
}
}
private static async updateUserAbacAttributes(ldap: LDAPConnection): Promise<void> {
const mapping = this.parseJson(settings.get('LDAP_ABAC_AttributeMap'));
if (!mapping) {
logger.error('LDAP to ABAC attribute mapping is not valid JSON');
return;
}
for await (const user of Users.findLDAPUsers()) {
const ldapUser = await this.findLDAPUser(ldap, user);
if (!ldapUser) {
continue;
}
await Abac.addSubjectAttributes(user, ldapUser, mapping, undefined);
}
}
private static async syncUserAbacAttribute(ldap: LDAPConnection, user: IUser): Promise<void> {
const mapping = this.parseJson(settings.get('LDAP_ABAC_AttributeMap'));
if (!mapping) {
logger.error('LDAP to ABAC attribute mapping is not valid JSON');
return;
}
const ldapUser = await this.findLDAPUser(ldap, user);
if (!ldapUser) {
return;
}
await Abac.addSubjectAttributes(user, ldapUser, mapping, undefined);
}
private static async findLDAPUser(ldap: LDAPConnection, user: IUser): Promise<ILDAPEntry | undefined> {
if (user.services?.ldap?.id) {
return ldap.findOneById(user.services.ldap.id, user.services.ldap.idAttribute);
}
if (user.username) {
return ldap.findOneByUsername(user.username);
}
searchLogger.debug({
msg: 'existing LDAP user not found during Sync',
ldapId: user.services?.ldap?.id,
ldapAttribute: user.services?.ldap?.idAttribute,
username: user.username,
});
}
private static async logoutDeactivatedUsers(ldap: LDAPConnection): Promise<void> {
const users = await Users.findConnectedLDAPUsers().toArray();
for await (const user of users) {
const ldapUser = await this.findLDAPUser(ldap, user);
if (!ldapUser) {
continue;
}
if (this.isUserDeactivated(ldapUser)) {
await Users.unsetLoginTokens(user._id);
}
}
}
}

View File

@@ -0,0 +1,209 @@
import type { IImportUser, ILDAPEntry } from '@rocket.chat/core-typings';
import type { Logger } from '@rocket.chat/logger';
import { expect, spy } from 'chai';
import { copyCustomFieldsLDAP } from './copyCustomFieldsLDAP';
describe('LDAP copyCustomFieldsLDAP', () => {
it('should copy custom fields from ldapUser to rcUser', () => {
const ldapUser = {
mail: 'test@test.com',
givenName: 'Test',
} as unknown as ILDAPEntry;
const userData = {
name: 'Test',
username: 'test',
} as unknown as IImportUser;
copyCustomFieldsLDAP(
{
ldapUser,
userData,
syncCustomFields: true,
customFieldsSettings: JSON.stringify({
mappedGivenName: { type: 'text', required: false },
}),
customFieldsMap: JSON.stringify({
givenName: 'mappedGivenName',
}),
},
{
debug: () => undefined,
error: () => undefined,
} as unknown as Logger,
);
expect(userData).to.have.property('customFields');
expect(userData.customFields).to.be.eql({ mappedGivenName: 'Test' });
});
it('should copy custom fields from ldapUser to rcUser already having other custom fields', () => {
const ldapUser = {
mail: 'test@test.com',
givenName: 'Test',
} as unknown as ILDAPEntry;
const userData = {
name: 'Test',
username: 'test',
customFields: {
custom: 'Test',
},
} as unknown as IImportUser;
copyCustomFieldsLDAP(
{
ldapUser,
userData,
syncCustomFields: true,
customFieldsSettings: JSON.stringify({
mappedGivenName: { type: 'text', required: false },
}),
customFieldsMap: JSON.stringify({
givenName: 'mappedGivenName',
}),
},
{
debug: () => undefined,
error: () => undefined,
} as unknown as Logger,
);
expect(userData).to.have.property('customFields');
expect(userData.customFields).to.be.eql({ custom: 'Test', mappedGivenName: 'Test' });
});
it('should not copy custom fields from ldapUser to rcUser if syncCustomFields is false', () => {
const ldapUser = {
mail: 'test@test.com',
givenName: 'Test',
} as unknown as ILDAPEntry;
const userData = {
name: 'Test',
username: 'test',
} as unknown as IImportUser;
copyCustomFieldsLDAP(
{
ldapUser,
userData,
syncCustomFields: false,
customFieldsSettings: JSON.stringify({
mappedGivenName: { type: 'text', required: false },
}),
customFieldsMap: JSON.stringify({
givenName: 'mappedGivenName',
}),
},
{
debug: () => undefined,
error: () => undefined,
} as unknown as Logger,
);
expect(userData).to.not.have.property('customFields');
});
it('should call logger.error if customFieldsSettings is not a valid JSON', () => {
const debug = spy();
const error = spy();
const ldapUser = {
mail: 'test@test.com',
givenName: 'Test',
} as unknown as ILDAPEntry;
const userData = {
name: 'Test',
username: 'test',
} as unknown as IImportUser;
copyCustomFieldsLDAP(
{
ldapUser,
userData,
syncCustomFields: true,
customFieldsSettings: `${JSON.stringify({
mappedGivenName: { type: 'text', required: false },
})}}`,
customFieldsMap: JSON.stringify({
givenName: 'mappedGivenName',
}),
},
{
debug,
error,
} as unknown as Logger,
);
expect(error).to.have.been.called.exactly(1);
});
it('should call logger.error if customFieldsMap is not a valid JSON', () => {
const debug = spy();
const error = spy();
const ldapUser = {
mail: 'test@test.com',
givenName: 'Test',
} as unknown as ILDAPEntry;
const userData = {
name: 'Test',
username: 'test',
} as unknown as IImportUser;
copyCustomFieldsLDAP(
{
ldapUser,
userData,
syncCustomFields: true,
customFieldsSettings: JSON.stringify({
mappedGivenName: { type: 'text', required: false },
}),
customFieldsMap: `${JSON.stringify({
givenName: 'mappedGivenName',
})}}`,
},
{
debug,
error,
} as unknown as Logger,
);
expect(error).to.have.been.called.exactly(1);
});
it('should call logger.debug if some custom fields are not mapped but still mapping the other fields', () => {
const debug = spy();
const error = spy();
const ldapUser = {
mail: 'test@test.com',
givenName: 'Test',
} as unknown as ILDAPEntry;
const userData = {
name: 'Test',
username: 'test',
} as unknown as IImportUser;
copyCustomFieldsLDAP(
{
ldapUser,
userData,
syncCustomFields: true,
customFieldsSettings: JSON.stringify({
mappedGivenName: { type: 'text', required: false },
}),
customFieldsMap: JSON.stringify({
givenName: 'mappedGivenName',
test: 'test',
}),
},
{
debug,
error,
} as unknown as Logger,
);
expect(debug).to.have.been.called.exactly(1);
expect(userData).to.have.property('customFields');
expect(userData.customFields).to.be.eql({ mappedGivenName: 'Test' });
});
});

View File

@@ -0,0 +1,71 @@
import type { IImportUser, ILDAPEntry } from '@rocket.chat/core-typings';
import type { Logger } from '@rocket.chat/logger';
import { replacesNestedValues } from './replacesNestedValues';
import { getNestedProp } from '../../../../server/lib/getNestedProp';
import { templateVarHandler } from '../../../../server/lib/utils/lib/templateVarHandler';
export const copyCustomFieldsLDAP = (
{
ldapUser,
userData,
customFieldsSettings,
customFieldsMap,
syncCustomFields,
}: {
ldapUser: ILDAPEntry;
userData: IImportUser;
syncCustomFields: boolean;
customFieldsSettings: string;
customFieldsMap: string;
},
logger: Logger,
): void => {
if (!syncCustomFields) {
return;
}
if (!customFieldsMap || !customFieldsSettings) {
if (customFieldsMap) {
logger.debug('Skipping LDAP custom fields because there are no custom map fields configured.');
return;
}
logger.debug('Skipping LDAP custom fields because there are no custom fields configured.');
return;
}
const map: Record<string, string> = (() => {
try {
return JSON.parse(customFieldsMap);
} catch (err) {
logger.error({ msg: 'Error parsing LDAP custom fields map.', err });
}
})();
if (!map) {
return;
}
let customFields: Record<string, any>;
try {
customFields = JSON.parse(customFieldsSettings) as Record<string, any>;
} catch (err) {
logger.error({ msg: 'Failed to parse Custom Fields', err });
return;
}
Object.entries(map).forEach(([ldapField, userField]) => {
if (!getNestedProp(customFields, userField)) {
logger.debug({ msg: 'User attribute does not exist', userField });
return;
}
const value = templateVarHandler(ldapField, ldapUser);
if (!value) {
return;
}
userData.customFields = replacesNestedValues({ ...userData.customFields }, userField, value);
});
};

View File

@@ -0,0 +1,87 @@
import { expect } from 'chai';
import { replacesNestedValues } from './replacesNestedValues';
describe('LDAP replacesNestedValues', () => {
it('should replace shallow values', () => {
const result = replacesNestedValues(
{
a: 1,
},
'a',
2,
);
expect(result).to.eql({
a: 2,
});
});
it('should replace undefined values', () => {
const result = replacesNestedValues({}, 'a', 2);
expect(result).to.eql({
a: 2,
});
});
it('should replace nested values', () => {
const result = replacesNestedValues(
{
a: {
b: 1,
},
},
'a.b',
2,
);
expect(result).to.eql({
a: {
b: 2,
},
});
});
it('should replace undefined nested values', () => {
const result = replacesNestedValues(
{
a: {},
},
'a.b',
2,
);
expect(result).to.eql({
a: {
b: 2,
},
});
});
it('should fail if the value being replaced is not an object', () => {
expect(() =>
replacesNestedValues(
{
a: [],
},
'a.b',
2,
),
).to.throw();
expect(() =>
replacesNestedValues(
{
a: 1,
},
'a.b',
2,
),
).to.throw();
expect(() =>
replacesNestedValues(
{
a: { b: [] },
},
'a.b',
2,
),
).to.throw();
});
});

View File

@@ -0,0 +1,26 @@
export const replacesNestedValues = (obj: Record<string, unknown>, key: string, value: unknown): Record<string, unknown> => {
const keys = key.split('.');
const lastKey = keys.shift();
if (!lastKey) {
throw new Error(`Failed to assign custom field: ${key}`);
}
if (keys.length && obj[lastKey] !== undefined && (typeof obj[lastKey] !== 'object' || Array.isArray(obj[lastKey]))) {
throw new Error(`Failed to assign custom field: ${key}`);
}
if (keys.length === 0 && typeof obj[lastKey] === 'object') {
throw new Error(`Failed to assign custom field: ${key}`);
}
return {
...obj,
...(keys.length === 0 && {
[lastKey]: value,
}),
...(keys.length > 0 && {
[lastKey]: replacesNestedValues(obj[lastKey] as Record<string, unknown>, keys.join('.'), value),
}),
};
};

View File

@@ -0,0 +1,43 @@
import { AirGappedRestriction, License } from '@rocket.chat/license';
import { Settings, Statistics } from '@rocket.chat/models';
import { i18n } from '../../../../server/lib/i18n';
import { notifyOnSettingChangedById } from '../../../../server/lib/notifyListener';
import { sendMessagesToAdmins } from '../../../../server/lib/sendMessagesToAdmins';
import { updateAuditedBySystem } from '../../../../server/settings/lib/auditedSettingUpdates';
const updateRestrictionSetting = async (remainingDays: number) => {
await updateAuditedBySystem({
reason: 'updateRestrictionSetting',
})(Settings.updateValueById, 'Cloud_Workspace_AirGapped_Restrictions_Remaining_Days', remainingDays);
void notifyOnSettingChangedById('Cloud_Workspace_AirGapped_Restrictions_Remaining_Days');
};
const sendRocketCatWarningToAdmins = async (remainingDays: number) => {
const lastDayOrNoRestrictionsAtAll = remainingDays <= 0;
if (lastDayOrNoRestrictionsAtAll) {
return;
}
if (AirGappedRestriction.isWarningPeriod(remainingDays)) {
await sendMessagesToAdmins({
msgs: async ({ adminUser }) => ({
msg: i18n.t('AirGapped_Restriction_Warning', { lng: adminUser.language || 'en', remainingDays }),
}),
});
}
};
AirGappedRestriction.on('remainingDays', async ({ days }: { days: number }) => {
await updateRestrictionSetting(days);
await sendRocketCatWarningToAdmins(days);
});
License.onValidateLicense(async () => {
const token = await Statistics.findLastStatsToken();
void AirGappedRestriction.computeRestriction(token);
});
License.onRemoveLicense(async () => {
const token = await Statistics.findLastStatsToken();
void AirGappedRestriction.computeRestriction(token);
});

View File

@@ -0,0 +1,50 @@
import type { IAppStorageItem } from '@rocket.chat/apps/dist/server/storage/IAppStorageItem';
import { Apps } from '@rocket.chat/core-services';
import type { LicenseModule } from '@rocket.chat/core-typings';
import { License, type LicenseImp } from '@rocket.chat/license';
import { getInstallationSourceFromAppStorageItem } from '../../../../lib/apps/getInstallationSourceFromAppStorageItem';
type _canEnableAppDependencies = {
Apps: typeof Apps;
License: LicenseImp;
};
export const _canEnableApp = async ({ Apps, License }: _canEnableAppDependencies, app: IAppStorageItem): Promise<void> => {
if (!(await Apps.isInitialized())) {
throw new Error('apps-engine-not-initialized');
}
// Migrated apps were installed before the validation was implemented
// so they're always allowed to be enabled
if (app.migrated) {
return;
}
if (app.info.addon && !License.hasModule(app.info.addon as LicenseModule)) {
throw new Error('app-addon-not-valid');
}
const source = getInstallationSourceFromAppStorageItem(app);
switch (source) {
case 'private':
if (await License.shouldPreventAction('privateApps')) {
throw new Error('license-prevented');
}
break;
default:
if (await License.shouldPreventAction('marketplaceApps')) {
throw new Error('license-prevented');
}
const marketplaceInfo = app.marketplaceInfo?.[0];
if (marketplaceInfo?.isEnterpriseOnly && !License.hasValidLicense()) {
throw new Error('invalid-license');
}
break;
}
};
export const canEnableApp = async (app: IAppStorageItem): Promise<void> => _canEnableApp({ Apps, License }, app);

View File

@@ -0,0 +1,35 @@
import crypto from 'node:crypto';
import { License } from '@rocket.chat/license';
import { showErrorBox } from '../../../../server/lib/logger/showBox';
export function enforceFipsLicense(): void {
if (!crypto.getFips()) {
return;
}
if (License.hasModule('fips')) {
return;
}
const reason = !License.encryptedLicense
? 'No license was found in this workspace'
: (!License.hasValidLicense() && 'The license applied to this workspace is invalid or expired') ||
"The license applied to this workspace does not include the 'fips' module";
showErrorBox(
'FIPS LICENSE ERROR',
[
`${reason}.`,
'',
'This server is running in FIPS mode, which requires a valid license',
"including the 'fips' module.",
'',
'Provide your license through the ROCKETCHAT_LICENSE environment variable,',
'or contact Rocket.Chat to obtain one: https://www.rocket.chat/sales-contact',
].join('\n'),
);
process.exit(1);
}

View File

@@ -0,0 +1,34 @@
import type { ISetting } from '@rocket.chat/core-typings';
import { Settings, Users } from '@rocket.chat/models';
type WizardSettings = Array<ISetting>;
export const getSeatsRequestLink = async (url: string, params?: Record<string, string>): Promise<string> => {
const workspaceId = await Settings.findOneById('Cloud_Workspace_Id');
const activeUsers = await Users.getActiveLocalUserCount();
const wizardSettings: WizardSettings = await Settings.findSetupWizardSettings().toArray();
const newUrl = new URL(url);
if (workspaceId?.value) {
newUrl.searchParams.append('workspaceId', String(workspaceId.value));
}
if (activeUsers) {
newUrl.searchParams.append('activeUsers', String(activeUsers));
}
wizardSettings
.filter(({ _id, value }) => ['Industry', 'Country', 'Size'].includes(_id) && value)
.forEach((setting) => {
newUrl.searchParams.append(setting._id.toLowerCase(), String(setting.value));
});
if (params) {
Object.entries(params).forEach(([key, value]) => {
newUrl.searchParams.append(key, String(value));
});
}
return newUrl.toString();
};

View File

@@ -0,0 +1,21 @@
import { Apps } from '@rocket.chat/core-services';
import type { LicenseAppSources } from '@rocket.chat/core-typings';
import { getInstallationSourceFromAppStorageItem } from '../../../../../lib/apps/getInstallationSourceFromAppStorageItem';
export async function getAppCount(source: LicenseAppSources): Promise<number> {
if (!(await Apps.isInitialized())) {
return 0;
}
const apps = await Apps.getApps({ enabled: true });
if (!apps || !Array.isArray(apps)) {
return 0;
}
const storageItems = await Promise.all(apps.map((app) => Apps.getAppStorageItemById(app.id)));
const activeAppsFromSameSource = storageItems.filter((item) => item && getInstallationSourceFromAppStorageItem(item) === source);
return activeAppsFromSameSource.length;
}

View File

@@ -0,0 +1,57 @@
import type { ILicense } from '@rocket.chat/core-services';
import { api, ServiceClassInternal } from '@rocket.chat/core-services';
import type { LicenseModule } from '@rocket.chat/core-typings';
import { License } from '@rocket.chat/license';
import { guestPermissions } from '../authorization/guestPermissions';
import { resetEnterprisePermissions } from '../authorization/resetEnterprisePermissions';
export class LicenseService extends ServiceClassInternal implements ILicense {
protected name = 'license';
constructor() {
super();
License.onValidateLicense((): void => {
if (!License.hasValidLicense()) {
return;
}
void api.broadcast('authorization.guestPermissions', guestPermissions);
void resetEnterprisePermissions();
});
License.onModule((licenseModule) => {
void api.broadcast('license.module', licenseModule);
});
this.onEvent('license.actions', (preventedActions) => License.syncShouldPreventActionResults(preventedActions));
this.onEvent('license.sync', () => License.sync());
}
override async started(): Promise<void> {
if (!License.hasValidLicense()) {
return;
}
void api.broadcast('authorization.guestPermissions', guestPermissions);
await resetEnterprisePermissions();
}
hasModule(feature: LicenseModule): boolean {
return License.hasModule(feature);
}
hasValidLicense(): boolean {
return License.hasValidLicense();
}
getModules(): string[] {
return License.getModules();
}
getGuestPermissions(): string[] {
return guestPermissions;
}
}

View File

@@ -0,0 +1,28 @@
import { settingsRegistry } from '../../../../server/settings';
// The proper name for this group is Premium, but we can't change it because it's already in use and we will break the settings
// TODO: Keep this until next major updates
await settingsRegistry.addGroup('Enterprise', async function () {
await this.section('Enterprise', async function () {
await this.add('Enterprise_License', '', {
type: 'string',
i18nLabel: 'Premium_License',
alert: 'Premium_License_alert',
});
await this.add('Enterprise_License_Data', '', {
type: 'string',
hidden: true,
public: false,
});
await this.add('Enterprise_License_Status', '', {
readonly: true,
type: 'string',
i18nLabel: 'Status',
});
});
await this.add('Cloud_Workspace_AirGapped_Restrictions_Remaining_Days', -1, {
type: 'int',
readonly: true,
public: true,
});
});

View File

@@ -0,0 +1,180 @@
import { api } from '@rocket.chat/core-services';
import type { LicenseLimitKind } from '@rocket.chat/core-typings';
import { applyLicense, applyLicenseOrRemove, applyNewestLicense, License } from '@rocket.chat/license';
import { Subscriptions, Users, Settings, LivechatContacts } from '@rocket.chat/models';
import { wrapExceptions } from '@rocket.chat/tools';
import moment from 'moment';
import { getAppCount } from './lib/getAppCount';
import { callbacks } from '../../../../server/lib/callbacks';
import { syncWorkspace } from '../../../../server/lib/cloud/syncWorkspace';
import { SystemLogger } from '../../../../server/lib/logger/system';
import { notifyOnSettingChangedById } from '../../../../server/lib/notifyListener';
import { settings } from '../../../../server/settings';
const applyStartupLicense = async (): Promise<void> => {
// Licenses can't be validated before the workspace URL is known; set it explicitly
// so the startup license is never left pending behind the Site_Url watcher.
await License.setWorkspaceUrl(settings.get<string>('Site_Url'));
await applyNewestLicense(License, settings.get<string>('Enterprise_License') ?? '', process.env.ROCKETCHAT_LICENSE ?? '');
};
export const startLicense = async () => {
settings.watch<string>('Site_Url', (value) => {
if (value) {
void License.setWorkspaceUrl(value);
}
});
License.onValidateLicense(async () => {
(await Settings.updateValueById('Enterprise_License', License.encryptedLicense)).modifiedCount &&
void notifyOnSettingChangedById('Enterprise_License');
(await Settings.updateValueById('Enterprise_License_Status', 'Valid')).modifiedCount &&
void notifyOnSettingChangedById('Enterprise_License_Status');
});
License.onInvalidateLicense(async () => {
(await Settings.updateValueById('Enterprise_License_Status', 'Invalid')).modifiedCount &&
void notifyOnSettingChangedById('Enterprise_License_Status');
});
License.onRemoveLicense(async () => {
(await Settings.updateValueById('Enterprise_License', '')).modifiedCount &&
void notifyOnSettingChangedById('Enterprise_License_Status');
(await Settings.updateValueById('Enterprise_License_Status', 'Invalid')).modifiedCount &&
void notifyOnSettingChangedById('Enterprise_License_Status');
});
/**
* This is a debounced function that will sync the workspace data to the cloud.
* it caches the context, waits for a second and then syncs the data.
*/
const syncByTriggerDebounced = (() => {
let timeout: NodeJS.Timeout | undefined;
const contexts: Set<string> = new Set();
return async (context: string) => {
contexts.add(context);
if (timeout) {
clearTimeout(timeout);
}
timeout = setTimeout(() => {
timeout = undefined;
void syncByTrigger([...contexts]);
contexts.clear();
}, 1000);
};
})();
const syncByTrigger = async (contexts: string[]) => {
if (!License.encryptedLicense) {
return;
}
const existingData = wrapExceptions(() => JSON.parse(settings.get<string>('Enterprise_License_Data'))).catch(() => ({})) ?? {};
const date = new Date();
const day = date.getDate();
const month = date.getMonth() + 1;
const year = date.getFullYear();
const period = `${year}-${month}-${day}`;
const [, , signed] = License.encryptedLicense.split('.');
// Check if this sync has already been done. Based on License, behavior.
if ([...contexts.values()].every((context) => existingData.signed === signed && existingData[context] === period)) {
return;
}
const obj = Object.fromEntries(contexts.map((context) => [context, period]));
(
await Settings.updateValueById(
'Enterprise_License_Data',
JSON.stringify({
...(existingData.signed === signed && existingData),
...existingData,
...obj,
signed,
}),
)
).modifiedCount && void notifyOnSettingChangedById('Enterprise_License_Data');
try {
await syncWorkspace();
} catch (error) {
console.error(error);
}
};
License.setLicenseLimitCounter('activeUsers', () => Users.getActiveLocalUserCount());
License.setLicenseLimitCounter('guestUsers', () => Users.getActiveLocalGuestCount());
License.setLicenseLimitCounter('roomsPerGuest', async (context) =>
context?.userId ? Subscriptions.countByUserIdExceptType(context.userId, 'd') : 0,
);
License.setLicenseLimitCounter('privateApps', () => getAppCount('private'));
License.setLicenseLimitCounter('marketplaceApps', () => getAppCount('marketplace'));
License.setLicenseLimitCounter('monthlyActiveContacts', () => LivechatContacts.countContactsOnPeriod(moment.utc().format('YYYY-MM')));
return new Promise<void>((resolve) => {
// When settings are loaded, apply the current license if there is one.
settings.onReady(async () => {
import('./airGappedRestrictions');
await applyStartupLicense();
License.onInstall(() => {
if (License.hasOfflineLicense()) {
// startup level so it is visible at the default Log_Level, like 'License installed'
SystemLogger.startup(
'Offline license detected: outbound connections to Rocket.Chat Cloud services and the Rocket.Chat Push Gateway are disabled',
);
}
});
// After the current license is already loaded, watch the setting value to react to new licenses being applied.
settings.change<string>('Enterprise_License', (license) => applyLicenseOrRemove(license, true));
callbacks.add('workspaceLicenseRemoved', () => License.remove());
callbacks.add('workspaceLicenseChanged', (updatedLicense) => applyLicense(updatedLicense, true));
License.onInstall(async () => void api.broadcast('license.actions', {} as Record<Partial<LicenseLimitKind>, boolean>));
License.onInvalidate(async () => void api.broadcast('license.actions', {} as Record<Partial<LicenseLimitKind>, boolean>));
License.onBehaviorTriggered('prevent_action', (context) => syncByTriggerDebounced(`prevent_action_${context.limit}`));
License.onBehaviorTriggered('start_fair_policy', async (context) => syncByTriggerDebounced(`start_fair_policy_${context.limit}`));
License.onBehaviorTriggered('disable_modules', async (context) => syncByTriggerDebounced(`disable_modules_${context.limit}`));
License.onChange(() => api.broadcast('license.sync'));
License.onBehaviorToggled('prevent_action', (context) => {
if (!context.limit) {
return;
}
void api.broadcast('license.actions', {
[context.limit]: true,
} as Record<Partial<LicenseLimitKind>, boolean>);
});
License.onBehaviorToggled('allow_action', (context) => {
if (!context.limit) {
return;
}
void api.broadcast('license.actions', {
[context.limit]: false,
} as Record<Partial<LicenseLimitKind>, boolean>);
});
resolve();
});
});
};

View File

@@ -0,0 +1,178 @@
import { api } from '@rocket.chat/core-services';
import type { IMessage, IRoom, IUser, IReadReceipt, IReadReceiptWithUser } from '@rocket.chat/core-typings';
import { LivechatVisitors, ReadReceipts, ReadReceiptsArchive, Messages, Rooms, Subscriptions, Users } from '@rocket.chat/models';
import { SystemLogger } from '../../../../server/lib/logger/system';
import { notifyOnRoomChangedById, notifyOnMessageChange } from '../../../../server/lib/notifyListener';
import { settings } from '../../../../server/settings';
// debounced function by roomId, so multiple calls within 2 seconds to same roomId runs only once
const list: Record<string, NodeJS.Timeout> = {};
const debounceByRoomId = function <T extends Pick<IRoom, '_id'>>(fn: (room: T) => Promise<void>) {
return function (this: unknown, room: T) {
clearTimeout(list[room._id]);
list[room._id] = setTimeout(() => {
void fn.call(this, room);
delete list[room._id];
}, 2000);
};
};
const updateMessages = debounceByRoomId(async ({ _id, lm }: Pick<IRoom, '_id' | 'lm'>) => {
// @TODO maybe store firstSubscription in room object so we don't need to call the above update method
const firstSubscription = await Subscriptions.getMinimumLastSeenByRoomId(_id);
if (!firstSubscription?.ls) {
return;
}
const result = await Messages.setVisibleMessagesAsRead(_id, firstSubscription.ls);
if (result.modifiedCount > 0) {
void api.broadcast('notify.messagesRead', { rid: _id, until: firstSubscription.ls });
}
if (lm && lm <= firstSubscription.ls) {
await Rooms.setLastMessageAsRead(_id);
void notifyOnRoomChangedById(_id);
}
});
class ReadReceiptClass {
async markMessagesAsRead(roomId: string, userId: string, userLastSeen: Date) {
if (!settings.get('Message_Read_Receipt_Enabled')) {
return;
}
const room = await Rooms.findOneById(roomId, { projection: { lm: 1 } });
// if users last seen is greater than room's last message, it means the user already have this room marked as read
if (!room || (room.lm && userLastSeen > room.lm)) {
return;
}
await this.storeReadReceipts(
() => {
return Messages.findVisibleUnreadMessagesByRoomAndDate(roomId, userLastSeen).toArray();
},
roomId,
userId,
);
updateMessages(room);
}
async markMessageAsReadBySender(message: IMessage, { _id: roomId }: { _id: string }, userId: string) {
if (!settings.get('Message_Read_Receipt_Enabled')) {
return;
}
if (!message.unread) {
return;
}
// mark message as read if the sender is the only one in the room
const isUserAlone = (await Subscriptions.countUnarchivedByRoomIdAndNotUserId(roomId, userId)) === 0;
if (isUserAlone) {
const result = await Messages.setAsReadById(message._id);
if (result.modifiedCount > 0) {
void notifyOnMessageChange({
id: message._id,
});
}
}
await this.storeReadReceipts(
() => {
return Promise.resolve([message]);
},
roomId,
userId,
);
}
async storeThreadMessagesReadReceipts(tmid: string, userId: string, userLastSeen: Date) {
if (!settings.get('Message_Read_Receipt_Enabled')) {
return;
}
const message = await Messages.findOneById(tmid, { projection: { tlm: 1, rid: 1 } });
// if users last seen is greater than thread's last message, it means the user has already marked this thread as read
if (!message || (message.tlm && userLastSeen > message.tlm)) {
return;
}
await this.storeReadReceipts(
() => {
return Messages.findUnreadThreadMessagesByDate(message.rid, tmid, userId, userLastSeen).toArray();
},
message.rid,
userId,
);
}
private async storeReadReceipts(
getMessages: () => Promise<Pick<IMessage, '_id' | 't' | 'pinned' | 'drid' | 'tmid'>[]>,
roomId: string,
userId: string,
) {
if (settings.get('Message_Read_Receipt_Store_Users')) {
const ts = new Date();
const receipts = (await getMessages()).map((message) => ({
_id: message._id + userId,
roomId,
userId,
messageId: message._id,
ts,
}));
if (receipts.length === 0) {
return;
}
try {
await ReadReceipts.insertMany(receipts);
} catch (err) {
SystemLogger.error({ msg: 'Error inserting read receipts per user', err });
}
}
}
async getReceipts(message: Pick<IMessage, '_id' | 'receiptsArchived'>): Promise<IReadReceiptWithUser[]> {
// Query hot storage (always)
const hotReceipts = await ReadReceipts.findByMessageId(message._id).toArray();
// Query cold storage only if message has archived receipts
let coldReceipts: IReadReceipt[] = [];
if (message.receiptsArchived) {
coldReceipts = await ReadReceiptsArchive.findByMessageId(message._id).toArray();
}
// Combine receipts from both storages
const receipts = [...new Map([...hotReceipts, ...coldReceipts].map((receipt) => [receipt._id, receipt])).values()];
// get unique receipts user ids
const userIds = [...new Set(receipts.map((receipt) => receipt.userId))];
// get users for the receipts
const users = await Users.findByIds(userIds, { projection: { username: 1, name: 1 } }).toArray();
const usersMap: Map<string, Pick<IUser, '_id' | 'username' | 'name'>> = new Map(users.map((user) => [user._id, user]));
// Resolve livechat visitors for any unresolved user IDs
const unresolvedIds = userIds.filter((id) => !usersMap.has(id));
if (unresolvedIds.length > 0) {
const visitors = await LivechatVisitors.findByIds(unresolvedIds, { projection: { username: 1, name: 1 } }).toArray();
for (const visitor of visitors) {
usersMap.set(visitor._id, { _id: visitor._id, username: visitor.username, name: visitor.name });
}
}
return Promise.all(
receipts.map(async (receipt) => ({
...receipt,
user: usersMap.get(receipt.userId),
})),
);
}
}
export const ReadReceipt = new ReadReceiptClass();

View File

@@ -0,0 +1,7 @@
import { License } from '@rocket.chat/license';
await License.onLicense('message-read-receipt', async () => {
await import('../../hooks/messages/afterReadMessages');
await import('../../hooks/messages/afterSaveMessage');
await import('../../hooks/messages/afterDeleteRoom');
});

View File

@@ -0,0 +1,97 @@
import type { IUser } from '@rocket.chat/core-typings';
import { Logger } from '@rocket.chat/logger';
import { Roles, Rooms, Users } from '@rocket.chat/models';
import { addUserToRoom } from '../../../../server/lib/rooms/addUserToRoom';
import { createRoom } from '../../../../server/lib/rooms/createRoom';
import { getValidRoomName } from '../../../../server/lib/utils/lib/getValidRoomName';
import { syncUserRoles } from '../syncUserRoles';
const logger = new Logger('OAuth');
export class OAuthEEManager {
static async mapSSOGroupsToChannels(
user: IUser,
identity: Record<string, any>,
groupClaimName: string,
channelsMap: Record<string, any> | undefined,
channelsAdmin: string,
): Promise<void> {
if (channelsMap && user && identity && groupClaimName) {
const groupsFromSSO = identity[groupClaimName] || [];
const userChannelAdmin = await Users.findOneByUsernameIgnoringCase(channelsAdmin);
if (!userChannelAdmin) {
logger.error({ msg: 'could not create channel, user not found', channelsAdmin });
return;
}
for await (const ssoGroup of Object.keys(channelsMap)) {
if (typeof ssoGroup === 'string') {
let channels = channelsMap[ssoGroup];
if (!Array.isArray(channels)) {
channels = [channels];
}
for await (const channel of channels) {
const name = await getValidRoomName(channel.trim(), undefined, { allowDuplicates: true });
let room = await Rooms.findOneByNonValidatedName(name);
if (!room) {
const createdRoom = await createRoom('c', channel, userChannelAdmin, [], false, false);
if (!createdRoom?.rid) {
logger.error({ msg: 'could not create channel', channel });
return;
}
room = createdRoom;
}
if (room && Array.isArray(groupsFromSSO) && groupsFromSSO.includes(ssoGroup)) {
await addUserToRoom(room._id, user);
}
}
}
}
}
}
static async updateRolesFromSSO(
user: Record<string, any>,
identity: Record<string, any>,
roleClaimName: string,
rolesToSync: string[],
): Promise<void> {
if (user && identity && roleClaimName) {
const rolesFromSSO = await this.mapRolesFromSSO(identity, roleClaimName);
if (!Array.isArray(user.roles)) {
user.roles = [];
}
const rolesIdsFromSSO = (await Roles.findInIdsOrNames(rolesFromSSO).toArray()).map((role) => role._id);
const allowedRoles = (await Roles.findInIdsOrNames(rolesToSync).toArray()).map((role) => role._id);
await syncUserRoles(user._id, rolesIdsFromSSO, {
allowedRoles,
});
}
}
// Returns list of roles from SSO identity
static async mapRolesFromSSO(identity: Record<string, any>, roleClaimName: string): Promise<string[]> {
if (!identity || !roleClaimName || !identity[roleClaimName] || !Array.isArray(identity[roleClaimName])) {
return [];
}
const baseRoles = identity[roleClaimName] as string[];
const filteredRoles = baseRoles.filter((val) => val !== 'offline_access' && val !== 'uma_authorization');
const validRoleList = [];
for await (const role of filteredRoles) {
if (await Roles.findOneByIdOrName(role)) {
validRoleList.push(role);
}
}
return validRoleList;
}
}

View File

@@ -0,0 +1,105 @@
import { Agenda } from '@rocket.chat/agenda';
import type { IUser } from '@rocket.chat/core-typings';
import type { MainLogger } from '@rocket.chat/logger';
import { LivechatRooms, Users } from '@rocket.chat/models';
import { Meteor } from 'meteor/meteor';
import { MongoInternals } from 'meteor/mongo';
import moment from 'moment';
import { schedulerLogger } from './logger';
import { closeRoom } from '../../../../server/lib/omnichannel/closeRoom';
const SCHEDULER_NAME = 'omnichannel_auto_close_on_hold_scheduler';
export class AutoCloseOnHoldSchedulerClass {
scheduler: Agenda;
schedulerUser: IUser;
running: boolean;
logger: MainLogger;
constructor() {
this.logger = schedulerLogger.section('AutoCloseOnHoldScheduler');
}
public async init(): Promise<void> {
if (this.running) {
return;
}
this.scheduler = new Agenda({
mongo: (MongoInternals.defaultRemoteCollectionDriver().mongo as any).client.db(),
db: { collection: SCHEDULER_NAME },
defaultConcurrency: 1,
processEvery: process.env.TEST_MODE === 'true' || process.env.TEST_MODE === 'api' ? '3 seconds' : '1 minute',
});
await this.scheduler.start();
this.running = true;
this.logger.info('Service started');
}
public async scheduleRoom(roomId: string, timeout: number, comment: string): Promise<void> {
if (!this.running) {
throw new Error('AutoCloseOnHoldScheduler is not running');
}
this.logger.debug({ msg: 'Scheduling room to be closed', roomId, timeoutSeconds: timeout });
await this.unscheduleRoom(roomId);
const jobName = `${SCHEDULER_NAME}-${roomId}`;
const when = moment(new Date()).add(timeout, 's').toDate();
this.scheduler.define(jobName, this.executeJob.bind(this));
await this.scheduler.schedule(when, jobName, { roomId, comment });
}
public async unscheduleRoom(roomId: string): Promise<void> {
if (!this.running) {
throw new Error('AutoCloseOnHoldScheduler is not running');
}
this.logger.debug({ msg: 'Unscheduling room', roomId });
const jobName = `${SCHEDULER_NAME}-${roomId}`;
await this.scheduler.cancel({ name: jobName });
}
private async executeJob({ attrs: { data } }: any = {}): Promise<void> {
this.logger.debug({ msg: 'Executing job for room', roomId: data.roomId });
const { roomId, comment } = data;
const [room, user] = await Promise.all([LivechatRooms.findOneById(roomId), this.getSchedulerUser()]);
if (!room || !user) {
throw new Error(
`Unable to process AutoCloseOnHoldScheduler job because room or user not found for roomId: ${roomId} and userId: rocket.cat`,
);
}
const payload = {
room,
user,
comment,
};
await closeRoom(payload);
}
private async getSchedulerUser(): Promise<IUser> {
if (!this.schedulerUser) {
const schedulerUser = await Users.findOneById('rocket.cat');
if (!schedulerUser) {
throw new Error('Scheduler user not found');
}
this.schedulerUser = schedulerUser;
}
return this.schedulerUser;
}
}
export const AutoCloseOnHoldScheduler = new AutoCloseOnHoldSchedulerClass();
Meteor.startup(() => {
void AutoCloseOnHoldScheduler.init();
});

View File

@@ -0,0 +1,144 @@
import { Agenda } from '@rocket.chat/agenda';
import type { IUser } from '@rocket.chat/core-typings';
import type { MainLogger } from '@rocket.chat/logger';
import { LivechatRooms, Users } from '@rocket.chat/models';
import { Meteor } from 'meteor/meteor';
import { MongoInternals } from 'meteor/mongo';
import { schedulerLogger } from './logger';
import { forwardRoomToAgent } from '../../../../server/lib/omnichannel/Helper';
import { RoutingManager } from '../../../../server/lib/omnichannel/RoutingManager';
import { returnRoomAsInquiry } from '../../../../server/lib/omnichannel/rooms';
import { settings } from '../../../../server/settings';
const SCHEDULER_NAME = 'omnichannel_scheduler';
export class AutoTransferChatSchedulerClass {
scheduler: Agenda;
running: boolean;
user: IUser;
logger: MainLogger;
constructor() {
this.logger = schedulerLogger.section('AutoTransferChatScheduler');
}
public async init(): Promise<void> {
if (this.running) {
return;
}
this.scheduler = new Agenda({
mongo: (MongoInternals.defaultRemoteCollectionDriver().mongo as any).client.db(),
db: { collection: SCHEDULER_NAME },
defaultConcurrency: 1,
processEvery: process.env.TEST_MODE === 'true' || process.env.TEST_MODE === 'api' ? '3 seconds' : '1 minute',
});
await this.scheduler.start();
this.running = true;
this.logger.info('Service started');
}
private async getSchedulerUser(): Promise<IUser & { userType: 'user' }> {
const user = await Users.findOneById('rocket.cat');
if (!user) {
this.logger.error('Error while transferring room: user not found');
throw new Error('error-no-cat');
}
return {
...user,
userType: 'user',
};
}
public async scheduleRoom(roomId: string, timeout: number): Promise<void> {
this.logger.debug({ msg: 'Scheduling room to be transferred', roomId, timeoutSeconds: timeout });
await this.unscheduleRoom(roomId);
const jobName = `${SCHEDULER_NAME}-${roomId}`;
const when = new Date();
when.setSeconds(when.getSeconds() + timeout);
this.scheduler.define(jobName, this.executeJob.bind(this));
await this.scheduler.schedule(when, jobName, { roomId });
await LivechatRooms.setAutoTransferOngoingById(roomId);
}
public async unscheduleRoom(roomId: string): Promise<void> {
this.logger.debug({ msg: 'Unscheduling room', roomId });
const jobName = `${SCHEDULER_NAME}-${roomId}`;
await LivechatRooms.unsetAutoTransferOngoingById(roomId);
await this.scheduler.cancel({ name: jobName });
}
private async transferRoom(roomId: string): Promise<void> {
this.logger.debug({ msg: 'Transferring room', roomId });
const room = await LivechatRooms.findOneById(roomId);
if (!room?.open || !room?.servedBy?._id) {
throw new Error('Room is not open or is not being served by an agent');
}
const {
departmentId,
servedBy: { _id: ignoreAgentId },
} = room;
const timeoutDuration = settings.get<number>('Livechat_auto_transfer_chat_timeout').toString();
if (!RoutingManager.getConfig()?.autoAssignAgent) {
this.logger.debug({ msg: 'Auto-assign agent is disabled, returning room as inquiry', roomId });
await returnRoomAsInquiry(room, departmentId, {
scope: 'autoTransferUnansweredChatsToQueue',
comment: timeoutDuration,
transferredBy: await this.getSchedulerUser(),
});
return;
}
const agent = await RoutingManager.getNextAgent(departmentId, ignoreAgentId);
if (!agent) {
this.logger.error({
msg: 'No agent found to transfer unanswered room',
roomId: room._id,
timeoutSeconds: timeoutDuration,
});
return;
}
this.logger.debug({ msg: 'Transferring room to agent', roomId, agentId: agent.agentId });
const transferredBy = await this.getSchedulerUser();
await forwardRoomToAgent(room, {
userId: agent.agentId,
transferredBy,
transferredTo: agent,
scope: 'autoTransferUnansweredChatsToAgent',
comment: timeoutDuration,
});
}
private async executeJob({ attrs: { data } }: any = {}): Promise<void> {
const { roomId } = data;
try {
await this.transferRoom(roomId);
await Promise.all([LivechatRooms.setAutoTransferredAtById(roomId), this.unscheduleRoom(roomId)]);
} catch (error) {
this.logger.error({ msg: 'Error while executing auto-transfer job', schedulerName: SCHEDULER_NAME, roomId, err: error });
}
}
}
export const AutoTransferChatScheduler = new AutoTransferChatSchedulerClass();
Meteor.startup(() => {
void AutoTransferChatScheduler.init();
});

View File

@@ -0,0 +1,49 @@
import type { ILivechatDepartment } from '@rocket.chat/core-typings';
import { LivechatDepartment } from '@rocket.chat/models';
import { applyDepartmentRestrictions } from '@rocket.chat/omni-core';
import { escapeRegExp } from '@rocket.chat/tools';
import type { Filter } from 'mongodb';
export const findAllDepartmentsAvailable = async (
uid: string,
unitId: string,
offset: number,
count: number,
text?: string,
onlyMyDepartments = false,
): Promise<{ departments: ILivechatDepartment[]; total: number }> => {
const filterReg = new RegExp(escapeRegExp(text || ''), 'i');
let query: Filter<ILivechatDepartment> = {
type: { $ne: 'u' },
$or: [{ ancestors: { $in: [[unitId], undefined, []] } }, { ancestors: { $exists: false } }],
...(text && { name: filterReg }),
};
if (onlyMyDepartments) {
query = await applyDepartmentRestrictions(query, uid);
}
const { cursor, totalCount } = LivechatDepartment.findPaginated(query, { limit: count, skip: offset, sort: { name: 1 } });
const [departments, total] = await Promise.all([cursor.toArray(), totalCount]);
return { departments, total };
};
export const findAllDepartmentsByUnit = async (
unitId: string,
offset: number,
count: number,
): Promise<{ departments: ILivechatDepartment[]; total: number }> => {
const { cursor, totalCount } = LivechatDepartment.findPaginated(
{
ancestors: { $in: [unitId] },
},
{ limit: count, skip: offset, sort: { name: 1, _id: 1 } },
);
const [departments, total] = await Promise.all([cursor.toArray(), totalCount]);
return { departments, total };
};

View File

@@ -0,0 +1,376 @@
import { api } from '@rocket.chat/core-services';
import type {
ILivechatDepartment,
IOmnichannelRoom,
IOmnichannelServiceLevelAgreements,
InquiryWithAgentInfo,
} from '@rocket.chat/core-typings';
import type { Updater } from '@rocket.chat/models';
import {
Rooms as RoomRaw,
LivechatRooms,
LivechatDepartment as LivechatDepartmentRaw,
LivechatCustomField,
LivechatInquiry,
Users,
} from '@rocket.chat/models';
import moment from 'moment';
import type { Document } from 'mongodb';
import { OmnichannelQueueInactivityMonitor } from './QueueInactivityMonitor';
import { updateInquiryQueueSla } from './SlaHelper';
import { memoizeDebounce } from './debounceByParams';
import { logger } from './logger';
import { getOmniChatSortQuery } from '../../../../app/livechat/lib/inquiries';
import { callbacks } from '../../../../server/lib/callbacks';
import { getInquirySortMechanismSetting } from '../../../../server/lib/omnichannel/settings';
import { settings } from '../../../../server/settings';
type QueueInfo = {
message: {
text: any;
user: {
_id: string;
username: string;
};
};
statistics: Document;
numberMostRecentChats: number;
};
export const isAgentWithinChatLimits = async ({
agentId,
departmentId,
totalChats,
departmentChats,
}: {
agentId: string;
departmentId?: string;
totalChats: number;
departmentChats: number;
}): Promise<boolean> => {
let agentLimit = 0;
let globalLimit = 0;
const user = await Users.getAgentInfo(agentId, settings.get('Livechat_show_agent_info'));
const rawAgentLimit = user?.livechat?.maxNumberSimultaneousChat;
if (rawAgentLimit !== undefined && rawAgentLimit !== null) {
const numericAgentLimit = Number(rawAgentLimit);
if (numericAgentLimit > 0) {
agentLimit = numericAgentLimit;
}
}
if (agentLimit === 0) {
const settingLimit = settings.get<number>('Livechat_maximum_chats_per_agent');
if (settingLimit > 0) {
globalLimit = settingLimit;
}
}
if (departmentId) {
const department = await LivechatDepartmentRaw.findOneById<Pick<ILivechatDepartment, 'maxNumberSimultaneousChat'>>(departmentId, {
projection: { maxNumberSimultaneousChat: 1 },
});
let departmentLimit = 0;
if (department?.maxNumberSimultaneousChat !== undefined && department.maxNumberSimultaneousChat !== null) {
const numericDeptLimit = Number(department.maxNumberSimultaneousChat);
if (numericDeptLimit > 0) {
departmentLimit = numericDeptLimit;
}
}
if (departmentLimit > 0) {
if (agentLimit > 0) {
logger.debug({
msg: 'Applying chat limits',
departmentId,
agentId,
limits: ['department', 'agent'],
totalChats,
departmentChats,
agentLimit,
departmentLimit,
});
return departmentChats < departmentLimit && totalChats < agentLimit;
}
if (globalLimit > 0) {
logger.debug({
msg: 'Applying chat limits',
departmentId,
agentId,
limits: ['department', 'global'],
totalChats,
departmentChats,
globalLimit,
departmentLimit,
});
return departmentChats < departmentLimit && totalChats < globalLimit;
}
logger.debug({
msg: 'Applying chat limits',
departmentId,
agentId,
limits: ['department'],
totalChats,
departmentChats,
departmentLimit,
});
return departmentChats < departmentLimit;
}
}
if (agentLimit > 0) {
logger.debug({
msg: 'Applying chat limits',
departmentId,
agentId,
limits: ['agent'],
totalChats,
agentLimit,
});
return totalChats < agentLimit;
}
if (globalLimit > 0) {
logger.debug({
msg: 'Applying chat limits',
departmentId,
agentId,
limits: ['global'],
totalChats,
globalLimit,
});
return totalChats < globalLimit;
}
logger.debug({ msg: 'No applicable limit found for user', agentId });
return true;
};
const getWaitingQueueMessage = async (departmentId?: string) => {
const department = departmentId ? await LivechatDepartmentRaw.findOneById(departmentId) : null;
if (department?.waitingQueueMessage) {
return department.waitingQueueMessage;
}
return settings.get<string>('Livechat_waiting_queue_message');
};
const getQueueInfo = async (department?: string) => {
const numberMostRecentChats = settings.get<number>('Livechat_number_most_recent_chats_estimate_wait_time');
const statistics = await RoomRaw.getMostRecentAverageChatDurationTime(numberMostRecentChats, department);
const text = await getWaitingQueueMessage(department);
const message = {
text,
user: { _id: 'rocket.cat', username: 'rocket.cat' },
};
return { message, statistics, numberMostRecentChats };
};
const getSpotEstimatedWaitTime = (spot: number, maxNumberSimultaneousChat: number, avgChatDuration: number) => {
if (!maxNumberSimultaneousChat || !avgChatDuration) {
return;
}
// X = spot
// N = maxNumberSimultaneousChat
// Estimated Wait Time = ([(N-1)/X]+1) *Average Chat Time of Most Recent X(Default = 100) Chats
return ((spot - 1) / maxNumberSimultaneousChat + 1) * avgChatDuration;
};
const normalizeQueueInfo = async ({
position,
queueInfo,
department,
}: {
position: number;
department?: string;
queueInfo?: QueueInfo;
}) => {
if (!queueInfo) {
queueInfo = await getQueueInfo(department);
}
const { message, numberMostRecentChats, statistics: { avgChatDuration } = {} } = queueInfo;
const spot = position + 1;
const estimatedWaitTimeSeconds = getSpotEstimatedWaitTime(spot, numberMostRecentChats, avgChatDuration);
return { spot, message, estimatedWaitTimeSeconds };
};
export const dispatchInquiryPosition = async (inquiry: Omit<InquiryWithAgentInfo, 'v'>, queueInfo?: QueueInfo) => {
const { position, department } = inquiry;
// Avoid broadcasting if no position was determined
if (position === undefined) {
return;
}
const data = await normalizeQueueInfo({ position, queueInfo, department });
return setTimeout(() => {
void api.broadcast('omnichannel.room', inquiry.rid, {
type: 'queueData',
data,
});
}, 1000);
};
const dispatchWaitingQueueStatus = async (department?: string) => {
if (!settings.get('Livechat_waiting_queue') && !settings.get('Omnichannel_calculate_dispatch_service_queue_statistics')) {
return;
}
const queue = await LivechatInquiry.getCurrentSortedQueueAsync({
department,
queueSortBy: getOmniChatSortQuery(getInquirySortMechanismSetting()),
});
if (!queue.length) {
return;
}
const queueInfo = await getQueueInfo(department);
queue.forEach((inquiry) => {
void dispatchInquiryPosition(inquiry, queueInfo);
});
};
// When dealing with lots of queued items we need to make sure to notify their position
// but we don't need to notify _each_ change that takes place, just their final position
export const debouncedDispatchWaitingQueueStatus = memoizeDebounce(dispatchWaitingQueueStatus, 1200);
export const setPredictedVisitorAbandonmentTime = async (
room: Pick<IOmnichannelRoom, '_id' | 'responseBy' | 'departmentId'>,
roomUpdater?: Updater<IOmnichannelRoom>,
) => {
if (
!room.responseBy?.firstResponseTs ||
!settings.get('Livechat_abandoned_rooms_action') ||
settings.get('Livechat_abandoned_rooms_action') === 'none'
) {
return;
}
let secondsToAdd = settings.get<number>('Livechat_visitor_inactivity_timeout');
const department = room.departmentId ? await LivechatDepartmentRaw.findOneById(room.departmentId) : null;
if (department?.visitorInactivityTimeoutInSeconds) {
secondsToAdd = department.visitorInactivityTimeoutInSeconds;
}
if (secondsToAdd <= 0) {
return;
}
const willBeAbandonedAt = moment(room.responseBy.firstResponseTs).add(Number(secondsToAdd), 'seconds').toDate();
if (roomUpdater) {
await LivechatRooms.getPredictedVisitorAbandonmentByRoomIdUpdateQuery(willBeAbandonedAt, roomUpdater);
} else {
await LivechatRooms.setPredictedVisitorAbandonmentByRoomId(room._id, willBeAbandonedAt);
}
};
export const updatePredictedVisitorAbandonment = async () => {
if (!settings.get('Livechat_abandoned_rooms_action') || settings.get('Livechat_abandoned_rooms_action') === 'none') {
await LivechatRooms.unsetAllPredictedVisitorAbandonment();
} else {
// Eng day: use a promise queue to update the predicted visitor abandonment time instead of all at once
const extraQuery = await callbacks.run('livechat.applyRoomRestrictions', {});
const promisesArray: Promise<void>[] = [];
await LivechatRooms.findOpen(extraQuery).forEach((room) => {
promisesArray.push(setPredictedVisitorAbandonmentTime(room));
});
await Promise.all(promisesArray);
}
};
export const updateQueueInactivityTimeout = async () => {
const queueTimeout = settings.get<number>('Livechat_max_queue_wait_time');
if (queueTimeout <= 0) {
await OmnichannelQueueInactivityMonitor.stop();
return;
}
await LivechatInquiry.getQueuedInquiries({ projection: { _updatedAt: 1 } }).forEach((inq) => {
const aggregatedDate = moment(inq._updatedAt).add(queueTimeout, 'minutes');
try {
void OmnichannelQueueInactivityMonitor.scheduleInquiry(inq._id, new Date(aggregatedDate.format()));
} catch (e) {
// this will usually happen if other instance attempts to re-create a job
logger.error({ err: e });
}
});
};
export const updateSLAInquiries = async (
executedBy: string,
sla?: Pick<IOmnichannelServiceLevelAgreements, '_id' | 'dueTimeInMinutes'>,
) => {
if (!sla) {
return;
}
const { _id: slaId } = sla;
const promises: Promise<void>[] = [];
const extraQuery = await callbacks.run('livechat.applyRoomRestrictions', {}, { userId: executedBy });
await LivechatRooms.findOpenBySlaId(slaId, {}, extraQuery).forEach((room) => {
promises.push(updateInquiryQueueSla(room._id, sla));
});
await Promise.allSettled(promises);
};
export const getLivechatCustomFields = async () => {
const customFields = await LivechatCustomField.find({
visibility: 'visible',
scope: 'visitor',
public: true,
}).toArray();
return customFields.map(({ _id, label, regexp, required = false, type, defaultValue = null, options }) => ({
_id,
label,
regexp,
required,
type,
defaultValue,
...(options && options !== '' && { options: options.split(',') }),
}));
};
export const getLivechatQueueInfo = async (room?: IOmnichannelRoom) => {
if (!room) {
return null;
}
if (!settings.get('Livechat_waiting_queue')) {
return null;
}
if (!settings.get('Omnichannel_calculate_dispatch_service_queue_statistics')) {
return null;
}
const { _id: rid, departmentId: department } = room;
const inquiry = await LivechatInquiry.findOneByRoomId(rid, { projection: { _id: 1, status: 1 } });
if (!inquiry) {
return null;
}
const { _id, status } = inquiry;
if (status !== 'queued') {
return null;
}
const [inq] = await LivechatInquiry.getCurrentSortedQueueAsync({
inquiryId: _id,
department,
queueSortBy: getOmniChatSortQuery(getInquirySortMechanismSetting()),
});
if (!inq) {
return null;
}
return normalizeQueueInfo(inq);
};

View File

@@ -0,0 +1,186 @@
import { MeteorError } from '@rocket.chat/core-services';
import type { IOmnichannelBusinessUnit, IOmnichannelServiceLevelAgreements, IUser, ILivechatTag } from '@rocket.chat/core-typings';
import { Users, OmnichannelServiceLevelAgreements, LivechatTag, LivechatUnitMonitors, LivechatUnit } from '@rocket.chat/models';
import { getUnitsFromUser } from '@rocket.chat/omni-core-ee';
import { Match, check } from 'meteor/check';
import { Meteor } from 'meteor/meteor';
import { updateSLAInquiries } from './Helper';
import { removeSLAFromRooms } from './SlaHelper';
import { callbacks } from '../../../../server/lib/callbacks';
import { addUserRolesAsync } from '../../../../server/lib/roles/addUserRoles';
import { removeUserFromRolesAsync } from '../../../../server/lib/roles/removeUserFromRoles';
export const LivechatEnterprise = {
async addMonitor(username: string) {
const user = await Users.findOneByUsername<Pick<IUser, '_id' | 'username' | 'roles'>>(username, {
projection: { _id: 1, username: 1, roles: 1 },
});
if (!user) {
throw new MeteorError('error-invalid-user', 'Invalid user', {
method: 'livechat:addMonitor',
});
}
if (!(await addUserRolesAsync(user._id, ['livechat-monitor']))) {
throw new MeteorError('error-adding-monitor-role', 'Error adding monitor role', {
method: 'livechat:addMonitor',
});
}
return user;
},
async removeMonitor(username: string) {
const user = await Users.findOneByUsername<Pick<IUser, '_id'>>(username, {
projection: { _id: 1 },
});
if (!user) {
throw new Meteor.Error('error-invalid-user', 'Invalid user', {
method: 'livechat:removeMonitor',
});
}
if (!(await removeUserFromRolesAsync(user._id, ['livechat-monitor']))) {
return false;
}
// remove this monitor from any unit it is assigned to
await LivechatUnitMonitors.removeByMonitorId(user._id);
return true;
},
async removeUnit(_id: string, userId: string) {
check(_id, String);
const unitsFromUser = await getUnitsFromUser(userId);
const result = await LivechatUnit.removeByIdAndUnit(_id, unitsFromUser);
if (!result.deletedCount) {
throw new Meteor.Error('unit-not-found', 'Unit not found', { method: 'livechat:removeUnit' });
}
return result;
},
async saveUnit(
_id: string | null,
unitData: Omit<IOmnichannelBusinessUnit, '_id'>,
unitMonitors: { monitorId: string; username: string },
unitDepartments: { departmentId: string }[],
userId: string,
) {
check(_id, Match.Maybe(String));
check(unitData, {
name: String,
visibility: String,
enabled: Match.Optional(Boolean),
description: Match.Optional(String),
email: Match.Optional(String),
showOnOfflineForm: Match.Optional(Boolean),
});
check(unitMonitors, [
Match.ObjectIncluding({
monitorId: String,
username: String,
}),
]);
check(unitDepartments, [
Match.ObjectIncluding({
departmentId: String,
}),
]);
let ancestors: string[] = [];
if (_id) {
const unitsFromUser = await getUnitsFromUser(userId);
const unit = await LivechatUnit.findOneById<Pick<IOmnichannelBusinessUnit, '_id' | 'ancestors'>>(
_id,
{
projection: { _id: 1, ancestors: 1 },
},
{ unitsFromUser },
);
if (!unit) {
throw new Meteor.Error('error-unit-not-found', 'Unit not found', {
method: 'livechat:saveUnit',
});
}
ancestors = unit.ancestors || [];
}
const validUserMonitors = await Users.findUsersInRolesWithQuery(
'livechat-monitor',
{ _id: { $in: unitMonitors.map(({ monitorId }) => monitorId) } },
{ projection: { _id: 1, username: 1 } },
).toArray();
const monitors = validUserMonitors.map(({ _id: monitorId, username }) => ({
monitorId,
username: username!,
}));
return LivechatUnit.createOrUpdateUnit(_id, unitData, ancestors, monitors, unitDepartments);
},
async removeTag(_id: string) {
const tag = await LivechatTag.findOneById<Pick<ILivechatTag, '_id' | 'name'>>(_id, { projection: { _id: 1, name: 1 } });
if (!tag) {
throw new Meteor.Error('tag-not-found', 'Tag not found', { method: 'livechat:removeTag' });
}
await callbacks.run('livechat.afterTagRemoved', tag);
return LivechatTag.removeById(_id);
},
async saveTag(_id: string | undefined, tagData: { name: string; description?: string }, tagDepartments: string[] | undefined) {
return LivechatTag.createOrUpdateTag(_id, tagData, tagDepartments);
},
async saveSLA(
_id: string | null,
slaData: Pick<IOmnichannelServiceLevelAgreements, 'name' | 'description' | 'dueTimeInMinutes'>,
executedBy: string,
) {
const oldSLA =
_id &&
(await OmnichannelServiceLevelAgreements.findOneById<Pick<IOmnichannelServiceLevelAgreements, 'dueTimeInMinutes'>>(_id, {
projection: { dueTimeInMinutes: 1 },
}));
const exists = await OmnichannelServiceLevelAgreements.findDuplicate(_id, slaData.name, slaData.dueTimeInMinutes);
if (exists) {
throw new Error('error-duplicated-sla');
}
const sla = await OmnichannelServiceLevelAgreements.createOrUpdatePriority(slaData, _id);
if (!oldSLA) {
return sla;
}
const { dueTimeInMinutes: oldDueTimeInMinutes } = oldSLA;
const { dueTimeInMinutes } = sla;
if (oldDueTimeInMinutes !== dueTimeInMinutes) {
await updateSLAInquiries(executedBy, sla);
}
return sla;
},
async removeSLA(executedBy: string, _id: string) {
const removedResult = await OmnichannelServiceLevelAgreements.removeById(_id);
if (!removedResult || removedResult.deletedCount !== 1) {
throw new Error(`SLA with id ${_id} not found`);
}
await removeSLAFromRooms(_id, executedBy);
},
};

View File

@@ -0,0 +1,134 @@
import { Agenda } from '@rocket.chat/agenda';
import type { IUser, IOmnichannelRoom } from '@rocket.chat/core-typings';
import type { MainLogger } from '@rocket.chat/logger';
import { LivechatRooms, LivechatInquiry as LivechatInquiryRaw, Users } from '@rocket.chat/models';
import { Meteor } from 'meteor/meteor';
import { MongoInternals } from 'meteor/mongo';
import type { Db } from 'mongodb';
import { schedulerLogger } from './logger';
import { i18n } from '../../../../server/lib/i18n';
import { closeRoom } from '../../../../server/lib/omnichannel/closeRoom';
import { settings } from '../../../../server/settings';
const SCHEDULER_NAME = 'omnichannel_queue_inactivity_monitor';
export class OmnichannelQueueInactivityMonitorClass {
scheduler: Agenda;
running: boolean;
logger: MainLogger;
_name: string;
user: IUser;
message: string;
_db: Db;
bindedCloseRoom: any;
constructor() {
this._db = MongoInternals.defaultRemoteCollectionDriver().mongo.db;
this.running = false;
this._name = 'Omnichannel-Queue-Inactivity-Monitor';
this.logger = schedulerLogger.section(this._name);
this.scheduler = new Agenda({
mongo: (MongoInternals.defaultRemoteCollectionDriver().mongo as any).client.db(),
db: { collection: SCHEDULER_NAME },
defaultConcurrency: 1,
processEvery: process.env.TEST_MODE === 'true' || process.env.TEST_MODE === 'api' ? '3 seconds' : '1 minute',
});
this.createIndex();
const language = settings.get<string>('Language') || 'en';
this.message = i18n.t('Closed_automatically_chat_queued_too_long', { lng: language });
this.bindedCloseRoom = this.closeRoom.bind(this);
}
private async getRocketCatUser(): Promise<IUser | null> {
return Users.findOneById('rocket.cat');
}
getName(inquiryId: string): string {
return `${this._name}-${inquiryId}`;
}
createIndex(): void {
void this._db.collection(SCHEDULER_NAME).createIndex(
{
'data.inquiryId': 1,
},
{ unique: true },
);
}
async start(): Promise<void> {
if (this.running) {
return;
}
await this.scheduler.start();
this.logger.info('Service started');
this.running = true;
}
async scheduleInquiry(inquiryId: string, time: Date): Promise<void> {
await this.stopInquiry(inquiryId);
this.logger.debug({ msg: 'Scheduling automatic close of inquiry', inquiryId, scheduledAt: time });
const name = this.getName(inquiryId);
this.scheduler.define(name, this.bindedCloseRoom);
const job = this.scheduler.create(name, { inquiryId });
job.schedule(time);
job.unique({ 'data.inquiryId': inquiryId });
await job.save();
}
async stop(): Promise<void> {
if (!this.running) {
return;
}
await this.scheduler.cancel({});
this.running = false;
}
async stopInquiry(inquiryId: string): Promise<void> {
const name = this.getName(inquiryId);
await this.scheduler.cancel({ name });
}
async closeRoomAction(room: IOmnichannelRoom): Promise<void> {
const comment = this.message;
return closeRoom({
comment,
room,
user: await this.getRocketCatUser(),
});
}
async closeRoom({ attrs: { data } }: any = {}): Promise<void> {
const { inquiryId } = data;
// TODO: add projection and maybe use findOneQueued to avoid fetching the whole inquiry
const inquiry = await LivechatInquiryRaw.findOneById(inquiryId);
if (inquiry?.status !== 'queued') {
return;
}
const room = await LivechatRooms.findOneById(inquiry.rid);
if (!room) {
this.logger.error({ msg: 'Unable to find room to close in queue inactivity monitor', inquiryId, roomId: inquiry.rid });
return;
}
await Promise.all([this.closeRoomAction(room), this.stopInquiry(inquiryId)]);
this.logger.info({ msg: 'Closed room due to queue inactivity', roomId: inquiry.rid, inquiryId });
}
}
export const OmnichannelQueueInactivityMonitor = new OmnichannelQueueInactivityMonitorClass();
Meteor.startup(async () => {
void OmnichannelQueueInactivityMonitor.start();
});

View File

@@ -0,0 +1,69 @@
import { Message } from '@rocket.chat/core-services';
import type { IOmnichannelServiceLevelAgreements, IUser } from '@rocket.chat/core-typings';
import { LivechatInquiry, LivechatRooms } from '@rocket.chat/models';
import { callbacks } from '../../../../server/lib/callbacks';
import {
notifyOnRoomChangedById,
notifyOnLivechatInquiryChangedByRoom,
notifyOnLivechatInquiryChanged,
} from '../../../../server/lib/notifyListener';
export const removeSLAFromRooms = async (slaId: string, userId: string) => {
const extraQuery = await callbacks.run('livechat.applyRoomRestrictions', {}, { userId });
const openRooms = await LivechatRooms.findOpenBySlaId(slaId, { projection: { _id: 1 } }, extraQuery).toArray();
const openRoomIds: string[] = openRooms.map(({ _id }) => _id);
if (openRooms.length) {
await LivechatInquiry.bulkUnsetSla(openRoomIds);
void notifyOnLivechatInquiryChangedByRoom(openRoomIds, 'updated');
}
await LivechatRooms.bulkRemoveSlaFromRoomsById(slaId);
void notifyOnRoomChangedById(openRoomIds, 'updated');
};
export const updateInquiryQueueSla = async (roomId: string, sla: Pick<IOmnichannelServiceLevelAgreements, 'dueTimeInMinutes' | '_id'>) => {
const inquiry = await LivechatInquiry.findOneByRoomId(roomId);
if (!inquiry) {
return;
}
const { dueTimeInMinutes, _id: slaId } = sla;
const estimatedWaitingTimeQueue = dueTimeInMinutes;
await LivechatInquiry.setSlaForRoom(inquiry.rid, {
slaId,
estimatedWaitingTimeQueue,
});
void notifyOnLivechatInquiryChanged({ ...inquiry, slaId, estimatedWaitingTimeQueue, _updatedAt: new Date() }, 'updated');
};
export const updateRoomSlaWeights = async (roomId: string, sla: Pick<IOmnichannelServiceLevelAgreements, 'dueTimeInMinutes' | '_id'>) => {
await LivechatRooms.setSlaForRoomById(roomId, sla);
};
export const removeInquiryQueueSla = async (roomId: string) => {
await LivechatInquiry.unsetSlaForRoom(roomId);
};
export const removeSlaFromRoom = async (roomId: string) => {
await LivechatRooms.removeSlaFromRoomById(roomId);
};
export const addSlaChangeHistoryToRoom = async (
roomId: string,
user: Pick<IUser, '_id' | 'name' | 'username'>,
sla?: Pick<IOmnichannelServiceLevelAgreements, 'name'>,
) => {
await Message.saveSystemMessage('omnichannel_sla_change_history', roomId, '', user, {
slaData: {
definedBy: {
_id: user._id,
username: user.username,
},
...(sla && { sla }),
},
});
};

View File

@@ -0,0 +1,188 @@
import { OmnichannelEEService } from '@rocket.chat/core-services';
import type { ILivechatVisitor, IOmnichannelRoom, IUser, ILivechatDepartment } from '@rocket.chat/core-typings';
import { cronJobs } from '@rocket.chat/cron';
import type { MainLogger } from '@rocket.chat/logger';
import { LivechatVisitors, LivechatRooms, LivechatDepartment, Users } from '@rocket.chat/models';
import { schedulerLogger } from './logger';
import { callbacks } from '../../../../server/lib/callbacks';
import { i18n } from '../../../../server/lib/i18n';
import { notifyOnRoomChangedById } from '../../../../server/lib/notifyListener';
import { closeRoom } from '../../../../server/lib/omnichannel/closeRoom';
import { settings } from '../../../../server/settings';
const isPromiseRejectedResult = (result: any): result is PromiseRejectedResult => result && result.status === 'rejected';
export class VisitorInactivityMonitor {
_started: boolean;
_name: string;
messageCache: Map<string, string>;
user: IUser;
logger: MainLogger;
private scheduler = cronJobs;
constructor() {
this._started = false;
this._name = 'Omnichannel Visitor Inactivity Monitor';
this.messageCache = new Map();
this.logger = schedulerLogger.section(this._name);
}
async start() {
await this._startMonitoring();
this._initializeMessageCache();
const cat = await Users.findOneById('rocket.cat');
if (cat) {
this.user = cat;
}
}
private async _startMonitoring() {
if (this.isRunning()) {
this.logger.debug('Already running');
return;
}
const everyMinute = '* * * * *';
await this.scheduler.add(this._name, everyMinute, async () => this.handleAbandonedRooms());
this._started = true;
this.logger.info('Service started');
}
async stop() {
if (!this.isRunning()) {
return;
}
await this.scheduler.remove(this._name);
this._started = false;
this.logger.info('Service stopped');
}
isRunning() {
return this._started;
}
_initializeMessageCache() {
this.messageCache.clear();
}
async _getDepartmentAbandonedCustomMessage(departmentId: string) {
if (this.messageCache.has(departmentId)) {
return this.messageCache.get(departmentId);
}
const department = await LivechatDepartment.findOneById<Pick<ILivechatDepartment, '_id' | 'abandonedRoomsCloseCustomMessage'>>(
departmentId,
{ projection: { _id: 1, abandonedRoomsCloseCustomMessage: 1 } },
);
if (!department) {
this.logger.error({ msg: 'Department not found', departmentId });
return;
}
this.messageCache.set(department._id, department.abandonedRoomsCloseCustomMessage);
return department.abandonedRoomsCloseCustomMessage;
}
async closeRooms(room: IOmnichannelRoom) {
let comment = await this.getDefaultAbandonedCustomMessage('close', room.v._id);
if (room.departmentId) {
comment = (await this._getDepartmentAbandonedCustomMessage(room.departmentId)) || comment;
}
await closeRoom({
comment,
room,
user: this.user,
});
void notifyOnRoomChangedById(room._id);
this.logger.info({ msg: 'Closed room due to visitor inactivity', roomId: room._id });
}
async placeRoomOnHold(room: IOmnichannelRoom) {
const comment = await this.getDefaultAbandonedCustomMessage('on-hold', room.v._id);
const result = await Promise.allSettled([
OmnichannelEEService.placeRoomOnHold(room, comment, this.user),
LivechatRooms.unsetPredictedVisitorAbandonmentByRoomId(room._id),
]);
const rejected = result.filter(isPromiseRejectedResult).map((r) => r.reason);
if (rejected.length) {
this.logger.error({ msg: 'Error placing room on hold', error: rejected });
throw new Error('Error placing room on hold. Please check logs for more details.');
}
void notifyOnRoomChangedById(room._id);
}
async handleAbandonedRooms() {
const action = settings.get<string>('Livechat_abandoned_rooms_action');
if (!action || action === 'none') {
return;
}
const extraQuery = await callbacks.run('livechat.applyRoomRestrictions', {});
const promises: Promise<void>[] = [];
await LivechatRooms.findAbandonedOpenRooms(new Date(), extraQuery).forEach((room) => {
switch (action) {
case 'close': {
this.logger.info({ msg: 'Closing room due to abandoned visitor', roomId: room._id });
promises.push(this.closeRooms(room));
break;
}
case 'on-hold': {
this.logger.info({ msg: 'Placing room on hold due to abandoned visitor', roomId: room._id });
promises.push(this.placeRoomOnHold(room));
break;
}
}
});
const result = await Promise.allSettled(promises);
const errors = result.filter(isPromiseRejectedResult).map((r) => r.reason);
if (errors.length) {
this.logger.error({ msg: 'Error while removing priority from rooms', count: errors.length, reason: errors[0] });
}
this._initializeMessageCache();
}
private async getDefaultAbandonedCustomMessage(abandonmentAction: 'close' | 'on-hold', visitorId: string) {
const visitor = await LivechatVisitors.findOneEnabledById<Pick<ILivechatVisitor, 'name' | 'username'>>(visitorId, {
projection: {
name: 1,
username: 1,
},
});
if (!visitor) {
this.logger.error({
msg: 'Error getting default abandoned custom message: visitor not found',
visitorId,
});
throw new Error('error-invalid_visitor');
}
const timeout = settings.get<number>('Livechat_visitor_inactivity_timeout');
const guest = visitor.name || visitor.username;
if (abandonmentAction === 'on-hold') {
return i18n.t('Omnichannel_On_Hold_due_to_inactivity', {
guest,
timeout,
});
}
return (
settings.get<string>('Livechat_abandoned_rooms_closed_custom_message') ||
i18n.t('Omnichannel_chat_closed_due_to_inactivity', {
guest,
timeout,
})
);
}
}

View File

@@ -0,0 +1,180 @@
import type { ILivechatBusinessHour } from '@rocket.chat/core-typings';
import { LivechatBusinessHourTypes } from '@rocket.chat/core-typings';
import { LivechatDepartment, LivechatDepartmentAgents, Users } from '@rocket.chat/models';
import { businessHourManager } from '../../../../../server/lib/omnichannel/business-hour';
import type { IBusinessHourType } from '../../../../../server/lib/omnichannel/business-hour/AbstractBusinessHour';
import { AbstractBusinessHourType } from '../../../../../server/lib/omnichannel/business-hour/AbstractBusinessHour';
import {
filterBusinessHoursThatMustBeOpened,
makeAgentsUnavailableBasedOnBusinessHour,
} from '../../../../../server/lib/omnichannel/business-hour/Helper';
import { bhLogger } from '../logger';
type IBusinessHoursExtraProperties = {
timezoneName: string;
departmentsToApplyBusinessHour?: string;
};
class CustomBusinessHour extends AbstractBusinessHourType implements IBusinessHourType {
name = LivechatBusinessHourTypes.CUSTOM;
async getBusinessHour(id: string): Promise<ILivechatBusinessHour | null> {
if (!id) {
return null;
}
const businessHour = await this.BusinessHourRepository.findOneById(id);
if (!businessHour) {
return null;
}
businessHour.departments = await LivechatDepartment.findByBusinessHourId(businessHour._id, {
projection: { name: 1 },
}).toArray();
return businessHour;
}
async saveBusinessHour(businessHour: ILivechatBusinessHour & IBusinessHoursExtraProperties): Promise<ILivechatBusinessHour> {
const existingBusinessHour = (await this.BusinessHourRepository.findOne(
{ name: businessHour.name },
{ projection: { _id: 1 } },
)) as ILivechatBusinessHour;
if (existingBusinessHour && existingBusinessHour._id !== businessHour._id) {
throw new Error('error-business-hour-name-already-in-use');
}
const { timezoneName, departmentsToApplyBusinessHour, ...businessHourData } = businessHour;
businessHourData.timezone = {
name: timezoneName,
utc: this.getUTCFromTimezone(timezoneName),
};
const businessHourToReturn = { ...businessHourData, departmentsToApplyBusinessHour };
delete businessHourData.departments;
const businessHourId = await this.baseSaveBusinessHour(businessHourData);
// Internal callers (e.g. the DST verifier) re-save business hours without the
// departments field; only reconcile department links when it is provided,
// otherwise an internal re-save would unlink every department.
if (departmentsToApplyBusinessHour !== undefined) {
const departments = departmentsToApplyBusinessHour.split(',').filter(Boolean);
const currentDepartments = (
await LivechatDepartment.findByBusinessHourId(businessHourId, {
projection: { _id: 1 },
}).toArray()
).map((dept) => dept._id);
const toRemove = [...currentDepartments.filter((dept) => !departments.includes(dept))];
const toAdd = [...departments.filter((dept: string) => !currentDepartments.includes(dept))];
await this.removeBusinessHourFromDepartmentsIfNeeded(businessHourId, toRemove);
await this.removeBusinessHourFromDepartmentsAgents(businessHourId, toRemove);
// Now will check if the department which we're currently adding to BH is not
// associated with any other BH. If it is, then it will remove the old BH from all user's
// cache. It will not add the new BH right now as it will be done in afterSaveBusinessHour.
await this.removeBHFromPreviouslyConnectedDepartmentAgentsIfRequired(toAdd);
await this.addBusinessHourToDepartmentsIfNeeded(businessHourId, toAdd);
}
businessHourToReturn._id = businessHourId;
return businessHourToReturn;
}
async removeBusinessHourById(businessHourId: string): Promise<void> {
const businessHour = await this.BusinessHourRepository.findOneById(businessHourId, {});
if (!businessHour) {
return;
}
await this.BusinessHourRepository.removeById(businessHourId);
await this.removeBusinessHourFromAgents(businessHourId);
await LivechatDepartment.removeBusinessHourFromDepartmentsByBusinessHourId(businessHourId);
await makeAgentsUnavailableBasedOnBusinessHour();
}
private async removeBusinessHourFromAgents(businessHourId: string): Promise<void> {
const departmentIds = (
await LivechatDepartment.findByBusinessHourId(businessHourId, {
projection: { _id: 1 },
}).toArray()
).map((dept) => dept._id);
await this.removeBusinessHourFromDepartmentsAgents(businessHourId, departmentIds);
}
private async removeBusinessHourFromDepartmentsAgents(businessHourId: string, departmentIds: string[]): Promise<void> {
if (!departmentIds.length) {
return;
}
const agentIds = (
await LivechatDepartmentAgents.findByDepartmentIds(departmentIds, {
projection: { agentId: 1 },
}).toArray()
).map((dept) => dept.agentId);
await this.UsersRepository.removeBusinessHourByAgentIds(agentIds, businessHourId);
}
private async removeBusinessHourFromDepartmentsIfNeeded(businessHourId: string, departmentsToRemove: string[]): Promise<void> {
if (!departmentsToRemove.length) {
return;
}
await LivechatDepartment.removeBusinessHourFromDepartmentsByIdsAndBusinessHourId(departmentsToRemove, businessHourId);
}
private async removeBHFromPreviouslyConnectedDepartmentAgentsIfRequired(departmentIds: string[]): Promise<void> {
// we need to do 2 things here.
// 1st is to check if any of the departments are associated with any BH. If they are, then we need to remove
// that BH from all agents of that department.
// 2nd is to check if any of the departments are not associated with BH, meaning default BH
// is applied. So if default BH is open, then we need to remove it from all agents of that department.
const bhIdsConnectedToDepartment = [];
const departmentsWithoutBH = [];
const departments = await LivechatDepartment.findInIds(departmentIds, {
projection: {
businessHourId: 1,
},
}).toArray();
for (const dept of departments) {
if (dept?.businessHourId) {
bhIdsConnectedToDepartment.push(dept.businessHourId);
} else {
departmentsWithoutBH.push(dept._id);
}
}
await Users.closeAgentsBusinessHoursByBusinessHourIds(bhIdsConnectedToDepartment);
// start of step 2
const defaultBusinessHour = await this.BusinessHourRepository.findOneDefaultBusinessHour();
if (!defaultBusinessHour) {
bhLogger.error('No default business hour found');
return;
}
const isDefaultBHActive = (await filterBusinessHoursThatMustBeOpened([defaultBusinessHour])).length > 0;
if (!isDefaultBHActive) {
bhLogger.debug('Default business hour is not active. No need to remove it from agents');
return;
}
const agentsConnectedToDefaultBH = await (
await LivechatDepartmentAgents.findByDepartmentIds(departmentIds, {
projection: { agentId: 1 },
}).toArray()
).map((dept) => dept.agentId);
await Users.removeBusinessHourByAgentIds(agentsConnectedToDefaultBH, defaultBusinessHour._id);
await makeAgentsUnavailableBasedOnBusinessHour();
}
private async addBusinessHourToDepartmentsIfNeeded(businessHourId: string, departmentsToAdd: string[]): Promise<void> {
if (!departmentsToAdd.length) {
return;
}
await LivechatDepartment.addBusinessHourToDepartmentsByIds(departmentsToAdd, businessHourId);
}
}
businessHourManager.registerBusinessHourType(new CustomBusinessHour());

View File

@@ -0,0 +1,53 @@
import type { ILivechatBusinessHour } from '@rocket.chat/core-typings';
import { LivechatBusinessHourTypes } from '@rocket.chat/core-typings';
import { LivechatDepartment, LivechatDepartmentAgents, Users } from '@rocket.chat/models';
import {
makeAgentsUnavailableBasedOnBusinessHour,
makeOnlineAgentsAvailable,
} from '../../../../../server/lib/omnichannel/business-hour/Helper';
import { getAgentIdsForBusinessHour } from '../../../../../server/lib/omnichannel/business-hour/getAgentIdsForBusinessHour';
import { businessHourLogger } from '../../../../../server/lib/omnichannel/logger';
export const getAgentIdsToHandle = async (businessHour: Pick<ILivechatBusinessHour, '_id' | 'type'>): Promise<string[]> => {
if (businessHour.type === LivechatBusinessHourTypes.DEFAULT) {
return getAgentIdsForBusinessHour();
}
const departmentIds = (
await LivechatDepartment.findEnabledByBusinessHourId(businessHour._id, {
projection: { _id: 1 },
}).toArray()
).map((dept) => dept._id);
return (
await LivechatDepartmentAgents.findByDepartmentIds(departmentIds, {
projection: { agentId: 1 },
}).toArray()
).map((dept) => dept.agentId);
};
export const openBusinessHour = async (
businessHour: Pick<ILivechatBusinessHour, '_id' | 'type'>,
updateLivechatStatus = true,
): Promise<void> => {
const agentIds = await getAgentIdsToHandle(businessHour);
businessHourLogger.debug({
msg: 'Opening business hour',
businessHour: businessHour._id,
totalAgents: agentIds.length,
top10AgentIds: agentIds.slice(0, 10),
});
await Users.addBusinessHourByAgentIds(agentIds, businessHour._id);
await makeOnlineAgentsAvailable(agentIds);
if (updateLivechatStatus) {
await makeAgentsUnavailableBasedOnBusinessHour();
}
};
export const removeBusinessHourByAgentIds = async (agentIds: string[], businessHourId: string): Promise<void> => {
if (!agentIds.length) {
return;
}
await Users.removeBusinessHourByAgentIds(agentIds, businessHourId);
await makeAgentsUnavailableBasedOnBusinessHour();
};

View File

@@ -0,0 +1,362 @@
import { LivechatBusinessHourTypes } from '@rocket.chat/core-typings';
import type { AtLeast, ILivechatDepartment, ILivechatBusinessHour } from '@rocket.chat/core-typings';
import { LivechatDepartment, LivechatDepartmentAgents, Users } from '@rocket.chat/models';
import { isTruthy } from '@rocket.chat/tools';
import moment from 'moment';
import { openBusinessHour, removeBusinessHourByAgentIds } from './Helper';
import { businessHourManager } from '../../../../../server/lib/omnichannel/business-hour';
import type { IBusinessHourBehavior } from '../../../../../server/lib/omnichannel/business-hour/AbstractBusinessHour';
import { AbstractBusinessHourBehavior } from '../../../../../server/lib/omnichannel/business-hour/AbstractBusinessHour';
import {
filterBusinessHoursThatMustBeOpened,
filterBusinessHoursThatMustBeOpenedByDay,
makeOnlineAgentsAvailable,
makeAgentsUnavailableBasedOnBusinessHour,
} from '../../../../../server/lib/omnichannel/business-hour/Helper';
import { closeBusinessHour } from '../../../../../server/lib/omnichannel/business-hour/closeBusinessHour';
import { settings } from '../../../../../server/settings';
import { bhLogger } from '../logger';
export class MultipleBusinessHoursBehavior extends AbstractBusinessHourBehavior implements IBusinessHourBehavior {
constructor() {
super();
this.onAddAgentToDepartment = this.onAddAgentToDepartment.bind(this);
this.onRemoveAgentFromDepartment = this.onRemoveAgentFromDepartment.bind(this);
this.onRemoveDepartment = this.onRemoveDepartment.bind(this);
this.onDepartmentArchived = this.onDepartmentArchived.bind(this);
this.onDepartmentDisabled = this.onDepartmentDisabled.bind(this);
this.onNewAgentCreated = this.onNewAgentCreated.bind(this);
}
async onStartBusinessHours(): Promise<void> {
try {
await this.UsersRepository.removeBusinessHoursFromAllUsers();
// TODO is this required? since we're calling `this.openBusinessHour(businessHour)` later on, which will call this again (kinda)
await makeAgentsUnavailableBasedOnBusinessHour();
const currentTime = moment.utc(moment().utc().format('dddd:HH:mm'), 'dddd:HH:mm');
const day = currentTime.format('dddd');
const activeBusinessHours = await this.BusinessHourRepository.findActiveAndOpenBusinessHoursByDay(day, {
projection: {
workHours: 1,
timezone: 1,
type: 1,
active: 1,
},
});
const businessHoursToOpen = await filterBusinessHoursThatMustBeOpened(activeBusinessHours);
bhLogger.info({
msg: 'Starting Multiple Business Hours',
totalBusinessHoursToOpen: businessHoursToOpen.length,
top10BusinessHoursToOpen: businessHoursToOpen.slice(0, 10),
});
for (const businessHour of businessHoursToOpen) {
void this.openBusinessHour(businessHour).catch((err) => {
bhLogger.error({ msg: 'Error while opening business hour during start', businessHourId: businessHour._id, err });
});
}
} catch (err) {
bhLogger.error({ msg: 'Error while starting business hours', err });
}
}
async openBusinessHoursByDayAndHour(day: string, hour: string): Promise<void> {
const businessHours = await this.BusinessHourRepository.findActiveBusinessHoursToOpen(day, hour, undefined, {
projection: {
_id: 1,
type: 1,
},
});
for (const businessHour of businessHours) {
void this.openBusinessHour(businessHour);
}
}
async closeBusinessHoursByDayAndHour(day: string, hour: string): Promise<void> {
const businessHours = await this.BusinessHourRepository.findActiveBusinessHoursToClose(day, hour, undefined, {
projection: {
_id: 1,
type: 1,
},
});
for (const businessHour of businessHours) {
void this.closeBusinessHour(businessHour);
}
}
async afterSaveBusinessHours(businessHourData: ILivechatBusinessHour): Promise<void> {
const businessHour = await this.BusinessHourRepository.findOneById(businessHourData._id);
if (!businessHour) {
return;
}
const businessHourIdToOpen = (await filterBusinessHoursThatMustBeOpened([businessHour])).map((businessHour) => businessHour._id);
if (!businessHourIdToOpen.length) {
return closeBusinessHour(businessHour);
}
return openBusinessHour(businessHour);
}
async onAddAgentToDepartment(options: { departmentId: string; agentsId: string[] }): Promise<any> {
const { departmentId, agentsId } = options;
const department = await LivechatDepartment.findOneById<Pick<ILivechatDepartment, 'businessHourId'>>(departmentId, {
projection: { businessHourId: 1 },
});
if (!department || !agentsId.length) {
return options;
}
const defaultBusinessHour = await this.BusinessHourRepository.findOneDefaultBusinessHour();
if (!defaultBusinessHour) {
return options;
}
if (!department.businessHourId) {
// If this department doesn't have a business hour, we need to apply default business hour to these agents
// And then reset their status based on these BH
const isDefaultBusinessHourActive = (await filterBusinessHoursThatMustBeOpened([defaultBusinessHour])).length > 0;
if (!isDefaultBusinessHourActive) {
bhLogger.debug('Default business hour is not active. No need to apply it to agents');
return options;
}
await this.UsersRepository.addBusinessHourByAgentIds(agentsId, defaultBusinessHour._id);
await makeOnlineAgentsAvailable(agentsId);
return options;
}
// This department has a business hour, so we need to
// 1. Remove default business hour from these agents if they have it
// 2. Add this department's business hour to these agents
// 3. Update their status based on these BH
await removeBusinessHourByAgentIds(agentsId, defaultBusinessHour._id);
const businessHour = await this.BusinessHourRepository.findOneById(department.businessHourId);
if (!businessHour) {
return options;
}
const businessHourToOpen = await filterBusinessHoursThatMustBeOpened([businessHour]);
if (!businessHourToOpen.length) {
return options;
}
await this.UsersRepository.addBusinessHourByAgentIds(agentsId, businessHour._id);
await makeOnlineAgentsAvailable(agentsId);
return options;
}
async onRemoveAgentFromDepartment(options: Record<string, any> = {}): Promise<any> {
const { departmentId, agentsId } = options;
const department = await LivechatDepartment.findOneById<Pick<ILivechatDepartment, 'businessHourId'>>(departmentId, {
projection: { businessHourId: 1 },
});
if (!department || !agentsId.length) {
return options;
}
return this.handleRemoveAgentsFromDepartments(department, agentsId, options);
}
async onRemoveDepartment(options: { department: AtLeast<ILivechatDepartment, '_id' | 'businessHourId'>; agentsIds: string[] }) {
const { department, agentsIds } = options;
if (!department || !agentsIds?.length) {
return options;
}
return this.onDepartmentDisabled(department);
}
async onDepartmentDisabled(department: AtLeast<ILivechatDepartment, 'businessHourId' | '_id'>): Promise<void> {
if (!department.businessHourId) {
return;
}
// Get business hour
let businessHour = await this.BusinessHourRepository.findOneById(department.businessHourId);
if (!businessHour) {
bhLogger.error({
msg: 'onDepartmentDisabled: business hour not found',
businessHourId: department.businessHourId,
});
return;
}
// Unlink business hour from department
await LivechatDepartment.removeBusinessHourFromDepartmentsByIdsAndBusinessHourId([department._id], businessHour._id);
// cleanup user's cache for default business hour and this business hour
const defaultBH = await this.BusinessHourRepository.findOneDefaultBusinessHour();
if (!defaultBH) {
throw new Error('Default business hour not found');
}
await this.UsersRepository.closeAgentsBusinessHoursByBusinessHourIds([businessHour._id, defaultBH._id]);
// If i'm the only one, disable the business hour
const imTheOnlyOne = !(await LivechatDepartment.countByBusinessHourIdExcludingDepartmentId(businessHour._id, department._id));
if (imTheOnlyOne) {
bhLogger.warn({
msg: 'onDepartmentDisabled: department is the only one on business hour, disabling it',
departmentId: department._id,
businessHourId: businessHour._id,
});
await this.BusinessHourRepository.disableBusinessHour(businessHour._id);
businessHour = await this.BusinessHourRepository.findOneById(department.businessHourId);
if (!businessHour) {
throw new Error(`Business hour ${department.businessHourId} not found`);
}
}
// start default business hour and this BH if needed
if (!settings.get('Livechat_enable_business_hours')) {
return;
}
const businessHourToOpen = await filterBusinessHoursThatMustBeOpened([businessHour, defaultBH]);
for await (const bh of businessHourToOpen) {
await openBusinessHour(bh, false);
}
await makeAgentsUnavailableBasedOnBusinessHour();
await businessHourManager.restartCronJobsIfNecessary();
}
async onDepartmentArchived(department: Pick<ILivechatDepartment, '_id' | 'businessHourId'>): Promise<void> {
bhLogger.debug({ msg: 'Processing department archived event on multiple business hours', department });
return this.onDepartmentDisabled(department);
}
override allowAgentChangeServiceStatus(agentId: string): Promise<boolean> {
return this.UsersRepository.isAgentWithinBusinessHours(agentId);
}
async onNewAgentCreated(agentId: string): Promise<void> {
await this.applyAnyOpenBusinessHourToAgent(agentId);
await makeAgentsUnavailableBasedOnBusinessHour([agentId]);
}
private async applyAnyOpenBusinessHourToAgent(agentId: string): Promise<void> {
const currentTime = moment().utc();
const day = currentTime.format('dddd');
const allActiveBusinessHoursForEntireWeek = await this.BusinessHourRepository.findActiveBusinessHours({
projection: {
workHours: 1,
timezone: 1,
type: 1,
active: 1,
},
});
const openedBusinessHours = await filterBusinessHoursThatMustBeOpenedByDay(allActiveBusinessHoursForEntireWeek, day);
if (!openedBusinessHours.length) {
bhLogger.debug({
msg: 'Business hour status check failed for agent. No opened business hour found for the current day',
agentId,
});
return;
}
const agentDepartments = await LivechatDepartmentAgents.find(
{ departmentEnabled: true, agentId },
{ projection: { agentId: 1, departmentId: 1 } },
).toArray();
if (!agentDepartments.length) {
// check if default businessHour is active
const isDefaultBHActive = openedBusinessHours.find(({ type }) => type === LivechatBusinessHourTypes.DEFAULT);
if (isDefaultBHActive?._id) {
await Users.openAgentBusinessHoursByBusinessHourIdsAndAgentId([isDefaultBHActive._id], agentId);
return;
}
bhLogger.debug({
msg: 'Business hour status check failed for agent. Found default business hour to be inactive',
agentId,
});
return;
}
// check if any one these departments have a opened business hour linked to it
const departments = (await LivechatDepartment.findInIds(
agentDepartments.map(({ departmentId }) => departmentId),
{ projection: { _id: 1, businessHourId: 1 } },
).toArray()) as Pick<ILivechatDepartment, '_id' | 'businessHourId'>[];
const departmentsWithActiveBH = departments.filter(
({ businessHourId }) => businessHourId && openedBusinessHours.findIndex(({ _id }) => _id === businessHourId) !== -1,
);
if (!departmentsWithActiveBH.length) {
// No opened business hour found for any of the departments connected to the agent
// check if this agent has any departments that is connected to any non-default business hour
// if no such departments found then check default BH and if it is active, then allow the agent to change service status
const hasAtLeastOneDepartmentWithNonDefaultBH = departments.some(({ businessHourId }) => {
// check if business hour is active
return businessHourId && allActiveBusinessHoursForEntireWeek.findIndex(({ _id }) => _id === businessHourId) !== -1;
});
if (!hasAtLeastOneDepartmentWithNonDefaultBH) {
const isDefaultBHActive = openedBusinessHours.find(({ type }) => type === LivechatBusinessHourTypes.DEFAULT);
if (isDefaultBHActive?._id) {
await Users.openAgentBusinessHoursByBusinessHourIdsAndAgentId([isDefaultBHActive._id], agentId);
return;
}
}
bhLogger.debug({
msg: 'Business hour status check failed for agent. No opened business hour found for any of the departments connected to the agent',
agentId,
});
return;
}
const activeBusinessHoursForAgent = departmentsWithActiveBH.map(({ businessHourId }) => businessHourId).filter(isTruthy);
await Users.openAgentBusinessHoursByBusinessHourIdsAndAgentId(activeBusinessHoursForAgent, agentId);
bhLogger.debug({
msg: `Business hour status check passed for agent. Found opened business hour for departments connected to the agent`,
activeBusinessHoursForAgent,
});
}
private async handleRemoveAgentsFromDepartments(department: Record<string, any>, agentsIds: string[], options: any): Promise<any> {
const agentIdsWithoutDepartment: string[] = [];
const agentIdsToRemoveCurrentBusinessHour: string[] = [];
const [agentsWithDepartment, [agentsOfDepartment] = []] = await Promise.all([
LivechatDepartmentAgents.findByAgentIds(agentsIds, { projection: { agentId: 1 } }).toArray(),
...[department?.businessHourId ? LivechatDepartment.findAgentsByBusinessHourId(department.businessHourId).toArray() : []],
]);
for (const agentId of agentsIds) {
if (!agentsWithDepartment.find((agent) => agent.agentId === agentId)) {
agentIdsWithoutDepartment.push(agentId);
}
if (!agentsOfDepartment?.agentIds?.find((agent) => agent === agentId)) {
agentIdsToRemoveCurrentBusinessHour.push(agentId);
}
}
if (department.businessHourId) {
await removeBusinessHourByAgentIds(agentIdsToRemoveCurrentBusinessHour, department.businessHourId);
}
if (!agentIdsWithoutDepartment.length) {
return options;
}
const defaultBusinessHour = await this.BusinessHourRepository.findOneDefaultBusinessHour();
if (!defaultBusinessHour) {
return options;
}
const businessHourToOpen = await filterBusinessHoursThatMustBeOpened([defaultBusinessHour]);
if (!businessHourToOpen.length) {
return options;
}
await this.UsersRepository.addBusinessHourByAgentIds(agentIdsWithoutDepartment, defaultBusinessHour._id);
return options;
}
private async openBusinessHour(businessHour: Pick<ILivechatBusinessHour, '_id' | 'type'>): Promise<void> {
return openBusinessHour(businessHour);
}
private async closeBusinessHour(businessHour: Pick<ILivechatBusinessHour, '_id' | 'type'>): Promise<void> {
await closeBusinessHour(businessHour);
}
}

View File

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

View File

@@ -0,0 +1,50 @@
import type { ILivechatBusinessHour } from '@rocket.chat/core-typings';
import { LivechatBusinessHours, LivechatDepartment } from '@rocket.chat/models';
import { escapeRegExp } from '@rocket.chat/tools';
import { hasPermissionAsync } from '../../../../../../server/lib/authorization/hasPermission';
import type { IPaginatedResponse, IPagination } from '../../../../api/v1/omnichannel/lib/definition';
interface IResponse extends IPaginatedResponse {
businessHours: ILivechatBusinessHour[];
}
export async function findBusinessHours(userId: string, { offset, count, sort }: IPagination, name?: string): Promise<IResponse> {
if (!(await hasPermissionAsync(userId, 'view-livechat-business-hours'))) {
throw new Error('error-not-authorized');
}
const query = {};
if (name) {
const filterReg = new RegExp(escapeRegExp(name), 'i');
Object.assign(query, { name: filterReg });
}
const { cursor, totalCount } = LivechatBusinessHours.findPaginated(query, {
sort: sort || { name: 1 },
skip: offset,
limit: count,
});
const [businessHours, total] = await Promise.all([cursor.toArray(), totalCount]);
// add departments to businessHours
const businessHoursWithDepartments = await Promise.all(
businessHours.map(async (businessHour) => {
const currentDepartments = await LivechatDepartment.findByBusinessHourId(businessHour._id, {
projection: { _id: 1, name: 1 },
}).toArray();
if (currentDepartments.length) {
businessHour.departments = currentDepartments;
}
return businessHour;
}),
);
return {
businessHours: businessHoursWithDepartments,
count: businessHours.length,
offset,
total,
};
}

View File

@@ -0,0 +1,23 @@
import debounce from 'lodash.debounce';
import mem from 'mem';
interface IMemoizeDebouncedFunction<F extends (...args: any[]) => any> {
(...args: Parameters<F>): void;
flush: (...args: Parameters<F>) => void;
}
// Debounce `func` based on passed parameters
// ref: https://github.com/lodash/lodash/issues/2403#issuecomment-816137402
export function memoizeDebounce<F extends (...args: any[]) => any>(func: F, wait = 0, options: any = {}): IMemoizeDebouncedFunction<F> {
const debounceMemo = mem((..._args: Parameters<F>) => debounce(func, wait, options));
function wrappedFunction(this: IMemoizeDebouncedFunction<F>, ...args: Parameters<F>): ReturnType<F> | undefined {
return debounceMemo(...args)(...args);
}
wrappedFunction.flush = (...args: Parameters<F>): void => {
debounceMemo(...args).flush();
};
return wrappedFunction as unknown as IMemoizeDebouncedFunction<F>;
}

View File

@@ -0,0 +1,39 @@
import { License } from '@rocket.chat/license';
import { patchOmniCore } from '@rocket.chat/omni-core-ee';
import { Meteor } from 'meteor/meteor';
import '../../hooks/omnichannel/afterTakeInquiry';
import '../../hooks/omnichannel/beforeNewInquiry';
import '../../hooks/omnichannel/beforeNewRoom';
import '../../hooks/omnichannel/beforeRoutingChat';
import '../../hooks/omnichannel/checkAgentBeforeTakeInquiry';
import '../../hooks/omnichannel/handleNextAgentPreferredEvents';
import '../../hooks/omnichannel/onCheckRoomParamsApi';
import '../../hooks/omnichannel/onLoadConfigApi';
import '../../hooks/omnichannel/onSaveVisitorInfo';
import '../../hooks/omnichannel/scheduleAutoTransfer';
import '../../hooks/omnichannel/resumeOnHold';
import '../../hooks/omnichannel/afterOnHold';
import '../../hooks/omnichannel/onTransferFailure';
import './routing/LoadBalancing';
import './routing/LoadRotation';
import './AutoCloseOnHoldScheduler';
import './business-hour';
import '../../api/v1/omnichannel';
import { createDefaultPriorities } from './priorities';
patchOmniCore();
await License.onLicense('livechat-enterprise', async () => {
require('../../hooks/omnichannel');
await import('./startup');
const { createPermissions } = await import('./permissions');
const { createSettings } = await import('./settings');
await import('./unit');
Meteor.startup(() => {
void createSettings();
void createPermissions();
void createDefaultPriorities();
});
});

View File

@@ -0,0 +1,11 @@
import { Logger } from '@rocket.chat/logger';
export const logger = new Logger('LivechatEnterprise');
export const queriesLogger = logger.section('Queries');
export const helperLogger = logger.section('Helper');
export const cbLogger = logger.section('Callbacks');
export const bhLogger = logger.section('Business-Hours');
export const contactLogger = logger.section('Contacts');
export const schedulerLogger = new Logger('Scheduler');

View File

@@ -0,0 +1,331 @@
import type { IOutboundMessage, IOutboundProvider, IOutboundProviderMetadata, ValidOutboundProvider } from '@rocket.chat/core-typings';
import { ajv } from '@rocket.chat/rest-typings';
import type { OutboundCommsEndpoints } from '../../../api/v1/omnichannel/outbound';
declare module '@rocket.chat/rest-typings' {
// eslint-disable-next-line @typescript-eslint/naming-convention, @typescript-eslint/no-empty-interface
interface Endpoints extends OutboundCommsEndpoints {}
}
type GenericErrorResponse = { success: boolean; message: string };
type GETOutboundProviderParams = { type?: ValidOutboundProvider };
const GETOutboundProviderSchema = {
type: 'object',
properties: {
type: {
type: 'string',
enum: ['phone', 'email'],
},
},
required: [],
additionalProperties: false,
};
export const GETOutboundProviderParamsSchema = ajv.compile<GETOutboundProviderParams>(GETOutboundProviderSchema);
const GETOutboundProvidersResponse = {
type: 'object',
properties: {
providers: {
type: 'array',
items: {
type: 'object',
properties: {
providerId: {
type: 'string',
},
providerName: {
type: 'string',
},
supportsTemplates: {
type: 'boolean',
},
providerType: {
type: 'string',
},
},
},
},
},
};
export const GETOutboundProvidersResponseSchema = ajv.compile<{ providers: IOutboundProvider[] }>(GETOutboundProvidersResponse);
const GETOutboundProviderBadRequestError = {
type: 'object',
properties: {
success: {
type: 'boolean',
},
message: {
type: 'string',
},
},
};
export const GETOutboundProviderBadRequestErrorSchema = ajv.compile<GenericErrorResponse>(GETOutboundProviderBadRequestError);
type POSTOutboundMessageParamsType = IOutboundMessage;
const POSTOutboundMessageSchema = {
type: 'object',
required: ['to', 'type'],
properties: {
to: { type: 'string', minLength: 1 },
type: { type: 'string' },
templateProviderPhoneNumber: { type: 'string' },
template: {
type: 'object',
required: ['name', 'language'],
properties: {
name: { type: 'string' },
language: {
type: 'object',
required: ['code'],
properties: {
code: { type: 'string' },
policy: {
type: 'string',
enum: ['deterministic', 'fallback'],
},
},
additionalProperties: false,
},
components: {
type: 'array',
items: {
type: 'object',
required: ['type', 'parameters'],
properties: {
type: {
type: 'string',
enum: ['header', 'body', 'footer', 'button'],
},
parameters: {
type: 'array',
items: {
oneOf: [
{
type: 'object',
required: ['type', 'text'],
properties: {
type: { const: 'text' },
text: { type: 'string' },
},
additionalProperties: false,
},
{
type: 'object',
required: ['type', 'currency'],
properties: {
type: { const: 'currency' },
currency: {
type: 'object',
required: ['fallbackValue', 'code', 'amount1000'],
properties: {
fallbackValue: { type: 'string' },
code: { type: 'string' },
amount1000: { type: 'number' },
},
additionalProperties: false,
},
},
additionalProperties: false,
},
{
type: 'object',
required: ['type', 'dateTime'],
properties: {
type: { const: 'date_time' },
dateTime: {
type: 'object',
required: ['fallbackValue'],
properties: {
fallbackValue: { type: 'string' },
timestamp: { type: 'number' },
dayOfWeek: { type: 'number' },
dayOfMonth: { type: 'number' },
year: { type: 'number' },
month: { type: 'number' },
hour: { type: 'number' },
minute: { type: 'number' },
},
additionalProperties: false,
},
},
additionalProperties: false,
},
{
type: 'object',
required: ['type', 'link'],
properties: {
type: { const: 'media' },
link: { type: 'string' },
format: { type: 'string', enum: ['image', 'video', 'document'] },
},
additionalProperties: false,
},
],
},
},
},
additionalProperties: false,
},
},
namespace: { type: 'string' },
},
additionalProperties: false,
},
agentId: { type: 'string' },
departmentId: { type: 'string' },
},
additionalProperties: false,
};
export const POSTOutboundMessageParams = ajv.compile<POSTOutboundMessageParamsType>(POSTOutboundMessageSchema);
const POSTOutboundMessageSuccess = {
type: 'object',
properties: {
success: { type: 'boolean', enum: [true] },
},
additionalProperties: false,
};
export const POSTOutboundMessageSuccessSchema = ajv.compile<void>(POSTOutboundMessageSuccess);
const OutboundProviderMetadataSchema = {
type: 'object',
properties: {
metadata: {
type: 'object',
properties: {
providerId: {
type: 'string',
},
providerName: {
type: 'string',
},
supportsTemplates: {
type: 'boolean',
},
providerType: {
type: 'string',
enum: ['phone', 'email'],
},
templates: {
type: 'object',
additionalProperties: {
type: 'array',
items: {
type: 'object',
properties: {
id: {
type: 'string',
},
name: {
type: 'string',
},
language: {
type: 'string',
},
type: {
type: 'string',
},
category: {
type: 'string',
},
status: {
type: 'string',
},
qualityScore: {
type: 'object',
required: ['score', 'reasons'],
properties: {
score: {
type: 'string',
enum: ['GREEN', 'YELLOW', 'RED', 'UNKNOWN'],
},
reasons: {
type: ['array', 'null'],
items: {
type: 'string',
},
},
},
},
components: {
type: 'array',
items: {
type: 'object',
oneOf: [
{
properties: {
type: { const: 'header' },
format: {
type: 'string',
enum: ['text', 'image', 'video', 'document'],
},
text: { type: 'string' },
example: {
type: 'object',
properties: {
headerText: {
type: 'array',
items: { type: 'string' },
},
},
},
},
},
{
properties: {
type: { const: 'body' },
text: { type: 'string' },
example: {
type: 'object',
properties: {
bodyText: {
type: 'array',
items: {
type: 'array',
items: { type: 'string' },
},
},
},
},
},
required: ['type', 'text'],
},
{
properties: {
type: { const: 'footer' },
text: { type: 'string' },
},
required: ['type', 'text'],
},
],
},
},
createdAt: { type: 'string' },
createdBy: { type: 'string' },
modifiedAt: { type: 'string' },
modifiedBy: { type: 'string' },
namespace: { type: 'string' },
wabaAccountId: { type: 'string' },
phoneNumber: { type: 'string' },
partnerId: { type: 'string' },
externalId: { type: 'string' },
updatedExternal: { type: 'string' },
rejectedReason: {
type: ['string', 'null'],
},
},
},
},
},
},
},
},
};
export const GETOutboundProviderMetadataSchema = ajv.compile<{ metadata: IOutboundProviderMetadata }>(OutboundProviderMetadataSchema);

View File

@@ -0,0 +1,36 @@
import { Permissions, Roles } from '@rocket.chat/models';
import { createOrUpdateProtectedRoleAsync } from '../../../../server/lib/roles/createOrUpdateProtectedRole';
const livechatMonitorRole = 'livechat-monitor';
const livechatManagerRole = 'livechat-manager';
const adminRole = 'admin';
const livechatAgentRole = 'livechat-agent';
export const omnichannelEEPermissions = [
{ _id: 'manage-livechat-units', roles: [adminRole, livechatManagerRole] },
{ _id: 'manage-livechat-monitors', roles: [adminRole, livechatManagerRole] },
{ _id: 'manage-livechat-tags', roles: [adminRole, livechatManagerRole] },
{ _id: 'manage-livechat-priorities', roles: [adminRole, livechatManagerRole] },
{ _id: 'manage-livechat-sla', roles: [adminRole, livechatManagerRole] },
{ _id: 'manage-livechat-canned-responses', roles: [adminRole, livechatManagerRole, livechatMonitorRole] },
{ _id: 'request-pdf-transcript', roles: [adminRole, livechatManagerRole, livechatMonitorRole, livechatAgentRole] },
{ _id: 'view-livechat-reports', roles: [adminRole, livechatManagerRole, livechatMonitorRole] },
{ _id: 'block-livechat-contact', roles: [adminRole, livechatManagerRole, livechatMonitorRole, livechatAgentRole] },
{ _id: 'unblock-livechat-contact', roles: [adminRole, livechatManagerRole, livechatMonitorRole, livechatAgentRole] },
{ _id: 'outbound.send-messages', roles: [adminRole, livechatManagerRole, livechatMonitorRole, livechatAgentRole] },
{ _id: 'outbound.can-assign-queues', roles: [adminRole, livechatManagerRole] },
{ _id: 'outbound.can-assign-any-agent', roles: [adminRole, livechatManagerRole, livechatMonitorRole] },
{ _id: 'outbound.can-assign-self-only', roles: [livechatAgentRole] },
];
export const createPermissions = async (): Promise<void> => {
const monitorRole = await Roles.findOneById(livechatMonitorRole, { projection: { _id: 1 } });
if (!monitorRole) {
await createOrUpdateProtectedRoleAsync(livechatMonitorRole, {
name: livechatMonitorRole,
});
}
await Promise.all(omnichannelEEPermissions.map(async (permission) => Permissions.create(permission._id, permission.roles)));
};

View File

@@ -0,0 +1,39 @@
import type { ILivechatPriority } from '@rocket.chat/core-typings';
import { LivechatPriorityWeight } from '@rocket.chat/core-typings';
import { LivechatPriority } from '@rocket.chat/models';
const defaultPriorities: Omit<ILivechatPriority, '_id' | '_updatedAt'>[] = [
{
i18n: 'Lowest',
sortItem: LivechatPriorityWeight.LOWEST,
dirty: false,
},
{
i18n: 'Low',
sortItem: LivechatPriorityWeight.LOW,
dirty: false,
},
{
i18n: 'Medium',
sortItem: LivechatPriorityWeight.MEDIUM,
dirty: false,
},
{
i18n: 'High',
sortItem: LivechatPriorityWeight.HIGH,
dirty: false,
},
{
i18n: 'Highest',
sortItem: LivechatPriorityWeight.HIGHEST,
dirty: false,
},
];
export const createDefaultPriorities = async (): Promise<void> => {
const priorities = await LivechatPriority.estimatedDocumentCount();
if (!priorities) {
await LivechatPriority.insertMany(defaultPriorities);
}
};

View File

@@ -0,0 +1,43 @@
import { OmnichannelTranscript, QueueWorker } from '@rocket.chat/core-services';
import type { AtLeast, IOmnichannelRoom } from '@rocket.chat/core-typings';
import ExpiryMap from 'expiry-map';
import { logger } from './logger';
// Allow to request a transcript again after 15 seconds, assuming the first one didn't complete
// This won't prevent multiple transcript generated for the same room in a multi-instance deployment since state is not shared, but we're ok with the drawbacks
const LockMap = new ExpiryMap<string, boolean>(15000);
const serviceName = 'omnichannel-transcript' as const;
export const requestPdfTranscript = async (
room: AtLeast<IOmnichannelRoom, '_id' | 'open' | 'v' | 'pdfTranscriptFileId'>,
requestedBy: string,
): Promise<void> => {
if (room.open) {
throw new Error('room-still-open');
}
if (!room.v) {
throw new Error('improper-room-state');
}
// Don't request a transcript if there's already one requested
if (LockMap.has(room._id) || room.pdfTranscriptFileId) {
// TODO: use logger
logger.info({ msg: `Transcript already requested`, roomId: room._id });
return;
}
LockMap.set(room._id, true);
const details = { details: { rid: room._id, userId: requestedBy, from: serviceName } };
// Make the whole process sync when running on test mode
// This will prevent the usage of timeouts on the tests of this functionality :)
if (process.env.TEST_MODE) {
await OmnichannelTranscript.workOnPdf(details);
return;
}
logger.info({ msg: 'Queuing work for room', roomId: room._id });
await QueueWorker.queueWork('work', `${serviceName}.workOnPdf`, details);
};

View File

@@ -0,0 +1,52 @@
import type { IOmnichannelRoom } from '@rocket.chat/core-typings';
import { LivechatDepartment } from '@rocket.chat/models';
import { getUnitsFromUser } from '@rocket.chat/omni-core-ee';
import type { FilterOperators } from 'mongodb';
import { cbLogger } from './logger';
export const restrictQuery = async ({
originalQuery = {},
unitsFilter,
userId,
}: {
originalQuery?: FilterOperators<IOmnichannelRoom>;
unitsFilter?: string[];
userId?: string;
}) => {
const query = { ...originalQuery };
let userUnits = await getUnitsFromUser(userId);
if (!Array.isArray(userUnits)) {
if (Array.isArray(unitsFilter) && unitsFilter.length) {
return { ...query, departmentAncestors: { $in: unitsFilter } };
}
return query;
}
if (Array.isArray(unitsFilter) && unitsFilter.length) {
const userUnit = new Set([...userUnits]);
const filteredUnits = new Set(unitsFilter);
// IF user is trying to filter by a unit he doens't have access to, apply empty filter (no matches)
userUnits = [...userUnit.intersection(filteredUnits)];
}
const departments = await LivechatDepartment.find(
{ $or: [{ ancestors: { $in: userUnits } }, { _id: { $in: userUnits } }] },
{ projection: { _id: 1 } },
).toArray();
const expressions = query.$and || [];
const condition = {
$or: [
{ departmentAncestors: { $in: userUnits } },
{ departmentId: { $in: departments.map(({ _id }) => _id) } },
{ departmentId: { $exists: false } },
],
};
query.$and = [condition, ...expressions];
cbLogger.debug({ msg: 'Applying room query restrictions', userUnits, query });
return query;
};

View File

@@ -0,0 +1,9 @@
export interface IRoutingManagerConfig {
previewRoom: boolean;
showConnecting: boolean;
showQueue: boolean;
showQueueLink: boolean;
returnQueue: boolean;
enableTriggerAction: boolean;
autoAssignAgent: boolean;
}

View File

@@ -0,0 +1,56 @@
import { Users } from '@rocket.chat/models';
import type { IRoutingManagerConfig } from './IRoutingManagerConfig';
import { RoutingManager } from '../../../../../server/lib/omnichannel/RoutingManager';
import { settings } from '../../../../../server/settings';
import { getChatLimitsQuery } from '../../../hooks/omnichannel/applySimultaneousChatsRestrictions';
import { logger } from '../logger';
/* Load Balancing Queuing method:
*
* default method where the agent with the least number
* of open chats is paired with the incoming livechat
*/
class LoadBalancing {
private _config: IRoutingManagerConfig;
constructor() {
this._config = {
previewRoom: false,
showConnecting: false,
showQueue: false,
showQueueLink: false,
returnQueue: false,
enableTriggerAction: true,
autoAssignAgent: true,
};
}
get config(): IRoutingManagerConfig {
return this._config;
}
async getNextAgent(department?: string, ignoreAgentId?: string) {
const enabledWhenIdle = settings.get<boolean>('Livechat_enabled_when_agent_idle');
const enabledWhenOffline = settings.get<boolean>('Livechat_accept_chats_with_no_agents');
const extraQuery = await getChatLimitsQuery(department);
const unavailableUsers = await Users.getUnavailableAgents(department, extraQuery, enabledWhenIdle, enabledWhenOffline);
logger.debug({ msg: 'Ignoring unavailable agents from assignment', unavailableUsers, department, enabledWhenIdle, enabledWhenOffline });
const nextAgent = await Users.getNextLeastBusyAgent(
department,
ignoreAgentId,
enabledWhenIdle,
unavailableUsers.map((u) => u.username),
enabledWhenOffline,
);
if (!nextAgent) {
return;
}
const { agentId, username } = nextAgent;
return { agentId, username };
}
}
RoutingManager.registerMethod('Load_Balancing', LoadBalancing);

View File

@@ -0,0 +1,56 @@
import type { IOmnichannelCustomAgent } from '@rocket.chat/core-typings';
import { Users } from '@rocket.chat/models';
import type { IRoutingManagerConfig } from './IRoutingManagerConfig';
import { RoutingManager } from '../../../../../server/lib/omnichannel/RoutingManager';
import { settings } from '../../../../../server/settings';
import { getChatLimitsQuery } from '../../../hooks/omnichannel/applySimultaneousChatsRestrictions';
import { logger } from '../logger';
/* Load Rotation Queuing method:
* Routing method where the agent with the oldest routing time is the next agent to serve incoming chats
*/
class LoadRotation {
private _config: IRoutingManagerConfig;
constructor() {
this._config = {
previewRoom: false,
showConnecting: false,
showQueue: false,
showQueueLink: false,
returnQueue: false,
enableTriggerAction: true,
autoAssignAgent: true,
};
}
get config(): IRoutingManagerConfig {
return this._config;
}
public async getNextAgent(department?: string, ignoreAgentId?: string): Promise<IOmnichannelCustomAgent | undefined> {
const enabledWhenIdle = settings.get<boolean>('Livechat_enabled_when_agent_idle');
const enabledWhenOffline = settings.get<boolean>('Livechat_accept_chats_with_no_agents');
const extraQuery = await getChatLimitsQuery(department);
const unavailableUsers = await Users.getUnavailableAgents(department, extraQuery, enabledWhenIdle, enabledWhenOffline);
logger.debug({ msg: 'Ignoring unavailable agents from assignment', unavailableUsers, department, enabledWhenIdle, enabledWhenOffline });
const nextAgent = await Users.getLastAvailableAgentRouted(
department,
ignoreAgentId,
enabledWhenIdle,
unavailableUsers.map((user) => user.username),
enabledWhenOffline,
);
if (!nextAgent?.username) {
return;
}
const { agentId, username } = nextAgent;
return { agentId, username };
}
}
RoutingManager.registerMethod('Load_Rotation', LoadRotation);

View File

@@ -0,0 +1,345 @@
import { OmnichannelSortingMechanismSettingType } from '@rocket.chat/core-typings';
import { Settings } from '@rocket.chat/models';
import { settingsRegistry } from '../../../../server/settings';
const omnichannelEnabledQuery = { _id: 'Livechat_enabled', value: true };
const businessHoursEnabled = { _id: 'Livechat_enable_business_hours', value: true };
export const createSettings = async (): Promise<void> => {
await settingsRegistry.add('Livechat_abandoned_rooms_action', 'none', {
type: 'select',
group: 'Omnichannel',
section: 'Sessions',
values: [
{ key: 'none', i18nLabel: 'Do_Nothing' },
{ key: 'close', i18nLabel: 'Livechat_close_chat' },
{ key: 'on-hold', i18nLabel: 'Omnichannel_onHold_Chat' },
],
enterprise: true,
public: true,
invalidValue: 'none',
modules: ['livechat-enterprise'],
enableQuery: omnichannelEnabledQuery,
});
await settingsRegistry.add('Livechat_abandoned_rooms_closed_custom_message', '', {
type: 'string',
group: 'Omnichannel',
section: 'Sessions',
i18nLabel: 'Livechat_abandoned_rooms_closed_custom_message',
enableQuery: [{ _id: 'Livechat_abandoned_rooms_action', value: 'close' }, omnichannelEnabledQuery],
enterprise: true,
invalidValue: '',
modules: ['livechat-enterprise'],
});
await settingsRegistry.add('Omnichannel_max_fallback_forward_depth', 3, {
type: 'int',
group: 'Omnichannel',
section: 'Routing',
i18nLabel: 'Omnichannel_max_fallback_forward_depth',
enterprise: true,
invalidValue: 0,
modules: ['livechat-enterprise'],
enableQuery: omnichannelEnabledQuery,
});
await settingsRegistry.add('Livechat_last_chatted_agent_routing', false, {
type: 'boolean',
group: 'Omnichannel',
section: 'Routing',
enterprise: true,
invalidValue: false,
modules: ['livechat-enterprise'],
enableQuery: omnichannelEnabledQuery,
});
await settingsRegistry.addGroup('Omnichannel', async function () {
await this.section('Business_Hours', async function () {
await this.add('Livechat_business_hour_type', 'Single', {
type: 'select',
values: [
{
key: 'Single',
i18nLabel: 'Single',
},
{
key: 'Multiple',
i18nLabel: 'Multiple',
},
],
public: true,
i18nLabel: 'Livechat_business_hour_type',
enterprise: true,
invalidValue: 'Single',
modules: ['livechat-enterprise'],
enableQuery: [omnichannelEnabledQuery, businessHoursEnabled],
});
});
await this.section('Queue_management', async function () {
await this.add('Livechat_waiting_queue', false, {
type: 'boolean',
group: 'Omnichannel',
section: 'Queue_management',
i18nLabel: 'Waiting_queue',
enterprise: true,
invalidValue: false,
modules: ['livechat-enterprise'],
enableQuery: omnichannelEnabledQuery,
public: true,
});
await this.add('Livechat_waiting_queue_message', '', {
type: 'string',
group: 'Omnichannel',
section: 'Queue_management',
i18nLabel: 'Waiting_queue_message',
i18nDescription: 'Waiting_queue_message_description',
enableQuery: [{ _id: 'Livechat_waiting_queue', value: true }, omnichannelEnabledQuery],
enterprise: true,
invalidValue: '',
modules: ['livechat-enterprise'],
});
await this.add('Livechat_maximum_chats_per_agent', 0, {
type: 'int',
group: 'Omnichannel',
section: 'Queue_management',
i18nLabel: 'Max_number_of_chats_per_agent',
i18nDescription: 'Max_number_of_chats_per_agent_description',
enableQuery: [{ _id: 'Livechat_waiting_queue', value: true }, omnichannelEnabledQuery],
enterprise: true,
invalidValue: 0,
modules: ['livechat-enterprise'],
});
await this.add('Omnichannel_calculate_dispatch_service_queue_statistics', true, {
type: 'boolean',
group: 'Omnichannel',
section: 'Queue_management',
i18nLabel: 'Omnichannel_calculate_dispatch_service_queue_statistics',
enableQuery: [{ _id: 'Livechat_waiting_queue', value: true }, omnichannelEnabledQuery],
enterprise: true,
invalidValue: false,
modules: ['livechat-enterprise'],
});
await this.add('Livechat_number_most_recent_chats_estimate_wait_time', 100, {
type: 'int',
group: 'Omnichannel',
section: 'Queue_management',
i18nLabel: 'Number_of_most_recent_chats_estimate_wait_time',
i18nDescription: 'Number_of_most_recent_chats_estimate_wait_time_description',
enableQuery: [{ _id: 'Livechat_waiting_queue', value: true }, omnichannelEnabledQuery],
enterprise: true,
invalidValue: 100,
modules: ['livechat-enterprise'],
});
await this.add('Livechat_max_queue_wait_time', -1, {
type: 'int',
group: 'Omnichannel',
section: 'Queue_management',
i18nLabel: 'Livechat_maximum_queue_wait_time',
enableQuery: omnichannelEnabledQuery,
i18nDescription: 'Livechat_maximum_queue_wait_time_description',
enterprise: true,
invalidValue: -1,
modules: ['livechat-enterprise'],
});
await this.add('Omnichannel_sorting_mechanism', 'Timestamp', {
type: 'select',
values: [
{ key: OmnichannelSortingMechanismSettingType.Timestamp, i18nLabel: 'Timestamp' },
{ key: OmnichannelSortingMechanismSettingType.Priority, i18nLabel: 'Priorities' },
{ key: OmnichannelSortingMechanismSettingType.SLAs, i18nLabel: 'SLA_Policies' },
],
group: 'Omnichannel',
section: 'Queue_management',
i18nLabel: 'Sorting_mechanism',
enableQuery: [omnichannelEnabledQuery],
enterprise: true,
public: true,
modules: ['livechat-enterprise'],
invalidValue: '',
});
});
});
await settingsRegistry.add('Livechat_AdditionalWidgetScripts', '', {
type: 'string',
group: 'Omnichannel',
section: 'Livechat',
enterprise: true,
invalidValue: '',
multiline: true,
i18nLabel: 'Livechat_AdditionalWidgetScripts',
i18nDescription: 'Livechat_AdditionalWidgetScripts_Description',
enableQuery: [omnichannelEnabledQuery],
modules: ['livechat-enterprise'],
});
await settingsRegistry.add('Livechat_WidgetLayoutClasses', '', {
type: 'string',
group: 'Omnichannel',
section: 'Livechat',
enterprise: true,
invalidValue: '',
multiline: true,
i18nLabel: 'Livechat_WidgetLayoutClasses',
i18nDescription: 'Livechat_WidgetLayoutClasses_Description',
enableQuery: [omnichannelEnabledQuery],
modules: ['livechat-enterprise'],
});
await settingsRegistry.add('Livechat_widget_position', 'right', {
type: 'select',
group: 'Omnichannel',
section: 'Livechat',
i18nLabel: 'Livechat_widget_position_on_the_screen',
public: true,
values: [
{ key: 'left', i18nLabel: 'Left' },
{ key: 'right', i18nLabel: 'Right' },
],
enterprise: true,
invalidValue: 'right',
modules: ['livechat-enterprise'],
enableQuery: omnichannelEnabledQuery,
});
await settingsRegistry.add('Livechat_background', '', {
type: 'string',
group: 'Omnichannel',
section: 'Livechat',
i18nDescription: 'Livechat_background_description',
placeholder: '#FFFFFF',
public: true,
enterprise: true,
invalidValue: '',
modules: ['livechat-enterprise'],
enableQuery: omnichannelEnabledQuery,
});
await settingsRegistry.add('Livechat_hide_watermark', false, {
type: 'boolean',
group: 'Omnichannel',
section: 'Livechat',
invalidValue: false,
enableQuery: omnichannelEnabledQuery,
i18nDescription: 'Livechat_hide_watermark_description',
enterprise: true,
sorter: 999,
modules: ['livechat-enterprise'],
});
await settingsRegistry.add('Omnichannel_contact_manager_routing', true, {
type: 'boolean',
group: 'Omnichannel',
section: 'Routing',
enterprise: true,
invalidValue: false,
modules: ['livechat-enterprise'],
enableQuery: omnichannelEnabledQuery,
});
await settingsRegistry.add('Livechat_auto_close_on_hold_chats_timeout', 3600, {
type: 'int',
group: 'Omnichannel',
section: 'Sessions',
enterprise: true,
invalidValue: 0,
modules: ['livechat-enterprise'],
enableQuery: omnichannelEnabledQuery,
});
await settingsRegistry.add('Livechat_auto_close_on_hold_chats_custom_message', '', {
type: 'string',
group: 'Omnichannel',
section: 'Sessions',
enableQuery: [{ _id: 'Livechat_auto_close_on_hold_chats_timeout', value: { $gte: 1 } }, omnichannelEnabledQuery],
enterprise: true,
invalidValue: '',
modules: ['livechat-enterprise'],
});
await settingsRegistry.add('Livechat_allow_manual_on_hold', false, {
type: 'boolean',
group: 'Omnichannel',
section: 'Sessions',
enterprise: true,
invalidValue: false,
public: true,
modules: ['livechat-enterprise'],
enableQuery: omnichannelEnabledQuery,
});
await settingsRegistry.add('Livechat_allow_manual_on_hold_upon_agent_engagement_only', true, {
type: 'boolean',
group: 'Omnichannel',
section: 'Sessions',
enterprise: true,
invalidValue: false,
public: true,
modules: ['livechat-enterprise'],
enableQuery: { _id: 'Livechat_allow_manual_on_hold', value: true },
});
await settingsRegistry.add('Livechat_auto_transfer_chat_timeout', 0, {
type: 'int',
group: 'Omnichannel',
section: 'Sessions',
i18nDescription: 'Livechat_auto_transfer_chat_timeout_description',
enterprise: true,
invalidValue: 0,
modules: ['livechat-enterprise'],
enableQuery: omnichannelEnabledQuery,
});
await settingsRegistry.add('Accounts_Default_User_Preferences_omnichannelTranscriptPDF', false, {
type: 'boolean',
public: true,
i18nLabel: 'Omnichannel_transcript_pdf',
});
await settingsRegistry.add('Livechat_hide_system_messages', ['uj', 'ul', 'livechat-close'], {
type: 'multiSelect',
group: 'Omnichannel',
section: 'Livechat',
enterprise: true,
modules: ['livechat-enterprise'],
invalidValue: ['uj', 'ul', 'livechat-close'],
public: true,
values: [
{ key: 'uj', i18nLabel: 'Message_HideType_uj' },
{ key: 'ul', i18nLabel: 'Message_HideType_ul' },
{ key: 'livechat-close', i18nLabel: 'Message_HideType_livechat_closed' },
{ key: 'livechat-started', i18nLabel: 'Message_HideType_livechat_started' },
{ key: 'livechat_transfer_history', i18nLabel: 'Message_HideType_livechat_transfer_history' },
],
});
await Settings.addOptionValueById('Livechat_Routing_Method', {
key: 'Load_Balancing',
i18nLabel: 'Load_Balancing',
});
await Settings.addOptionValueById('Livechat_Routing_Method', {
key: 'Load_Rotation',
i18nLabel: 'Load_Rotation',
});
await settingsRegistry.add('Livechat_hide_expand_chat', false, {
type: 'boolean',
group: 'Omnichannel',
section: 'Livechat',
i18nDescription: 'Livechat_hide_expand_chat_description',
invalidValue: false,
modules: ['livechat-enterprise'],
enterprise: false,
public: false,
enableQuery: omnichannelEnabledQuery,
});
};

View File

@@ -0,0 +1,44 @@
import { Meteor } from 'meteor/meteor';
import { updatePredictedVisitorAbandonment, updateQueueInactivityTimeout } from './Helper';
import { VisitorInactivityMonitor } from './VisitorInactivityMonitor';
import { MultipleBusinessHoursBehavior } from './business-hour/Multiple';
import { logger } from './logger';
import { businessHourManager } from '../../../../server/lib/omnichannel/business-hour';
import { SingleBusinessHourBehavior } from '../../../../server/lib/omnichannel/business-hour/Single';
import { settings } from '../../../../server/settings';
const visitorActivityMonitor = new VisitorInactivityMonitor();
const businessHours = {
Multiple: new MultipleBusinessHoursBehavior(),
Single: new SingleBusinessHourBehavior(),
};
settings.change('Livechat_max_queue_wait_time', async () => {
await updateQueueInactivityTimeout();
});
Meteor.startup(async () => {
settings.watch('Livechat_abandoned_rooms_action', async (value) => {
await updatePredictedVisitorAbandonment();
if (!value || value === 'none') {
return visitorActivityMonitor.stop();
}
await visitorActivityMonitor.start();
});
settings.change('Livechat_visitor_inactivity_timeout', async () => {
await updatePredictedVisitorAbandonment();
});
settings.change<string>('Livechat_business_hour_type', async (value) => {
logger.debug({ msg: 'Changing business hour type', value });
if (!Object.keys(businessHours).includes(value)) {
logger.error({ msg: 'Invalid business hour type', value });
return;
}
businessHourManager.registerBusinessHourBehavior(businessHours[value as keyof typeof businessHours]);
if (settings.get('Livechat_enable_business_hours')) {
await businessHourManager.restartManager();
logger.debug('Business hour manager started');
}
});
});

View File

@@ -0,0 +1,17 @@
import { LivechatUnit } from '@rocket.chat/models';
import { getUnitsFromUser } from '@rocket.chat/omni-core-ee';
import { Meteor } from 'meteor/meteor';
import type { CheckUnitsFromUser } from '../../../../server/api/v1/omnichannel/lib/livechat';
import { checkUnitsFromUser } from '../../../../server/api/v1/omnichannel/lib/livechat';
checkUnitsFromUser.patch(async (_next, { businessUnit, userId }: CheckUnitsFromUser) => {
if (!businessUnit) {
return;
}
const unitsFromUser = await getUnitsFromUser(userId);
const unit = await LivechatUnit.findOneById(businessUnit, { projection: { _id: 1 } }, { unitsFromUser });
if (!unit) {
throw new Meteor.Error('error-unit-not-found', `Error! No Active Business Unit found with id: ${businessUnit}`);
}
});

View File

@@ -0,0 +1,35 @@
import { api, MeteorError } from '@rocket.chat/core-services';
import type { IRole } from '@rocket.chat/core-typings';
import { Roles } from '@rocket.chat/models';
import { isValidRoleScope } from '../../../../lib/roles/isValidRoleScope';
import { notifyOnRoleChanged } from '../../../../server/lib/notifyListener';
type InsertRoleOptions = {
broadcastUpdate?: boolean;
};
export const insertRoleAsync = async (roleData: Omit<IRole, '_id' | '_updatedAt'>, options: InsertRoleOptions = {}): Promise<IRole> => {
const { name, scope, description, mandatory2fa } = roleData;
if (await Roles.findOneByName(name)) {
throw new MeteorError('error-duplicate-role-names-not-allowed', 'Role name already exists');
}
if (!isValidRoleScope(scope)) {
throw new MeteorError('error-invalid-scope', 'Invalid scope');
}
const role = await Roles.createWithRandomId(name, scope, description, false, mandatory2fa);
void notifyOnRoleChanged(role);
if (options.broadcastUpdate) {
void api.broadcast('user.roleUpdate', {
type: 'changed',
_id: role._id,
});
}
return role;
};

View File

@@ -0,0 +1,62 @@
import { api, MeteorError } from '@rocket.chat/core-services';
import type { IRole } from '@rocket.chat/core-typings';
import { Roles } from '@rocket.chat/models';
import { isValidRoleScope } from '../../../../lib/roles/isValidRoleScope';
import { notifyOnRoleChangedById } from '../../../../server/lib/notifyListener';
type UpdateRoleOptions = {
broadcastUpdate?: boolean;
};
export const updateRole = async (
roleId: IRole['_id'],
roleData: Omit<IRole, '_id' | '_updatedAt'>,
options: UpdateRoleOptions = {},
): Promise<IRole> => {
const role = await Roles.findOneById(roleId);
if (!role) {
throw new MeteorError('error-invalid-roleId', 'This role does not exist');
}
if (role.protected && ((roleData.name && roleData.name !== role.name) || (roleData.scope && roleData.scope !== role.scope))) {
throw new MeteorError('error-role-protected', 'Role is protected');
}
if (roleData.name) {
const otherRole = await Roles.findOneByName(roleData.name, { projection: { _id: 1 } });
if (otherRole && otherRole._id !== role._id) {
throw new MeteorError('error-duplicate-role-names-not-allowed', 'Role name already exists');
}
} else {
roleData.name = role.name;
}
if (roleData.scope) {
if (!isValidRoleScope(roleData.scope)) {
throw new MeteorError('error-invalid-scope', 'Invalid scope');
}
} else {
roleData.scope = role.scope;
}
await Roles.updateById(roleId, roleData.name, roleData.scope, roleData.description, roleData.mandatory2fa);
void notifyOnRoleChangedById(roleId);
if (options.broadcastUpdate) {
void api.broadcast('user.roleUpdate', {
type: 'changed',
_id: roleId,
scope: roleData.scope,
});
}
const updatedRole = await Roles.findOneById(roleId);
if (!updatedRole) {
throw new MeteorError('error-role-not-found', 'Role not found');
}
return updatedRole;
};

View File

@@ -0,0 +1,90 @@
import { api } from '@rocket.chat/core-services';
import type { IUser, IRole, AtLeast } from '@rocket.chat/core-typings';
import { License } from '@rocket.chat/license';
import { Users } from '@rocket.chat/models';
import { addUserRolesAsync } from '../../../server/lib/roles/addUserRoles';
import { removeUserFromRolesAsync } from '../../../server/lib/roles/removeUserFromRoles';
import { settings } from '../../../server/settings';
type setUserRolesOptions = {
// If specified, the function will not add nor remove any role that is not on this list.
allowedRoles?: Array<IRole['_id']>;
// If set to true, roles will only be added, not removed
skipRemovingRoles?: boolean;
// the scope value (eg: room id) to assign the roles to
scope?: string;
};
function filterRoleList(
roleList: Array<IRole['_id']>,
rolesToFilterOut: Array<IRole['_id']>,
rolesToFilterIn?: Array<IRole['_id']>,
): Array<IRole['_id']> {
const filteredRoles = roleList.filter((roleId) => !rolesToFilterOut.includes(roleId));
if (!rolesToFilterIn) {
return filteredRoles;
}
return filteredRoles.filter((roleId) => rolesToFilterIn.includes(roleId));
}
function broadcastRoleChange(
type: 'changed' | 'added' | 'removed',
roleList: Array<IRole['_id']>,
user: AtLeast<IUser, '_id' | 'username'>,
): void {
if (!settings.get('UI_DisplayRoles')) {
return;
}
const { _id, username } = user;
for (const roleId of roleList) {
void api.broadcast('user.roleUpdate', {
type,
_id: roleId,
u: {
_id,
username,
},
});
}
}
export async function syncUserRoles(
uid: IUser['_id'],
newRoleList: Array<IRole['_id']>,
{ allowedRoles, skipRemovingRoles, scope }: setUserRolesOptions,
): Promise<void> {
const user = await Users.findOneById<Pick<IUser, '_id' | 'username' | 'roles'>>(uid, { projection: { username: 1, roles: 1 } });
if (!user) {
throw new Error('error-user-not-found');
}
const existingRoles = user.roles;
const rolesToAdd = filterRoleList(newRoleList, existingRoles, allowedRoles);
const rolesToRemove = filterRoleList(existingRoles, newRoleList, allowedRoles);
if (!rolesToAdd.length && !rolesToRemove.length) {
return;
}
const wasGuest = existingRoles.length === 1 && existingRoles[0] === 'guest';
if (wasGuest && (await License.shouldPreventAction('activeUsers'))) {
throw new Error('error-license-user-limit-reached');
}
if (rolesToAdd.length && (await addUserRolesAsync(uid, rolesToAdd, scope))) {
broadcastRoleChange('added', rolesToAdd, user);
}
if (skipRemovingRoles || !rolesToRemove.length) {
return;
}
if (await removeUserFromRolesAsync(uid, rolesToRemove, scope)) {
broadcastRoleChange('removed', rolesToRemove, user);
}
}