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