snapshot of https://github.com/RocketChat/Rocket.Chat.git (develop, 2026-09-16; blobs > 131072B omitted)
Some checks failed
Create issues from TODOs / todos (push) Failing after 3s
Close inactive issues / close-issues (push) Has been cancelled
Deploy GitHub Pages / deploy-preview (push) Has been cancelled
CI / ⚙️ Variables Setup (push) Has been cancelled
CI / ⚙️ Test Guard (push) Has been cancelled
CI / 🚀 Notify external services - draft (push) Has been cancelled
CI / 📦 Build Packages (push) Has been cancelled
CI / 📦 Meteor Build (coverage) (push) Has been cancelled
CI / 📦 Meteor Build (production) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], fips) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Publish Docker Images (ghcr.io) (push) Has been cancelled
CI / 📦 Track Image Sizes (push) Has been cancelled
CI / 🔎 Code Check - Action Lint (push) Has been cancelled
CI / 🔎 Code Check (push) Has been cancelled
CI / 🔨 Test Storybook (push) Has been cancelled
CI / 🔨 Test Unit (push) Has been cancelled
CI / 🔨 Test API (CE) (push) Has been cancelled
CI / 🔨 Test API Livechat (CE) (push) Has been cancelled
CI / 🔨 Test UI (CE) (push) Has been cancelled
CI / 🔨 Test API (EE) (push) Has been cancelled
CI / 🔨 Test API Livechat (EE) (push) Has been cancelled
CI / 🔨 Test API Apps (deno-runtime - EE) (push) Has been cancelled
CI / 🔨 Test UI (EE) (push) Has been cancelled
CI / 🔨 Test API (FIPS) (push) Has been cancelled
CI / 🔨 Test API Livechat (FIPS) (push) Has been cancelled
CI / 🔨 Test UI (FIPS) (push) Has been cancelled
CI / 🔨 Test Federation Matrix (push) Has been cancelled
CI / 📊 Report Coverage (push) Has been cancelled
CI / ✅ Tests Done (push) Has been cancelled
CI / 🚀 Publish build assets (push) Has been cancelled
CI / 🚀 Publish Docker Images (DockerHub) (push) Has been cancelled
CI / 🚀 Notify external services (push) Has been cancelled
CI / Update Version Durability (push) Has been cancelled
Release / Release (push) Has been cancelled
Auto-close duplicate issues / auto-close-duplicates (push) Failing after 1s
Code scanning - action / CodeQL-Build (push) Failing after 1s

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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