snapshot of https://github.com/RocketChat/Rocket.Chat.git (develop, 2026-09-16; blobs > 131072B omitted)
Some checks failed
Create issues from TODOs / todos (push) Failing after 3s
Close inactive issues / close-issues (push) Has been cancelled
Deploy GitHub Pages / deploy-preview (push) Has been cancelled
CI / ⚙️ Variables Setup (push) Has been cancelled
CI / ⚙️ Test Guard (push) Has been cancelled
CI / 🚀 Notify external services - draft (push) Has been cancelled
CI / 📦 Build Packages (push) Has been cancelled
CI / 📦 Meteor Build (coverage) (push) Has been cancelled
CI / 📦 Meteor Build (production) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], fips) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Publish Docker Images (ghcr.io) (push) Has been cancelled
CI / 📦 Track Image Sizes (push) Has been cancelled
CI / 🔎 Code Check - Action Lint (push) Has been cancelled
CI / 🔎 Code Check (push) Has been cancelled
CI / 🔨 Test Storybook (push) Has been cancelled
CI / 🔨 Test Unit (push) Has been cancelled
CI / 🔨 Test API (CE) (push) Has been cancelled
CI / 🔨 Test API Livechat (CE) (push) Has been cancelled
CI / 🔨 Test UI (CE) (push) Has been cancelled
CI / 🔨 Test API (EE) (push) Has been cancelled
CI / 🔨 Test API Livechat (EE) (push) Has been cancelled
CI / 🔨 Test API Apps (deno-runtime - EE) (push) Has been cancelled
CI / 🔨 Test UI (EE) (push) Has been cancelled
CI / 🔨 Test API (FIPS) (push) Has been cancelled
CI / 🔨 Test API Livechat (FIPS) (push) Has been cancelled
CI / 🔨 Test UI (FIPS) (push) Has been cancelled
CI / 🔨 Test Federation Matrix (push) Has been cancelled
CI / 📊 Report Coverage (push) Has been cancelled
CI / ✅ Tests Done (push) Has been cancelled
CI / 🚀 Publish build assets (push) Has been cancelled
CI / 🚀 Publish Docker Images (DockerHub) (push) Has been cancelled
CI / 🚀 Notify external services (push) Has been cancelled
CI / Update Version Durability (push) Has been cancelled
Release / Release (push) Has been cancelled
Auto-close duplicate issues / auto-close-duplicates (push) Failing after 1s
Code scanning - action / CodeQL-Build (push) Failing after 1s
Some checks failed
Create issues from TODOs / todos (push) Failing after 3s
Close inactive issues / close-issues (push) Has been cancelled
Deploy GitHub Pages / deploy-preview (push) Has been cancelled
CI / ⚙️ Variables Setup (push) Has been cancelled
CI / ⚙️ Test Guard (push) Has been cancelled
CI / 🚀 Notify external services - draft (push) Has been cancelled
CI / 📦 Build Packages (push) Has been cancelled
CI / 📦 Meteor Build (coverage) (push) Has been cancelled
CI / 📦 Meteor Build (production) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], fips) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Publish Docker Images (ghcr.io) (push) Has been cancelled
CI / 📦 Track Image Sizes (push) Has been cancelled
CI / 🔎 Code Check - Action Lint (push) Has been cancelled
CI / 🔎 Code Check (push) Has been cancelled
CI / 🔨 Test Storybook (push) Has been cancelled
CI / 🔨 Test Unit (push) Has been cancelled
CI / 🔨 Test API (CE) (push) Has been cancelled
CI / 🔨 Test API Livechat (CE) (push) Has been cancelled
CI / 🔨 Test UI (CE) (push) Has been cancelled
CI / 🔨 Test API (EE) (push) Has been cancelled
CI / 🔨 Test API Livechat (EE) (push) Has been cancelled
CI / 🔨 Test API Apps (deno-runtime - EE) (push) Has been cancelled
CI / 🔨 Test UI (EE) (push) Has been cancelled
CI / 🔨 Test API (FIPS) (push) Has been cancelled
CI / 🔨 Test API Livechat (FIPS) (push) Has been cancelled
CI / 🔨 Test UI (FIPS) (push) Has been cancelled
CI / 🔨 Test Federation Matrix (push) Has been cancelled
CI / 📊 Report Coverage (push) Has been cancelled
CI / ✅ Tests Done (push) Has been cancelled
CI / 🚀 Publish build assets (push) Has been cancelled
CI / 🚀 Publish Docker Images (DockerHub) (push) Has been cancelled
CI / 🚀 Notify external services (push) Has been cancelled
CI / Update Version Durability (push) Has been cancelled
Release / Release (push) Has been cancelled
Auto-close duplicate issues / auto-close-duplicates (push) Failing after 1s
Code scanning - action / CodeQL-Build (push) Failing after 1s
This commit is contained in:
@@ -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();
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
49
apps/meteor/ee/server/lib/omnichannel/Department.ts
Normal file
49
apps/meteor/ee/server/lib/omnichannel/Department.ts
Normal 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 };
|
||||
};
|
||||
376
apps/meteor/ee/server/lib/omnichannel/Helper.ts
Normal file
376
apps/meteor/ee/server/lib/omnichannel/Helper.ts
Normal 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);
|
||||
};
|
||||
186
apps/meteor/ee/server/lib/omnichannel/LivechatEnterprise.ts
Normal file
186
apps/meteor/ee/server/lib/omnichannel/LivechatEnterprise.ts
Normal 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);
|
||||
},
|
||||
};
|
||||
134
apps/meteor/ee/server/lib/omnichannel/QueueInactivityMonitor.ts
Normal file
134
apps/meteor/ee/server/lib/omnichannel/QueueInactivityMonitor.ts
Normal 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();
|
||||
});
|
||||
69
apps/meteor/ee/server/lib/omnichannel/SlaHelper.ts
Normal file
69
apps/meteor/ee/server/lib/omnichannel/SlaHelper.ts
Normal 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 }),
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -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,
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
180
apps/meteor/ee/server/lib/omnichannel/business-hour/Custom.ts
Normal file
180
apps/meteor/ee/server/lib/omnichannel/business-hour/Custom.ts
Normal 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());
|
||||
@@ -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();
|
||||
};
|
||||
362
apps/meteor/ee/server/lib/omnichannel/business-hour/Multiple.ts
Normal file
362
apps/meteor/ee/server/lib/omnichannel/business-hour/Multiple.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
import './Custom';
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
23
apps/meteor/ee/server/lib/omnichannel/debounceByParams.ts
Normal file
23
apps/meteor/ee/server/lib/omnichannel/debounceByParams.ts
Normal 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>;
|
||||
}
|
||||
39
apps/meteor/ee/server/lib/omnichannel/index.ts
Normal file
39
apps/meteor/ee/server/lib/omnichannel/index.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
11
apps/meteor/ee/server/lib/omnichannel/logger.ts
Normal file
11
apps/meteor/ee/server/lib/omnichannel/logger.ts
Normal 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');
|
||||
331
apps/meteor/ee/server/lib/omnichannel/outboundcomms/rest.ts
Normal file
331
apps/meteor/ee/server/lib/omnichannel/outboundcomms/rest.ts
Normal 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);
|
||||
36
apps/meteor/ee/server/lib/omnichannel/permissions.ts
Normal file
36
apps/meteor/ee/server/lib/omnichannel/permissions.ts
Normal 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)));
|
||||
};
|
||||
39
apps/meteor/ee/server/lib/omnichannel/priorities.ts
Normal file
39
apps/meteor/ee/server/lib/omnichannel/priorities.ts
Normal 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);
|
||||
}
|
||||
};
|
||||
@@ -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);
|
||||
};
|
||||
52
apps/meteor/ee/server/lib/omnichannel/restrictQuery.ts
Normal file
52
apps/meteor/ee/server/lib/omnichannel/restrictQuery.ts
Normal 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;
|
||||
};
|
||||
@@ -0,0 +1,9 @@
|
||||
export interface IRoutingManagerConfig {
|
||||
previewRoom: boolean;
|
||||
showConnecting: boolean;
|
||||
showQueue: boolean;
|
||||
showQueueLink: boolean;
|
||||
returnQueue: boolean;
|
||||
enableTriggerAction: boolean;
|
||||
autoAssignAgent: boolean;
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
345
apps/meteor/ee/server/lib/omnichannel/settings.ts
Normal file
345
apps/meteor/ee/server/lib/omnichannel/settings.ts
Normal 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,
|
||||
});
|
||||
};
|
||||
44
apps/meteor/ee/server/lib/omnichannel/startup.ts
Normal file
44
apps/meteor/ee/server/lib/omnichannel/startup.ts
Normal 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');
|
||||
}
|
||||
});
|
||||
});
|
||||
17
apps/meteor/ee/server/lib/omnichannel/unit.ts
Normal file
17
apps/meteor/ee/server/lib/omnichannel/unit.ts
Normal 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}`);
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user