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,17 @@
import { OmnichannelAnalytics } from '@rocket.chat/core-services';
import mem from 'mem';
export const getAgentOverviewDataCached = mem(OmnichannelAnalytics.getAgentOverviewData, {
maxAge: process.env.TEST_MODE === 'true' || process.env.TEST_MODE === 'api' ? 1 : 60000,
cacheKey: JSON.stringify,
});
// Agent overview data on realtime is cached for 5 seconds
// while the data on the overview page is cached for 1 minute
export const getAnalyticsOverviewDataCached = mem(OmnichannelAnalytics.getAnalyticsOverviewData, {
maxAge: 60000,
cacheKey: JSON.stringify,
});
export const getAnalyticsOverviewDataCachedForRealtime = mem(OmnichannelAnalytics.getAnalyticsOverviewData, {
maxAge: process.env.TEST_MODE === 'true' || process.env.TEST_MODE === 'api' ? 1 : 5000,
cacheKey: JSON.stringify,
});

View File

@@ -0,0 +1,4 @@
export const addServerUrlToIndex = (file: string): string => {
const rootUrl = (global as any).__meteor_runtime_config__.ROOT_URL.replace(/\/$/, '');
return file.replace('<body>', `<body><script> SERVER_URL = '${rootUrl}'; </script>`);
};

View File

@@ -0,0 +1,964 @@
import { Apps, AppEvents } from '@rocket.chat/apps';
import { LivechatTransferEventType } from '@rocket.chat/apps-engine/definition/livechat';
import { api, Message, Omnichannel } from '@rocket.chat/core-services';
import type {
ILivechatVisitor,
IOmnichannelRoom,
SelectedAgent,
ISubscription,
ILivechatInquiryRecord,
IUser,
TransferData,
ILivechatDepartmentAgents,
TransferByData,
ILivechatAgent,
ILivechatDepartment,
IOmnichannelRoomInfo,
IOmnichannelInquiryExtraData,
IOmnichannelRoomExtraData,
ILivechatContact,
} from '@rocket.chat/core-typings';
import { LivechatInquiryStatus, OmnichannelSourceType, DEFAULT_SLA_CONFIG, UserStatus } from '@rocket.chat/core-typings';
import { LivechatPriorityWeight } from '@rocket.chat/core-typings/src/ILivechatPriority';
import { Logger } from '@rocket.chat/logger';
import type { InsertionModel } from '@rocket.chat/model-typings';
import {
LivechatDepartmentAgents,
LivechatInquiry,
LivechatRooms,
LivechatDepartment,
Subscriptions,
Users,
LivechatContacts,
} from '@rocket.chat/models';
import { removeEmpty, validateEmail as validatorFunc } from '@rocket.chat/tools';
import { Match, check } from 'meteor/check';
import { Meteor } from 'meteor/meteor';
import type { ClientSession } from 'mongodb';
import { ObjectId } from 'mongodb';
import { queueInquiry, saveQueueInquiry } from './QueueManager';
import { RoutingManager } from './RoutingManager';
import { isVerifiedChannelInSource } from './contacts/isVerifiedChannelInSource';
import { migrateVisitorIfMissingContact } from './contacts/migrateVisitorIfMissingContact';
import { afterRoomQueued, beforeNewRoom } from './hooks';
import { checkOnlineAgents, getOnlineAgents } from './service-status';
import { saveTransferHistory } from './transfer';
import { sendNotification } from '../../hooks/messages/sendNotificationsOnMessage';
import { settings } from '../../settings';
import { hasRoleAsync } from '../authorization/hasRole';
import { callbacks } from '../callbacks';
import { i18n } from '../i18n';
import {
notifyOnLivechatDepartmentAgentChanged,
notifyOnLivechatDepartmentAgentChangedByAgentsAndDepartmentId,
notifyOnSubscriptionChangedById,
notifyOnSubscriptionChangedByRoomId,
notifyOnSubscriptionChanged,
notifyOnRoomChangedById,
notifyOnLivechatInquiryChangedByRoom,
} from '../notifyListener';
const logger = new Logger('LivechatHelper');
export const allowAgentSkipQueue = (agent: SelectedAgent) => {
check(
agent,
Match.ObjectIncluding({
agentId: String,
}),
);
return hasRoleAsync(agent.agentId, 'bot');
};
export const prepareLivechatRoom = async (
rid: string,
guest: ILivechatVisitor,
roomInfo: IOmnichannelRoomInfo = { source: { type: OmnichannelSourceType.OTHER } },
extraData?: IOmnichannelRoomExtraData,
): Promise<InsertionModel<IOmnichannelRoom>> => {
check(rid, String);
check(
guest,
Match.ObjectIncluding({
_id: String,
username: String,
status: Match.Maybe(String),
department: Match.Maybe(String),
}),
);
const extraRoomInfo = await beforeNewRoom(roomInfo, extraData);
const { _id, username, token, department: departmentId, status = 'online' } = guest;
const newRoomAt = new Date();
const source = extraRoomInfo.source || roomInfo.source;
if (settings.get<string>('Livechat_Require_Contact_Verification') === 'always') {
await LivechatContacts.setChannelVerifiedStatus({ visitorId: _id, source }, false);
}
const contactId = await migrateVisitorIfMissingContact(_id, source);
const contact =
contactId &&
(await LivechatContacts.findOneEnabledById<Pick<ILivechatContact, '_id' | 'name' | 'channels' | 'activity'>>(contactId, {
projection: { name: 1, channels: 1, activity: 1 },
}));
if (!contact) {
throw new Error('error-invalid-contact');
}
const verified = Boolean(contact.channels.some((channel) => isVerifiedChannelInSource(channel, _id, source)));
const activity = guest.activity || contact.activity;
logger.debug({
msg: 'Creating livechat room for visitor',
visitor: { _id, username, departmentId, status, activity },
});
// TODO: Solve `u` missing issue
return {
_id: rid,
msgs: 0,
usersCount: 1,
lm: newRoomAt,
fname: contact.name,
t: 'l' as const,
ts: newRoomAt,
departmentId,
v: {
_id,
username,
token,
status,
...(activity?.length && { activity }),
},
contactId,
cl: false,
open: true,
waitingResponse: true,
verified,
// this should be overridden by extraRoomInfo when provided
// in case it's not provided, we'll use this "default" type
source: {
type: OmnichannelSourceType.OTHER,
alias: 'unknown',
},
queuedAt: newRoomAt,
priorityWeight: LivechatPriorityWeight.NOT_SPECIFIED,
estimatedWaitingTimeQueue: DEFAULT_SLA_CONFIG.ESTIMATED_WAITING_TIME_QUEUE,
...extraRoomInfo,
// marker field for unique index - only new rooms have this field (see #39087)
// allows index creation to succeed even if old duplicates exist
_enforceSingleRoom: true,
} as InsertionModel<IOmnichannelRoom>;
};
export const createLivechatRoom = async (room: InsertionModel<IOmnichannelRoom>, session: ClientSession) => {
const result = await LivechatRooms.findOneAndUpdate(
removeEmpty(room),
{
$set: {},
},
{
upsert: true,
returnDocument: 'after',
session,
},
);
if (!result) {
throw new Error('Room not created');
}
return result;
};
export const createLivechatInquiry = async ({
rid,
name,
guest,
message,
initialStatus,
extraData,
session,
}: {
rid: string;
name?: string;
guest?: Pick<ILivechatVisitor, '_id' | 'username' | 'status' | 'department' | 'name' | 'token' | 'activity'>;
message?: string;
initialStatus?: LivechatInquiryStatus;
extraData?: IOmnichannelInquiryExtraData;
session?: ClientSession;
}) => {
check(rid, String);
check(name, String);
check(
guest,
Match.ObjectIncluding({
_id: String,
username: String,
status: Match.Maybe(String),
department: Match.Maybe(String),
activity: Match.Maybe([String]),
}),
);
const extraInquiryInfo = await callbacks.run('livechat.beforeInquiry', extraData);
const { _id, username, token, department, status = UserStatus.ONLINE } = guest;
const inquirySource = extraData?.source || { type: OmnichannelSourceType.OTHER };
const activity =
guest.activity ||
(await LivechatContacts.findOneByVisitor({ visitorId: guest._id, source: inquirySource }, { projection: { activity: 1 } }))?.activity;
const ts = new Date();
logger.debug({
msg: 'Creating livechat inquiry for visitor',
visitor: { _id, username, department, status, activity },
});
const result = await LivechatInquiry.findOneAndUpdate(
removeEmpty({
rid,
name,
ts,
department,
message: message ?? '',
status: initialStatus || LivechatInquiryStatus.READY,
v: {
_id,
username,
token,
status,
...(activity?.length && { activity }),
},
t: 'l',
priorityWeight: LivechatPriorityWeight.NOT_SPECIFIED,
estimatedWaitingTimeQueue: DEFAULT_SLA_CONFIG.ESTIMATED_WAITING_TIME_QUEUE,
...extraInquiryInfo,
}),
{
$set: {
_id: new ObjectId().toHexString(),
},
},
{
upsert: true,
returnDocument: 'after',
session,
},
);
logger.debug({
msg: 'Inquiry created for visitor',
inquiryId: result,
visitorId: _id,
});
if (!result) {
throw new Error('Inquiry not created');
}
return result;
};
export const createLivechatSubscription = async (
rid: string,
name: string,
guest: Pick<ILivechatVisitor, '_id' | 'username' | 'status' | 'name' | 'token' | 'phone'>,
agent: SelectedAgent,
department?: string,
) => {
check(rid, String);
check(name, String);
check(
guest,
Match.ObjectIncluding({
_id: String,
username: String,
status: Match.Maybe(String),
}),
);
check(
agent,
Match.ObjectIncluding({
agentId: String,
username: String,
}),
);
const existingSubscription = await Subscriptions.findOneByRoomIdAndUserId(rid, agent.agentId);
if (existingSubscription?._id) {
return existingSubscription;
}
const { _id, username, token, status = UserStatus.ONLINE } = guest;
const subscriptionData: InsertionModel<ISubscription> = {
rid,
name,
fname: name,
alert: true,
open: true,
unread: 1,
userMentions: 1,
groupMentions: 0,
u: {
_id: agent.agentId,
username: agent.username,
},
t: 'l',
desktopNotifications: 'all',
mobilePushNotifications: 'all',
emailNotifications: 'all',
v: {
_id,
username,
token,
status,
},
ts: new Date(),
...(department && { department }),
} as InsertionModel<ISubscription>;
const response = await Subscriptions.insertOne(subscriptionData);
if (response?.insertedId) {
void notifyOnSubscriptionChangedById(response.insertedId, 'inserted');
}
return response;
};
export const removeAgentFromSubscription = async (rid: string, { _id, username }: Pick<IUser, '_id' | 'username'>) => {
const room = await LivechatRooms.findOneById(rid);
const user = await Users.findOneById(_id);
if (!room || !user) {
return;
}
const deletedSubscription = await Subscriptions.removeByRoomIdAndUserId(rid, _id);
if (deletedSubscription) {
void notifyOnSubscriptionChanged(deletedSubscription, 'removed');
}
await Message.saveSystemMessage('ul', rid, username || '', { _id: user._id, username: user.username, name: user.name });
setImmediate(() => {
void Apps.self?.triggerEvent(AppEvents.IPostLivechatAgentUnassigned, { room, user });
});
};
export const parseAgentCustomFields = (customFields?: Record<string, any>) => {
if (!customFields) {
return;
}
const externalCustomFields = () => {
const accountCustomFields = settings.get<string>('Accounts_CustomFields');
if (!accountCustomFields || accountCustomFields.trim() === '') {
return [];
}
try {
const parseCustomFields = JSON.parse(accountCustomFields);
return Object.keys(parseCustomFields).filter((customFieldKey) => parseCustomFields[customFieldKey].sendToIntegrations === true);
} catch (error) {
logger.error({ msg: 'Error parsing agent custom fields', err: error });
return [];
}
};
const externalCF = externalCustomFields();
return Object.keys(customFields).reduce(
(newObj, key) => (externalCF.includes(key) ? { ...newObj, [key]: customFields[key] } : newObj),
{},
);
};
export const normalizeAgent = async (agentId?: string) => {
if (!agentId) {
return;
}
if (!settings.get('Livechat_show_agent_info')) {
return { hiddenInfo: true };
}
const agent = await Users.getAgentInfo(agentId, settings.get('Livechat_show_agent_email'));
if (!agent) {
return;
}
const { customFields: agentCustomFields, ...extraData } = agent;
const customFields = parseAgentCustomFields(agentCustomFields);
return Object.assign(extraData, { ...(customFields && { customFields }) }) as ILivechatAgent;
};
export const dispatchAgentDelegated = async (rid: string, agentId?: string) => {
const agent = await normalizeAgent(agentId);
void api.broadcast('omnichannel.room', rid, {
type: 'agentData',
data: agent,
});
};
/**
* @deprecated
*/
export const dispatchInquiryQueued = async (inquiry: ILivechatInquiryRecord, agent?: SelectedAgent | null) => {
if (!inquiry?._id) {
return;
}
logger.debug({
msg: 'Notifying agents of queued inquiry',
inquiryId: inquiry._id,
agentId: agent?.agentId,
});
const { department, rid, v } = inquiry;
const room = await LivechatRooms.findOneById(rid);
if (!room) {
return;
}
void afterRoomQueued(room);
if (RoutingManager.getConfig()?.autoAssignAgent) {
return;
}
if (agent && (await allowAgentSkipQueue(agent))) {
return;
}
await saveQueueInquiry(inquiry);
// Alert only the online agents of the queued request
const onlineAgents = await getOnlineAgents(department, agent);
if (!onlineAgents) {
logger.debug({ msg: 'Cannot notify agents of queued inquiry. No online agents found' });
return;
}
const notificationUserName = v && (v.name || v.username);
for await (const agent of onlineAgents) {
const { _id, active, emails, language, status, statusConnection, username } = agent;
await sendNotification({
// fake a subscription in order to make use of the function defined above
subscription: {
rid,
u: {
_id,
},
receiver: [
{
active,
emails,
language,
status,
statusConnection,
username,
},
],
name: '',
},
sender: v,
hasMentionToAll: true, // consider all agents to be in the room
hasReplyToThread: false,
disableAllMessageNotifications: false,
hasMentionToHere: false,
message: { _id: '', u: v, msg: '' },
// we should use server's language for this type of messages instead of user's
notificationMessage: i18n.t('User_started_a_new_conversation', { username: notificationUserName, lng: language }),
room: Object.assign(room, { name: i18n.t('New_chat_in_queue', { lng: language }) }),
mentionIds: [],
});
}
};
export const forwardRoomToAgent = async (room: IOmnichannelRoom, transferData: TransferData) => {
if (!room?.open) {
return false;
}
logger.debug({
msg: 'Forwarding room to agent',
roomId: room._id,
userId: transferData.userId,
});
const { userId: agentId, clientAction } = transferData;
if (!agentId) {
throw new Error('error-invalid-agent');
}
const user = await Users.findOneOnlineAgentById(
agentId,
settings.get<boolean>('Livechat_enabled_when_agent_idle'),
settings.get<boolean>('Livechat_accept_chats_with_no_agents'),
{},
);
if (!user) {
logger.debug({
msg: 'Agent is offline. Cannot forward',
agentId,
});
throw new Error('error-user-is-offline');
}
const { _id: rid, servedBy: oldServedBy } = room;
const inquiry = await LivechatInquiry.findOneByRoomId(rid, {});
if (!inquiry) {
logger.debug({
msg: 'No inquiries found for room. Cannot forward',
roomId: room._id,
});
throw new Error('error-invalid-inquiry');
}
if (oldServedBy && agentId === oldServedBy._id) {
throw new Error('error-selected-agent-room-agent-are-same');
}
const { username } = user;
const agent = { agentId, username };
// Remove department from inquiry to make sure the routing algorithm treat this as forwarding to agent and not as forwarding to department
delete inquiry.department;
// There are some Enterprise features that may interrupt the forwarding process
// Due to that we need to check whether the agent has been changed or not
logger.debug({
msg: 'Forwarding inquiry to agent',
inquiryId: inquiry._id,
agentId: agent.agentId,
});
const roomTaken = await RoutingManager.takeInquiry(
inquiry,
agent,
{
...(clientAction && { clientAction }),
},
room,
);
if (!roomTaken) {
logger.debug({
msg: 'Cannot forward inquiry',
inquiryId: inquiry._id,
});
return false;
}
await saveTransferHistory(room, transferData);
const { servedBy } = roomTaken;
if (servedBy) {
if (oldServedBy && servedBy._id !== oldServedBy._id) {
await RoutingManager.removeAllRoomSubscriptions(room, servedBy);
}
setImmediate(() => {
void Apps.self?.triggerEvent(AppEvents.IPostLivechatRoomTransferred, {
type: LivechatTransferEventType.AGENT,
room: rid,
from: oldServedBy?._id,
to: servedBy._id,
});
});
}
logger.debug({
msg: 'Inquiry taken by agent after forwarding',
inquiryId: inquiry._id,
agentId: agent.agentId,
});
await callbacks.run('livechat.afterForwardChatToAgent', { rid, servedBy, oldServedBy });
return true;
};
export const updateChatDepartment = async ({
rid,
newDepartmentId,
oldDepartmentId,
}: {
rid: string;
newDepartmentId: string;
oldDepartmentId?: string;
}) => {
const responses = await Promise.all([
LivechatRooms.changeDepartmentIdByRoomId(rid, newDepartmentId),
LivechatInquiry.changeDepartmentIdByRoomId(rid, newDepartmentId),
Subscriptions.changeDepartmentByRoomId(rid, newDepartmentId),
]);
if (responses[0].modifiedCount) {
void notifyOnRoomChangedById(rid);
}
if (responses[1].modifiedCount) {
void notifyOnLivechatInquiryChangedByRoom(rid);
}
if (responses[2].modifiedCount) {
void notifyOnSubscriptionChangedByRoomId(rid);
}
setImmediate(() => {
void Apps.self?.triggerEvent(AppEvents.IPostLivechatRoomTransferred, {
type: LivechatTransferEventType.DEPARTMENT,
room: rid,
from: oldDepartmentId,
to: newDepartmentId,
});
});
return callbacks.run('livechat.afterForwardChatToDepartment', {
rid,
newDepartmentId,
oldDepartmentId,
});
};
export const forwardRoomToDepartment = async (room: IOmnichannelRoom, guest: ILivechatVisitor, transferData: TransferData) => {
if (!room?.open) {
return false;
}
logger.debug({
msg: 'Attempting to forward room to department',
roomId: room._id,
departmentId: transferData.departmentId,
});
await callbacks.run('livechat.beforeForwardRoomToDepartment', { room, transferData });
const { _id: rid, servedBy: oldServedBy, departmentId: oldDepartmentId } = room;
let agent = null;
const inquiry = await LivechatInquiry.findOneByRoomId(rid, {});
if (!inquiry) {
logger.debug({
msg: 'Cannot forward room. No inquiries found',
roomId: room._id,
});
throw new Error('error-transferring-inquiry');
}
const { departmentId } = transferData;
if (!departmentId) {
logger.debug({
msg: 'Cannot forward room. No departmentId provided',
roomId: room._id,
});
throw new Error('error-transferring-inquiry-no-department');
}
if (oldDepartmentId === departmentId) {
throw new Error('error-forwarding-chat-same-department');
}
const { userId: agentId, clientAction } = transferData;
if (agentId) {
logger.debug({
msg: 'Forwarding room to department and user',
roomId: room._id,
departmentId,
agentId,
});
const user = await Users.findOneOnlineAgentById(
agentId,
settings.get<boolean>('Livechat_enabled_when_agent_idle'),
settings.get<boolean>('Livechat_accept_chats_with_no_agents'),
{},
);
if (!user) {
throw new Error('error-user-is-offline');
}
const isInDepartment = await LivechatDepartmentAgents.findOneByAgentIdAndDepartmentId(agentId, departmentId, {
projection: { _id: 1 },
});
if (!isInDepartment) {
throw new Error('error-user-not-belong-to-department');
}
const { username } = user;
agent = { agentId, username };
}
const department = await LivechatDepartment.findOneById<
Pick<ILivechatDepartment, 'allowReceiveForwardOffline' | 'fallbackForwardDepartment' | 'name'>
>(departmentId, {
projection: {
allowReceiveForwardOffline: 1,
fallbackForwardDepartment: 1,
name: 1,
},
});
// Cases:
// 1. Routing is manual
// 2. Server is out of macs
// 3. Department allows to forward when offline and department is offline
// 4. Department is online && waiting queue is enabled
const onlineAgents = await checkOnlineAgents(departmentId);
const isWaitingQueueEnabled = settings.get('Livechat_waiting_queue');
if (
!RoutingManager.getConfig()?.autoAssignAgent ||
!(await Omnichannel.isWithinMACLimit(room)) ||
(department?.allowReceiveForwardOffline && !onlineAgents) ||
(isWaitingQueueEnabled && onlineAgents)
) {
logger.debug({
msg: 'Room will be on department queue',
roomId: room._id,
departmentId,
departmentAllowOffline: department?.allowReceiveForwardOffline,
areAgentsOnline: onlineAgents,
isWaitingQueueEnabled,
});
await saveTransferHistory(room, transferData);
return RoutingManager.unassignAgent(inquiry, departmentId, true, agent);
}
// Fake the department to forward the inquiry - Case the forward process does not success
// the inquiry will stay in the same original department
inquiry.department = departmentId;
const roomTaken = await RoutingManager.delegateInquiry(
inquiry,
agent,
{
forwardingToDepartment: { oldDepartmentId },
...(clientAction && { clientAction }),
},
room,
);
if (!roomTaken) {
logger.debug({
msg: 'Cannot forward room. Unable to delegate inquiry',
roomId: room._id,
});
return false;
}
const { servedBy, chatQueued } = roomTaken;
if (!chatQueued && oldServedBy && oldServedBy._id === servedBy?._id) {
if (!department?.fallbackForwardDepartment?.length) {
logger.debug({
msg: 'Cannot forward room. Chat assigned to agent instead',
roomId: room._id,
agentId: servedBy._id,
previousAgentId: oldServedBy._id,
});
throw new Error('error-no-agents-available-for-service-on-department');
}
if (!transferData.originalDepartmentName) {
transferData.originalDepartmentName = department.name;
}
// if a chat has a fallback department, attempt to redirect chat to there [EE]
const transferSuccess = !!(await callbacks.run('livechat:onTransferFailure', room, { guest, transferData, department }));
// On CE theres no callback so it will return the room
if (typeof transferSuccess !== 'boolean' || !transferSuccess) {
logger.debug({
msg: 'Cannot forward room. Unable to delegate inquiry',
roomId: room._id,
});
return false;
}
return true;
}
// Send just 1 message to the room to inform the user that the chat was transferred
if (transferData.usingFallbackDep) {
const { _id, username } = transferData.transferredBy;
await Message.saveSystemMessage(
'livechat_transfer_history_fallback',
room._id,
'',
{ _id, username },
{
...(transferData.transferredBy.userType === 'visitor' && { token: room.v.token }),
transferData: {
...transferData,
prevDepartment: transferData.originalDepartmentName,
},
},
);
}
await saveTransferHistory(room, transferData);
if (oldServedBy) {
// if chat is queued then we don't ignore the new servedBy agent bcs at this
// point the chat is not assigned to him/her and it is still in the queue
await RoutingManager.removeAllRoomSubscriptions(room, !chatQueued ? servedBy : undefined);
}
await updateChatDepartment({ rid, newDepartmentId: departmentId, oldDepartmentId });
if (chatQueued) {
logger.debug({
msg: 'Forwarding successful. Marking inquiry as ready',
inquiryId: inquiry._id,
});
await LivechatInquiry.readyInquiry(inquiry._id);
await LivechatRooms.removeAgentByRoomId(rid);
await dispatchAgentDelegated(rid);
const newInquiry = await LivechatInquiry.findOneById(inquiry._id);
if (!newInquiry) {
logger.debug({
msg: 'Inquiry not found after forwarding',
inquiryId: inquiry._id,
});
throw new Error('error-invalid-inquiry');
}
await queueInquiry(newInquiry);
logger.debug({
msg: 'Inquiry queued successfully after forwarding',
inquiryId: inquiry._id,
});
}
return true;
};
type MakePropertyOptional<T, K extends keyof T> = Omit<T, K> & { [P in K]?: T[P] };
export const normalizeTransferredByData = (
transferredBy: MakePropertyOptional<TransferByData, 'userType'>,
room: IOmnichannelRoom,
): TransferByData => {
if (!transferredBy || !room) {
throw new Error('You must provide "transferredBy" and "room" params to "getTransferredByData"');
}
const { servedBy: { _id: agentId } = {} } = room;
const { _id, username, name, userType: transferType } = transferredBy;
const userType = transferType || (_id === agentId ? 'agent' : 'user');
return {
_id,
username,
...(name && { name }),
userType,
};
};
const parseFromIntOrStr = (value: string | number) => {
if (typeof value === 'number') {
return value;
}
return parseInt(value);
};
export const updateDepartmentAgents = async (
departmentId: string,
agents: {
upsert?: (Pick<ILivechatDepartmentAgents, 'agentId'> & { count?: number; order?: number })[];
remove?: Pick<ILivechatDepartmentAgents, 'agentId'>[];
},
departmentEnabled: boolean,
) => {
check(departmentId, String);
check(agents, {
upsert: Match.Maybe([
Match.ObjectIncluding({
agentId: String,
username: Match.Maybe(String),
count: Match.Maybe(Match.Integer),
order: Match.Maybe(Match.Integer),
}),
]),
remove: Match.Maybe([
Match.ObjectIncluding({
agentId: String,
username: Match.Maybe(String),
count: Match.Maybe(Match.Integer),
order: Match.Maybe(Match.Integer),
}),
]),
});
const { upsert = [], remove = [] } = agents;
const agentsUpdated = [];
const agentsRemoved = remove.map(({ agentId }: { agentId: string }) => agentId);
const agentsAdded = [];
if (agentsRemoved.length > 0) {
const removedIds = await LivechatDepartmentAgents.findByAgentsAndDepartmentId(agentsRemoved, departmentId, {
projection: { agentId: 1 },
}).toArray();
const { deletedCount } = await LivechatDepartmentAgents.removeByIds(removedIds.map(({ _id }) => _id));
if (deletedCount > 0) {
removedIds.forEach(({ _id, agentId }) => {
void notifyOnLivechatDepartmentAgentChanged(
{
_id,
agentId,
departmentId,
},
'removed',
);
});
}
callbacks.runAsync('livechat.removeAgentDepartment', { departmentId, agentsId: agentsRemoved });
}
for await (const agent of upsert) {
const agentFromDb = await Users.findOneById(agent.agentId, { projection: { _id: 1, username: 1 } });
if (!agentFromDb) {
continue;
}
const livechatDepartmentAgent = await LivechatDepartmentAgents.saveAgent({
agentId: agent.agentId,
departmentId,
username: agentFromDb.username || '',
count: agent.count ? parseFromIntOrStr(agent.count) : 0,
order: agent.order ? parseFromIntOrStr(agent.order) : 0,
departmentEnabled,
});
if (livechatDepartmentAgent.upsertedId) {
void notifyOnLivechatDepartmentAgentChanged(
{
_id: livechatDepartmentAgent.upsertedId as any,
agentId: agent.agentId,
departmentId,
},
'inserted',
);
} else {
agentsUpdated.push(agent.agentId);
}
agentsAdded.push(agent.agentId);
}
if (agentsAdded.length > 0) {
callbacks.runAsync('livechat.saveAgentDepartment', {
departmentId,
agentsId: agentsAdded,
});
}
if (agentsUpdated.length > 0) {
void notifyOnLivechatDepartmentAgentChangedByAgentsAndDepartmentId(agentsUpdated, departmentId);
}
if (agentsRemoved.length > 0 || agentsAdded.length > 0) {
const numAgents = await LivechatDepartmentAgents.countByDepartmentId(departmentId);
await LivechatDepartment.updateNumAgentsById(departmentId, numAgents);
}
return true;
};
export const validateEmail = (email: string) => {
if (!validatorFunc(email)) {
throw new Meteor.Error('error-invalid-email', `Invalid email ${email}`, {
function: 'Livechat.validateEmail',
email,
});
}
return true;
};

View File

@@ -0,0 +1,533 @@
import { Apps, AppEvents } from '@rocket.chat/apps';
import { AppsEngineException } from '@rocket.chat/apps-engine/definition/exceptions/AppsEngineException';
import { Message, Omnichannel } from '@rocket.chat/core-services';
import type {
ILivechatDepartment,
IOmnichannelRoomInfo,
IOmnichannelRoomExtraData,
AtLeast,
ILivechatInquiryRecord,
ILivechatVisitor,
IOmnichannelRoom,
SelectedAgent,
} from '@rocket.chat/core-typings';
import { LivechatInquiryStatus } from '@rocket.chat/core-typings';
import { Logger } from '@rocket.chat/logger';
import type { InsertionModel } from '@rocket.chat/model-typings';
import { LivechatContacts, LivechatDepartment, LivechatInquiry, LivechatRooms, Users } from '@rocket.chat/models';
import { Random } from '@rocket.chat/random';
import { Match, check } from 'meteor/check';
import { Meteor } from 'meteor/meteor';
import { createLivechatRoom, createLivechatInquiry, allowAgentSkipQueue, prepareLivechatRoom } from './Helper';
import { RoutingManager } from './RoutingManager';
import { isVerifiedChannelInSource } from './contacts/isVerifiedChannelInSource';
import { checkOnlineForDepartment } from './departmentsLib';
import { afterInquiryQueued, afterRoomQueued, beforeDelegateAgent, beforeRouteChat, onNewRoom } from './hooks';
import { checkOnlineAgents, getOnlineAgents } from './service-status';
import { getInquirySortMechanismSetting } from './settings';
import { getOmniChatSortQuery } from '../../../app/livechat/lib/inquiries';
import { i18n } from '../../../app/utils/lib/i18n';
import { dispatchInquiryPosition } from '../../../ee/server/lib/omnichannel/Helper';
import { client, shouldRetryTransaction } from '../../database/utils';
import { sendNotification } from '../../hooks/messages/sendNotificationsOnMessage';
import { settings } from '../../settings';
import { notifyOnLivechatInquiryChangedById, notifyOnLivechatInquiryChanged } from '../notifyListener';
const logger = new Logger('QueueManager');
export const saveQueueInquiry = async (inquiry: ILivechatInquiryRecord) => {
const queuedInquiry = await LivechatInquiry.queueInquiry(inquiry._id);
if (!queuedInquiry) {
return;
}
// After inquiry queued does not modify the inquiry, its safe to return the return of queueInquiry
await afterInquiryQueued(queuedInquiry);
void notifyOnLivechatInquiryChanged(queuedInquiry, 'updated', {
status: LivechatInquiryStatus.QUEUED,
queuedAt: new Date(),
takenAt: undefined,
});
return queuedInquiry;
};
/**
* @deprecated
*/
export const queueInquiry = async (inquiry: ILivechatInquiryRecord, defaultAgent?: SelectedAgent) => {
// No projection: requeueInquiry forwards the room into the routing pipeline, which expects a full room.
const room = await LivechatRooms.findOneById(inquiry.rid);
if (!room) {
await saveQueueInquiry(inquiry);
return;
}
return QueueManager.requeueInquiry(inquiry, room, defaultAgent);
};
const getDepartment = async (department: string): Promise<string | undefined> => {
if (!department) {
return;
}
if (await checkOnlineForDepartment(department)) {
return department;
}
const departmentDocument = await LivechatDepartment.findOneById<Pick<ILivechatDepartment, '_id' | 'fallbackForwardDepartment'>>(
department,
{
projection: { fallbackForwardDepartment: 1 },
},
);
if (departmentDocument?.fallbackForwardDepartment) {
return getDepartment(departmentDocument.fallbackForwardDepartment);
}
};
export class QueueManager {
static async requeueInquiry(inquiry: ILivechatInquiryRecord, room: IOmnichannelRoom, defaultAgent?: SelectedAgent) {
if (!(await Omnichannel.isWithinMACLimit(room))) {
logger.error({ msg: 'MAC limit reached, not routing inquiry', inquiry });
// We'll queue these inquiries so when new license is applied, they just start rolling again
// Minimizing disruption
await saveQueueInquiry(inquiry);
return;
}
const inquiryAgent = await RoutingManager.delegateAgent(defaultAgent, inquiry);
logger.debug({
msg: 'Delegating inquiry',
inquiryId: inquiry._id,
defaultAgentUsername: defaultAgent?.username,
});
const dbInquiry = await beforeRouteChat(inquiry, inquiryAgent);
if (!dbInquiry) {
throw new Error('inquiry-not-found');
}
if (dbInquiry.status === 'ready') {
logger.debug({
msg: 'Inquiry is ready. Delegating to agent',
inquiryId: inquiry._id,
agentUsername: inquiryAgent?.username,
});
return RoutingManager.delegateInquiry(dbInquiry, inquiryAgent, undefined, room);
}
}
private static fnQueueInquiryStatus: (typeof QueueManager)['getInquiryStatus'] | undefined;
public static patchInquiryStatus(fn: (typeof QueueManager)['getInquiryStatus']) {
this.fnQueueInquiryStatus = fn;
}
static async getInquiryStatus({ room, agent }: { room: IOmnichannelRoom; agent?: SelectedAgent }): Promise<LivechatInquiryStatus> {
if (this.fnQueueInquiryStatus) {
return this.fnQueueInquiryStatus({ room, agent });
}
const needVerification = ['once', 'always'].includes(settings.get<string>('Livechat_Require_Contact_Verification'));
if (needVerification && !(await this.isRoomContactVerified(room))) {
return LivechatInquiryStatus.VERIFYING;
}
if (!(await Omnichannel.isWithinMACLimit(room))) {
return LivechatInquiryStatus.QUEUED;
}
// bots should be able to skip the queue and the routing check
if (agent && (await allowAgentSkipQueue(agent))) {
return LivechatInquiryStatus.READY;
}
if (settings.get('Livechat_waiting_queue')) {
return LivechatInquiryStatus.QUEUED;
}
if (RoutingManager.getConfig()?.autoAssignAgent) {
return LivechatInquiryStatus.READY;
}
if (settings.get('Livechat_Routing_Method') === 'Manual_Selection' && agent) {
return LivechatInquiryStatus.QUEUED;
}
if (!agent) {
return LivechatInquiryStatus.QUEUED;
}
return LivechatInquiryStatus.READY;
}
static async processNewInquiry(inquiry: ILivechatInquiryRecord, room: IOmnichannelRoom, defaultAgent?: SelectedAgent | null) {
if (inquiry.status === LivechatInquiryStatus.VERIFYING) {
logger.debug({
msg: 'Inquiry is waiting for contact verification. Ignoring it',
inquiryId: inquiry._id,
defaultAgentUsername: defaultAgent?.username,
status: inquiry.status,
});
if (defaultAgent) {
await LivechatInquiry.setDefaultAgentById(inquiry._id, defaultAgent);
}
return;
}
if (inquiry.status === LivechatInquiryStatus.READY) {
logger.debug({
msg: 'Inquiry is ready. Delegating',
inquiryId: inquiry._id,
defaultAgentUsername: defaultAgent?.username,
status: inquiry.status,
});
return RoutingManager.delegateInquiry(inquiry, defaultAgent, undefined, room);
}
if (inquiry.status === LivechatInquiryStatus.QUEUED) {
await Promise.all([afterInquiryQueued(inquiry), afterRoomQueued(room)]);
if (defaultAgent) {
logger.debug({ msg: 'Setting default agent for inquiry', inquiryId: inquiry._id, agentUsername: defaultAgent.username });
await LivechatInquiry.setDefaultAgentById(inquiry._id, defaultAgent);
}
return this.dispatchInquiryQueued(inquiry, room, defaultAgent);
}
}
static async verifyInquiry(inquiry: ILivechatInquiryRecord, room: IOmnichannelRoom) {
if (inquiry.status !== LivechatInquiryStatus.VERIFYING) {
return;
}
const { defaultAgent: agent } = inquiry;
const newStatus = await QueueManager.getInquiryStatus({ room, agent });
if (newStatus === inquiry.status) {
throw new Error('error-failed-to-verify-inquiry');
}
const newInquiry = await LivechatInquiry.setStatusById(inquiry._id, newStatus);
await this.processNewInquiry(newInquiry, room, agent);
const newRoom = await LivechatRooms.findOneById<Pick<IOmnichannelRoom, '_id' | 'servedBy' | 'departmentId'>>(room._id, {
projection: { servedBy: 1, departmentId: 1 },
});
if (!newRoom) {
logger.error({ msg: 'Room not found after inquiry verification', roomId: room._id });
throw new Error('room-not-found');
}
await this.dispatchInquiryPosition(inquiry, newRoom);
}
static async isRoomContactVerified(room: IOmnichannelRoom): Promise<boolean> {
if (!room.contactId) {
return false;
}
const contact = await LivechatContacts.findOneEnabledById(room.contactId, { projection: { channels: 1 } });
if (!contact) {
return false;
}
return Boolean(contact.channels.some((channel) => isVerifiedChannelInSource(channel, room.v._id, room.source)));
}
static async startConversation(
rid: string,
insertionRoom: InsertionModel<IOmnichannelRoom>,
guest: ILivechatVisitor,
roomInfo: IOmnichannelRoomInfo,
defaultAgent?: SelectedAgent,
message?: string,
extraData?: IOmnichannelRoomExtraData,
attempts = 3,
): Promise<{ room: IOmnichannelRoom; inquiry: ILivechatInquiryRecord }> {
const session = client.startSession();
try {
session.startTransaction();
const room = await createLivechatRoom(insertionRoom, session);
logger.debug({ msg: 'Room created for visitor', visitorId: guest._id, roomId: room._id });
const inquiry = await createLivechatInquiry({
rid,
name: room.fname,
initialStatus: await this.getInquiryStatus({ room, agent: defaultAgent }),
guest,
message,
extraData: { ...extraData, source: roomInfo.source },
session,
});
await session.commitTransaction();
return { room, inquiry };
} catch (e) {
await session.abortTransaction();
if (shouldRetryTransaction(e)) {
if (attempts > 0) {
logger.debug({ msg: 'Retrying transaction because of transient error', attemptsLeft: attempts });
return this.startConversation(rid, insertionRoom, guest, roomInfo, defaultAgent, message, extraData, attempts - 1);
}
throw new Error('error-failed-to-start-conversation');
}
throw e;
} finally {
await session.endSession();
}
}
static async requestRoom({
guest,
rid = Random.id(),
message,
roomInfo,
agent,
extraData: { customFields, ...extraData } = {},
}: {
guest: ILivechatVisitor;
rid?: string;
message?: string;
roomInfo: IOmnichannelRoomInfo;
agent?: SelectedAgent;
extraData?: IOmnichannelRoomExtraData;
}) {
logger.debug({ msg: 'Requesting room for guest', guestId: guest._id });
check(
guest,
Match.ObjectIncluding({
_id: String,
username: String,
status: Match.Maybe(String),
department: Match.Maybe(String),
name: Match.Maybe(String),
activity: Match.Maybe([String]),
}),
);
const defaultAgent =
(await beforeDelegateAgent(agent, {
department: guest.department,
})) || undefined;
const department = guest.department && (await getDepartment(guest.department));
/**
* we have 4 cases here
* 1. agent and no department
* 2. no agent and no department
* 3. no agent and department
* 4. agent and department informed
*
* in case 1, we check if the agent is online
* in case 2, we check if there is at least one online agent in the whole service
* in case 3, we check if there is at least one online agent in the department
*
* the case 4 is weird, but we are not throwing an error, just because the application works in some mysterious way
* we don't have explicitly defined what to do in this case so we just kept the old behavior
* it seems that agent has priority over department
* but some cases department is handled before agent
*
*/
if (!settings.get('Livechat_accept_chats_with_no_agents')) {
if (agent && !defaultAgent) {
throw new Meteor.Error('no-agent-online', 'Sorry, no online agents');
}
if (!defaultAgent && guest.department && !department) {
throw new Meteor.Error('no-agent-online', 'Sorry, no online agents');
}
if (!agent && !guest.department && !(await checkOnlineAgents())) {
throw new Meteor.Error('no-agent-online', 'Sorry, no online agents');
}
}
const insertionRoom = await prepareLivechatRoom(rid, { ...guest, ...(department && { department }) }, roomInfo, {
...extraData,
...(Boolean(customFields) && { customFields }),
});
try {
await Apps.self?.triggerEvent(AppEvents.IPreLivechatRoomCreatePrevent, insertionRoom);
} catch (error: any) {
if (error.name === AppsEngineException.name) {
throw new Meteor.Error('error-app-prevented', error.message);
}
throw error;
}
// Transactional start of the conversation. This should prevent rooms from being created without inquiries and viceversa.
// All the actions that happened inside createLivechatRoom are now outside this transaction
const { room, inquiry } = await this.startConversation(rid, insertionRoom, guest, roomInfo, defaultAgent, message, extraData);
await onNewRoom(room);
await Message.saveSystemMessageAndNotifyUser(
'livechat-started',
rid,
'',
{ _id: guest._id, username: guest.username },
{ groupable: false, token: guest.token },
);
void Apps.self?.triggerEvent(AppEvents.IPostLivechatRoomStarted, room);
await this.processNewInquiry(inquiry, room, defaultAgent);
const newRoom = await LivechatRooms.findOneById(rid);
if (!newRoom) {
logger.error({ msg: 'Room not found', roomId: rid });
throw new Error('room-not-found');
}
await this.dispatchInquiryPosition(inquiry, newRoom);
return newRoom;
}
static async dispatchInquiryPosition(
inquiry: ILivechatInquiryRecord,
room: AtLeast<IOmnichannelRoom, 'servedBy' | 'departmentId'>,
): Promise<void> {
if (
!room.servedBy &&
inquiry.status !== LivechatInquiryStatus.VERIFYING &&
settings.get('Livechat_waiting_queue') &&
settings.get('Omnichannel_calculate_dispatch_service_queue_statistics')
) {
const [inq] = await LivechatInquiry.getCurrentSortedQueueAsync({
inquiryId: inquiry._id,
department: room.departmentId,
queueSortBy: getOmniChatSortQuery(getInquirySortMechanismSetting()),
});
if (inq) {
void dispatchInquiryPosition(inq);
}
}
}
static async unarchiveRoom(archivedRoom: IOmnichannelRoom) {
if (!archivedRoom) {
throw new Error('no-room-to-unarchive');
}
const { _id: rid, open, closedAt, fname: name, servedBy, v, departmentId: department, lastMessage: message, source } = archivedRoom;
if (!rid || !closedAt || !!open) {
return archivedRoom;
}
logger.debug({ msg: 'Attempting to unarchive room', roomId: rid });
const oldInquiry = await LivechatInquiry.findOneByRoomId<Pick<ILivechatInquiryRecord, '_id'>>(rid, { projection: { _id: 1 } });
if (oldInquiry) {
logger.debug({ msg: 'Removing old inquiry before unarchiving room', inquiryId: oldInquiry._id, roomId: rid });
await LivechatInquiry.removeByRoomId(rid);
void notifyOnLivechatInquiryChangedById(oldInquiry._id, 'removed');
}
const guest = {
...v,
...(department && { department }),
};
let defaultAgent: SelectedAgent | undefined;
const isAgentAvailable = (username: string) =>
Users.findOneOnlineAgentByUserList(
username,
{ projection: { _id: 1 } },
settings.get<boolean>('Livechat_enabled_when_agent_idle'),
settings.get<boolean>('Livechat_accept_chats_with_no_agents'),
);
if (servedBy?.username && (await isAgentAvailable(servedBy.username))) {
defaultAgent = { agentId: servedBy._id, username: servedBy.username };
}
// TODO: unarchive to return updated room
await LivechatRooms.unarchiveOneById(rid);
const room = await LivechatRooms.findOneById(rid);
if (!room) {
throw new Error('room-not-found');
}
const inquiry = await createLivechatInquiry({
rid,
name,
guest,
message: message?.msg,
extraData: { source },
});
if (!inquiry) {
throw new Error('inquiry-not-found');
}
await this.requeueInquiry(inquiry, room, defaultAgent);
logger.debug({ msg: 'Inquiry queued', inquiryId: inquiry._id });
return room;
}
private static dispatchInquiryQueued = async (inquiry: ILivechatInquiryRecord, room: IOmnichannelRoom, agent?: SelectedAgent | null) => {
if (RoutingManager.getConfig()?.autoAssignAgent) {
return;
}
logger.debug({ msg: 'Notifying agents of queued inquiry', inquiryId: inquiry._id });
const { department, rid, v } = inquiry;
// Alert only the online agents of the queued request
const onlineAgents = await getOnlineAgents(department, agent);
if (!onlineAgents) {
logger.debug('Cannot notify agents of queued inquiry. No online agents found');
return;
}
const notificationUserName = v && (v.name || v.username);
for await (const agent of onlineAgents) {
const { _id, active, emails, language, status, statusConnection, username } = agent;
await sendNotification({
// fake a subscription in order to make use of the function defined above
subscription: {
rid,
u: {
_id,
},
receiver: [
{
active,
emails,
language,
status,
statusConnection,
username,
},
],
name: '',
},
sender: v,
hasMentionToAll: true, // consider all agents to be in the room
hasReplyToThread: false,
disableAllMessageNotifications: false,
hasMentionToHere: false,
message: { _id: '', u: v, msg: '' },
// we should use server's language for this type of messages instead of user's
notificationMessage: i18n.t('User_started_a_new_conversation', { username: notificationUserName, lng: language }),
room: { ...room, name: i18n.t('New_chat_in_queue', { lng: language }) },
mentionIds: [],
});
}
};
}

View File

@@ -0,0 +1,381 @@
import { Apps, AppEvents } from '@rocket.chat/apps';
import { Message } from '@rocket.chat/core-services';
import type {
ILivechatInquiryRecord,
ILivechatVisitor,
IOmnichannelRoom,
IRoutingMethod,
IRoutingMethodConstructor,
RoutingMethodConfig,
SelectedAgent,
InquiryWithAgentInfo,
TransferData,
IUser,
} from '@rocket.chat/core-typings';
import { LivechatInquiryStatus } from '@rocket.chat/core-typings';
import { Logger } from '@rocket.chat/logger';
import { LivechatInquiry, LivechatRooms, Subscriptions, Rooms, Users } from '@rocket.chat/models';
import { Match, check } from 'meteor/check';
import { Meteor } from 'meteor/meteor';
import {
createLivechatSubscription,
dispatchAgentDelegated,
dispatchInquiryQueued,
forwardRoomToAgent,
forwardRoomToDepartment,
removeAgentFromSubscription,
updateChatDepartment,
allowAgentSkipQueue,
} from './Helper';
import { conditionalLockAgent } from './conditionalLockAgent';
import { afterTakeInquiry, beforeDelegateAgent } from './hooks';
import { settings } from '../../settings';
import { callbacks } from '../callbacks';
import { notifyOnLivechatInquiryChangedById, notifyOnLivechatInquiryChanged } from '../notifyListener';
const logger = new Logger('RoutingManager');
type Routing = {
methods: Record<string, IRoutingMethod>;
isMethodSet(): boolean;
registerMethod(name: string, Method: IRoutingMethodConstructor): void;
getMethod(): IRoutingMethod;
getConfig(): RoutingMethodConfig | undefined;
getNextAgent(department?: string, ignoreAgentId?: string): Promise<SelectedAgent | null | undefined>;
delegateInquiry(
inquiry: InquiryWithAgentInfo,
agent?: SelectedAgent | null,
options?: { clientAction?: boolean; forwardingToDepartment?: { oldDepartmentId?: string; transferData?: any } },
room?: IOmnichannelRoom,
): Promise<(IOmnichannelRoom & { chatQueued?: boolean }) | null | false>;
unassignAgent(
inquiry: ILivechatInquiryRecord,
departmentId?: string,
shouldQueue?: boolean,
agent?: SelectedAgent | null,
): Promise<boolean>;
takeInquiry(
inquiry: Omit<
ILivechatInquiryRecord,
'estimatedInactivityCloseTimeAt' | 'message' | 't' | 'source' | 'estimatedWaitingTimeQueue' | 'priorityWeight' | '_updatedAt'
>,
agent: SelectedAgent | null,
options: { clientAction?: boolean; forwardingToDepartment?: { oldDepartmentId?: string; transferData?: any } },
room: IOmnichannelRoom,
): Promise<IOmnichannelRoom | false>;
transferRoom(room: IOmnichannelRoom, guest: ILivechatVisitor, transferData: TransferData): Promise<boolean>;
delegateAgent(agent: SelectedAgent | undefined, inquiry: ILivechatInquiryRecord): Promise<SelectedAgent | null | undefined>;
removeAllRoomSubscriptions(room: Pick<IOmnichannelRoom, '_id'>, ignoreUser?: { _id: string }): Promise<void>;
assignAgent(inquiry: InquiryWithAgentInfo, agent: SelectedAgent): Promise<{ inquiry: InquiryWithAgentInfo; user: IUser }>;
};
export const RoutingManager: Routing = {
methods: {},
isMethodSet() {
return settings.get<string>('Livechat_Routing_Method') !== '';
},
// eslint-disable-next-line @typescript-eslint/naming-convention
registerMethod(name, Method) {
this.methods[name] = new Method();
},
getMethod() {
const setting = settings.get<string>('Livechat_Routing_Method');
if (!this.methods[setting]) {
throw new Meteor.Error('error-routing-method-not-available');
}
return this.methods[setting];
},
getConfig() {
return this.getMethod().config;
},
async getNextAgent(department, ignoreAgentId) {
logger.debug({
msg: 'Getting next available agent with method',
routingMethod: settings.get('Livechat_Routing_Method'),
department,
ignoreAgentId,
});
return this.getMethod().getNextAgent(department, ignoreAgentId);
},
async delegateInquiry(inquiry, agent, options = {}, room) {
const { department, rid } = inquiry;
logger.debug({ msg: 'Attempting to delegate inquiry', inquiryId: inquiry._id });
if (
!agent ||
(agent.username &&
!(await Users.findOneOnlineAgentByUserList(
agent.username,
{},
settings.get<boolean>('Livechat_enabled_when_agent_idle'),
settings.get<boolean>('Livechat_accept_chats_with_no_agents'),
)) &&
!(await allowAgentSkipQueue(agent)))
) {
logger.debug({ msg: 'Agent offline or invalid. Using routing method to get next agent', inquiryId: inquiry._id });
agent = await this.getNextAgent(department);
logger.debug({ msg: 'Routing method returned agent for inquiry', inquiryId: inquiry._id, agentId: agent?.agentId });
}
if (!agent) {
logger.debug({ msg: 'No agents available. Unable to delegate inquiry', inquiryId: inquiry._id });
// When an inqury reaches here on CE, it will stay here as 'ready' since on CE there's no mechanism to re queue it.
// When reaching this point, managers have to manually transfer the inquiry to another room. This is expected.
return LivechatRooms.findOneById(rid);
}
if (!room) {
throw new Meteor.Error('error-invalid-room');
}
logger.debug({ msg: 'Inquiry will be taken by agent', inquiryId: inquiry._id, agentId: agent.agentId });
return this.takeInquiry(inquiry, agent, options, room);
},
async assignAgent(inquiry: InquiryWithAgentInfo, agent: SelectedAgent): Promise<{ inquiry: InquiryWithAgentInfo; user: IUser }> {
check(
agent,
Match.ObjectIncluding({
agentId: String,
username: String,
}),
);
logger.debug({ msg: 'Assigning agent to inquiry', agentId: agent.agentId, inquiryId: inquiry._id });
const { rid, name, v, department } = inquiry;
if (!(await createLivechatSubscription(rid, name, v, agent, department))) {
logger.debug({ msg: 'Cannot assign agent to inquiry. Cannot create subscription', inquiryId: inquiry._id, agentId: agent.agentId });
throw new Meteor.Error('error-creating-subscription', 'Error creating subscription');
}
await LivechatRooms.changeAgentByRoomId(rid, agent);
await Rooms.incUsersCountById(rid, 1);
const user = await Users.findOneById(agent.agentId);
if (!user) {
throw new Error('error-user-not-found');
}
await Promise.all([Message.saveSystemMessage('command', rid, 'connected', user), Message.saveSystemMessage('uj', rid, '', user)]);
await dispatchAgentDelegated(rid, agent.agentId);
logger.debug({ msg: 'Agent assigned to inquiry. Instances notified', agentId: agent.agentId, inquiryId: inquiry._id });
return { inquiry, user };
},
async unassignAgent(inquiry, departmentId, shouldQueue = false, defaultAgent?: SelectedAgent | null) {
const { rid, department } = inquiry;
const room = await LivechatRooms.findOneById(rid);
logger.debug({
msg: 'Removing assignations of inquiry',
inquiryId: inquiry._id,
departmentId,
room: { _id: room?._id, open: room?.open, servedBy: room?.servedBy },
shouldQueue,
defaultAgent,
});
if (!room?.open) {
logger.debug({ msg: 'Cannot unassign agent from inquiry. Room already closed', inquiryId: inquiry._id });
return false;
}
if (departmentId && departmentId !== department) {
logger.debug({
msg: 'Switching department for inquiry',
inquiryId: inquiry._id,
currentDepartment: department,
nextDepartment: departmentId,
});
await updateChatDepartment({
rid,
newDepartmentId: departmentId,
oldDepartmentId: department,
});
// Fake the department to delegate the inquiry;
inquiry.department = departmentId;
}
const { servedBy } = room;
if (servedBy) {
await LivechatRooms.removeAgentByRoomId(rid);
await this.removeAllRoomSubscriptions(room);
await dispatchAgentDelegated(rid);
}
if (shouldQueue) {
const queuedInquiry = await LivechatInquiry.queueInquiry(inquiry._id, room.lastMessage, defaultAgent);
if (queuedInquiry) {
inquiry = queuedInquiry;
void notifyOnLivechatInquiryChanged(inquiry, 'updated', {
status: LivechatInquiryStatus.QUEUED,
queuedAt: new Date(),
takenAt: undefined,
});
}
}
await dispatchInquiryQueued(inquiry);
return true;
},
async takeInquiry(inquiry, agent, options = { clientAction: false }, room) {
check(
agent,
Match.ObjectIncluding({
agentId: String,
username: String,
}),
);
check(
inquiry,
Match.ObjectIncluding({
_id: String,
rid: String,
status: String,
}),
);
logger.debug({ msg: 'Attempting to take Inquiry', inquiryId: inquiry._id, agentId: agent.agentId });
const { _id, rid } = inquiry;
if (!room?.open) {
logger.debug({ msg: 'Cannot take inquiry. Room is closed', inquiryId: inquiry._id });
return room;
}
if (room.servedBy && room.servedBy._id === agent.agentId) {
logger.debug({ msg: 'Cannot take inquiry. Already taken by agent', inquiryId: inquiry._id, agentId: room.servedBy._id });
return room;
}
const lock = await conditionalLockAgent(agent.agentId);
if (!lock.acquired && lock.required) {
logger.debug({
msg: 'Cannot take inquiry because agent is currently locked by another process',
agentId: agent.agentId,
inquiryId: _id,
});
if (options.clientAction && !options.forwardingToDepartment) {
throw new Error('error-agent-is-locked');
}
agent = null;
}
if (agent) {
try {
await callbacks.run('livechat.checkAgentBeforeTakeInquiry', {
agent,
inquiry,
options,
});
} catch (e) {
await lock.unlock();
if (options.clientAction && !options.forwardingToDepartment) {
throw e;
}
agent = null;
}
}
if (!agent) {
logger.debug({ msg: 'Cannot take inquiry. Precondition failed for agent', inquiryId: inquiry._id });
const cbRoom = await callbacks.run<'livechat.onAgentAssignmentFailed'>('livechat.onAgentAssignmentFailed', room, {
inquiry,
options,
});
return cbRoom;
}
try {
const result = await LivechatInquiry.takeInquiry(_id, inquiry.lockedAt);
if (result.modifiedCount === 0) {
logger.error({ msg: 'Failed to take inquiry because lockedAt did not match', inquiryId: _id, lockedAt: inquiry.lockedAt });
throw new Error('error-taking-inquiry-lockedAt-mismatch');
}
logger.info({ msg: 'Inquiry taken', inquiryId: _id, agentId: agent.agentId });
// assignAgent changes the room data to add the agent serving the conversation. afterTakeInquiry expects room object to be updated
const { inquiry: returnedInquiry, user } = await this.assignAgent(inquiry, agent);
const roomAfterUpdate = await LivechatRooms.findOneById(rid);
if (!roomAfterUpdate) {
// This should never happen
throw new Error('error-room-not-found');
}
void Apps.self?.triggerEvent(AppEvents.IPostLivechatAgentAssigned, { room: roomAfterUpdate, user });
void afterTakeInquiry({ inquiry: returnedInquiry, room: roomAfterUpdate, agent });
void notifyOnLivechatInquiryChangedById(inquiry._id, 'updated', {
status: LivechatInquiryStatus.TAKEN,
takenAt: new Date(),
defaultAgent: undefined,
estimatedInactivityCloseTimeAt: undefined,
queuedAt: undefined,
});
return roomAfterUpdate;
} finally {
await lock.unlock();
}
},
async transferRoom(room, guest, transferData) {
logger.debug({ msg: 'Transferring room', roomId: room._id, transferredBy: transferData.transferredBy._id });
if (transferData.departmentId) {
logger.debug({ msg: 'Transferring room to department', roomId: room._id, departmentId: transferData.departmentId });
return forwardRoomToDepartment(room, guest, transferData);
}
if (transferData.userId) {
logger.debug({ msg: 'Transferring room to user', roomId: room._id, userId: transferData.userId });
return forwardRoomToAgent(room, transferData);
}
logger.debug({ msg: 'Unable to transfer room. No target provided', roomId: room._id });
return false;
},
async delegateAgent(agent, inquiry) {
const defaultAgent = await beforeDelegateAgent(agent, {
department: inquiry?.department,
});
if (defaultAgent) {
logger.debug({ msg: 'Delegating Inquiry to agent', inquiryId: inquiry._id, agentUsername: defaultAgent.username });
await LivechatInquiry.setDefaultAgentById(inquiry._id, defaultAgent);
void notifyOnLivechatInquiryChanged(inquiry, 'updated', { defaultAgent });
}
logger.debug({ msg: 'Queueing inquiry', inquiryId: inquiry._id });
await dispatchInquiryQueued(inquiry, defaultAgent);
return defaultAgent;
},
async removeAllRoomSubscriptions(room, ignoreUser) {
const { _id: roomId } = room;
for await (const { u } of Subscriptions.findByRoomId(roomId, { projection: { u: 1 } })) {
if (ignoreUser?._id === u._id) {
continue;
}
await removeAgentFromSubscription(roomId, u);
}
},
};

View File

@@ -0,0 +1,13 @@
import type { ILivechatContactVisitorAssociation, IOmnichannelSource } from '@rocket.chat/core-typings';
export const Visitors = {
makeVisitorAssociation(visitorId: string, roomInfo: IOmnichannelSource): ILivechatContactVisitorAssociation {
return {
visitorId,
source: {
type: roomInfo.type,
id: roomInfo.id,
},
};
},
};

View File

@@ -0,0 +1,72 @@
import { LivechatRooms, LivechatAgentActivity } from '@rocket.chat/models';
type Params = {
start: Date;
end: Date;
options?: any;
};
export const findAllAverageServiceTimeAsync = async ({ start, end, options = {} }: Params) => {
if (!start || !end) {
throw new Error('"start" and "end" must be provided');
}
const total = await LivechatRooms.findAllAverageServiceTimeByAgents({
start,
end,
onlyCount: true,
}).toArray();
return {
agents: await LivechatRooms.findAllAverageServiceTimeByAgents({
start,
end,
options,
}).toArray(),
total: total.length ? total[0].total : 0,
};
};
export const findAllServiceTimeAsync = async ({ start, end, options = {} }: Params) => {
if (!start || !end) {
throw new Error('"start" and "end" must be provided');
}
const total = await LivechatRooms.findAllServiceTimeByAgent({
start,
end,
onlyCount: true,
}).toArray();
return {
agents: await LivechatRooms.findAllServiceTimeByAgent({ start, end, options }).toArray(),
total: total.length ? total[0].total : 0,
};
};
export const findAvailableServiceTimeHistoryAsync = async ({
start,
end,
fullReport,
options = {},
}: {
start: string;
end: string;
fullReport: boolean;
options: { sort?: Record<string, number>; offset?: number; count?: number };
}) => {
if (!start || !end) {
throw new Error('"start" and "end" must be provided');
}
const total = await LivechatAgentActivity.findAvailableServiceTimeHistory({
start,
end,
fullReport,
onlyCount: true,
}).toArray();
return {
agents: await LivechatAgentActivity.findAvailableServiceTimeHistory({
start,
end,
fullReport,
options,
}).toArray(),
total: total.length ? total[0].total : 0,
};
};

View File

@@ -0,0 +1,395 @@
import { OmnichannelAnalytics } from '@rocket.chat/core-services';
import type { IUser } from '@rocket.chat/core-typings';
import { LivechatRooms, Users, LivechatVisitors, LivechatAgentActivity } from '@rocket.chat/models';
import mem from 'mem';
import moment from 'moment';
import { secondsToHHMMSS } from '../../../../lib/utils/secondsToHHMMSS';
import { settings } from '../../../settings';
import { getAnalyticsOverviewDataCachedForRealtime } from '../AnalyticsTyped';
import {
findPercentageOfAbandonedRoomsAsync,
findAllAverageOfChatDurationTimeAsync,
findAllAverageWaitingTimeAsync,
findAllNumberOfAbandonedRoomsAsync,
findAllAverageServiceTimeAsync,
} from './departments';
const findAllChatsStatusAsync = async ({ start, end, departmentId = undefined }: { start: Date; end: Date; departmentId?: string }) => {
if (!start || !end) {
throw new Error('"start" and "end" must be provided');
}
return {
open: await LivechatRooms.countAllOpenChatsBetweenDate({ start, end, departmentId }),
closed: await LivechatRooms.countAllClosedChatsBetweenDate({ start, end, departmentId }),
queued: await LivechatRooms.countAllQueuedChatsBetweenDate({ start, end, departmentId }),
onhold: await LivechatRooms.getOnHoldConversationsBetweenDate(start, end, departmentId),
};
};
const getProductivityMetricsAsync = async ({
start,
end,
departmentId = undefined,
user,
}: {
start: string;
end: string;
departmentId?: string;
user: Pick<IUser, '_id' | 'utcOffset' | 'language'>;
}) => {
if (!start || !end) {
throw new Error('"start" and "end" must be provided');
}
const totalizers =
(await OmnichannelAnalytics.getAnalyticsOverviewData({
daterange: {
from: start,
to: end,
},
analyticsOptions: {
name: 'Productivity',
},
departmentId,
utcOffset: user?.utcOffset,
language: user?.language || settings.get('Language') || 'en',
executedBy: user._id,
})) || [];
const averageWaitingTime = await findAllAverageWaitingTimeAsync({
start: new Date(start),
end: new Date(end),
departmentId,
});
const totalOfWaitingTime = averageWaitingTime.departments.length;
const sumOfWaitingTime = averageWaitingTime.departments.reduce((acc: number, serviceTime: { averageWaitingTimeInSeconds: number }) => {
acc += serviceTime.averageWaitingTimeInSeconds;
return acc;
}, 0);
const totalOfAvarageWaitingTime = totalOfWaitingTime === 0 ? 0 : sumOfWaitingTime / totalOfWaitingTime;
return {
totalizers: [...totalizers, { title: 'Avg_of_waiting_time', value: secondsToHHMMSS(totalOfAvarageWaitingTime) }],
};
};
const getAgentsProductivityMetricsAsync = async ({
start,
end,
departmentId = undefined,
user,
}: {
start: string;
end: string;
departmentId?: string;
user: Pick<IUser, '_id' | 'utcOffset' | 'language'>;
}) => {
if (!start || !end) {
throw new Error('"start" and "end" must be provided');
}
// TODO: check type of date
const averageOfAvailableServiceTime = (
await LivechatAgentActivity.findAllAverageAvailableServiceTime({
date: parseInt(moment(start).format('YYYYMMDD')) as any,
departmentId,
})
)[0];
const averageOfServiceTime = await findAllAverageServiceTimeAsync({
start: new Date(start),
end: new Date(end),
departmentId,
});
const totalizers =
(await OmnichannelAnalytics.getAnalyticsOverviewData({
daterange: {
from: start,
to: end,
},
analyticsOptions: {
name: 'Conversations',
},
departmentId,
utcOffset: user.utcOffset,
language: user.language || settings.get('Language') || 'en',
executedBy: user._id,
})) || [];
const totalOfServiceTime = averageOfServiceTime.departments.length;
const sumOfServiceTime = averageOfServiceTime.departments.reduce(
(
acc: number,
serviceTime: {
averageServiceTimeInSeconds: number;
},
) => {
acc += serviceTime.averageServiceTimeInSeconds;
return acc;
},
0,
);
const totalOfAverageAvailableServiceTime = averageOfAvailableServiceTime
? averageOfAvailableServiceTime.averageAvailableServiceTimeInSeconds
: 0;
const totalOfAverageServiceTime = totalOfServiceTime === 0 ? 0 : sumOfServiceTime / totalOfServiceTime;
return {
totalizers: [
...totalizers.filter((metric: { title: string }) => metric.title === 'Busiest_time'),
{
title: 'Avg_of_available_service_time',
value: secondsToHHMMSS(totalOfAverageAvailableServiceTime),
},
{ title: 'Avg_of_service_time', value: secondsToHHMMSS(totalOfAverageServiceTime) },
],
};
};
const getChatsMetricsAsync = async ({ start, end, departmentId = undefined }: { start: Date; end: Date; departmentId?: string }) => {
if (!start || !end) {
throw new Error('"start" and "end" must be provided');
}
const abandonedRooms = await findAllNumberOfAbandonedRoomsAsync({
start,
end,
departmentId,
});
const averageOfAbandonedRooms = await findPercentageOfAbandonedRoomsAsync({
start,
end,
departmentId,
});
const averageOfChatDurationTime = await findAllAverageOfChatDurationTimeAsync({
start,
end,
departmentId,
});
const totalOfAbandonedRooms = averageOfAbandonedRooms.departments.length;
const totalOfChatDurationTime = averageOfChatDurationTime.departments.length;
const sumOfPercentageOfAbandonedRooms = averageOfAbandonedRooms.departments.reduce(
(
acc: number,
abandonedRoom: {
percentageOfAbandonedChats: number;
},
) => {
acc += abandonedRoom.percentageOfAbandonedChats;
return acc;
},
0,
);
const sumOfChatDurationTime = averageOfChatDurationTime.departments.reduce(
(
acc: number,
chatDurationTime: {
averageChatDurationTimeInSeconds: number;
},
) => {
acc += chatDurationTime.averageChatDurationTimeInSeconds;
return acc;
},
0,
);
const totalAbandonedRooms = abandonedRooms.departments.reduce(
(
acc: number,
item: {
abandonedRooms: number;
},
) => {
acc += item.abandonedRooms;
return acc;
},
0,
);
const totalOfAverageAbandonedRooms = totalOfAbandonedRooms === 0 ? 0 : sumOfPercentageOfAbandonedRooms / totalOfAbandonedRooms;
const totalOfAverageChatDurationTime = totalOfChatDurationTime === 0 ? 0 : sumOfChatDurationTime / totalOfChatDurationTime;
return {
totalizers: [
{ title: 'Total_abandoned_chats', value: totalAbandonedRooms },
{ title: 'Avg_of_abandoned_chats', value: `${totalOfAverageAbandonedRooms}%` },
{
title: 'Avg_of_chat_duration_time',
value: secondsToHHMMSS(totalOfAverageChatDurationTime),
},
],
};
};
const getConversationsMetricsAsync = async ({
start,
end,
departmentId,
user,
}: {
start: string;
end: string;
departmentId?: string;
user: Pick<IUser, '_id' | 'utcOffset' | 'language'>;
}) => {
if (!start || !end) {
throw new Error('"start" and "end" must be provided');
}
const totalizers =
(await getAnalyticsOverviewDataCachedForRealtime({
daterange: {
from: start,
to: end,
},
analyticsOptions: {
name: 'Conversations',
},
...(departmentId && departmentId !== 'undefined' && { departmentId }),
utcOffset: user.utcOffset,
language: user.language || settings.get('Language') || 'en',
executedBy: user._id,
})) || [];
const metrics = ['Total_conversations', 'Open_conversations', 'On_Hold_conversations', 'Total_messages'];
const visitorsCount = await LivechatVisitors.countVisitorsBetweenDate({
start: new Date(start),
end: new Date(end),
department: departmentId,
});
return {
totalizers: [
...totalizers.filter((metric: { title: string }) => metrics.includes(metric.title)),
{ title: 'Total_visitors', value: visitorsCount },
],
};
};
const findAllChatMetricsByAgentAsync = async ({
start,
end,
departmentId = undefined,
}: {
start: Date;
end: Date;
departmentId?: string;
}) => {
if (!start || !end) {
throw new Error('"start" and "end" must be provided');
}
const open = await LivechatRooms.countAllOpenChatsByAgentBetweenDate({
start,
end,
departmentId,
});
const closed = await LivechatRooms.countAllClosedChatsByAgentBetweenDate({
start,
end,
departmentId,
});
const onhold = await LivechatRooms.countAllOnHoldChatsByAgentBetweenDate({
start,
end,
departmentId,
});
const result: Record<string, { open: number; closed: number; onhold?: number }> = {};
(open || []).forEach((agent: { chats: number; _id: string }) => {
result[agent._id] = { open: agent.chats, closed: 0, onhold: 0 };
});
(closed || []).forEach((agent: { _id: string; chats: number }) => {
result[agent._id] = {
open: result[agent._id] ? result[agent._id].open : 0,
closed: agent.chats,
};
});
(onhold || []).forEach((agent: { _id: string; chats: number }) => {
result[agent._id] = {
...result[agent._id],
onhold: agent.chats,
};
});
return result;
};
const findAllAgentsStatusAsync = async ({ departmentId = undefined }: { departmentId?: string }) =>
(await Users.countAllAgentsStatus({ departmentId }))[0];
const findAllChatMetricsByDepartmentAsync = async ({
start,
end,
departmentId = undefined,
}: {
start: Date;
end: Date;
departmentId?: string;
}) => {
if (!start || !end) {
throw new Error('"start" and "end" must be provided');
}
const open = await LivechatRooms.countAllOpenChatsByDepartmentBetweenDate({
start,
end,
departmentId,
});
const closed = await LivechatRooms.countAllClosedChatsByDepartmentBetweenDate({
start,
end,
departmentId,
});
const result: Record<string, { open: number; closed: number }> = {};
(open || []).forEach((department: { name: string; chats: number }) => {
result[department.name] = { open: department.chats, closed: 0 };
});
(closed || []).forEach((department: { name: string; chats: number }) => {
result[department.name] = {
open: result[department.name] ? result[department.name].open : 0,
closed: department.chats,
};
});
return result;
};
const findAllResponseTimeMetricsAsync = async ({
start,
end,
departmentId = undefined,
}: {
start: Date;
end: Date;
departmentId?: string;
}) => {
if (!start || !end) {
throw new Error('"start" and "end" must be provided');
}
const responseTimes = (await LivechatRooms.calculateResponseTimingsBetweenDates({ start, end, departmentId }))[0];
const reactionTimes = (await LivechatRooms.calculateReactionTimingsBetweenDates({ start, end, departmentId }))[0];
const durationTimings = (await LivechatRooms.calculateDurationTimingsBetweenDates({ start, end, departmentId }))[0];
return {
response: {
avg: responseTimes ? responseTimes.avg : 0,
longest: responseTimes ? responseTimes.longest : 0,
},
reaction: {
avg: reactionTimes ? reactionTimes.avg : 0,
longest: reactionTimes ? reactionTimes.longest : 0,
},
chatDuration: {
avg: durationTimings ? durationTimings.avg : 0,
longest: durationTimings ? durationTimings.longest : 0,
},
};
};
export const getConversationsMetricsAsyncCached = mem(getConversationsMetricsAsync, { maxAge: 5000, cacheKey: JSON.stringify });
export const getAgentsProductivityMetricsAsyncCached = mem(getAgentsProductivityMetricsAsync, { maxAge: 5000, cacheKey: JSON.stringify });
export const getChatsMetricsAsyncCached = mem(getChatsMetricsAsync, { maxAge: 5000, cacheKey: JSON.stringify });
export const getProductivityMetricsAsyncCached = mem(getProductivityMetricsAsync, { maxAge: 5000, cacheKey: JSON.stringify });
export const findAllChatsStatusAsyncCached = mem(findAllChatsStatusAsync, { maxAge: 5000, cacheKey: JSON.stringify });
export const findAllChatMetricsByAgentAsyncCached = mem(findAllChatMetricsByAgentAsync, { maxAge: 5000, cacheKey: JSON.stringify });
export const findAllAgentsStatusAsyncCached = mem(findAllAgentsStatusAsync, { maxAge: 5000, cacheKey: JSON.stringify });
export const findAllChatMetricsByDepartmentAsyncCached = mem(findAllChatMetricsByDepartmentAsync, {
maxAge: 5000,
cacheKey: JSON.stringify,
});
export const findAllResponseTimeMetricsAsyncCached = mem(findAllResponseTimeMetricsAsync, { maxAge: 5000, cacheKey: JSON.stringify });

View File

@@ -0,0 +1,192 @@
import { LivechatRooms, Messages } from '@rocket.chat/models';
import { settings } from '../../../settings';
type Params = {
start: Date;
end: Date;
options?: any;
departmentId?: string;
};
export const findAllRoomsAsync = async ({ start, end, answered, departmentId, options = {} }: Params & { answered?: boolean }) => {
if (!start || !end) {
throw new Error('"start" and "end" must be provided');
}
const total = await LivechatRooms.findAllRooms({
start,
answered,
end,
departmentId,
onlyCount: true,
}).toArray();
return {
departments: await LivechatRooms.findAllRooms({
start,
answered,
end,
departmentId,
options,
}).toArray(),
total: total.length ? total[0].total : 0,
};
};
export const findAllAverageOfChatDurationTimeAsync = async ({ start, end, departmentId, options = {} }: Params) => {
if (!start || !end) {
throw new Error('"start" and "end" must be provided');
}
const total = await LivechatRooms.findAllAverageOfChatDurationTime({
start,
end,
departmentId,
onlyCount: true,
}).toArray();
return {
departments: await LivechatRooms.findAllAverageOfChatDurationTime({
start,
end,
departmentId,
options,
}).toArray(),
total: total.length ? total[0].total : 0,
};
};
export const findAllAverageServiceTimeAsync = async ({ start, end, departmentId, options = {} }: Params) => {
if (!start || !end) {
throw new Error('"start" and "end" must be provided');
}
const total = await LivechatRooms.findAllAverageOfServiceTime({
start,
end,
departmentId,
onlyCount: true,
}).toArray();
return {
departments: await LivechatRooms.findAllAverageOfServiceTime({
start,
end,
departmentId,
options,
}).toArray(),
total: total.length ? total[0].total : 0,
};
};
export const findAllServiceTimeAsync = async ({ start, end, departmentId, options = {} }: Params) => {
if (!start || !end) {
throw new Error('"start" and "end" must be provided');
}
const total = await LivechatRooms.findAllServiceTime({
start,
end,
departmentId,
onlyCount: true,
}).toArray();
return {
departments: await LivechatRooms.findAllServiceTime({
start,
end,
departmentId,
options,
}).toArray(),
total: total.length ? total[0].total : 0,
};
};
export const findAllAverageWaitingTimeAsync = async ({ start, end, departmentId, options = {} }: Params) => {
if (!start || !end) {
throw new Error('"start" and "end" must be provided');
}
const total = await LivechatRooms.findAllAverageWaitingTime({
start,
end,
departmentId,
onlyCount: true,
}).toArray();
return {
departments: await LivechatRooms.findAllAverageWaitingTime({
start,
end,
departmentId,
options,
}).toArray(),
total: total.length ? total[0].total : 0,
};
};
export const findAllNumberOfTransferredRoomsAsync = async ({ start, end, departmentId, options = {} }: Params) => {
if (!start || !end) {
throw new Error('"start" and "end" must be provided');
}
const total = await Messages.findAllNumberOfTransferredRooms({
start,
end,
departmentId,
onlyCount: true,
}).toArray();
return {
departments: await Messages.findAllNumberOfTransferredRooms({
start,
end,
departmentId,
options,
}).toArray(),
total: total.length ? total[0].total : 0,
};
};
export const findAllNumberOfAbandonedRoomsAsync = async ({ start, end, departmentId, options = {} }: Params) => {
if (!start || !end) {
throw new Error('"start" and "end" must be provided');
}
const total = await (
await LivechatRooms.findAllNumberOfAbandonedRooms({
start,
end,
departmentId,
inactivityTimeout: settings.get('Livechat_visitor_inactivity_timeout'),
onlyCount: true,
})
).toArray();
return {
departments: await (
await LivechatRooms.findAllNumberOfAbandonedRooms({
start,
end,
departmentId,
inactivityTimeout: settings.get('Livechat_visitor_inactivity_timeout'),
options,
})
).toArray(),
total: total.length ? total[0].total : 0,
};
};
export const findPercentageOfAbandonedRoomsAsync = async ({ start, end, departmentId, options = {} }: Params) => {
if (!start || !end) {
throw new Error('"start" and "end" must be provided');
}
const total = await (
await LivechatRooms.findPercentageOfAbandonedRooms({
start,
end,
departmentId,
inactivityTimeout: settings.get('Livechat_visitor_inactivity_timeout'),
onlyCount: true,
})
).toArray();
return {
departments: await (
await LivechatRooms.findPercentageOfAbandonedRooms({
start,
end,
departmentId,
inactivityTimeout: settings.get('Livechat_visitor_inactivity_timeout'),
options,
})
).toArray(),
total: total.length ? total[0].total : 0,
};
};

View File

@@ -0,0 +1,144 @@
import type { AtLeast, ILivechatAgentStatus, ILivechatBusinessHour, ILivechatDepartment } from '@rocket.chat/core-typings';
import { UserStatus } from '@rocket.chat/core-typings';
import type { ILivechatBusinessHoursModel, IUsersModel } from '@rocket.chat/model-typings';
import { LivechatBusinessHours, Users } from '@rocket.chat/models';
import type { IWorkHoursCronJobsWrapper } from '@rocket.chat/models';
import moment from 'moment-timezone';
import type { UpdateFilter } from 'mongodb';
import { notifyOnUserChange } from '../../notifyListener';
export interface IBusinessHourBehavior {
findHoursToCreateJobs(): Promise<IWorkHoursCronJobsWrapper[]>;
openBusinessHoursByDayAndHour(day: string, hour: string): Promise<void>;
closeBusinessHoursByDayAndHour(day: string, hour: string): Promise<void>;
onDisableBusinessHours(): Promise<void>;
onAddAgentToDepartment(options?: { departmentId: string; agentsId: string[] }): Promise<any>;
onRemoveAgentFromDepartment(options?: Record<string, any>): Promise<any>;
onRemoveDepartment(options: { department: ILivechatDepartment; agentsIds: string[] }): Promise<any>;
onDepartmentDisabled(department?: AtLeast<ILivechatDepartment, '_id' | 'businessHourId'>): Promise<void>;
onDepartmentArchived(department: Pick<ILivechatDepartment, '_id' | 'businessHourId'>): Promise<void>;
onStartBusinessHours(): Promise<void>;
afterSaveBusinessHours(businessHourData: ILivechatBusinessHour): Promise<void>;
allowAgentChangeServiceStatus(agentId: string): Promise<boolean>;
changeAgentActiveStatus(agentId: string, status: string): Promise<any>;
// If a new agent is created, this callback will be called
onNewAgentCreated(agentId: string): Promise<void>;
}
export interface IBusinessHourType {
name: string;
getBusinessHour(id?: string): Promise<ILivechatBusinessHour | null>;
saveBusinessHour(businessHourData: ILivechatBusinessHour): Promise<ILivechatBusinessHour>;
removeBusinessHourById(id: string): Promise<void>;
}
export abstract class AbstractBusinessHourBehavior {
protected BusinessHourRepository: ILivechatBusinessHoursModel = LivechatBusinessHours;
protected UsersRepository: IUsersModel = Users;
async findHoursToCreateJobs(): Promise<IWorkHoursCronJobsWrapper[]> {
return this.BusinessHourRepository.findHoursToScheduleJobs();
}
async onDisableBusinessHours(): Promise<void> {
await this.UsersRepository.removeBusinessHoursFromAllUsers();
}
async allowAgentChangeServiceStatus(agentId: string): Promise<boolean> {
return this.UsersRepository.isAgentWithinBusinessHours(agentId);
}
async changeAgentActiveStatus(agentId: string, status: ILivechatAgentStatus): Promise<any> {
const result = await this.UsersRepository.setLivechatStatusIf(
agentId,
status,
// Why this works: statusDefault is the property set when a user manually changes their status
// So if it's set to offline, we can be sure the user will be offline after login and we can skip the update
{ livechatStatusSystemModified: true, statusDefault: { $ne: UserStatus.OFFLINE } },
{ livechatStatusSystemModified: true },
);
if (result.modifiedCount > 0) {
void notifyOnUserChange({
clientAction: 'updated',
id: agentId,
diff: { statusLivechat: 'available', livechatStatusSystemModified: true },
});
}
return result;
}
}
export abstract class AbstractBusinessHourType {
protected BusinessHourRepository: ILivechatBusinessHoursModel = LivechatBusinessHours;
protected UsersRepository: IUsersModel = Users;
protected async baseSaveBusinessHour(businessHourData: ILivechatBusinessHour): Promise<string> {
businessHourData.active = Boolean(businessHourData.active);
businessHourData = this.convertWorkHours(businessHourData);
if (businessHourData._id) {
await this.BusinessHourRepository.updateOne({ _id: businessHourData._id }, {
$set: businessHourData,
} as UpdateFilter<ILivechatBusinessHour>); // TODO: Remove this cast when TypeScript is updated
return businessHourData._id;
}
const { insertedId } = await this.BusinessHourRepository.insertOne(businessHourData);
return insertedId;
}
private convertWorkHours(businessHourData: ILivechatBusinessHour): ILivechatBusinessHour {
businessHourData.workHours.forEach((hour: any) => {
const startUtc = moment.tz(`${hour.day}:${hour.start}`, 'dddd:HH:mm', businessHourData.timezone.name).utc();
const finishUtc = moment.tz(`${hour.day}:${hour.finish}`, 'dddd:HH:mm', businessHourData.timezone.name).utc();
if (hour.open && finishUtc.isBefore(startUtc)) {
throw new Error('error-business-hour-finish-time-before-start-time');
}
if (hour.open && startUtc.isSame(finishUtc)) {
throw new Error('error-business-hour-finish-time-equals-start-time');
}
hour.start = {
time: hour.start,
utc: {
dayOfWeek: startUtc.clone().format('dddd'),
time: startUtc.clone().format('HH:mm'),
},
cron: {
dayOfWeek: this.formatDayOfTheWeekFromServerTimezoneAndUtcHour(startUtc, 'dddd'),
time: this.formatDayOfTheWeekFromServerTimezoneAndUtcHour(startUtc, 'HH:mm'),
},
};
hour.finish = {
time: hour.finish,
utc: {
dayOfWeek: finishUtc.clone().format('dddd'),
time: finishUtc.clone().format('HH:mm'),
},
cron: {
dayOfWeek: this.formatDayOfTheWeekFromServerTimezoneAndUtcHour(finishUtc, 'dddd'),
time: this.formatDayOfTheWeekFromServerTimezoneAndUtcHour(finishUtc, 'HH:mm'),
},
};
});
return businessHourData;
}
protected getUTCFromTimezone(timezone?: string): string {
if (!timezone) {
return String(moment().utcOffset() / 60);
}
return moment.tz(timezone).format('Z');
}
private formatDayOfTheWeekFromServerTimezoneAndUtcHour(utc: any, format: string): string {
return moment(utc.format('dddd:HH:mm'), 'dddd:HH:mm')
.add(moment().utcOffset() / 60, 'hours')
.format(format);
}
}

View File

@@ -0,0 +1,306 @@
import type { ILivechatBusinessHour, IBusinessHourTimezone } from '@rocket.chat/core-typings';
import { LivechatBusinessHourTypes } from '@rocket.chat/core-typings';
import type { AgendaCronJobs } from '@rocket.chat/cron';
import { LivechatBusinessHours, LivechatDepartment, Users } from '@rocket.chat/models';
import moment from 'moment-timezone';
import type { IBusinessHourBehavior, IBusinessHourType } from './AbstractBusinessHour';
import { closeBusinessHour } from './closeBusinessHour';
import { settings } from '../../../settings';
import { callbacks } from '../../callbacks';
import { notifyOnUserChange } from '../../notifyListener';
import { businessHourLogger } from '../logger';
const CRON_EVERY_MIDNIGHT_EXPRESSION = '0 0 * * *';
const CRON_DAYLIGHT_JOB_NAME = 'livechat-business-hour-daylight-saving-time-verifier';
export class BusinessHourManager {
private types: Map<string, IBusinessHourType> = new Map();
private behavior: IBusinessHourBehavior;
private cronJobs: AgendaCronJobs;
private cronJobsCache: string[] = [];
constructor(cronJobs: AgendaCronJobs) {
this.cronJobs = cronJobs;
this.openWorkHoursCallback = this.openWorkHoursCallback.bind(this);
this.closeWorkHoursCallback = this.closeWorkHoursCallback.bind(this);
}
async startManager(): Promise<void> {
await this.createCronJobsForWorkHours();
this.setupCallbacks();
await this.cleanupDisabledDepartmentReferences();
await this.behavior.onStartBusinessHours();
void this.startDaylightSavingTimeVerifier();
void this.registerDaylightSavingTimeCronJob();
}
async stopManager(): Promise<void> {
await this.removeCronJobs();
this.clearCronJobsCache();
this.removeCallbacks();
await this.behavior.onDisableBusinessHours();
await this.cronJobs.remove(CRON_DAYLIGHT_JOB_NAME);
}
async restartManager(): Promise<void> {
await this.stopManager();
await this.startManager();
}
async cleanupDisabledDepartmentReferences(): Promise<void> {
// Get business hours with departments enabled and disabled
const bhWithDepartments = await LivechatDepartment.getBusinessHoursWithDepartmentStatuses();
if (!bhWithDepartments.length) {
// If there are no bh, skip
return;
}
for await (const { _id: businessHourId, validDepartments, invalidDepartments } of bhWithDepartments) {
if (!invalidDepartments.length) {
continue;
}
// If there are no enabled departments, close the business hour
const allDepsAreDisabled = validDepartments.length === 0 && invalidDepartments.length > 0;
if (allDepsAreDisabled) {
const businessHour = await this.getBusinessHour(businessHourId, LivechatBusinessHourTypes.CUSTOM);
if (!businessHour) {
continue;
}
await closeBusinessHour(businessHour);
}
// Remove business hour from disabled departments
await LivechatDepartment.removeBusinessHourFromDepartmentsByIdsAndBusinessHourId(invalidDepartments, businessHourId);
}
}
async allowAgentChangeServiceStatus(agentId: string): Promise<boolean> {
if (!settings.get('Livechat_enable_business_hours')) {
return true;
}
return this.behavior.allowAgentChangeServiceStatus(agentId);
}
registerBusinessHourType(businessHourType: IBusinessHourType): void {
this.types.set(businessHourType.name, businessHourType);
}
registerBusinessHourBehavior(behavior: IBusinessHourBehavior): void {
this.behavior = behavior;
}
async getBusinessHour(id?: string, type?: string): Promise<ILivechatBusinessHour | null> {
const businessHourType = this.getBusinessHourType((type as string) || LivechatBusinessHourTypes.DEFAULT);
if (!businessHourType) {
return null;
}
return businessHourType.getBusinessHour(id);
}
async saveBusinessHour(businessHourData: ILivechatBusinessHour): Promise<void> {
const type = this.getBusinessHourType((businessHourData.type as string) || LivechatBusinessHourTypes.DEFAULT) as IBusinessHourType;
const saved = await type.saveBusinessHour(businessHourData);
if (!settings.get('Livechat_enable_business_hours')) {
return;
}
await this.behavior.afterSaveBusinessHours(saved);
await this.createCronJobsForWorkHours();
}
async removeBusinessHourByIdAndType(id: string, type: string): Promise<void> {
const businessHourType = this.getBusinessHourType(type) as IBusinessHourType;
await businessHourType.removeBusinessHourById(id);
if (!settings.get('Livechat_enable_business_hours')) {
return;
}
await this.createCronJobsForWorkHours();
}
async onLogin(agentId: string): Promise<any> {
if (!settings.get('Livechat_enable_business_hours')) {
return this.behavior.changeAgentActiveStatus(agentId, 'available');
}
const result = await Users.setLivechatStatusActiveBasedOnBusinessHours(agentId);
if (result.updatedCount > 0) {
void notifyOnUserChange({ clientAction: 'updated', id: agentId, diff: { statusLivechat: 'available ' } });
}
return result;
}
async restartCronJobsIfNecessary(): Promise<void> {
if (!settings.get('Livechat_enable_business_hours')) {
return;
}
await this.createCronJobsForWorkHours();
}
private setupCallbacks(): void {
callbacks.add(
'livechat.removeAgentDepartment',
this.behavior.onRemoveAgentFromDepartment.bind(this),
callbacks.priority.HIGH,
'business-hour-livechat-on-remove-agent-department',
);
callbacks.add(
'livechat.afterRemoveDepartment',
this.behavior.onRemoveDepartment.bind(this),
callbacks.priority.HIGH,
'business-hour-livechat-after-remove-department',
);
callbacks.add(
'livechat.saveAgentDepartment',
this.behavior.onAddAgentToDepartment.bind(this),
callbacks.priority.HIGH,
'business-hour-livechat-on-save-agent-department',
);
callbacks.add(
'livechat.afterDepartmentDisabled',
this.behavior.onDepartmentDisabled.bind(this),
callbacks.priority.HIGH,
'business-hour-livechat-on-department-disabled',
);
callbacks.add(
'livechat.afterDepartmentArchived',
this.behavior.onDepartmentArchived.bind(this),
callbacks.priority.HIGH,
'business-hour-livechat-on-department-archived',
);
callbacks.add(
'livechat.onNewAgentCreated',
this.behavior.onNewAgentCreated.bind(this),
callbacks.priority.HIGH,
'business-hour-livechat-on-agent-created',
);
}
private removeCallbacks(): void {
callbacks.remove('livechat.removeAgentDepartment', 'business-hour-livechat-on-remove-agent-department');
callbacks.remove('livechat.afterRemoveDepartment', 'business-hour-livechat-after-remove-department');
callbacks.remove('livechat.saveAgentDepartment', 'business-hour-livechat-on-save-agent-department');
callbacks.remove('livechat.afterDepartmentDisabled', 'business-hour-livechat-on-department-disabled');
callbacks.remove('livechat.afterDepartmentArchived', 'business-hour-livechat-on-department-archived');
callbacks.remove('livechat.onNewAgentCreated', 'business-hour-livechat-on-agent-created');
}
private async createCronJobsForWorkHours(): Promise<void> {
await this.removeCronJobs();
this.clearCronJobsCache();
const [workHours] = await this.behavior.findHoursToCreateJobs();
if (!workHours) {
return;
}
const { start, finish } = workHours;
await Promise.all(start.map(({ day, times }) => this.scheduleCronJob(times, day, 'open', this.openWorkHoursCallback)));
await Promise.all(finish.map(({ day, times }) => this.scheduleCronJob(times, day, 'close', this.closeWorkHoursCallback)));
}
private async scheduleCronJob(
items: string[],
day: string,
type: 'open' | 'close',
job: (day: string, hour: string) => void,
): Promise<void> {
await Promise.all(
items.map((hour) => {
const time = moment(hour, 'HH:mm').day(day);
const jobName = `${time.format('dddd')}/${time.format('HH:mm')}/${type}`;
const scheduleAt = `${time.minutes()} ${time.hours()} * * ${time.day()}`;
this.addToCache(jobName);
return this.cronJobs.add(jobName, scheduleAt, () => job(day, hour));
}),
);
}
private async openWorkHoursCallback(day: string, hour: string): Promise<void> {
try {
return await this.behavior.openBusinessHoursByDayAndHour(day, hour);
} catch (err) {
businessHourLogger.error({ msg: 'Error while opening business hours', err });
throw err;
}
}
private async closeWorkHoursCallback(day: string, hour: string): Promise<void> {
try {
return await this.behavior.closeBusinessHoursByDayAndHour(day, hour);
} catch (err) {
businessHourLogger.error({ msg: 'Error while closing business hours', err });
throw err;
}
}
private getBusinessHourType(type: string): IBusinessHourType | undefined {
return this.types.get(type);
}
private async removeCronJobs(): Promise<void> {
await Promise.all(this.cronJobsCache.map((jobName) => this.cronJobs.remove(jobName)));
}
private addToCache(jobName: string): void {
this.cronJobsCache.push(jobName);
}
private clearCronJobsCache(): void {
this.cronJobsCache = [];
}
hasDaylightSavingTimeChanged(timezone: IBusinessHourTimezone): boolean {
const now = moment().utc().tz(timezone.name);
const currentUTC = now.format('Z');
const existingTimezoneUTC = moment(timezone.utc, 'Z').utc().tz(timezone.name);
const DSTHasChanged = !moment(currentUTC, 'Z').utc().tz(timezone.name).isSame(existingTimezoneUTC);
return currentUTC !== timezone.utc && DSTHasChanged;
}
async registerDaylightSavingTimeCronJob(): Promise<void> {
await this.cronJobs.add(CRON_DAYLIGHT_JOB_NAME, CRON_EVERY_MIDNIGHT_EXPRESSION, this.startDaylightSavingTimeVerifier.bind(this));
}
async startDaylightSavingTimeVerifier(): Promise<void> {
const activeBusinessHours = await LivechatBusinessHours.findActiveBusinessHours();
const timezonesNeedingAdjustment = activeBusinessHours.filter(
({ timezone }) => timezone && this.hasDaylightSavingTimeChanged(timezone),
);
if (timezonesNeedingAdjustment.length === 0) {
return;
}
const result = await Promise.allSettled(
timezonesNeedingAdjustment.map((businessHour) => {
const businessHourType = this.getBusinessHourType(businessHour.type);
if (!businessHourType) {
return;
}
return businessHourType.saveBusinessHour({
...businessHour,
timezoneName: businessHour.timezone.name,
workHours: businessHour.workHours.map((hour) => ({ ...hour, start: hour.start.time, finish: hour.finish.time })) as Record<
string,
any
>[],
} as ILivechatBusinessHour & { timezoneName: string });
}),
);
const failed = result.filter((r) => r.status === 'rejected');
if (failed.length > 0) {
failed.forEach((error: any) => {
businessHourLogger.error({ msg: 'Failed to update business hours with new timezone', err: error });
});
}
await this.createCronJobsForWorkHours();
}
}

View File

@@ -0,0 +1,36 @@
import type { ILivechatBusinessHour } from '@rocket.chat/core-typings';
import { LivechatBusinessHourTypes } from '@rocket.chat/core-typings';
import moment from 'moment-timezone';
import type { IBusinessHourType } from './AbstractBusinessHour';
import { AbstractBusinessHourType } from './AbstractBusinessHour';
interface IExtraProperties {
timezoneName?: string;
}
export class DefaultBusinessHour extends AbstractBusinessHourType implements IBusinessHourType {
name = LivechatBusinessHourTypes.DEFAULT;
getBusinessHour(): Promise<ILivechatBusinessHour | null> {
return this.BusinessHourRepository.findOneDefaultBusinessHour();
}
async saveBusinessHour(businessHour: ILivechatBusinessHour & IExtraProperties): Promise<ILivechatBusinessHour> {
const { timezoneName, ...businessHourData } = businessHour;
if (!businessHourData._id) {
return businessHourData;
}
businessHourData.timezone = {
name: timezoneName || moment.tz.guess(),
utc: this.getUTCFromTimezone(timezoneName),
};
await this.baseSaveBusinessHour(businessHourData);
return businessHourData;
}
removeBusinessHourById(): Promise<void> {
return Promise.resolve();
}
}

View File

@@ -0,0 +1,106 @@
import type { ILivechatBusinessHour } from '@rocket.chat/core-typings';
import { ILivechatAgentStatus, LivechatBusinessHourTypes } from '@rocket.chat/core-typings';
import { LivechatBusinessHours, Users } from '@rocket.chat/models';
import moment from 'moment';
import { createDefaultBusinessHourRow } from './LivechatBusinessHours';
import { filterBusinessHoursThatMustBeOpened } from './filterBusinessHoursThatMustBeOpened';
import { notifyOnUserChangeAsync } from '../../notifyListener';
import { businessHourLogger } from '../logger';
export { filterBusinessHoursThatMustBeOpened };
export const filterBusinessHoursThatMustBeOpenedByDay = async (
businessHours: Pick<ILivechatBusinessHour, '_id' | 'type' | 'active' | 'workHours'>[],
day: string, // Format: moment.format('dddd')
): Promise<Pick<ILivechatBusinessHour, '_id' | 'type'>[]> => {
return filterBusinessHoursThatMustBeOpened(
businessHours.filter((businessHour) =>
businessHour.workHours.some((workHour) => workHour.start.utc.dayOfWeek === day || workHour.finish.utc.dayOfWeek === day),
),
);
};
export const openBusinessHourDefault = async (): Promise<void> => {
try {
await Users.removeBusinessHoursFromAllUsers();
const currentTime = moment(moment().format('dddd:HH:mm'), 'dddd:HH:mm');
const day = currentTime.format('dddd');
const activeBusinessHours = await LivechatBusinessHours.findDefaultActiveAndOpenBusinessHoursByDay(day, {
projection: {
workHours: 1,
timezone: 1,
type: 1,
active: 1,
},
});
const businessHoursToOpenIds = (await filterBusinessHoursThatMustBeOpened(activeBusinessHours)).map((businessHour) => businessHour._id);
businessHourLogger.debug({ msg: 'Opening default business hours', businessHoursToOpenIds });
await Users.openAgentsBusinessHoursByBusinessHourId(businessHoursToOpenIds);
if (businessHoursToOpenIds.length) {
await makeOnlineAgentsAvailable();
}
await makeAgentsUnavailableBasedOnBusinessHour();
} catch (err) {
businessHourLogger.error({ msg: 'Error while opening default business hours', err });
}
};
export const createDefaultBusinessHourIfNotExists = async (): Promise<void> => {
if ((await LivechatBusinessHours.countDocuments({ type: LivechatBusinessHourTypes.DEFAULT })) === 0) {
await LivechatBusinessHours.insertOne(createDefaultBusinessHourRow());
}
};
export async function makeAgentsUnavailableBasedOnBusinessHour(agentIds?: string[]) {
const results = await Users.findAgentsAvailableWithoutBusinessHours(agentIds).toArray();
const update = await Users.updateLivechatStatusByAgentIds(
results.map(({ _id }) => _id),
ILivechatAgentStatus.NOT_AVAILABLE,
);
if (update.modifiedCount === 0) {
return;
}
void notifyOnUserChangeAsync(async () =>
results.map(({ _id, openBusinessHours }) => {
return {
id: _id,
clientAction: 'updated',
diff: {
statusLivechat: 'not-available',
openBusinessHours,
},
};
}),
);
}
export async function makeOnlineAgentsAvailable(agentIds?: string[]) {
const results = await Users.findOnlineButNotAvailableAgents(agentIds).toArray();
const update = await Users.updateLivechatStatusByAgentIds(
results.map(({ _id }) => _id),
ILivechatAgentStatus.AVAILABLE,
);
if (update.modifiedCount === 0) {
return;
}
void notifyOnUserChangeAsync(async () =>
results.map(({ _id, openBusinessHours }) => {
return {
id: _id,
clientAction: 'updated',
diff: {
statusLivechat: 'available',
openBusinessHours,
},
};
}),
);
}

View File

@@ -0,0 +1,47 @@
import type { ILivechatBusinessHour } from '@rocket.chat/core-typings';
import { LivechatBusinessHourTypes } from '@rocket.chat/core-typings';
import moment from 'moment-timezone';
import { ObjectId } from 'mongodb';
export const createDefaultBusinessHourRow = (): Omit<ILivechatBusinessHour, '_updatedAt'> => {
const days = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'];
const closedDays = ['Saturday', 'Sunday'];
return {
_id: new ObjectId().toHexString(),
name: '',
active: true,
type: LivechatBusinessHourTypes.DEFAULT,
ts: new Date(),
workHours: days.map((day, index) => ({
day,
start: {
time: '08:00',
utc: {
dayOfWeek: moment(`${day}:08:00`, 'dddd:HH:mm').utc().format('dddd'),
time: moment(`${day}:08:00`, 'dddd:HH:mm').utc().format('HH:mm'),
},
cron: {
dayOfWeek: moment(`${day}:08:00`, 'dddd:HH:mm').format('dddd'),
time: moment(`${day}:08:00`, 'dddd:HH:mm').format('HH:mm'),
},
},
finish: {
time: '20:00',
utc: {
dayOfWeek: moment(`${day}:20:00`, 'dddd:HH:mm').utc().format('dddd'),
time: moment(`${day}:20:00`, 'dddd:HH:mm').utc().format('HH:mm'),
},
cron: {
dayOfWeek: moment(`${day}:20:00`, 'dddd:HH:mm').format('dddd'),
time: moment(`${day}:20:00`, 'dddd:HH:mm').format('HH:mm'),
},
},
code: index + 1,
open: !closedDays.includes(day),
})),
timezone: {
name: moment.tz.guess(),
utc: String(moment().utcOffset() / 60),
},
};
};

View File

@@ -0,0 +1,99 @@
import { ILivechatAgentStatus, LivechatBusinessHourTypes } from '@rocket.chat/core-typings';
import { LivechatBusinessHours, Users } from '@rocket.chat/models';
import type { IBusinessHourBehavior } from './AbstractBusinessHour';
import { AbstractBusinessHourBehavior } from './AbstractBusinessHour';
import { filterBusinessHoursThatMustBeOpened, makeAgentsUnavailableBasedOnBusinessHour, openBusinessHourDefault } from './Helper';
import { notifyOnUserChange } from '../../notifyListener';
import { businessHourLogger } from '../logger';
export class SingleBusinessHourBehavior extends AbstractBusinessHourBehavior implements IBusinessHourBehavior {
async openBusinessHoursByDayAndHour(): Promise<void> {
await openBusinessHourDefault();
}
async closeBusinessHoursByDayAndHour(day: string, hour: string): Promise<void> {
const businessHoursIds = (
await this.BusinessHourRepository.findActiveBusinessHoursToClose(day, hour, LivechatBusinessHourTypes.DEFAULT, {
projection: { _id: 1 },
})
).map((businessHour) => businessHour._id);
await this.UsersRepository.closeAgentsBusinessHoursByBusinessHourIds(businessHoursIds);
await makeAgentsUnavailableBasedOnBusinessHour();
}
async onStartBusinessHours(): Promise<void> {
await openBusinessHourDefault();
}
async onNewAgentCreated(agentId: string): Promise<void> {
const defaultBusinessHour = await LivechatBusinessHours.findOneDefaultBusinessHour();
if (!defaultBusinessHour) {
businessHourLogger.debug({
msg: 'No default business hour found for agentId',
agentId,
});
return;
}
const businessHourToOpen = await filterBusinessHoursThatMustBeOpened([defaultBusinessHour]);
if (!businessHourToOpen.length) {
businessHourLogger.debug({
msg: 'No business hours found. Moving agent to NOT_AVAILABLE status',
agentId,
newStatus: ILivechatAgentStatus.NOT_AVAILABLE,
});
const { modifiedCount } = await Users.setLivechatStatus(agentId, ILivechatAgentStatus.NOT_AVAILABLE);
if (modifiedCount > 0) {
void notifyOnUserChange({
id: agentId,
clientAction: 'updated',
diff: {
statusLivechat: ILivechatAgentStatus.NOT_AVAILABLE,
livechatStatusSystemModified: false,
},
});
}
return;
}
await Users.addBusinessHourByAgentIds([agentId], defaultBusinessHour._id);
businessHourLogger.debug({
msg: 'Business hours found. Moving agent to AVAILABLE status',
agentId,
newStatus: ILivechatAgentStatus.AVAILABLE,
});
}
afterSaveBusinessHours(): Promise<void> {
return openBusinessHourDefault();
}
removeBusinessHourById(): Promise<void> {
return Promise.resolve();
}
onAddAgentToDepartment(): Promise<any> {
return Promise.resolve();
}
onRemoveAgentFromDepartment(): Promise<void> {
return Promise.resolve();
}
onRemoveDepartment(): Promise<void> {
return Promise.resolve();
}
onDepartmentDisabled(): Promise<void> {
return Promise.resolve();
}
onDepartmentArchived(): Promise<void> {
return Promise.resolve();
}
}

View File

@@ -0,0 +1,27 @@
import type { ILivechatBusinessHour, IUser } from '@rocket.chat/core-typings';
import { Users } from '@rocket.chat/models';
import { makeFunction } from '@rocket.chat/patch-injection';
import { makeAgentsUnavailableBasedOnBusinessHour } from './Helper';
import { getAgentIdsForBusinessHour } from './getAgentIdsForBusinessHour';
import { businessHourLogger } from '../logger';
export const closeBusinessHourByAgentIds = async (
businessHourId: ILivechatBusinessHour['_id'],
agentIds: IUser['_id'][],
): Promise<void> => {
businessHourLogger.debug({
msg: 'Closing business hour',
businessHour: businessHourId,
totalAgents: agentIds.length,
top10AgentIds: agentIds.slice(0, 10),
});
await Users.removeBusinessHourByAgentIds(agentIds, businessHourId);
await makeAgentsUnavailableBasedOnBusinessHour();
};
export const closeBusinessHour = makeFunction(async (businessHour: Pick<ILivechatBusinessHour, '_id' | 'type'>): Promise<void> => {
const agentIds = await getAgentIdsForBusinessHour();
return closeBusinessHourByAgentIds(businessHour._id, agentIds);
});

View File

@@ -0,0 +1,541 @@
import { LivechatBusinessHourTypes } from '@rocket.chat/core-typings';
import { filterBusinessHoursThatMustBeOpened } from './filterBusinessHoursThatMustBeOpened';
describe('different timezones between server and business hours saturday ', () => {
beforeEach(() => jest.useFakeTimers().setSystemTime(new Date('2024-04-20T20:10:11Z')));
afterEach(() => jest.useRealTimers());
it('should return a bh when the finish time resolves to a different day on server', async () => {
const bh = await filterBusinessHoursThatMustBeOpened([
{
_id: '65c40fa9052d6750ae25df83',
active: true,
type: LivechatBusinessHourTypes.DEFAULT,
workHours: [
{
day: 'Sunday',
start: {
time: '00:00',
utc: {
dayOfWeek: 'Saturday',
time: '18:30',
},
cron: {
dayOfWeek: 'Saturday',
time: '15:30',
},
},
finish: {
time: '23:59',
utc: {
dayOfWeek: 'Sunday',
time: '18:29',
},
cron: {
dayOfWeek: 'Sunday',
time: '15:29',
},
},
open: true,
code: '',
},
],
},
]);
expect(bh.length).toEqual(1);
});
});
describe('different timezones between server and business hours sunday ', () => {
beforeEach(() => jest.useFakeTimers().setSystemTime(new Date('2025-07-27T11:02:11Z')));
afterEach(() => jest.useRealTimers());
it('should return a bh when the finish time resolves to a different day on server', async () => {
const bh = await filterBusinessHoursThatMustBeOpened([
{
_id: '68516f256ebb4bdceda2757e',
active: true,
type: LivechatBusinessHourTypes.DEFAULT,
workHours: [
{
day: 'Sunday',
start: {
time: '00:00',
utc: {
dayOfWeek: 'Saturday',
time: '18:30',
},
cron: {
dayOfWeek: 'Saturday',
time: '15:30',
},
},
finish: {
time: '23:59',
utc: {
dayOfWeek: 'Sunday',
time: '18:29',
},
cron: {
dayOfWeek: 'Sunday',
time: '15:29',
},
},
open: true,
code: '',
},
{
day: 'Monday',
start: {
time: '00:00',
utc: {
dayOfWeek: 'Sunday',
time: '18:30',
},
cron: {
dayOfWeek: 'Sunday',
time: '15:30',
},
},
finish: {
time: '23:59',
utc: {
dayOfWeek: 'Monday',
time: '18:29',
},
cron: {
dayOfWeek: 'Monday',
time: '15:29',
},
},
open: true,
code: '',
},
{
day: 'Tuesday',
start: {
time: '00:00',
utc: {
dayOfWeek: 'Monday',
time: '18:30',
},
cron: {
dayOfWeek: 'Monday',
time: '15:30',
},
},
finish: {
time: '23:59',
utc: {
dayOfWeek: 'Tuesday',
time: '18:29',
},
cron: {
dayOfWeek: 'Tuesday',
time: '15:29',
},
},
open: true,
code: '',
},
{
day: 'Wednesday',
start: {
time: '00:00',
utc: {
dayOfWeek: 'Tuesday',
time: '18:30',
},
cron: {
dayOfWeek: 'Tuesday',
time: '15:30',
},
},
finish: {
time: '23:59',
utc: {
dayOfWeek: 'Wednesday',
time: '18:29',
},
cron: {
dayOfWeek: 'Wednesday',
time: '15:29',
},
},
open: true,
code: '',
},
{
day: 'Thursday',
start: {
time: '00:00',
utc: {
dayOfWeek: 'Wednesday',
time: '18:30',
},
cron: {
dayOfWeek: 'Wednesday',
time: '15:30',
},
},
finish: {
time: '23:59',
utc: {
dayOfWeek: 'Thursday',
time: '18:29',
},
cron: {
dayOfWeek: 'Thursday',
time: '15:29',
},
},
open: true,
code: '',
},
{
day: 'Friday',
start: {
time: '00:00',
utc: {
dayOfWeek: 'Thursday',
time: '18:30',
},
cron: {
dayOfWeek: 'Thursday',
time: '15:30',
},
},
finish: {
time: '23:59',
utc: {
dayOfWeek: 'Friday',
time: '18:29',
},
cron: {
dayOfWeek: 'Friday',
time: '15:29',
},
},
open: true,
code: '',
},
{
day: 'Saturday',
start: {
time: '00:00',
utc: {
dayOfWeek: 'Friday',
time: '18:30',
},
cron: {
dayOfWeek: 'Friday',
time: '15:30',
},
},
finish: {
time: '23:59',
utc: {
dayOfWeek: 'Saturday',
time: '18:29',
},
cron: {
dayOfWeek: 'Saturday',
time: '15:29',
},
},
open: true,
code: '',
},
],
},
]);
expect(bh.length).toEqual(1);
});
});
describe('regular business hours', () => {
beforeEach(() => jest.useFakeTimers().setSystemTime(new Date('2025-08-07T22:02:11Z')));
afterEach(() => jest.useRealTimers());
it('should return a bh when the finish time resolves to a different day on server', async () => {
const bh = await filterBusinessHoursThatMustBeOpened([
{
_id: '68516f256ebb4bdceda2757e',
active: true,
type: LivechatBusinessHourTypes.DEFAULT,
workHours: [
{
day: 'Sunday',
start: {
time: '00:00',
utc: {
dayOfWeek: 'Sunday',
time: '03:00',
},
cron: {
dayOfWeek: 'Sunday',
time: '00:00',
},
},
finish: {
time: '00:01',
utc: {
dayOfWeek: 'Sunday',
time: '03:01',
},
cron: {
dayOfWeek: 'Sunday',
time: '00:01',
},
},
open: true,
code: '',
},
{
day: 'Monday',
start: {
time: '00:00',
utc: {
dayOfWeek: 'Monday',
time: '03:00',
},
cron: {
dayOfWeek: 'Monday',
time: '00:00',
},
},
finish: {
time: '00:01',
utc: {
dayOfWeek: 'Monday',
time: '03:01',
},
cron: {
dayOfWeek: 'Monday',
time: '00:01',
},
},
open: true,
code: '',
},
{
day: 'Tuesday',
start: {
time: '00:00',
utc: {
dayOfWeek: 'Tuesday',
time: '03:00',
},
cron: {
dayOfWeek: 'Tuesday',
time: '00:00',
},
},
finish: {
time: '00:01',
utc: {
dayOfWeek: 'Tuesday',
time: '03:01',
},
cron: {
dayOfWeek: 'Tuesday',
time: '00:01',
},
},
open: true,
code: '',
},
{
day: 'Wednesday',
start: {
time: '00:00',
utc: {
dayOfWeek: 'Wednesday',
time: '03:00',
},
cron: {
dayOfWeek: 'Wednesday',
time: '00:00',
},
},
finish: {
time: '00:01',
utc: {
dayOfWeek: 'Wednesday',
time: '03:01',
},
cron: {
dayOfWeek: 'Wednesday',
time: '00:01',
},
},
open: true,
code: '',
},
{
day: 'Thursday',
start: {
time: '00:00',
utc: {
dayOfWeek: 'Thursday',
time: '03:00',
},
cron: {
dayOfWeek: 'Thursday',
time: '00:00',
},
},
finish: {
time: '00:01',
utc: {
dayOfWeek: 'Thursday',
time: '03:01',
},
cron: {
dayOfWeek: 'Thursday',
time: '00:01',
},
},
open: true,
code: '',
},
{
day: 'Friday',
start: {
time: '00:00',
utc: {
dayOfWeek: 'Friday',
time: '03:00',
},
cron: {
dayOfWeek: 'Friday',
time: '00:00',
},
},
finish: {
time: '00:01',
utc: {
dayOfWeek: 'Friday',
time: '03:01',
},
cron: {
dayOfWeek: 'Friday',
time: '00:01',
},
},
open: true,
code: '',
},
{
day: 'Saturday',
start: {
time: '00:00',
utc: {
dayOfWeek: 'Saturday',
time: '03:00',
},
cron: {
dayOfWeek: 'Saturday',
time: '00:00',
},
},
finish: {
time: '00:01',
utc: {
dayOfWeek: 'Saturday',
time: '03:01',
},
cron: {
dayOfWeek: 'Saturday',
time: '00:01',
},
},
open: true,
code: '',
},
],
},
]);
expect(bh.length).toEqual(0);
});
});
describe('finish time boundary', () => {
afterEach(() => jest.useRealTimers());
// 00:00-23:59 in Asia/Kolkata, which on a UTC server runs from Saturday 18:30 to Sunday 18:29
const alwaysOpenSunday = [
{
_id: '68516f256ebb4bdceda2757e',
name: '',
active: true,
type: LivechatBusinessHourTypes.DEFAULT,
ts: new Date(),
workHours: [
{
day: 'Sunday',
start: {
time: '00:00',
utc: { dayOfWeek: 'Saturday', time: '18:30' },
cron: { dayOfWeek: 'Saturday', time: '18:30' },
},
finish: {
time: '23:59',
utc: { dayOfWeek: 'Sunday', time: '18:29' },
cron: { dayOfWeek: 'Sunday', time: '18:29' },
},
open: true,
code: '',
},
],
timezone: {
name: 'Asia/Kolkata',
utc: '+05:30',
},
},
];
it('should keep the business hour open during its finish minute', async () => {
jest.useFakeTimers().setSystemTime(new Date('2025-07-27T18:29:30Z'));
expect(await filterBusinessHoursThatMustBeOpened(alwaysOpenSunday)).toHaveLength(1);
});
it('should close the business hour once the finish minute has elapsed', async () => {
jest.useFakeTimers().setSystemTime(new Date('2025-07-27T18:30:30Z'));
expect(await filterBusinessHoursThatMustBeOpened(alwaysOpenSunday)).toHaveLength(0);
});
it('should keep a same-day business hour open during its finish minute', async () => {
jest.useFakeTimers().setSystemTime(new Date('2025-07-28T20:00:30Z'));
const bh = await filterBusinessHoursThatMustBeOpened([
{
_id: '68516f256ebb4bdceda2757f',
active: true,
type: LivechatBusinessHourTypes.DEFAULT,
workHours: [
{
day: 'Monday',
start: {
time: '08:00',
utc: { dayOfWeek: 'Monday', time: '08:00' },
cron: { dayOfWeek: 'Monday', time: '08:00' },
},
finish: {
time: '20:00',
utc: { dayOfWeek: 'Monday', time: '20:00' },
cron: { dayOfWeek: 'Monday', time: '20:00' },
},
open: true,
code: '',
},
],
},
]);
expect(bh).toHaveLength(1);
});
});

View File

@@ -0,0 +1,58 @@
import type { ILivechatBusinessHour } from '@rocket.chat/core-typings';
import moment from 'moment';
export const filterBusinessHoursThatMustBeOpened = async (
businessHours: Pick<ILivechatBusinessHour, '_id' | 'type' | 'active' | 'workHours'>[],
): Promise<Pick<ILivechatBusinessHour, '_id' | 'type'>[]> => {
const currentTime = moment(moment().format('dddd:HH:mm:ss'), 'dddd:HH:mm:ss');
return businessHours
.filter(
(businessHour) =>
businessHour.active &&
businessHour.workHours
.filter((hour) => hour.open)
.some((hour) => {
const localTimeStart = moment(`${hour.start.cron.dayOfWeek}:${hour.start.cron.time}:00`, 'dddd:HH:mm:ss');
const localTimeFinish = moment(`${hour.finish.cron.dayOfWeek}:${hour.finish.cron.time}:00`, 'dddd:HH:mm:ss');
/** because we use `dayOfWeek` moment decides if saturday/sunday belongs to the current week or the next one, this is a bit
* confusing and for that reason we need this workaround
*/
const currentDay = currentTime.format('dddd');
const localTimeStartDay = localTimeStart.format('dddd');
// This only works for sundays (where we can test if sunday is before saturday = something is wrong)
if (localTimeStart.isAfter(localTimeFinish)) {
localTimeStart.subtract(1, 'week');
}
if (localTimeFinish.isBefore(localTimeStart)) {
localTimeFinish.add(1, 'week');
}
// During Saturday, if current weekday is the same but the start time is after the current time, we need to subtract a week
if (currentDay === localTimeStartDay && localTimeStart.diff(currentTime, 'days') > 0) {
localTimeStart.subtract(1, 'week');
}
// During Saturday, if current weekday is the same but the finish time is before the current time, we need to add a week
if (currentDay === localTimeStartDay && localTimeFinish.diff(currentTime, 'days') < 0) {
localTimeFinish.add(1, 'week');
}
// Work hours are configured at minute granularity, so a window stays open until the end of its
// `finish` minute. Treating `finish` as exclusive leaves consecutive daily windows disjoint:
// a 00:00-23:59 business hour (the only way the UI can express "always open") would close
// for the whole 23:59 minute, every day.
const afterFinishMinute = localTimeFinish.clone().add(1, 'minute');
return currentTime.isSameOrAfter(localTimeStart) && currentTime.isBefore(afterFinishMinute);
}),
)
.map((businessHour) => ({
_id: businessHour._id,
type: businessHour.type,
}));
};

View File

@@ -0,0 +1,45 @@
import type { IUser } from '@rocket.chat/core-typings';
import { LivechatDepartment, LivechatDepartmentAgents, Users } from '@rocket.chat/models';
const getAllAgentIdsWithoutDepartment = async (): Promise<string[]> => {
// Fetch departments with agents excluding archived ones (disabled ones still can be tied to business hours)
// Then find the agents that are not in any of those departments
const departmentIds = (await LivechatDepartment.findNotArchived({ projection: { _id: 1 } }).toArray()).map(({ _id }) => _id);
const agentIdsWithDepartment = await LivechatDepartmentAgents.findAllAgentsConnectedToListOfDepartments(departmentIds);
const agentIdsWithoutDepartment = (
await Users.findUsersInRolesWithQuery(
'livechat-agent',
{
_id: { $nin: agentIdsWithDepartment },
},
{ projection: { _id: 1 } },
).toArray()
).map((user) => user._id);
return agentIdsWithoutDepartment;
};
const getAllAgentIdsWithDepartmentNotConnectedToBusinessHour = async (): Promise<string[]> => {
const activeDepartmentsWithoutBusinessHour = (
await LivechatDepartment.findActiveDepartmentsWithoutBusinessHour({
projection: { _id: 1 },
}).toArray()
).map((dept) => dept._id);
const agentIdsWithDepartmentNotConnectedToBusinessHour = await LivechatDepartmentAgents.findAllAgentsConnectedToListOfDepartments(
activeDepartmentsWithoutBusinessHour,
);
return agentIdsWithDepartmentNotConnectedToBusinessHour;
};
export const getAgentIdsForBusinessHour = async (): Promise<IUser['_id'][]> => {
const [withoutDepartment, withDepartmentNotConnectedToBusinessHour] = await Promise.all([
getAllAgentIdsWithoutDepartment(),
getAllAgentIdsWithDepartmentNotConnectedToBusinessHour(),
]);
return [...new Set([...withoutDepartment, ...withDepartmentNotConnectedToBusinessHour])];
};

View File

@@ -0,0 +1,23 @@
import type { IUser } from '@rocket.chat/core-typings';
import { cronJobs } from '@rocket.chat/cron';
import { Accounts } from 'meteor/accounts-base';
import { Meteor } from 'meteor/meteor';
import { BusinessHourManager } from './BusinessHourManager';
import { DefaultBusinessHour } from './Default';
import { SingleBusinessHourBehavior } from './Single';
import { callbacks } from '../../callbacks';
export const businessHourManager = new BusinessHourManager(cronJobs);
Meteor.startup(async () => {
const { BusinessHourBehaviorClass } = await callbacks.run('on-business-hour-start', {
BusinessHourBehaviorClass: SingleBusinessHourBehavior,
});
businessHourManager.registerBusinessHourBehavior(new BusinessHourBehaviorClass());
businessHourManager.registerBusinessHourType(new DefaultBusinessHour());
Accounts.onLogin(({ user }: { user: IUser }) => {
void (user?.roles?.includes('livechat-agent') && !user?.roles?.includes('bot') && businessHourManager.onLogin(user._id));
});
});

View File

@@ -0,0 +1,87 @@
import type { IUser, IRoom, IOmnichannelRoom } from '@rocket.chat/core-typings';
import { LivechatRooms, Subscriptions } from '@rocket.chat/models';
import { closeRoom } from './closeRoom';
import type { CloseRoomParams } from './localTypes';
import { hasPermissionAsync } from '../authorization/hasPermission';
export const closeLivechatRoom = async (
user: IUser,
roomId: IRoom['_id'],
{
comment,
tags,
generateTranscriptPdf,
transcriptEmail,
forceClose = false,
}: {
comment?: string;
tags?: string[];
generateTranscriptPdf?: boolean;
transcriptEmail?:
| {
sendToVisitor: false;
}
| {
sendToVisitor: true;
requestData: Pick<NonNullable<IOmnichannelRoom['transcriptRequest']>, 'email' | 'subject'>;
};
forceClose?: boolean;
},
): Promise<void> => {
const room = await LivechatRooms.findOneById(roomId);
if (!room) {
throw new Error('error-invalid-room');
}
const subscription = await Subscriptions.findOneByRoomIdAndUserId(roomId, user._id, { projection: { _id: 1 } });
if (!subscription && !(await hasPermissionAsync(user, 'close-others-livechat-room'))) {
throw new Error('error-not-authorized');
}
const options: CloseRoomParams['options'] = {
clientAction: true,
tags,
...(generateTranscriptPdf && { pdfTranscript: { requestedBy: user._id } }),
...(transcriptEmail && {
...(transcriptEmail.sendToVisitor
? {
emailTranscript: {
sendToVisitor: true,
requestData: {
email: transcriptEmail.requestData.email,
subject: transcriptEmail.requestData.subject,
requestedAt: new Date(),
requestedBy: user,
},
},
}
: {
emailTranscript: {
sendToVisitor: false,
},
}),
}),
};
if (forceClose) {
return closeRoom({
room,
user,
options,
comment,
forceClose,
});
}
if (!room.open) {
throw new Error('error-room-already-closed');
}
return closeRoom({
room,
user,
options,
comment,
});
};

View File

@@ -0,0 +1,34 @@
import type { IUser } from '@rocket.chat/core-typings';
import { LivechatRooms } from '@rocket.chat/models';
import { closeRoom } from './closeRoom';
import { settings } from '../../settings';
import { callbacks } from '../callbacks';
import { i18n } from '../i18n';
type SubscribedRooms = {
rid: string;
t: string;
};
export const closeOmnichannelConversations = async (
user: IUser,
subscribedRooms: SubscribedRooms[],
executedBy?: string,
): Promise<void> => {
const extraQuery = await callbacks.run('livechat.applyRoomRestrictions', {}, { userId: executedBy });
const roomsInfo = LivechatRooms.findByIds(
subscribedRooms.map(({ rid }) => rid),
{},
extraQuery,
);
const language = settings.get<string>('Language') || 'en';
const comment = i18n.t('Agent_deactivated', { lng: language });
const promises: Promise<void>[] = [];
await roomsInfo.forEach((room) => {
promises.push(closeRoom({ user, room, comment }));
});
await Promise.all(promises);
};

View File

@@ -0,0 +1,297 @@
import { Apps, AppEvents } from '@rocket.chat/apps';
import { Message } from '@rocket.chat/core-services';
import type { ILivechatDepartment, ILivechatInquiryRecord, IOmnichannelRoom, IOmnichannelRoomClosingInfo } from '@rocket.chat/core-typings';
import { isOmnichannelRoom } from '@rocket.chat/core-typings';
import { LivechatDepartment, LivechatInquiry, LivechatRooms, Subscriptions, Users } from '@rocket.chat/models';
import { applyDepartmentRestrictions } from '@rocket.chat/omni-core';
import type { ClientSession } from 'mongodb';
import type { CloseRoomParams, CloseRoomParamsByUser, CloseRoomParamsByVisitor } from './localTypes';
import { livechatLogger as logger } from './logger';
import { parseTranscriptRequest } from './parseTranscriptRequest';
import { client, shouldRetryTransaction } from '../../database/utils';
import { settings } from '../../settings';
import { callbacks } from '../callbacks';
import {
notifyOnLivechatInquiryChanged,
notifyOnRoomChanged,
notifyOnRoomChangedById,
notifyOnSubscriptionChanged,
} from '../notifyListener';
type ChatCloser = { _id: string; username: string | undefined };
const isRoomClosedByUserParams = (params: CloseRoomParams): params is CloseRoomParamsByUser =>
(params as CloseRoomParamsByUser).user !== undefined;
const isRoomClosedByVisitorParams = (params: CloseRoomParams): params is CloseRoomParamsByVisitor =>
(params as CloseRoomParamsByVisitor).visitor !== undefined;
export async function closeRoom(params: CloseRoomParams, attempts = 2): Promise<void> {
let newRoom: IOmnichannelRoom;
let chatCloser: ChatCloser;
let removedInquiryObj: ILivechatInquiryRecord | null;
const session = client.startSession();
try {
session.startTransaction();
const { room, closedBy, removedInquiry } = await doCloseRoom(params, session);
await session.commitTransaction();
newRoom = room;
chatCloser = closedBy;
removedInquiryObj = removedInquiry;
} catch (e) {
logger.error({ err: e, msg: 'Failed to close room', afterAttempts: attempts });
if (session.inTransaction()) {
await session.abortTransaction();
}
// Dont propagate transaction errors
if (shouldRetryTransaction(e)) {
if (attempts > 0) {
logger.debug({ msg: 'Retrying close room because of transient error', attemptsLeft: attempts });
return closeRoom(params, attempts - 1);
}
throw new Error('error-room-cannot-be-closed-try-again');
}
throw e;
} finally {
await session.endSession();
}
// Note: when reaching this point, the room has been closed
// Transaction is commited and so these messages can be sent here.
return afterRoomClosed(newRoom, chatCloser, removedInquiryObj, params);
}
async function afterRoomClosed(
newRoom: IOmnichannelRoom,
chatCloser: ChatCloser,
inquiry: ILivechatInquiryRecord | null,
params: CloseRoomParams,
): Promise<void> {
if (!chatCloser) {
// this should never happen
return;
}
// Note: we are okay with these messages being sent outside of the transaction. The process of sending a message
// is huge and involves multiple db calls. Making it transactionable this way would be really hard.
// And passing just _some_ actions to the transaction creates some deadlocks since messages are updated in the afterSaveMessages callbacks.
const transcriptRequested =
!!params.room.transcriptRequest || (!settings.get('Livechat_enable_transcript') && settings.get('Livechat_transcript_send_always'));
logger.debug({ msg: 'Sending closing message to room', roomId: newRoom._id });
await Message.saveSystemMessageAndNotifyUser('livechat-close', newRoom._id, params.comment ?? '', chatCloser, {
groupable: false,
transcriptRequested,
...(isRoomClosedByVisitorParams(params) && { token: params.visitor.token }),
});
if (settings.get('Livechat_enable_transcript') && !settings.get('Livechat_transcript_send_always')) {
await Message.saveSystemMessage('command', newRoom._id, 'promptTranscript', chatCloser);
}
logger.debug({ msg: 'Running callbacks for room', roomId: newRoom._id });
process.nextTick(() => {
/**
* @deprecated the `AppEvents.ILivechatRoomClosedHandler` event will be removed
* in the next major version of the Apps-Engine
*/
void Apps.self?.triggerEvent(AppEvents.ILivechatRoomClosedHandler, newRoom);
void Apps.self?.triggerEvent(AppEvents.IPostLivechatRoomClosed, newRoom);
});
const visitor = isRoomClosedByVisitorParams(params) ? params.visitor : undefined;
const opts = await parseTranscriptRequest(params.room, params.options, visitor);
if (process.env.TEST_MODE) {
await callbacks.run('livechat.closeRoom', {
room: newRoom,
options: opts,
});
} else {
callbacks.runAsync('livechat.closeRoom', {
room: newRoom,
options: opts,
});
}
void notifyOnRoomChangedById(newRoom._id);
if (inquiry) {
void notifyOnLivechatInquiryChanged(inquiry, 'removed');
}
logger.debug({ msg: 'Room was closed', roomId: newRoom._id });
}
async function doCloseRoom(
params: CloseRoomParams,
session: ClientSession,
): Promise<{ room: IOmnichannelRoom; closedBy: ChatCloser; removedInquiry: ILivechatInquiryRecord | null }> {
const { comment } = params;
const { room, forceClose } = params;
logger.debug({ msg: 'Attempting to close room', roomId: room._id, forceClose });
if (!room || !isOmnichannelRoom(room) || (!forceClose && !room.open)) {
logger.debug({ msg: 'Room is not open', roomId: room._id });
throw new Error('error-room-closed');
}
const commentRequired = settings.get('Livechat_request_comment_when_closing_conversation');
if (commentRequired && !comment?.trim()) {
throw new Error('error-comment-is-required');
}
const { updatedOptions: options } = await resolveChatTags(room, params.options);
logger.debug({ msg: 'Resolved chat tags for room', roomId: room._id });
const now = new Date();
const { _id: rid, servedBy } = room;
const serviceTimeDuration = servedBy && (now.getTime() - new Date(servedBy.ts).getTime()) / 1000;
const closeData: IOmnichannelRoomClosingInfo = {
closedAt: now,
chatDuration: (now.getTime() - new Date(room.ts).getTime()) / 1000,
...(serviceTimeDuration && { serviceTimeDuration }),
...options,
};
logger.debug({ msg: 'Room was closed', roomId: room._id, closedAt: closeData.closedAt, chatDuration: closeData.chatDuration });
if (isRoomClosedByUserParams(params)) {
const { user } = params;
logger.debug({ msg: 'Closing by user', userId: user?._id });
closeData.closer = 'user';
closeData.closedBy = {
_id: user?._id || '',
username: user?.username,
};
} else if (isRoomClosedByVisitorParams(params)) {
const { visitor } = params;
logger.debug({ msg: 'Closing by visitor', visitorId: params.visitor._id });
closeData.closer = 'visitor';
closeData.closedBy = {
_id: visitor._id,
username: visitor.username,
};
} else {
throw new Error('Error: Please provide details of the user or visitor who closed the room');
}
logger.debug({ msg: 'Updating DB for room with close data', roomId: room._id });
const inquiry = await LivechatInquiry.findOneByRoomId(rid, { session });
const removedInquiry = await LivechatInquiry.removeByRoomId(rid, { session });
if (!params.forceClose && removedInquiry && removedInquiry.deletedCount !== 1) {
throw new Error('Error removing inquiry');
}
if (removedInquiry.deletedCount) {
void notifyOnLivechatInquiryChanged(inquiry!, 'removed');
}
const updatedRoom = await LivechatRooms.closeRoomById(rid, closeData, { session });
if (!params.forceClose && updatedRoom?.modifiedCount !== 1) {
throw new Error('Error closing room');
}
const subs = await Subscriptions.countByRoomId(rid, { session });
if (subs) {
const removedSubs = await Subscriptions.removeByRoomId(rid, {
async onTrash(doc) {
void notifyOnSubscriptionChanged(doc, 'removed');
},
session,
});
if (!params.forceClose && removedSubs.deletedCount !== subs) {
throw new Error('Error removing subscriptions');
}
}
logger.debug({ msg: 'DB updated for room', roomId: room._id });
// Retrieve the closed room
const newRoom = await LivechatRooms.findOneById(rid, { session });
if (!newRoom) {
throw new Error('Error: Room not found');
}
void notifyOnRoomChanged(newRoom, 'updated');
return { room: newRoom, closedBy: closeData.closedBy, removedInquiry: inquiry };
}
async function resolveChatTags(
room: IOmnichannelRoom,
options: CloseRoomParams['options'] = {},
): Promise<{ updatedOptions: CloseRoomParams['options'] }> {
logger.debug({ msg: 'Resolving chat tags for room', roomId: room._id });
const concatUnique = (...arrays: (string[] | undefined)[]): string[] => [
...new Set(([] as string[]).concat(...arrays.filter((a): a is string[] => !!a))),
];
const { departmentId, tags: optionsTags } = room;
const { clientAction, tags: oldRoomTags } = options;
const roomTags = concatUnique(oldRoomTags, optionsTags);
if (!departmentId) {
return {
updatedOptions: {
...options,
...(roomTags.length && { tags: roomTags }),
},
};
}
const department = await LivechatDepartment.findOneById<Pick<ILivechatDepartment, 'requestTagBeforeClosingChat' | 'chatClosingTags'>>(
departmentId,
{
projection: { requestTagBeforeClosingChat: 1, chatClosingTags: 1 },
},
);
if (!department) {
return {
updatedOptions: {
...options,
...(roomTags.length && { tags: roomTags }),
},
};
}
const { requestTagBeforeClosingChat, chatClosingTags } = department;
const extraRoomTags = concatUnique(roomTags, chatClosingTags);
if (!requestTagBeforeClosingChat) {
return {
updatedOptions: {
...options,
...(extraRoomTags.length && { tags: extraRoomTags }),
},
};
}
const checkRoomTags = !clientAction || (roomTags && roomTags.length > 0);
const checkDepartmentTags = chatClosingTags && chatClosingTags.length > 0;
if (!checkRoomTags || !checkDepartmentTags) {
throw new Error('error-tags-must-be-assigned-before-closing-chat');
}
return {
updatedOptions: {
...options,
...(extraRoomTags.length && { tags: extraRoomTags }),
},
};
}
export async function closeOpenChats(userId: string, comment?: string) {
logger.debug({ msg: 'Closing open chats for user', userId });
const user = await Users.findOneById(userId);
const extraQuery = await applyDepartmentRestrictions({}, userId);
const openChats = LivechatRooms.findOpenByAgent(userId, extraQuery);
const promises: Promise<void>[] = [];
await openChats.forEach((room) => {
promises.push(closeRoom({ user, room, comment }));
});
await Promise.all(promises);
}

View File

@@ -0,0 +1,36 @@
import { Users } from '@rocket.chat/models';
import { settings } from '../../settings';
import { hasRoleAsync } from '../authorization/hasRole';
type LockResult = {
acquired: boolean;
required: boolean;
unlock: () => Promise<void>;
};
export async function conditionalLockAgent(agentId: string): Promise<LockResult> {
// Lock and chats limits enforcement are only required when waiting_queue is enabled and the agent is not a bot
const shouldLock = settings.get<boolean>('Livechat_waiting_queue');
if (!shouldLock || (await hasRoleAsync(agentId, 'bot'))) {
return {
acquired: false,
required: false,
unlock: async () => {
// no-op
},
};
}
const lockTime = new Date();
const lockAcquired = await Users.acquireAgentLock(agentId, lockTime);
return {
acquired: !!lockAcquired,
required: true,
unlock: async () => {
await Users.releaseAgentLock(agentId, lockTime);
},
};
}

View File

@@ -0,0 +1,336 @@
import type {
ILivechatContact,
ILivechatVisitor,
ILivechatContactChannel,
ILivechatContactConflictingField,
IUser,
DeepWritable,
IOmnichannelSource,
} from '@rocket.chat/core-typings';
import { LivechatContacts } from '@rocket.chat/models';
import type { ClientSession, UpdateFilter } from 'mongodb';
import { getContactManagerIdByUsername } from './getContactManagerIdByUsername';
import { isSameChannel } from '../../../../app/livechat/lib/isSameChannel';
type ManagerValue = { id: string } | { username: string };
type ContactFields = {
email: string;
phone: string;
name: string;
username: string;
manager: ManagerValue;
channel: ILivechatContactChannel;
activity: string[];
};
type CustomFieldAndValue = { type: `customFields.${string}`; value: string };
export type FieldAndValue =
| { type: keyof Omit<ContactFields, 'manager' | 'channel'>; value: string }
| { type: 'manager'; value: ManagerValue }
| { type: 'channel'; value: ILivechatContactChannel }
| { type: 'activity'; value: string[] }
| CustomFieldAndValue;
type ConflictHandlingMode = 'conflict' | 'overwrite' | 'ignore';
type MergeFieldsIntoContactParams = {
fields: FieldAndValue[];
contact: ILivechatContact;
conflictHandlingMode?: ConflictHandlingMode;
session?: ClientSession;
};
export class ContactMerger {
private managerList = new Map<Required<IUser>['username'], IUser['_id'] | undefined>();
private getManagerId(manager: ManagerValue): IUser['_id'] | undefined {
if ('id' in manager) {
return manager.id;
}
return this.managerList.get(manager.username);
}
private isSameManager(manager1: ManagerValue, manager2: ManagerValue): boolean {
if ('id' in manager1 && 'id' in manager2) {
return manager1.id === manager2.id;
}
if ('username' in manager1 && 'username' in manager2) {
return manager1.username === manager2.username;
}
const id1 = this.getManagerId(manager1);
const id2 = this.getManagerId(manager2);
if (!id1 || !id2) {
return false;
}
return id1 === id2;
}
private isSameField(field1: FieldAndValue, field2: FieldAndValue): boolean {
if (field1.type === 'manager' && field2.type === 'manager') {
return this.isSameManager(field1.value, field2.value);
}
if (field1.type === 'channel' && field2.type === 'channel') {
return isSameChannel(field1.value.visitor, field2.value.visitor);
}
if (field1.type !== field2.type) {
return false;
}
if (field1.value === field2.value) {
return true;
}
return false;
}
private async loadDataForFields(session: ClientSession | undefined, ...fieldLists: FieldAndValue[][]): Promise<void> {
for (const fieldList of fieldLists) {
for (const field of fieldList) {
if (field.type !== 'manager' || 'id' in field.value) {
continue;
}
if (!field.value.username) {
continue;
}
if (this.managerList.has(field.value.username)) {
continue;
}
const id = await getContactManagerIdByUsername(field.value.username, session);
this.managerList.set(field.value.username, id);
}
}
}
static async createWithFields(session: ClientSession | undefined, ...fieldLists: FieldAndValue[][]): Promise<ContactMerger> {
const merger = new ContactMerger();
await merger.loadDataForFields(session, ...fieldLists);
return merger;
}
static getAllFieldsFromContact(contact: ILivechatContact): FieldAndValue[] {
const { customFields = {}, name, contactManager, activity } = contact;
const fields = new Set<FieldAndValue>();
contact.emails?.forEach(({ address: value }) => fields.add({ type: 'email', value }));
contact.phones?.forEach(({ phoneNumber: value }) => fields.add({ type: 'phone', value }));
contact.channels.forEach((value) => fields.add({ type: 'channel', value }));
if (name) {
fields.add({ type: 'name', value: name });
}
if (contactManager) {
fields.add({ type: 'manager', value: { id: contactManager } });
}
if (activity) {
fields.add({ type: 'activity', value: activity });
}
Object.keys(customFields).forEach((key) =>
fields.add({ type: `customFields.${key}`, value: customFields[key] } as CustomFieldAndValue),
);
// If the contact already has conflicts, load their values as well
if (contact.conflictingFields) {
for (const conflict of contact.conflictingFields) {
fields.add({ type: conflict.field, value: conflict.value } as FieldAndValue);
}
}
return [...fields];
}
static async getAllFieldsFromVisitor(visitor: ILivechatVisitor, source?: IOmnichannelSource): Promise<FieldAndValue[]> {
const { livechatData: customFields = {}, contactManager, name, username } = visitor;
const fields = new Set<FieldAndValue>();
visitor.visitorEmails?.forEach(({ address: value }) => fields.add({ type: 'email', value }));
visitor.phone?.forEach(({ phoneNumber: value }) => fields.add({ type: 'phone', value }));
if (name) {
fields.add({ type: 'name', value: name });
}
if (username) {
fields.add({ type: 'username', value: username });
}
if (contactManager?.username) {
fields.add({ type: 'manager', value: { username: contactManager?.username } });
}
Object.keys(customFields).forEach((key) =>
fields.add({ type: `customFields.${key}`, value: customFields[key] } as CustomFieldAndValue),
);
if (source) {
fields.add({
type: 'channel',
value: {
name: source.label || source.type.toString(),
visitor: {
visitorId: visitor._id,
source: {
type: source.type,
id: source.id,
},
},
blocked: false,
verified: false,
details: source,
},
});
}
return [...fields];
}
static getFieldValuesByType<T extends keyof ContactFields>(fields: FieldAndValue[], type: T): ContactFields[T][] {
return fields.filter((field) => field.type === type).map(({ value }) => value) as ContactFields[T][];
}
static async mergeFieldsIntoContact({
fields,
contact,
conflictHandlingMode = 'conflict',
session,
}: MergeFieldsIntoContactParams): Promise<void> {
const existingFields = ContactMerger.getAllFieldsFromContact(contact);
const overwriteData = conflictHandlingMode === 'overwrite';
const merger = await ContactMerger.createWithFields(session, fields, existingFields);
const newFields = fields.filter((field) => {
// If the field already exists with the same value, ignore it
if (existingFields.some((existingField) => merger.isSameField(existingField, field))) {
return false;
}
// If the field is an username and the contact already has a name, ignore it as well
if (field.type === 'username' && existingFields.some(({ type }) => type === 'name')) {
return false;
}
return true;
});
const newPhones = ContactMerger.getFieldValuesByType(newFields, 'phone');
const newEmails = ContactMerger.getFieldValuesByType(newFields, 'email');
const newChannels = ContactMerger.getFieldValuesByType(newFields, 'channel');
const newActivities = ContactMerger.getFieldValuesByType(newFields, 'activity');
const newNamesOnly = ContactMerger.getFieldValuesByType(newFields, 'name');
const newCustomFields = newFields.filter(({ type }) => type.startsWith('customFields.')) as CustomFieldAndValue[];
// Usernames are ignored unless the contact has no other name
const newUsernames = !contact.name && !newNamesOnly.length ? ContactMerger.getFieldValuesByType(newFields, 'username') : [];
const dataToSet: DeepWritable<UpdateFilter<ILivechatContact>['$set']> = {};
// Names, Managers and Custom Fields need are set as conflicting fields if the contact already has them
const newNames = [...newNamesOnly, ...newUsernames];
const newManagers = ContactMerger.getFieldValuesByType(newFields, 'manager')
.map((manager) => {
if ('id' in manager) {
return manager.id;
}
return merger.getManagerId(manager);
})
.filter((id) => Boolean(id));
if (newNames.length && (!contact.name || overwriteData)) {
const firstName = newNames.shift();
if (firstName) {
dataToSet.name = firstName;
}
}
if (newManagers.length && (!contact.contactManager || overwriteData)) {
const firstManager = newManagers.shift();
if (firstManager) {
dataToSet.contactManager = firstManager;
}
}
if (newActivities.length) {
const newActivity = newActivities.shift();
if (newActivity) {
const distinctActivities = new Set([...newActivity, ...(contact.activity || [])]);
const latestActivities = Array.from(distinctActivities).sort().slice(-12);
dataToSet.activity = latestActivities;
}
}
const customFieldsPerName = new Map<string, CustomFieldAndValue[]>();
for (const customField of newCustomFields) {
if (!customFieldsPerName.has(customField.type)) {
customFieldsPerName.set(customField.type, []);
}
customFieldsPerName.get(customField.type)?.push(customField);
}
for (const [key, customFields] of customFieldsPerName) {
const fieldName = key.replace('customFields.', '');
// If the contact does not have this custom field yet, save the first value directly to the contact instead of as a conflict
if (!contact.customFields?.[fieldName] || overwriteData) {
const first = customFields.shift();
if (first) {
dataToSet[key] = first.value;
}
}
}
const allConflicts: ILivechatContactConflictingField[] =
conflictHandlingMode !== 'conflict'
? []
: [
...newNames.map((name): ILivechatContactConflictingField => ({ field: 'name', value: name })),
...newManagers.map((manager): ILivechatContactConflictingField => ({ field: 'manager', value: manager as string })),
];
// Phones, Emails and Channels are simply added to the contact's existing list
const dataToAdd: UpdateFilter<ILivechatContact>['$addToSet'] = {
...(newPhones.length ? { phones: { $each: newPhones.map((phoneNumber) => ({ phoneNumber })) } } : {}),
...(newEmails.length ? { emails: { $each: newEmails.map((address) => ({ address })) } } : {}),
...(newChannels.length ? { channels: { $each: newChannels } } : {}),
...(allConflicts.length ? { conflictingFields: { $each: allConflicts } } : {}),
};
if (newChannels.length) {
dataToSet.preRegistration = false;
}
const updateData: UpdateFilter<ILivechatContact> = {
...(Object.keys(dataToSet).length ? { $set: dataToSet } : {}),
...(Object.keys(dataToAdd).length ? { $addToSet: dataToAdd } : {}),
};
if (Object.keys(updateData).length) {
await LivechatContacts.updateById(contact._id, updateData, { session });
}
}
public static async mergeVisitorIntoContact(
visitor: ILivechatVisitor,
contact: ILivechatContact,
source?: IOmnichannelSource,
): Promise<void> {
const fields = await ContactMerger.getAllFieldsFromVisitor(visitor, source);
await ContactMerger.mergeFieldsIntoContact({
fields,
contact,
conflictHandlingMode: contact.unknown ? 'overwrite' : 'conflict',
});
}
}

View File

@@ -0,0 +1,20 @@
import type { ILivechatContact } from '@rocket.chat/core-typings';
import { LivechatContacts } from '@rocket.chat/models';
/**
* Adds a new email into the contact's email list, if the email is already in the list it does not add anything
* and simply return the data, since the email was aready registered :P
*
* @param contactId the id of the contact that will be updated
* @param email the email that will be added to the contact
* @returns the updated contact
*/
export async function addContactEmail(contactId: ILivechatContact['_id'], email: string): Promise<ILivechatContact> {
const contact = await LivechatContacts.addEmail(contactId, email);
if (!contact) {
throw new Error('error-contact-not-found');
}
return contact;
}

View File

@@ -0,0 +1,52 @@
import type { ILivechatContactChannel, IVisitorLastChat } from '@rocket.chat/core-typings';
import { LivechatContacts } from '@rocket.chat/models';
import { getAllowedCustomFields } from './getAllowedCustomFields';
import { validateContactManager } from './validateContactManager';
import { validateCustomFields } from './validateCustomFields';
export type CreateContactParams = {
name: string;
emails?: string[];
phones?: string[];
unknown: boolean;
customFields?: Record<string, string | unknown>;
lastChat?: IVisitorLastChat;
contactManager?: string;
channels?: ILivechatContactChannel[];
importIds?: string[];
shouldValidateCustomFields?: boolean;
};
export async function createContact({
name,
emails,
phones,
customFields: receivedCustomFields = {},
lastChat,
contactManager,
channels = [],
unknown,
importIds,
shouldValidateCustomFields = true,
}: CreateContactParams): Promise<string> {
if (contactManager) {
await validateContactManager(contactManager);
}
const customFields = shouldValidateCustomFields
? validateCustomFields(await getAllowedCustomFields(), receivedCustomFields)
: receivedCustomFields;
return LivechatContacts.insertContact({
name,
...(emails && { emails: emails?.map((address) => ({ address })) }),
...(phones && { phones: phones?.map((phoneNumber) => ({ phoneNumber })) }),
...(contactManager && { contactManager }),
...(channels && { channels }),
...(customFields && { customFields }),
...(lastChat && { lastChat }),
unknown,
...(importIds?.length && { importIds }),
});
}

View File

@@ -0,0 +1,24 @@
import type { ILivechatVisitor, IOmnichannelSource } from '@rocket.chat/core-typings';
import { LivechatRooms } from '@rocket.chat/models';
import { createContact } from './createContact';
import { mapVisitorToContact } from './mapVisitorToContact';
export async function createContactFromVisitor(visitor: ILivechatVisitor, source: IOmnichannelSource): Promise<string> {
const contactData = await mapVisitorToContact(visitor, source);
const contactId = await createContact(contactData);
await LivechatRooms.setContactByVisitorAssociation(
{
visitorId: visitor._id,
source: { type: source.type, ...(source.id ? { id: source.id } : {}) },
},
{
_id: contactId,
name: contactData.name,
},
);
return contactId;
}

View File

@@ -0,0 +1,23 @@
import type { ILivechatContact } from '@rocket.chat/core-typings';
import { LivechatContacts, LivechatRooms } from '@rocket.chat/models';
import { settings } from '../../../settings';
import { removeGuest } from '../guests';
export async function disableContactById(contactId: string): Promise<void> {
const contact = await LivechatContacts.findOneEnabledById<Pick<ILivechatContact, '_id' | 'channels'>>(contactId);
if (!contact) {
throw new Error('error-contact-not-found');
}
// Checking if the contact has any open channel/room before removing its data.
const contactOpenRooms = await LivechatRooms.checkContactOpenRooms(contactId);
if (contactOpenRooms && !settings.get<boolean>('Livechat_Allow_collect_and_store_HTTP_header_informations')) {
throw new Error('error-contact-has-open-rooms');
}
// Cleaning contact/visitor data;
await Promise.all(contact.channels.map((channel) => removeGuest({ _id: channel.visitor.visitorId })));
await LivechatContacts.disableByContactId(contactId);
}

View File

@@ -0,0 +1,12 @@
import type { ILivechatCustomField } from '@rocket.chat/core-typings';
import { LivechatCustomField } from '@rocket.chat/models';
export async function getAllowedCustomFields(): Promise<Pick<ILivechatCustomField, '_id' | 'label' | 'regexp' | 'required'>[]> {
return LivechatCustomField.findByScope(
'visitor',
{
projection: { _id: 1, label: 1, regexp: 1, required: 1 },
},
false,
).toArray();
}

View File

@@ -0,0 +1,26 @@
import type { ILivechatContact, ILivechatContactChannel } from '@rocket.chat/core-typings';
import { LivechatContacts } from '@rocket.chat/models';
export async function getContactChannelsGrouped(contactId: string): Promise<ILivechatContactChannel[]> {
const contact = await LivechatContacts.findOneEnabledById<Pick<ILivechatContact, 'channels'>>(contactId, { projection: { channels: 1 } });
if (!contact?.channels) {
return [];
}
const groupedChannels = new Map<string, ILivechatContactChannel>();
contact.channels.forEach((channel: ILivechatContactChannel) => {
const existingChannel = groupedChannels.get(channel.name);
if (!existingChannel) {
return groupedChannels.set(channel.name, channel);
}
if ((channel.lastChat?.ts?.valueOf() || 0) > (existingChannel?.lastChat?.ts?.valueOf() || 0)) {
groupedChannels.set(channel.name, channel);
}
});
return [...groupedChannels.values()];
}

View File

@@ -0,0 +1,76 @@
import type { ILivechatContact, IOmnichannelRoom } from '@rocket.chat/core-typings';
import type { FindPaginated } from '@rocket.chat/model-typings';
import { LivechatContacts, LivechatRooms } from '@rocket.chat/models';
import { makeFunction } from '@rocket.chat/patch-injection';
import type { PaginatedResult, VisitorSearchChatsResult } from '@rocket.chat/rest-typings';
import type { FindOptions, Sort, FindCursor } from 'mongodb';
export type GetContactHistoryParams = {
contactId: string;
source?: string;
count: number;
offset: number;
sort: Sort;
};
export const fetchContactHistory = makeFunction(
async ({
contactId,
options,
}: {
contactId: string;
options?: FindOptions<IOmnichannelRoom>;
extraParams?: Record<string, any>;
}): Promise<FindPaginated<FindCursor<IOmnichannelRoom>>> =>
LivechatRooms.findClosedRoomsByContactPaginated({
contactId,
options,
}),
);
export const getContactHistory = makeFunction(
async (params: GetContactHistoryParams): Promise<PaginatedResult<{ history: VisitorSearchChatsResult[] }>> => {
const { contactId, count, offset, sort } = params;
const contact = await LivechatContacts.findOneEnabledById<Pick<ILivechatContact, '_id'>>(contactId, { projection: { _id: 1 } });
if (!contact) {
throw new Error('error-contact-not-found');
}
const options: FindOptions<IOmnichannelRoom> = {
sort: sort || { closedAt: -1 },
skip: offset,
limit: count,
projection: {
fname: 1,
ts: 1,
v: 1,
msgs: 1,
servedBy: 1,
closedAt: 1,
closedBy: 1,
closer: 1,
tags: 1,
source: 1,
lastMessage: 1,
verified: 1,
},
};
const { totalCount, cursor } = await fetchContactHistory({
contactId: contact._id,
options,
extraParams: params,
});
const [total, history] = await Promise.all([totalCount, cursor.toArray()]);
return {
history,
count: history.length,
offset,
total,
};
},
);

View File

@@ -0,0 +1,8 @@
import type { ILivechatContact, ILivechatContactVisitorAssociation } from '@rocket.chat/core-typings';
import { LivechatContacts } from '@rocket.chat/models';
export async function getContactIdByVisitor(visitor: ILivechatContactVisitorAssociation): Promise<ILivechatContact['_id'] | undefined> {
const contact = await LivechatContacts.findOneByVisitor<Pick<ILivechatContact, '_id'>>(visitor, { projection: { _id: 1 } });
return contact?._id;
}

View File

@@ -0,0 +1,12 @@
import type { IUser } from '@rocket.chat/core-typings';
import { Users } from '@rocket.chat/models';
import type { ClientSession } from 'mongodb';
export async function getContactManagerIdByUsername(
username: Required<IUser>['username'],
session?: ClientSession,
): Promise<IUser['_id'] | undefined> {
const user = await Users.findOneByUsername<Pick<IUser, '_id'>>(username, { projection: { _id: 1 }, session });
return user?._id;
}

View File

@@ -0,0 +1,51 @@
import type { IUser } from '@rocket.chat/core-typings';
import { isNotUndefined } from '@rocket.chat/core-typings';
import { LivechatContacts, Users } from '@rocket.chat/models';
import type { PaginatedResult, ILivechatContactWithManagerData } from '@rocket.chat/rest-typings';
import type { FindCursor, Sort } from 'mongodb';
export type GetContactsParams = {
searchText?: string;
count: number;
offset: number;
sort: Sort;
unknown?: boolean;
};
export async function getContacts(params: GetContactsParams): Promise<PaginatedResult<{ contacts: ILivechatContactWithManagerData[] }>> {
const { searchText, count, offset, sort, unknown } = params;
const { cursor, totalCount } = LivechatContacts.findPaginatedContacts(
{ searchText, unknown },
{
limit: count,
skip: offset,
sort: sort ?? { name: 1 },
},
);
const [rawContacts, total] = await Promise.all([cursor.toArray(), totalCount]);
const managerIds = [...new Set(rawContacts.map(({ contactManager }) => contactManager))].filter(isNotUndefined);
const managersCursor: FindCursor<[string, Pick<IUser, '_id' | 'name' | 'username'>]> = Users.findByIds(managerIds, {
projection: { name: 1, username: 1 },
}).map((manager) => [manager._id, manager]);
const managersData = await managersCursor.toArray();
const mappedManagers = Object.fromEntries(managersData);
const contacts: ILivechatContactWithManagerData[] = rawContacts.map((contact) => {
const { contactManager, ...data } = contact;
return {
...data,
...(contactManager ? { contactManager: mappedManagers[contactManager] } : {}),
};
});
return {
contacts,
count,
offset,
total,
};
}

View File

@@ -0,0 +1,12 @@
import type { ILivechatContact, ILivechatVisitor, IOmnichannelSource } from '@rocket.chat/core-typings';
import { makeFunction } from '@rocket.chat/patch-injection';
export const isAgentAvailableToTakeContactInquiry = makeFunction(
async (
_visitorId: ILivechatVisitor['_id'],
_source: IOmnichannelSource,
_contactId: ILivechatContact['_id'],
): Promise<{ error: string; value: false } | { value: true }> => ({
value: true,
}),
);

View File

@@ -0,0 +1,110 @@
import { expect } from 'chai';
import proxyquire from 'proxyquire';
const { isVerifiedChannelInSource } = proxyquire.noCallThru().load('./isVerifiedChannelInSource', {});
describe('isVerifiedChannelInSource', () => {
it('should return false if channel is not verified', () => {
const channel = {
verified: false,
};
const visitorId = 'visitor1';
const source = {
type: 'widget',
};
expect(isVerifiedChannelInSource(channel, visitorId, source)).to.be.false;
});
it('should return false if channel visitorId is different from visitorId', () => {
const channel = {
verified: true,
visitor: {
visitorId: 'visitor2',
},
};
const visitorId = 'visitor1';
const source = {
type: 'widget',
};
expect(isVerifiedChannelInSource(channel, visitorId, source)).to.be.false;
});
it('should return false if channel visitor source type is different from source type', () => {
const channel = {
verified: true,
visitor: {
visitorId: 'visitor1',
source: {
type: 'web',
},
},
};
const visitorId = 'visitor1';
const source = {
type: 'widget',
};
expect(isVerifiedChannelInSource(channel, visitorId, source)).to.be.false;
});
it('should return false if channel visitor source id is different from source id', () => {
const channel = {
verified: true,
visitor: {
visitorId: 'visitor1',
source: {
type: 'widget',
id: 'source1',
},
},
};
const visitorId = 'visitor1';
const source = {
type: 'widget',
id: 'source2',
};
expect(isVerifiedChannelInSource(channel, visitorId, source)).to.be.false;
});
it('should return false if source id is not defined and channel visitor source id is defined', () => {
const channel = {
verified: true,
visitor: {
visitorId: 'visitor1',
source: {
type: 'widget',
id: 'source1',
},
},
};
const visitorId = 'visitor1';
const source = {
type: 'widget',
};
expect(isVerifiedChannelInSource(channel, visitorId, source)).to.be.false;
});
it('should return true if all conditions are met', () => {
const channel = {
verified: true,
visitor: {
visitorId: 'visitor1',
source: {
type: 'widget',
id: 'source1',
},
},
};
const visitorId = 'visitor1';
const source = {
type: 'widget',
id: 'source1',
};
expect(isVerifiedChannelInSource(channel, visitorId, source)).to.be.true;
});
});

View File

@@ -0,0 +1,25 @@
import type { ILivechatContactChannel, ILivechatVisitor, IOmnichannelSource } from '@rocket.chat/core-typings';
export const isVerifiedChannelInSource = (
channel: ILivechatContactChannel,
visitorId: ILivechatVisitor['_id'],
source: IOmnichannelSource,
) => {
if (!channel.verified) {
return false;
}
if (channel.visitor.visitorId !== visitorId) {
return false;
}
if (channel.visitor.source.type !== source.type) {
return false;
}
if ((source.id || channel.visitor.source.id) && channel.visitor.source.id !== source.id) {
return false;
}
return true;
};

View File

@@ -0,0 +1,214 @@
import { OmnichannelSourceType, type ILivechatVisitor, type IOmnichannelSource } from '@rocket.chat/core-typings';
import { expect } from 'chai';
import proxyquire from 'proxyquire';
import sinon from 'sinon';
import type { CreateContactParams } from './createContact';
const getContactManagerIdByUsername = sinon.stub();
const getAllowedCustomFields = sinon.stub();
const { mapVisitorToContact } = proxyquire.noCallThru().load('./mapVisitorToContact', {
'./getContactManagerIdByUsername': {
getContactManagerIdByUsername,
},
'./getAllowedCustomFields': { getAllowedCustomFields },
});
const testDate = new Date();
const dataMap: [Partial<ILivechatVisitor>, IOmnichannelSource, CreateContactParams][] = [
[
{
_id: 'visitor1',
username: 'Username',
name: 'Name',
visitorEmails: [{ address: 'email1@domain.com' }, { address: 'email2@domain.com' }],
phone: [{ phoneNumber: '10' }, { phoneNumber: '20' }],
contactManager: {
username: 'user1',
},
lastChat: { _id: 'afdsfdasf', ts: testDate },
},
{
type: OmnichannelSourceType.WIDGET,
},
{
name: 'Name',
emails: ['email1@domain.com', 'email2@domain.com'],
phones: ['10', '20'],
unknown: true,
channels: [
{
name: 'widget',
visitor: {
visitorId: 'visitor1',
source: {
type: OmnichannelSourceType.WIDGET,
},
},
blocked: false,
verified: false,
details: {
type: OmnichannelSourceType.WIDGET,
},
lastChat: { _id: 'afdsfdasf', ts: testDate },
},
],
lastChat: { _id: 'afdsfdasf', ts: testDate },
customFields: undefined,
shouldValidateCustomFields: false,
contactManager: 'manager1',
},
],
[
{
_id: 'visitor1',
username: 'Username',
lastChat: { _id: 'afdsfdasf', ts: testDate },
},
{
type: OmnichannelSourceType.SMS,
},
{
name: 'Username',
emails: undefined,
phones: undefined,
unknown: true,
channels: [
{
name: 'sms',
visitor: {
visitorId: 'visitor1',
source: {
type: OmnichannelSourceType.SMS,
},
},
blocked: false,
verified: false,
details: {
type: OmnichannelSourceType.SMS,
},
lastChat: { _id: 'afdsfdasf', ts: testDate },
},
],
customFields: undefined,
shouldValidateCustomFields: false,
contactManager: undefined,
lastChat: { _id: 'afdsfdasf', ts: testDate },
},
],
[
{
_id: 'visitor1',
username: 'Username',
activity: ['2024-11'],
lastChat: {
_id: 'last-chat-id',
ts: testDate,
},
},
{
type: OmnichannelSourceType.WIDGET,
},
{
name: 'Username',
emails: undefined,
phones: undefined,
unknown: false,
channels: [
{
name: 'widget',
visitor: {
visitorId: 'visitor1',
source: {
type: OmnichannelSourceType.WIDGET,
},
},
blocked: false,
verified: false,
details: {
type: OmnichannelSourceType.WIDGET,
},
lastChat: {
_id: 'last-chat-id',
ts: testDate,
},
},
],
customFields: undefined,
shouldValidateCustomFields: false,
lastChat: {
_id: 'last-chat-id',
ts: testDate,
},
contactManager: undefined,
},
],
[
{
_id: 'visitor1',
username: 'Username',
livechatData: {
customFieldId: 'customFieldValue',
invalidCustomFieldId: 'invalidCustomFieldValue',
},
activity: [],
lastChat: { _id: 'afdsfdasf', ts: testDate },
},
{
type: OmnichannelSourceType.WIDGET,
},
{
name: 'Username',
emails: undefined,
phones: undefined,
unknown: true,
channels: [
{
name: 'widget',
visitor: {
visitorId: 'visitor1',
source: {
type: OmnichannelSourceType.WIDGET,
},
},
blocked: false,
verified: false,
details: {
type: OmnichannelSourceType.WIDGET,
},
lastChat: { _id: 'afdsfdasf', ts: testDate },
},
],
customFields: {
customFieldId: 'customFieldValue',
},
shouldValidateCustomFields: false,
contactManager: undefined,
lastChat: { _id: 'afdsfdasf', ts: testDate },
},
],
];
describe('mapVisitorToContact', () => {
beforeEach(() => {
getContactManagerIdByUsername.reset();
getContactManagerIdByUsername.callsFake((username) => {
if (username === 'user1') {
return 'manager1';
}
return undefined;
});
getAllowedCustomFields.resolves([{ _id: 'customFieldId', label: 'custom-field-label' }]);
});
dataMap.forEach(([visitor, source, contact], index) => {
it(`should map an ILivechatVisitor + IOmnichannelSource to an ILivechatContact [${index}]`, async () => {
expect(await mapVisitorToContact(visitor, source)).to.be.deep.equal(contact);
});
});
});

View File

@@ -0,0 +1,40 @@
import type { ILivechatVisitor, IOmnichannelSource } from '@rocket.chat/core-typings';
import type { CreateContactParams } from './createContact';
import { getAllowedCustomFields } from './getAllowedCustomFields';
import { getContactManagerIdByUsername } from './getContactManagerIdByUsername';
import { validateCustomFields } from './validateCustomFields';
export async function mapVisitorToContact(visitor: ILivechatVisitor, source: IOmnichannelSource): Promise<CreateContactParams> {
return {
name: visitor.name || visitor.username,
emails: visitor.visitorEmails?.map(({ address }) => address),
phones: visitor.phone?.map(({ phoneNumber }) => phoneNumber),
unknown: !visitor.activity || visitor.activity.length === 0,
channels: [
{
name: source.label || source.type.toString(),
visitor: {
visitorId: visitor._id,
source: {
type: source.type,
...(source.id ? { id: source.id } : {}),
},
},
blocked: false,
verified: false,
details: source,
lastChat: visitor.lastChat,
},
],
customFields:
visitor.livechatData &&
validateCustomFields(await getAllowedCustomFields(), visitor.livechatData, {
ignoreAdditionalFields: true,
ignoreValidationErrors: true,
}),
shouldValidateCustomFields: false,
lastChat: visitor.lastChat,
contactManager: visitor.contactManager?.username && (await getContactManagerIdByUsername(visitor.contactManager.username)),
};
}

View File

@@ -0,0 +1,8 @@
import type { ILivechatContact, ILivechatContactVisitorAssociation } from '@rocket.chat/core-typings';
import { makeFunction } from '@rocket.chat/patch-injection';
import type { ClientSession } from 'mongodb';
export const mergeContacts = makeFunction(
async (_contactId: string, _visitor: ILivechatContactVisitorAssociation, _session?: ClientSession): Promise<ILivechatContact | null> =>
null,
);

View File

@@ -0,0 +1,25 @@
import type { ILivechatVisitor, IOmnichannelSource, ILivechatContact } from '@rocket.chat/core-typings';
import { LivechatVisitors } from '@rocket.chat/models';
import { livechatContactsLogger as logger } from '../logger';
import { getContactIdByVisitor } from './getContactIdByVisitor';
import { migrateVisitorToContactId } from './migrateVisitorToContactId';
export async function migrateVisitorIfMissingContact(
visitorId: ILivechatVisitor['_id'],
source: IOmnichannelSource,
): Promise<ILivechatContact['_id'] | null> {
logger.debug({ msg: 'Detecting visitor contact ID' });
// Check if there is any contact already linking to this visitorId and source
const contactId = await getContactIdByVisitor({ visitorId, source });
if (contactId) {
return contactId;
}
const visitor = await LivechatVisitors.findOneById(visitorId);
if (!visitor) {
throw new Error('Failed to migrate visitor data into Contact information: visitor not found.');
}
return migrateVisitorToContactId({ visitor, source, requireRoom: false });
}

View File

@@ -0,0 +1,69 @@
import { expect } from 'chai';
import proxyquire from 'proxyquire';
import sinon from 'sinon';
const modelsMock = {
LivechatContacts: {
findContactMatchingVisitor: sinon.stub(),
},
LivechatRooms: {
setContactByVisitorAssociation: sinon.stub(),
findNewestByContactVisitorAssociation: sinon.stub(),
},
};
const createContactFromVisitor = sinon.stub();
const mergeVisitorIntoContact = sinon.stub();
const { migrateVisitorToContactId } = proxyquire.noCallThru().load('./migrateVisitorToContactId', {
'./createContactFromVisitor': {
createContactFromVisitor,
},
'./ContactMerger': {
ContactMerger: {
mergeVisitorIntoContact,
},
},
'@rocket.chat/models': modelsMock,
'../logger': {
livechatContactsLogger: {
debug: sinon.stub(),
},
},
});
describe('migrateVisitorToContactId', () => {
beforeEach(() => {
modelsMock.LivechatContacts.findContactMatchingVisitor.reset();
modelsMock.LivechatRooms.setContactByVisitorAssociation.reset();
modelsMock.LivechatRooms.findNewestByContactVisitorAssociation.reset();
createContactFromVisitor.reset();
mergeVisitorIntoContact.reset();
});
it('should not create a contact if there is no source for the visitor', async () => {
expect(await migrateVisitorToContactId({ visitor: { _id: 'visitor1' } })).to.be.null;
});
it('should attempt to create a new contact if there is no free existing contact matching the visitor data', async () => {
modelsMock.LivechatContacts.findContactMatchingVisitor.resolves(undefined);
const visitor = { _id: 'visitor1' };
const source = { type: 'other' };
modelsMock.LivechatRooms.findNewestByContactVisitorAssociation.resolves({ _id: 'room1', v: { _id: visitor._id }, source });
createContactFromVisitor.resolves('contactCreated');
expect(await migrateVisitorToContactId({ visitor: { _id: 'visitor1' }, source })).to.be.equal('contactCreated');
});
it('should not attempt to create a new contact if one is found for the visitor', async () => {
const visitor = { _id: 'visitor1' };
const contact = { _id: 'contact1' };
const source = { type: 'sms' };
modelsMock.LivechatRooms.findNewestByContactVisitorAssociation.resolves({ _id: 'room1', v: { _id: visitor._id }, source });
modelsMock.LivechatContacts.findContactMatchingVisitor.resolves(contact);
createContactFromVisitor.resolves('contactCreated');
expect(await migrateVisitorToContactId({ visitor, source })).to.be.equal('contact1');
expect(mergeVisitorIntoContact.calledOnceWith(visitor, contact, source)).to.be.true;
});
});

View File

@@ -0,0 +1,55 @@
import type { ILivechatVisitor, IOmnichannelSource, ILivechatContact, IOmnichannelRoom } from '@rocket.chat/core-typings';
import { LivechatContacts, LivechatRooms } from '@rocket.chat/models';
import { livechatContactsLogger as logger } from '../logger';
import { ContactMerger } from './ContactMerger';
import { createContactFromVisitor } from './createContactFromVisitor';
/**
This function assumes you already ensured that the visitor is not yet linked to any contact
**/
export async function migrateVisitorToContactId({
visitor,
source,
requireRoom = true,
}: {
visitor: ILivechatVisitor;
source: IOmnichannelSource;
requireRoom?: boolean;
}): Promise<ILivechatContact['_id'] | null> {
if (requireRoom) {
// Do not migrate the visitor with this source if they have no rooms matching it
const anyRoom = await LivechatRooms.findNewestByContactVisitorAssociation<Pick<IOmnichannelRoom, '_id'>>(
{ visitorId: visitor._id, source },
{
projection: { _id: 1 },
},
);
if (!anyRoom) {
return null;
}
}
// Search for any contact that is not yet associated with any visitor and that have the same email or phone number as this visitor.
const existingContact = await LivechatContacts.findContactMatchingVisitor(visitor);
if (!existingContact) {
logger.debug({ msg: 'Creating a new contact for existing visitor', visitorId: visitor._id });
return createContactFromVisitor(visitor, source);
}
// There is already an existing contact with no linked visitors and matching this visitor's phone or email, so let's use it
logger.debug({ msg: 'Adding channel to existing contact', contactId: existingContact._id });
await ContactMerger.mergeVisitorIntoContact(visitor, existingContact, source);
// Update all existing rooms matching the visitor id and source to set the contactId to them
await LivechatRooms.setContactByVisitorAssociation(
{
visitorId: visitor._id,
source,
},
existingContact,
);
return existingContact._id;
}

View File

@@ -0,0 +1,14 @@
import type { ILivechatContact } from '@rocket.chat/core-typings';
import { LivechatContacts } from '@rocket.chat/models';
export const patchContact = async (
contactId: ILivechatContact['_id'],
data: { set?: Partial<ILivechatContact>; unset?: Partial<Record<keyof ILivechatContact, '' | 1>> },
): Promise<ILivechatContact | null> => {
const { set = {}, unset = {} } = data;
if (Object.keys(set).length === 0 && Object.keys(unset).length === 0) {
return LivechatContacts.findOneEnabledById(contactId);
}
return LivechatContacts.patchContact(contactId, data);
};

View File

@@ -0,0 +1,129 @@
import { expect } from 'chai';
import proxyquire from 'proxyquire';
import sinon from 'sinon';
const modelsMock = {
'Users': {
findOneAgentById: sinon.stub(),
findOneByUsername: sinon.stub(),
},
'LivechatContacts': {
findOneById: sinon.stub(),
insertOne: sinon.stub(),
upsertContact: sinon.stub(),
updateContact: sinon.stub(),
findContactMatchingVisitor: sinon.stub(),
},
'LivechatRooms': {
findNewestByVisitorIdOrToken: sinon.stub(),
setContactIdByVisitorIdOrToken: sinon.stub(),
findByVisitorId: sinon.stub(),
},
'LivechatVisitors': {
findOneById: sinon.stub(),
updateById: sinon.stub(),
updateOne: sinon.stub(),
getVisitorByToken: sinon.stub(),
findOneGuestByEmailAddress: sinon.stub(),
},
'LivechatCustomField': {
findByScope: sinon.stub(),
},
'@global': true,
};
const { registerContact } = proxyquire.noCallThru().load('./registerContact', {
'meteor/meteor': sinon.stub(),
'@rocket.chat/models': modelsMock,
'@rocket.chat/tools': { wrapExceptions: sinon.stub() },
});
describe('registerContact', () => {
beforeEach(() => {
modelsMock.Users.findOneByUsername.reset();
modelsMock.LivechatVisitors.getVisitorByToken.reset();
modelsMock.LivechatVisitors.updateOne.reset();
modelsMock.LivechatVisitors.findOneGuestByEmailAddress.reset();
modelsMock.LivechatCustomField.findByScope.reset();
modelsMock.LivechatRooms.findByVisitorId.reset();
});
it(`should throw an error if there's no token`, async () => {
modelsMock.Users.findOneByUsername.returns(undefined);
await expect(
registerContact({
email: 'test@test.com',
username: 'username',
name: 'Name',
contactManager: {
username: 'unknown',
},
}),
).to.eventually.be.rejectedWith('error-invalid-contact-data');
});
it(`should throw an error if the token is not a string`, async () => {
modelsMock.Users.findOneByUsername.returns(undefined);
await expect(
registerContact({
token: 15,
email: 'test@test.com',
username: 'username',
name: 'Name',
contactManager: {
username: 'unknown',
},
}),
).to.eventually.be.rejectedWith('error-invalid-contact-data');
});
it(`should throw an error if there's an invalid manager username`, async () => {
modelsMock.Users.findOneByUsername.returns(undefined);
await expect(
registerContact({
token: 'token',
email: 'test@test.com',
username: 'username',
name: 'Name',
contactManager: {
username: 'unknown',
},
}),
).to.eventually.be.rejectedWith('error-contact-manager-not-found');
});
it(`should throw an error if the manager username does not belong to a livechat agent`, async () => {
modelsMock.Users.findOneByUsername.returns({ roles: ['user'] });
await expect(
registerContact({
token: 'token',
email: 'test@test.com',
username: 'username',
name: 'Name',
contactManager: {
username: 'username',
},
}),
).to.eventually.be.rejectedWith('error-invalid-contact-manager');
});
it('should register a contact when passing valid data', async () => {
modelsMock.LivechatVisitors.getVisitorByToken.returns({ _id: 'visitor1' });
modelsMock.LivechatCustomField.findByScope.returns({ toArray: () => [] });
modelsMock.LivechatRooms.findByVisitorId.returns({ toArray: () => [] });
modelsMock.LivechatVisitors.updateOne.returns(undefined);
await expect(
registerContact({
token: 'token',
email: 'test@test.com',
username: 'username',
name: 'Name',
}),
).to.eventually.be.equal('visitor1');
});
});

View File

@@ -0,0 +1,120 @@
import { MeteorError } from '@rocket.chat/core-services';
import type { ILivechatVisitor, IOmnichannelRoom } from '@rocket.chat/core-typings';
import { LivechatVisitors, Users, LivechatRooms, LivechatInquiry, Rooms, Subscriptions } from '@rocket.chat/models';
import type { MatchKeysAndValues, OnlyFieldsOfType } from 'mongodb';
import { getAllowedCustomFields } from './getAllowedCustomFields';
import { validateCustomFields } from './validateCustomFields';
import { callbacks } from '../../callbacks';
import { notifyOnRoomChangedById, notifyOnSubscriptionChangedByRoomId, notifyOnLivechatInquiryChangedByRoom } from '../../notifyListener';
type RegisterContactProps = {
_id?: string;
token: string;
name: string;
username?: string;
email?: string;
phone?: string;
customFields?: Record<string, unknown | string>;
contactManager?: {
username: string;
};
};
export async function registerContact(
{ token, name, email = '', phone, username, customFields = {}, contactManager }: RegisterContactProps,
userId: string,
): Promise<string> {
if (!token || typeof token !== 'string') {
throw new MeteorError('error-invalid-contact-data', 'Invalid visitor token');
}
const visitorEmail = email.trim().toLowerCase();
if (contactManager?.username) {
// verify if the user exists with this username and has a livechat-agent role
const manager = await Users.findOneByUsername(contactManager.username, { projection: { roles: 1 } });
if (!manager) {
throw new MeteorError('error-contact-manager-not-found', `No user found with username ${contactManager.username}`);
}
if (!manager.roles || !Array.isArray(manager.roles) || !manager.roles.includes('livechat-agent')) {
throw new MeteorError('error-invalid-contact-manager', 'The contact manager must have the role "livechat-agent"');
}
}
const existingUserByToken = await LivechatVisitors.getVisitorByToken(token, { projection: { _id: 1 } });
let visitorId = existingUserByToken?._id;
if (!existingUserByToken) {
if (!username) {
username = await LivechatVisitors.getNextVisitorUsername();
}
const existingUserByEmail = await LivechatVisitors.findOneGuestByEmailAddress(visitorEmail);
visitorId = existingUserByEmail?._id;
if (!existingUserByEmail) {
const userData = {
username,
ts: new Date(),
token,
};
visitorId = (await LivechatVisitors.insertOne(userData)).insertedId;
}
}
const allowedCF = await getAllowedCustomFields();
const livechatData: Record<string, string> = validateCustomFields(allowedCF, customFields, { ignoreAdditionalFields: true });
const fieldsToRemove = {
// if field is explicitely set to empty string, remove
...(phone === '' && { phone: 1 }),
...(visitorEmail === '' && { visitorEmails: 1 }),
...(!contactManager?.username && { contactManager: 1 }),
};
const updateUser: { $set: MatchKeysAndValues<ILivechatVisitor>; $unset?: OnlyFieldsOfType<ILivechatVisitor> } = {
$set: {
token,
name,
livechatData,
// if phone has some value, set
...(phone && { phone: [{ phoneNumber: phone }] }),
...(visitorEmail && { visitorEmails: [{ address: visitorEmail }] }),
...(contactManager?.username && { contactManager: { username: contactManager.username } }),
},
...(Object.keys(fieldsToRemove).length && { $unset: fieldsToRemove }),
};
await LivechatVisitors.updateOne({ _id: visitorId }, updateUser);
const extraQuery = await callbacks.run('livechat.applyRoomRestrictions', {}, { userId });
const rooms: IOmnichannelRoom[] = await LivechatRooms.findByVisitorId(visitorId, {}, extraQuery).toArray();
if (rooms?.length) {
for (const room of rooms) {
const { _id: rid } = room;
const responses = await Promise.all([
Rooms.setFnameById(rid, name),
LivechatInquiry.setNameByRoomId(rid, name),
Subscriptions.updateDisplayNameByRoomId(rid, name),
]);
if (responses[0]?.modifiedCount) {
void notifyOnRoomChangedById(rid);
}
if (responses[1]?.modifiedCount) {
void notifyOnLivechatInquiryChangedByRoom(rid, 'updated', { name });
}
if (responses[2]?.modifiedCount) {
void notifyOnSubscriptionChangedByRoomId(rid);
}
}
}
return visitorId as string;
}

View File

@@ -0,0 +1,278 @@
import type { ILivechatContact } from '@rocket.chat/core-typings';
import { expect } from 'chai';
import proxyquire from 'proxyquire';
import sinon from 'sinon';
const modelsMock = {
LivechatContacts: {
findOneEnabledById: sinon.stub(),
patchContact: sinon.stub(),
},
Settings: {
incrementValueById: sinon.stub(),
},
};
const validateContactManagerMock = sinon.stub();
const { patchContact } = proxyquire.noCallThru().load('./patchContact.ts', {
'@rocket.chat/models': modelsMock,
});
const { resolveContactConflicts } = proxyquire.noCallThru().load('./resolveContactConflicts', {
'@rocket.chat/models': modelsMock,
'./validateContactManager': {
validateContactManager: validateContactManagerMock,
},
'./patchContact': {
patchContact,
},
});
describe('resolveContactConflicts', () => {
beforeEach(() => {
modelsMock.LivechatContacts.findOneEnabledById.reset();
modelsMock.Settings.incrementValueById.reset();
modelsMock.LivechatContacts.patchContact.reset();
validateContactManagerMock.reset();
});
it('should update the contact with the resolved custom field', async () => {
modelsMock.LivechatContacts.findOneEnabledById.resolves({
_id: 'contactId',
customFields: { customField: 'newValue' },
conflictingFields: [{ field: 'customFields.customField', value: 'oldValue' }],
});
modelsMock.Settings.incrementValueById.resolves(1);
modelsMock.LivechatContacts.patchContact.resolves({
_id: 'contactId',
customFields: { customField: 'newestValue' },
conflictingFields: [],
} as Partial<ILivechatContact>);
await resolveContactConflicts({ contactId: 'contactId', customFields: { customField: 'newestValue' } });
expect(modelsMock.LivechatContacts.findOneEnabledById.getCall(0).args[0]).to.be.equal('contactId');
expect(modelsMock.LivechatContacts.patchContact.getCall(0).args[0]).to.be.equal('contactId');
expect(modelsMock.LivechatContacts.patchContact.getCall(0).args[1]).to.be.deep.contain({
set: {
customFields: { customField: 'newestValue' },
conflictingFields: [],
},
});
});
it('should update the contact with the resolved name', async () => {
modelsMock.LivechatContacts.findOneEnabledById.resolves({
_id: 'contactId',
name: 'Old Name',
customFields: { customField: 'newValue' },
conflictingFields: [{ field: 'name', value: 'Old Name' }],
});
modelsMock.Settings.incrementValueById.resolves(1);
modelsMock.LivechatContacts.patchContact.resolves({
_id: 'contactId',
name: 'New Name',
customFields: { customField: 'newValue' },
conflictingFields: [],
} as Partial<ILivechatContact>);
await resolveContactConflicts({ contactId: 'contactId', name: 'New Name' });
expect(modelsMock.LivechatContacts.findOneEnabledById.getCall(0).args[0]).to.be.equal('contactId');
expect(modelsMock.LivechatContacts.patchContact.getCall(0).args[0]).to.be.equal('contactId');
expect(modelsMock.LivechatContacts.patchContact.getCall(0).args[1]).to.be.deep.contain({
set: {
name: 'New Name',
conflictingFields: [],
},
});
});
it('should update the contact with the resolved contact manager', async () => {
modelsMock.LivechatContacts.findOneEnabledById.resolves({
_id: 'contactId',
name: 'Name',
contactManager: 'contactManagerId',
customFields: { customField: 'value' },
conflictingFields: [{ field: 'manager', value: 'oldManagerId' }],
});
validateContactManagerMock.resolves();
modelsMock.Settings.incrementValueById.resolves(1);
modelsMock.LivechatContacts.patchContact.resolves({
_id: 'contactId',
name: 'Name',
contactManager: 'newContactManagerId',
customFields: { customField: 'value' },
conflictingFields: [],
} as Partial<ILivechatContact>);
await resolveContactConflicts({ contactId: 'contactId', contactManager: 'newContactManagerId' });
expect(modelsMock.LivechatContacts.findOneEnabledById.getCall(0).args[0]).to.be.equal('contactId');
expect(validateContactManagerMock.getCall(0).args[0]).to.be.equal('newContactManagerId');
expect(modelsMock.LivechatContacts.patchContact.getCall(0).args[0]).to.be.equal('contactId');
expect(modelsMock.LivechatContacts.patchContact.getCall(0).args[1]).to.be.deep.contain({
set: {
contactManager: 'newContactManagerId',
conflictingFields: [],
},
});
});
it('should wipe all conflicts if wipeConflicts = true', async () => {
modelsMock.LivechatContacts.findOneEnabledById.resolves({
_id: 'contactId',
name: 'Name',
customFields: { customField: 'newValue' },
conflictingFields: [
{ field: 'name', value: 'NameTest' },
{ field: 'customFields.customField', value: 'value' },
],
});
modelsMock.Settings.incrementValueById.resolves({ _id: 'Resolved_Conflicts_Count', value: 2 });
modelsMock.LivechatContacts.patchContact.resolves({
_id: 'contactId',
name: 'New Name',
customFields: { customField: 'newValue' },
conflictingFields: [],
} as Partial<ILivechatContact>);
const result = await resolveContactConflicts({ contactId: 'contactId', name: 'New Name', wipeConflicts: true });
expect(modelsMock.LivechatContacts.findOneEnabledById.getCall(0).args[0]).to.be.equal('contactId');
expect(modelsMock.Settings.incrementValueById.getCall(0).args[0]).to.be.equal('Resolved_Conflicts_Count');
expect(modelsMock.Settings.incrementValueById.getCall(0).args[1]).to.be.equal(2);
expect(modelsMock.LivechatContacts.patchContact.getCall(0).args[0]).to.be.equal('contactId');
expect(modelsMock.LivechatContacts.patchContact.getCall(0).args[1]).to.be.deep.contain({
set: {
name: 'New Name',
conflictingFields: [],
},
});
expect(result).to.be.deep.equal({
_id: 'contactId',
name: 'New Name',
customFields: { customField: 'newValue' },
conflictingFields: [],
});
});
it('should only resolve specified conflicts when wipeConflicts = false', async () => {
modelsMock.LivechatContacts.findOneEnabledById.resolves({
_id: 'contactId',
name: 'Name',
customFields: { customField: 'newValue' },
conflictingFields: [
{ field: 'name', value: 'NameTest' },
{ field: 'customFields.customField', value: 'value' },
],
});
modelsMock.LivechatContacts.patchContact.resolves({
_id: 'contactId',
name: 'New Name',
customFields: { customField: 'newValue' },
conflictingFields: [{ field: 'customFields.customField', value: 'value' }],
} as Partial<ILivechatContact>);
const result = await resolveContactConflicts({ contactId: 'contactId', name: 'New Name', wipeConflicts: false });
expect(modelsMock.LivechatContacts.findOneEnabledById.getCall(0).args[0]).to.be.equal('contactId');
// When wipeConflicts is false, incrementValueById should NOT be called
expect(modelsMock.Settings.incrementValueById.called).to.be.false;
expect(modelsMock.LivechatContacts.patchContact.getCall(0).args[0]).to.be.equal('contactId');
expect(modelsMock.LivechatContacts.patchContact.getCall(0).args[1]).to.be.deep.contain({
set: {
name: 'New Name',
conflictingFields: [{ field: 'customFields.customField', value: 'value' }],
},
});
expect(result).to.be.deep.equal({
_id: 'contactId',
name: 'New Name',
customFields: { customField: 'newValue' },
conflictingFields: [{ field: 'customFields.customField', value: 'value' }],
});
});
it('should throw an error if the contact does not exist', async () => {
modelsMock.LivechatContacts.findOneEnabledById.resolves(undefined);
await expect(resolveContactConflicts({ contactId: 'id', customFields: { customField: 'newValue' } })).to.be.rejectedWith(
'error-contact-not-found',
);
expect(modelsMock.LivechatContacts.patchContact.called).to.be.false;
});
it('should throw an error if the contact has no conflicting fields', async () => {
modelsMock.LivechatContacts.findOneEnabledById.resolves({
_id: 'contactId',
name: 'Name',
contactManager: 'contactManagerId',
customFields: { customField: 'value' },
conflictingFields: [],
});
await expect(resolveContactConflicts({ contactId: 'id', customFields: { customField: 'newValue' } })).to.be.rejectedWith(
'error-contact-has-no-conflicts',
);
expect(modelsMock.LivechatContacts.patchContact.called).to.be.false;
});
it('should unset contactManager when explicitly set to empty string', async () => {
modelsMock.LivechatContacts.findOneEnabledById.resolves({
_id: 'contactId',
name: 'Name',
contactManager: 'oldManagerId',
customFields: { customField: 'value' },
conflictingFields: [{ field: 'manager', value: 'oldManagerId' }],
});
modelsMock.LivechatContacts.patchContact.resolves({
_id: 'contactId',
name: 'Name',
customFields: { customField: 'value' },
conflictingFields: [],
} as Partial<ILivechatContact>);
await resolveContactConflicts({ contactId: 'contactId', contactManager: '' });
expect(modelsMock.LivechatContacts.patchContact.getCall(0).args[1]).to.deep.include({
set: {
conflictingFields: [],
},
unset: { contactManager: '' },
});
expect(validateContactManagerMock.called).to.be.false;
});
it('should unset contactManager when explicitly set to undefined', async () => {
modelsMock.LivechatContacts.findOneEnabledById.resolves({
_id: 'contactId',
name: 'Name',
contactManager: 'oldManagerId',
customFields: { customField: 'value' },
conflictingFields: [{ field: 'manager', value: 'oldManagerId' }],
});
modelsMock.LivechatContacts.patchContact.resolves({
_id: 'contactId',
name: 'Name',
customFields: { customField: 'value' },
conflictingFields: [],
} as Partial<ILivechatContact>);
await resolveContactConflicts({ contactId: 'contactId', contactManager: undefined });
expect(modelsMock.LivechatContacts.patchContact.getCall(0).args[1]).to.deep.include({
set: {
conflictingFields: [],
},
unset: { contactManager: '' },
});
expect(validateContactManagerMock.called).to.be.false;
});
});

View File

@@ -0,0 +1,77 @@
import type { ILivechatContact, ILivechatContactConflictingField } from '@rocket.chat/core-typings';
import { LivechatContacts, Settings } from '@rocket.chat/models';
import { patchContact } from './patchContact';
import { validateContactManager } from './validateContactManager';
import { notifyOnSettingChanged } from '../../notifyListener';
export type ResolveContactConflictsParams = {
contactId: string;
name?: string;
customFields?: Record<string, unknown>;
contactManager?: string;
wipeConflicts?: boolean;
};
export async function resolveContactConflicts(params: ResolveContactConflictsParams): Promise<ILivechatContact> {
const { contactId, name, customFields, contactManager, wipeConflicts } = params;
const contact = await LivechatContacts.findOneEnabledById<Pick<ILivechatContact, '_id' | 'customFields' | 'conflictingFields'>>(
contactId,
{
projection: { _id: 1, customFields: 1, conflictingFields: 1 },
},
);
if (!contact) {
throw new Error('error-contact-not-found');
}
if (!contact.conflictingFields?.length) {
throw new Error('error-contact-has-no-conflicts');
}
if (contactManager) {
await validateContactManager(contactManager);
}
let updatedConflictingFieldsArr: ILivechatContactConflictingField[] = [];
if (wipeConflicts) {
const value = await Settings.incrementValueById('Resolved_Conflicts_Count', contact.conflictingFields.length, {
returnDocument: 'after',
});
if (value) {
void notifyOnSettingChanged(value);
}
} else {
const fieldsToRemove = new Set<string>(
[
name && 'name',
'contactManager' in params && 'manager',
...(customFields ? Object.keys(customFields).map((key) => `customFields.${key}`) : []),
].filter((field): field is string => !!field),
);
updatedConflictingFieldsArr = contact.conflictingFields.filter(
(conflictingField: ILivechatContactConflictingField) => !fieldsToRemove.has(conflictingField.field),
) as ILivechatContactConflictingField[];
}
const set = {
...(name && { name }),
...(contactManager && { contactManager }),
...(customFields && { customFields: { ...contact.customFields, ...customFields } }),
conflictingFields: updatedConflictingFieldsArr,
};
const unset: Partial<Record<keyof ILivechatContact, '' | 1>> =
'contactManager' in params && !contactManager ? { contactManager: '' } : {};
const updatedContact = await patchContact(contactId, { set, unset });
if (!updatedContact) {
throw new Error('error-contact-not-found');
}
return updatedContact;
}

View File

@@ -0,0 +1,81 @@
import { expect } from 'chai';
import proxyquire from 'proxyquire';
import sinon from 'sinon';
const modelsMock = {
LivechatContacts: {
findOneEnabledById: sinon.stub(),
patchContact: sinon.stub(),
},
LivechatRooms: {
updateContactDataByContactId: sinon.stub(),
},
};
const { patchContact } = proxyquire.noCallThru().load('./patchContact.ts', {
'@rocket.chat/models': modelsMock,
});
const { updateContact } = proxyquire.noCallThru().load('./updateContact', {
'./getAllowedCustomFields': {
getAllowedCustomFields: sinon.stub().resolves([]),
},
'./validateContactManager': {
validateContactManager: sinon.stub(),
},
'./validateCustomFields': {
validateCustomFields: sinon.stub(),
},
'@rocket.chat/models': modelsMock,
'./patchContact': {
patchContact,
},
});
describe('updateContact', () => {
beforeEach(() => {
modelsMock.LivechatContacts.findOneEnabledById.reset();
modelsMock.LivechatContacts.patchContact.reset();
modelsMock.LivechatRooms.updateContactDataByContactId.reset();
});
it('should throw an error if the contact does not exist', async () => {
modelsMock.LivechatContacts.findOneEnabledById.resolves(undefined);
await expect(updateContact('any_id')).to.be.rejectedWith('error-contact-not-found');
expect(modelsMock.LivechatContacts.patchContact.getCall(0)).to.be.null;
});
it('should update the contact with correct params', async () => {
modelsMock.LivechatContacts.findOneEnabledById.resolves({ _id: 'contactId' });
modelsMock.LivechatContacts.patchContact.resolves({ _id: 'contactId', name: 'John Doe' } as any);
const updatedContact = await updateContact({ contactId: 'contactId', name: 'John Doe' });
expect(modelsMock.LivechatContacts.patchContact.getCall(0).args[0]).to.be.equal('contactId');
expect(modelsMock.LivechatContacts.patchContact.getCall(0).args[1]).to.be.deep.contain({ set: { name: 'John Doe' } });
expect(updatedContact).to.be.deep.equal({ _id: 'contactId', name: 'John Doe' });
});
it('should be able to clear the contact manager when passing an empty string for contactManager', async () => {
modelsMock.LivechatContacts.findOneEnabledById.resolves({ _id: 'contactId', contactManager: 'manager' });
modelsMock.LivechatContacts.patchContact.resolves({ _id: 'contactId' } as any);
const updatedContact = await updateContact({ contactId: 'contactId', contactManager: '' });
expect(modelsMock.LivechatContacts.patchContact.getCall(0).args[0]).to.be.equal('contactId');
expect(modelsMock.LivechatContacts.patchContact.getCall(0).args[1]).to.be.deep.contain({ unset: { contactManager: '' } });
expect(updatedContact).to.be.deep.equal({ _id: 'contactId' });
});
it('should be able to clear the contact manager when passing undefined for contactManager', async () => {
modelsMock.LivechatContacts.findOneEnabledById.resolves({ _id: 'contactId', contactManager: 'manager' });
modelsMock.LivechatContacts.patchContact.resolves({ _id: 'contactId' } as any);
const updatedContact = await updateContact({ contactId: 'contactId', contactManager: undefined });
expect(modelsMock.LivechatContacts.patchContact.getCall(0).args[0]).to.be.equal('contactId');
expect(modelsMock.LivechatContacts.patchContact.getCall(0).args[1]).to.be.deep.contain({ unset: { contactManager: '' } });
expect(updatedContact).to.be.deep.equal({ _id: 'contactId' });
});
});

View File

@@ -0,0 +1,109 @@
import type { ILivechatContact, ILivechatContactChannel } from '@rocket.chat/core-typings';
import { LivechatContacts, LivechatInquiry, LivechatRooms, Settings, Subscriptions } from '@rocket.chat/models';
import { getAllowedCustomFields } from './getAllowedCustomFields';
import { patchContact } from './patchContact';
import { validateContactManager } from './validateContactManager';
import { validateCustomFields } from './validateCustomFields';
import {
notifyOnSubscriptionChangedByVisitorIds,
notifyOnRoomChangedByContactId,
notifyOnLivechatInquiryChangedByVisitorIds,
notifyOnSettingChanged,
} from '../../notifyListener';
export type UpdateContactParams = {
contactId: string;
name?: string;
emails?: string[];
phones?: string[];
customFields?: Record<string, unknown>;
contactManager?: string;
channels?: ILivechatContactChannel[];
wipeConflicts?: boolean;
};
export async function updateContact(params: UpdateContactParams): Promise<ILivechatContact> {
const { contactId, name, emails, phones, customFields: receivedCustomFields, contactManager, channels, wipeConflicts } = params;
const contact = await LivechatContacts.findOneEnabledById<Pick<ILivechatContact, '_id' | 'name' | 'customFields' | 'conflictingFields'>>(
contactId,
{
projection: { _id: 1, name: 1, customFields: 1, conflictingFields: 1 },
},
);
if (!contact) {
throw new Error('error-contact-not-found');
}
if (contactManager) {
await validateContactManager(contactManager);
}
if (wipeConflicts && contact.conflictingFields?.length) {
const value = await Settings.incrementValueById('Resolved_Conflicts_Count', contact.conflictingFields.length, {
returnDocument: 'after',
});
if (value) {
void notifyOnSettingChanged(value);
}
}
const workspaceAllowedCustomFields = await getAllowedCustomFields();
const workspaceAllowedCustomFieldsIds = workspaceAllowedCustomFields.map((customField) => customField._id);
const currentCustomFieldsIds = Object.keys(contact.customFields || {});
const notRegisteredCustomFields = currentCustomFieldsIds
.filter((customFieldId) => !workspaceAllowedCustomFieldsIds.includes(customFieldId))
.map((customFieldId) => ({ _id: customFieldId }));
const customFieldsToUpdate =
receivedCustomFields &&
validateCustomFields(workspaceAllowedCustomFields, receivedCustomFields, {
ignoreAdditionalFields: !!notRegisteredCustomFields.length,
});
if (receivedCustomFields && customFieldsToUpdate && notRegisteredCustomFields.length) {
const allowedCustomFields = [...workspaceAllowedCustomFields, ...notRegisteredCustomFields];
validateCustomFields(allowedCustomFields, receivedCustomFields);
notRegisteredCustomFields.forEach((notRegisteredCustomField) => {
customFieldsToUpdate[notRegisteredCustomField._id] = contact.customFields?.[notRegisteredCustomField._id] as string;
});
}
const set = {
...(name !== undefined && { name }),
...(emails && { emails: emails?.map((address) => ({ address })) }),
...(phones && { phones: phones?.map((phoneNumber) => ({ phoneNumber })) }),
...(contactManager && { contactManager }),
...(channels && { channels }),
...(customFieldsToUpdate && { customFields: customFieldsToUpdate }),
...(wipeConflicts && { conflictingFields: [] }),
};
const unset: Partial<Record<keyof ILivechatContact, '' | 1>> =
'contactManager' in params && !contactManager ? { contactManager: '' } : {};
const updatedContact = await patchContact(contactId, { set, unset });
if (!updatedContact) {
throw new Error('error-contact-not-found');
}
// If the contact name changed, update the name of its existing rooms and subscriptions
if (name !== undefined && name !== contact.name) {
await LivechatRooms.updateContactDataByContactId(contactId, { name });
void notifyOnRoomChangedByContactId(contactId);
const visitorIds = updatedContact.channels?.map((channel) => channel.visitor.visitorId);
if (visitorIds?.length) {
await Subscriptions.updateNameAndFnameByVisitorIds(visitorIds, name);
void notifyOnSubscriptionChangedByVisitorIds(visitorIds);
await LivechatInquiry.updateNameByVisitorIds(visitorIds, name);
void notifyOnLivechatInquiryChangedByVisitorIds(visitorIds, 'updated', { name });
}
}
return updatedContact;
}

View File

@@ -0,0 +1,32 @@
import { expect } from 'chai';
import proxyquire from 'proxyquire';
import sinon from 'sinon';
const modelsMock = {
Users: {
findOneAgentById: sinon.stub(),
},
};
const { validateContactManager } = proxyquire.noCallThru().load('./validateContactManager', {
'@rocket.chat/models': modelsMock,
});
describe('validateContactManager', () => {
beforeEach(() => {
modelsMock.Users.findOneAgentById.reset();
});
it('should throw an error if the user does not exist', async () => {
modelsMock.Users.findOneAgentById.resolves(undefined);
await expect(validateContactManager('any_id')).to.be.rejectedWith('error-contact-manager-not-found');
});
it('should not throw an error if the user has the "livechat-agent" role', async () => {
const user = { _id: 'userId' };
modelsMock.Users.findOneAgentById.resolves(user);
await expect(validateContactManager('userId')).to.not.be.rejected;
expect(modelsMock.Users.findOneAgentById.getCall(0).firstArg).to.be.equal('userId');
});
});

View File

@@ -0,0 +1,9 @@
import type { IUser } from '@rocket.chat/core-typings';
import { Users } from '@rocket.chat/models';
export async function validateContactManager(contactManagerUserId: string) {
const contactManagerUser = await Users.findOneAgentById<Pick<IUser, '_id'>>(contactManagerUserId, { projection: { _id: 1 } });
if (!contactManagerUser) {
throw new Error('error-contact-manager-not-found');
}
}

View File

@@ -0,0 +1,62 @@
import { expect } from 'chai';
import proxyquire from 'proxyquire';
const { validateCustomFields } = proxyquire.noCallThru().load('./validateCustomFields', {});
describe('validateCustomFields', () => {
const mockCustomFields = [{ _id: 'cf1', label: 'Custom Field 1', regexp: '^[0-9]+$', required: true }];
it('should validate custom fields correctly', () => {
expect(() => validateCustomFields(mockCustomFields, { cf1: '123' })).to.not.throw();
});
it('should throw an error if a required custom field is missing', () => {
expect(() => validateCustomFields(mockCustomFields, {})).to.throw();
});
it('should not throw an error if a required custom field is missing, but the ignoreValidationErrors option is provided', () => {
expect(() => validateCustomFields(mockCustomFields, {}, { ignoreValidationErrors: true }))
.not.to.throw()
.and.to.equal({});
});
it('should NOT throw an error when a non-required custom field is missing', () => {
const allowedCustomFields = [{ _id: 'field1', label: 'Field 1', required: false }];
const customFields = {};
expect(() => validateCustomFields(allowedCustomFields, customFields)).not.to.throw();
});
it('should throw an error if a custom field value does not match the regexp', () => {
expect(() => validateCustomFields(mockCustomFields, { cf1: 'invalid' })).to.throw();
});
it('should not throw an error if a custom field value does not match the regexp, but the ignoreValidationErrors option is provided', () => {
expect(() => validateCustomFields(mockCustomFields, { cf1: 'invalid' }, { ignoreValidationErrors: true }))
.not.to.throw()
.and.to.equal({});
});
it('should handle an empty customFields input without throwing an error', () => {
const allowedCustomFields = [{ _id: 'field1', label: 'Field 1', required: false }];
const customFields = {};
expect(() => validateCustomFields(allowedCustomFields, customFields)).not.to.throw();
});
it('should throw an error if a extra custom field is passed', () => {
const allowedCustomFields = [{ _id: 'field1', label: 'Field 1', required: false }];
const customFields = { field2: 'value' };
expect(() => validateCustomFields(allowedCustomFields, customFields)).to.throw();
});
it('should not throw an error if a extra custom field is passed, but the ignoreValidationErrors option is provided', () => {
const allowedCustomFields = [{ _id: 'field1', label: 'Field 1', required: false }];
const customFields = { field2: 'value' };
expect(() => validateCustomFields(allowedCustomFields, customFields, { ignoreAdditionalFields: true }))
.not.to.throw()
.and.to.equal({});
});
});

View File

@@ -0,0 +1,56 @@
import type { AtLeast, ILivechatCustomField } from '@rocket.chat/core-typings';
import { i18n } from '../../../../app/utils/lib/i18n';
import { trim } from '../../../../lib/utils/stringUtils';
export function validateCustomFields(
allowedCustomFields: AtLeast<ILivechatCustomField, '_id'>[],
customFields: Record<string, string | unknown>,
{
ignoreAdditionalFields = false,
ignoreValidationErrors = false,
}: { ignoreAdditionalFields?: boolean; ignoreValidationErrors?: boolean } = {},
): Record<string, string> {
const validValues: Record<string, string> = {};
for (const cf of allowedCustomFields) {
if (!customFields.hasOwnProperty(cf._id)) {
if (cf.required && !ignoreValidationErrors) {
throw new Error(i18n.t('error-invalid-custom-field-value', { field: cf.label || cf._id }));
}
continue;
}
const cfValue: string = trim(customFields[cf._id]);
if (!cfValue || typeof cfValue !== 'string') {
if (cf.required && !ignoreValidationErrors) {
throw new Error(i18n.t('error-invalid-custom-field-value', { field: cf.label || cf._id }));
}
continue;
}
if (cf.regexp) {
const regex = new RegExp(cf.regexp);
if (!regex.test(cfValue)) {
if (ignoreValidationErrors) {
continue;
}
throw new Error(i18n.t('error-invalid-custom-field-value', { field: cf.label || cf._id }));
}
}
validValues[cf._id] = cfValue;
}
if (!ignoreAdditionalFields) {
const allowedCustomFieldIds = new Set(allowedCustomFields.map((cf) => cf._id));
for (const key in customFields) {
if (!allowedCustomFieldIds.has(key)) {
throw new Error(i18n.t('error-custom-field-not-allowed', { key }));
}
}
}
return validValues;
}

View File

@@ -0,0 +1,12 @@
import type { ILivechatContact } from '@rocket.chat/core-typings';
import { makeFunction } from '@rocket.chat/patch-injection';
export type VerifyContactChannelParams = {
contactId: string;
field: string;
value: string;
visitorId: string;
roomId: string;
};
export const verifyContactChannel = makeFunction(async (_params: VerifyContactChannelParams): Promise<ILivechatContact | null> => null);

View File

@@ -0,0 +1,231 @@
import type { ILivechatContact, ILivechatCustomField, ILivechatVisitor } from '@rocket.chat/core-typings';
import { LivechatContacts, LivechatCustomField, LivechatRooms, LivechatVisitors } from '@rocket.chat/models';
import { livechatLogger } from './logger';
import { i18n } from '../../../app/utils/lib/i18n';
export const validateRequiredCustomFields = (
customFields: string[],
livechatCustomFields: Pick<ILivechatCustomField, '_id' | 'required'>[],
) => {
const errors: string[] = [];
const requiredCustomFields = livechatCustomFields.filter((field) => field.required);
requiredCustomFields.forEach((field) => {
if (!customFields.find((f) => f === field._id)) {
errors.push(field._id);
}
});
if (errors.length > 0) {
throw new Error(`Missing required custom fields: ${errors.join(', ')}`);
}
};
export async function updateContactsCustomFields(
contact: ILivechatContact,
validCustomFields: {
key: string;
value: string;
overwrite: boolean;
}[],
): Promise<void> {
const contactCustomFieldsToUpdate = validCustomFields.reduce(
(prev, curr) => {
if (curr.overwrite || !contact?.customFields?.[curr.key]) {
prev[`customFields.${curr.key}`] = curr.value;
return prev;
}
prev.conflictingFields ??= contact.conflictingFields || [];
prev.conflictingFields.push({ field: `customFields.${curr.key}`, value: curr.value });
return prev;
},
{} as Record<string, any>,
);
if (!Object.keys(contactCustomFieldsToUpdate).length) {
return;
}
livechatLogger.debug({ msg: 'Updating custom fields for contact', contactId: contact._id, contactCustomFieldsToUpdate });
await LivechatContacts.updateById(contact._id, { $set: contactCustomFieldsToUpdate });
}
export async function setCustomFields({
token,
key,
value,
overwrite,
}: {
key: string;
value: string;
overwrite: boolean;
token: string;
}): Promise<number> {
livechatLogger.debug({ msg: 'Setting custom fields data for visitor with token', token });
const customField = await LivechatCustomField.findOneById(key);
if (!customField) {
throw new Error('invalid-custom-field');
}
if (customField.regexp !== undefined && customField.regexp !== '') {
const regexp = new RegExp(customField.regexp);
if (!regexp.test(value)) {
throw new Error(i18n.t('error-invalid-custom-field-value', { field: key }));
}
}
let result;
if (customField.scope === 'room') {
result = await LivechatRooms.updateDataByToken(token, key, value, overwrite);
} else {
result = await LivechatVisitors.updateLivechatDataByToken(token, key, value, overwrite);
const visitor = await LivechatVisitors.getVisitorByToken(token, { projection: { _id: 1 } });
if (visitor) {
const contacts = await LivechatContacts.findAllByVisitorId(visitor._id).toArray();
if (contacts.length > 0) {
await Promise.all(contacts.map((contact) => updateContactsCustomFields(contact, [{ key, value, overwrite }])));
}
}
}
if (typeof result === 'boolean') {
// Note: this only happens when !overwrite is passed, in this case we don't do any db update
return 0;
}
return result.modifiedCount;
}
export async function setMultipleVisitorCustomFields(
visitor: ILivechatVisitor,
customFields: {
key: string;
value: string;
overwrite: boolean;
}[],
livechatCustomFields?: Pick<ILivechatCustomField, '_id' | 'required'>[],
) {
const keys = customFields.map((field) => field.key);
livechatCustomFields ??= await LivechatCustomField.findByScope('visitor', { projection: { _id: 1, required: 1 } }, false).toArray();
validateRequiredCustomFields(keys, livechatCustomFields);
const matchingCustomFields = livechatCustomFields.filter((field) => keys.includes(field._id));
const validCustomFields = customFields.filter((cf) => matchingCustomFields.find((mcf) => cf.key === mcf._id));
if (!validCustomFields.length) {
return false;
}
const visitorCustomFieldsToUpdate = validCustomFields.reduce(
(prev, curr) => {
if (curr.overwrite) {
prev[`livechatData.${curr.key}`] = curr.value;
return prev;
}
if (!visitor?.livechatData?.[curr.key]) {
prev[`livechatData.${curr.key}`] = curr.value;
}
return prev;
},
{} as Record<string, string>,
);
if (Object.keys(visitorCustomFieldsToUpdate).length) {
await LivechatVisitors.updateAllLivechatDataByToken(visitor.token, visitorCustomFieldsToUpdate);
}
const contacts = await LivechatContacts.findAllByVisitorId(visitor._id).toArray();
if (contacts.length) {
await Promise.all(contacts.map((contact) => updateContactsCustomFields(contact, validCustomFields)));
}
if (validCustomFields.length !== keys.length) {
livechatLogger.warn({
msg: 'Some custom fields were not processed',
visitorId: visitor._id,
missingKeys: keys.filter((key) => !validCustomFields.map((v) => v.key).includes(key)),
});
}
return true;
}
export async function setMultipleCustomFields({
visitor,
customFields,
}: {
visitor: ILivechatVisitor;
customFields: {
key: string;
value: string;
overwrite: boolean;
}[];
}): Promise<
{
key: string;
value: string;
overwrite: boolean;
}[]
> {
livechatLogger.debug({ msg: 'Setting custom fields data for visitor with token', token: visitor.token });
const keys = customFields.map((customField) => customField.key);
const dbFields = await LivechatCustomField.find(
{ _id: { $in: keys } },
{ projection: { _id: 1, required: 1, regexp: 1, scope: 1 } },
).toArray();
if (!dbFields.length) {
throw new Error('invalid-custom-field');
}
const { visitorFields, roomFields } = customFields.reduce(
(prev, customField) => {
const dbField = dbFields.find((customF) => customF._id === customField.key);
if (!dbField) {
throw new Error('invalid-custom-field');
}
if (dbField.regexp !== undefined && dbField.regexp !== '') {
const regexp = new RegExp(dbField.regexp);
if (!regexp.test(customField.value)) {
throw new Error(i18n.t('error-invalid-custom-field-value', { field: dbField._id }));
}
}
if (dbField.scope === 'visitor') {
prev.visitorFields ??= [];
prev.visitorFields.push(customField);
return prev;
}
prev.roomFields ??= [];
prev.roomFields.push(customField);
return prev;
},
{} as {
visitorFields: { key: string; value: string; overwrite: boolean }[];
roomFields: { key: string; value: string; overwrite: boolean }[];
},
);
if (roomFields?.length) {
await Promise.all(roomFields.map(({ key, value, overwrite }) => LivechatRooms.updateDataByToken(visitor.token, key, value, overwrite)));
livechatLogger.debug({ msg: 'Custom fields for room updated', visitor, roomFields });
}
if (visitorFields?.length) {
await setMultipleVisitorCustomFields(
visitor,
visitorFields,
dbFields.filter((field) => field.scope === 'visitor'),
);
livechatLogger.debug({ msg: 'Custom fields for visitor updated', visitor, visitorFields });
}
return [...(roomFields || []), ...(visitorFields || [])];
}

View File

@@ -0,0 +1,310 @@
import { AppEvents, Apps } from '@rocket.chat/apps';
import type { LivechatDepartmentDTO, ILivechatDepartment, ILivechatDepartmentAgents, ILivechatAgent } from '@rocket.chat/core-typings';
import { LivechatDepartment, LivechatDepartmentAgents, LivechatVisitors, LivechatRooms, Users } from '@rocket.chat/models';
import { isDepartmentCreationAvailable } from '@rocket.chat/omni-core';
import { check, Match } from 'meteor/check';
import { Meteor } from 'meteor/meteor';
import { updateDepartmentAgents } from './Helper';
import { afterDepartmentArchived, afterDepartmentUnarchived } from './hooks';
import { livechatLogger } from './logger';
import { settings } from '../../settings';
import { callbacks } from '../callbacks';
import { notifyOnLivechatDepartmentAgentChangedByDepartmentId, notifyOnLivechatDepartmentAgentChanged } from '../notifyListener';
/**
* @param {string|null} _id - The department id
* @param {Partial<import('@rocket.chat/core-typings').ILivechatDepartment>} departmentData
* @param {{upsert?: { agentId: string; count?: number; order?: number; }[], remove?: { agentId: string; count?: number; order?: number; }}} [departmentAgents] - The department agents
* @param {{_id?: string}} [departmentUnit] - The department's unit id
*/
export async function saveDepartment(
userId: string,
_id: string | null,
departmentData: LivechatDepartmentDTO,
departmentAgents?: {
upsert?: { agentId: string; count?: number; order?: number }[];
remove?: { agentId: string; count?: number; order?: number }[];
},
departmentUnit?: { _id?: string },
) {
if (departmentUnit?._id !== undefined && typeof departmentUnit._id !== 'string') {
throw new Meteor.Error('error-invalid-department-unit', 'Invalid department unit id provided', {
method: 'livechat:saveDepartment',
});
}
const department = _id
? await LivechatDepartment.findOneById<Pick<ILivechatDepartment, '_id' | 'archived' | 'enabled' | 'parentId'>>(_id, {
projection: { _id: 1, archived: 1, enabled: 1, parentId: 1 },
})
: null;
if (departmentUnit && !departmentUnit._id && department && department.parentId) {
const isLastDepartmentInUnit = (await LivechatDepartment.countDepartmentsInUnit(department.parentId)) === 1;
if (isLastDepartmentInUnit) {
throw new Meteor.Error('error-unit-cant-be-empty', "The last department in a unit can't be removed", {
method: 'livechat:saveDepartment',
});
}
}
if (!department && !(await isDepartmentCreationAvailable())) {
throw new Meteor.Error('error-max-departments-number-reached', 'Maximum number of departments reached', {
method: 'livechat:saveDepartment',
});
}
if (department?.archived && departmentData.enabled) {
throw new Meteor.Error('error-archived-department-cant-be-enabled', 'Archived departments cant be enabled', {
method: 'livechat:saveDepartment',
});
}
// TODO: Use AJV or Zod for validation (or the lib we are using rn)
const defaultValidations: Record<string, Match.Matcher<any> | BooleanConstructor | StringConstructor> = {
enabled: Boolean,
name: String,
description: Match.Optional(String),
showOnRegistration: Boolean,
email: String,
showOnOfflineForm: Boolean,
requestTagBeforeClosingChat: Match.Optional(Boolean),
chatClosingTags: Match.Optional([String]),
fallbackForwardDepartment: Match.Optional(String),
departmentsAllowedToForward: Match.Optional([String]),
allowReceiveForwardOffline: Match.Optional(Boolean),
};
// The Livechat Form department support addition/custom fields, so those fields need to be added before validating
Object.keys(departmentData).forEach((field) => {
if (!defaultValidations.hasOwnProperty(field)) {
defaultValidations[field] = Match.OneOf(String, Match.Integer, Boolean);
}
});
check(departmentData, defaultValidations);
check(
departmentAgents,
Match.Maybe({
upsert: Match.Maybe(Array),
remove: Match.Maybe(Array),
}),
);
const { requestTagBeforeClosingChat, chatClosingTags, fallbackForwardDepartment } = departmentData;
if (requestTagBeforeClosingChat && (!chatClosingTags || chatClosingTags.length === 0)) {
throw new Meteor.Error(
'error-validating-department-chat-closing-tags',
'At least one closing tag is required when the department requires tag(s) on closing conversations.',
{ method: 'livechat:saveDepartment' },
);
}
if (_id && !department) {
throw new Meteor.Error('error-department-not-found', 'Department not found', {
method: 'livechat:saveDepartment',
});
}
if (fallbackForwardDepartment === _id) {
throw new Meteor.Error(
'error-fallback-department-circular',
'Cannot save department. Circular reference between fallback department and department',
);
}
if (fallbackForwardDepartment) {
const fallbackDep = await LivechatDepartment.findOneById<Pick<ILivechatDepartment, '_id'>>(fallbackForwardDepartment, {
projection: { _id: 1 },
});
if (!fallbackDep) {
throw new Meteor.Error('error-fallback-department-not-found', 'Fallback department not found', {
method: 'livechat:saveDepartment',
});
}
}
const departmentDB = await LivechatDepartment.createOrUpdateDepartment(_id, departmentData);
if (departmentDB && departmentAgents) {
await updateDepartmentAgents(departmentDB._id, departmentAgents, departmentDB.enabled);
}
if (department?.enabled !== departmentData.enabled) {
void notifyOnLivechatDepartmentAgentChangedByDepartmentId(departmentDB._id, department ? 'updated' : 'inserted');
}
// Disable event
if (department?.enabled && !departmentDB?.enabled) {
await callbacks.run('livechat.afterDepartmentDisabled', departmentDB);
void Apps.self?.triggerEvent(AppEvents.IPostLivechatDepartmentDisabled, { department: departmentDB });
}
if (departmentUnit) {
await callbacks.run('livechat.manageDepartmentUnit', { userId, departmentId: departmentDB._id, unitId: departmentUnit._id });
}
return departmentDB;
}
export async function archiveDepartment(_id: string) {
const department = await LivechatDepartment.findOneById<Pick<ILivechatDepartment, '_id' | 'businessHourId'>>(_id, {
projection: { _id: 1, businessHourId: 1 },
});
if (!department) {
throw new Error('department-not-found');
}
const status = await LivechatDepartment.archiveDepartment(department._id);
if (status.modifiedCount) {
await afterDepartmentArchived(department);
}
}
export async function unarchiveDepartment(_id: string) {
const department = await LivechatDepartment.findOneById<Pick<ILivechatDepartment, '_id'>>(_id, { projection: { _id: 1 } });
if (!department) {
throw new Meteor.Error('department-not-found');
}
const status = await LivechatDepartment.unarchiveDepartment(department._id);
if (status.modifiedCount) {
await afterDepartmentUnarchived(department);
}
}
export async function saveDepartmentAgents(
_id: string,
departmentAgents: {
upsert?: (Pick<ILivechatDepartmentAgents, 'agentId' | 'username'> & {
count?: number;
order?: number;
})[];
remove?: Pick<ILivechatDepartmentAgents, 'agentId' | 'username'>[];
},
) {
const department = await LivechatDepartment.findOneById<Pick<ILivechatDepartment, 'enabled'>>(_id, { projection: { enabled: 1 } });
if (!department) {
throw new Meteor.Error('error-department-not-found', 'Department not found');
}
return updateDepartmentAgents(_id, departmentAgents, department.enabled);
}
export async function setDepartmentForGuest({ visitorId, department }: { visitorId: string; department: string }) {
livechatLogger.debug({
msg: 'Switching departments for visitor',
visitorId,
department,
});
const dep = await LivechatDepartment.findOneById<Pick<ILivechatDepartment, '_id'>>(department, { projection: { _id: 1 } });
if (!dep) {
throw new Meteor.Error('invalid-department', 'Provided department does not exists');
}
// Visitor is already validated at this point
return LivechatVisitors.updateDepartmentById(visitorId, department);
}
export async function removeDepartment(departmentId: string) {
livechatLogger.debug({ msg: 'Removing department', departmentId });
const department = await LivechatDepartment.findOneById<Pick<ILivechatDepartment, '_id' | 'businessHourId' | 'parentId'>>(departmentId, {
projection: { _id: 1, businessHourId: 1, parentId: 1 },
});
if (!department) {
throw new Error('error-department-not-found');
}
const { _id } = department;
const ret = await LivechatDepartment.removeById(_id);
if (ret.acknowledged !== true) {
throw new Error('error-failed-to-delete-department');
}
const removedAgents = await LivechatDepartmentAgents.findByDepartmentId(department._id, { projection: { agentId: 1 } }).toArray();
const actions = ['Removing department agents', 'Unsetting fallback department', 'Removing department from rooms'];
livechatLogger.debug({
msg: 'Post department removal actions',
departmentId: _id,
actions,
});
const promiseResponses = await Promise.allSettled([
LivechatDepartmentAgents.removeByDepartmentId(_id),
LivechatDepartment.unsetFallbackDepartmentByDepartmentId(_id),
LivechatRooms.bulkRemoveDepartmentAndUnitsFromRooms(_id),
]);
promiseResponses.forEach((response, index) => {
if (response.status === 'rejected') {
livechatLogger.error({
msg: 'Post removal action failed',
actionId: actions[index],
error: response.reason,
});
}
});
const { deletedCount } = promiseResponses[0].status === 'fulfilled' ? promiseResponses[0].value : { deletedCount: 0 };
if (deletedCount > 0) {
removedAgents.forEach(({ _id: docId, agentId }) => {
void notifyOnLivechatDepartmentAgentChanged(
{
_id: docId,
agentId,
departmentId: _id,
},
'removed',
);
});
}
await callbacks.run('livechat.afterRemoveDepartment', { department, agentsIds: removedAgents.map(({ agentId }) => agentId) });
void Apps.self?.triggerEvent(AppEvents.IPostLivechatDepartmentRemoved, { department });
return ret;
}
export async function getRequiredDepartment(onlineRequired = true) {
if (!onlineRequired) {
return LivechatDepartment.findOneEnabledWithAgentsAndRegistration();
}
const departments = LivechatDepartment.findEnabledWithAgentsAndRegistration();
for await (const dept of departments) {
const onlineAgents = await checkOnlineForDepartment(dept._id);
if (onlineAgents) {
return dept;
}
}
}
export async function checkOnlineForDepartment(departmentId: string) {
const depUsers = await LivechatDepartmentAgents.findByDepartmentId(departmentId, { projection: { username: 1 } }).toArray();
const onlineForDep = await Users.findOneOnlineAgentByUserList(
depUsers.map((agent) => agent.username),
{ projection: { _id: 1 } },
settings.get<boolean>('Livechat_enabled_when_agent_idle'),
settings.get<boolean>('Livechat_accept_chats_with_no_agents'),
);
return !!onlineForDep;
}
export async function getOnlineForDepartment(departmentId: string) {
const agents = await LivechatDepartmentAgents.findByDepartmentId(departmentId, { projection: { username: 1 } }).toArray();
const usernames = agents.map(({ username }) => username);
return Users.findOnlineUserFromList<ILivechatAgent>(
[...new Set(usernames)],
settings.get<boolean>('Livechat_enabled_when_agent_idle'),
settings.get<boolean>('Livechat_accept_chats_with_no_agents'),
);
}

View File

@@ -0,0 +1,23 @@
import type { MessageTypesValues, IRoom } from '@rocket.chat/core-typings';
import { Rooms, Messages } from '@rocket.chat/models';
import { Meteor } from 'meteor/meteor';
export async function getRoomMessages({ rid }: { rid: string }) {
const room = await Rooms.findOneById<Pick<IRoom, 't'>>(rid, { projection: { t: 1 } });
if (room?.t !== 'l') {
throw new Meteor.Error('invalid-room');
}
const ignoredMessageTypes: MessageTypesValues[] = [
'livechat_navigation_history',
'livechat_transcript_history',
'command',
'livechat-close',
'livechat-started',
'livechat_video_call',
];
return Messages.findVisibleByRoomIdNotContainingTypes(rid, ignoredMessageTypes, {
sort: { ts: 1 },
});
}

View File

@@ -0,0 +1,205 @@
import { Apps, AppEvents } from '@rocket.chat/apps';
import type { ILivechatVisitor, IOmnichannelRoom, IUser, UserStatus } from '@rocket.chat/core-typings';
import {
LivechatVisitors,
LivechatCustomField,
LivechatInquiry,
LivechatRooms,
Messages,
ReadReceipts,
ReadReceiptsArchive,
Subscriptions,
LivechatContacts,
Users,
} from '@rocket.chat/models';
import UAParser from 'ua-parser-js';
import { parseAgentCustomFields } from './Helper';
import type { ICRMData } from './localTypes';
import { livechatLogger } from './logger';
import { trim } from '../../../lib/utils/stringUtils';
import { hasPermissionAsync } from '../authorization/hasPermission';
import { i18n } from '../i18n';
import { FileUpload } from '../media/file-upload';
import { notifyOnSubscriptionChanged, notifyOnLivechatInquiryChanged, notifyOnLivechatInquiryChangedByToken } from '../notifyListener';
export async function saveGuest(
guestData: Pick<ILivechatVisitor, '_id' | 'name' | 'livechatData'> & { email?: string; phone?: string },
userId: string,
) {
const { _id, name, email, phone, livechatData = {} } = guestData;
const visitor = await LivechatVisitors.findOneById(_id, { projection: { _id: 1 } });
if (!visitor) {
throw new Error('error-invalid-visitor');
}
livechatLogger.debug({ msg: 'Saving guest', guestData });
const updateData = {
...(name && { name }),
...(email && { email }),
...(phone && { phone }),
livechatData: {},
};
const customFields: Record<string, any> = {};
if ((!userId || (await hasPermissionAsync(userId, 'edit-livechat-room-customfields'))) && Object.keys(livechatData).length) {
livechatLogger.debug({ msg: 'Saving custom fields for visitor', visitorId: _id, livechatData });
for await (const field of LivechatCustomField.findByScope('visitor')) {
if (!livechatData.hasOwnProperty(field._id)) {
continue;
}
const value = trim(livechatData[field._id]);
if (value !== '' && field.regexp !== undefined && field.regexp !== '') {
const regexp = new RegExp(field.regexp);
if (!regexp.test(value)) {
throw new Error(i18n.t('error-invalid-custom-field-value'));
}
}
customFields[field._id] = value;
}
updateData.livechatData = customFields;
livechatLogger.debug({
msg: 'About to update custom fields for visitor',
visitorId: _id,
customFieldCount: Object.keys(customFields).length,
});
}
const ret = await LivechatVisitors.saveGuestById(_id, updateData);
setImmediate(() => {
void Apps.self?.triggerEvent(AppEvents.IPostLivechatGuestSaved, _id);
});
return ret;
}
export async function removeGuest({ _id }: { _id: string }) {
await cleanGuestHistory(_id);
return LivechatVisitors.disableById(_id);
}
export async function removeContactsByVisitorId({ _id }: { _id: string }) {
// A visitor shouldn't have many contacts associated, so we can remove them like this
const contacts = await LivechatContacts.findAllByVisitorId(_id).toArray();
if (!contacts.length) {
livechatLogger.debug({ msg: 'No contacts found for visitor', visitorId: _id });
await removeGuest({ _id });
}
// And a contact shouldn't have many channels associated, so we can do this
livechatLogger.debug({ msg: 'Removing channels for contacts', visitorId: _id, contacts: contacts.map(({ _id }) => _id) });
for await (const contact of contacts) {
for await (const { visitor } of contact.channels) {
await removeGuest({ _id: visitor.visitorId });
}
await LivechatContacts.disableByVisitorId(_id);
}
}
async function cleanGuestHistory(_id: string) {
// This shouldn't be possible, but just in case
if (!_id) {
throw new Error('error-invalid-guest');
}
// TODO: optimize function => instead of removing one by one, fetch the _ids of the rooms and then remove them in bulk
const cursor = LivechatRooms.findByVisitorId(_id, { projection: { _id: 1 } });
for await (const room of cursor) {
await Promise.all([
Subscriptions.removeByRoomId(room._id, {
async onTrash(doc) {
void notifyOnSubscriptionChanged(doc, 'removed');
},
}),
FileUpload.removeFilesByRoomId(room._id),
Messages.removeByRoomId(room._id),
ReadReceipts.removeByRoomId(room._id),
ReadReceiptsArchive.removeByRoomId(room._id),
]);
}
await LivechatRooms.removeByVisitorId(_id);
const livechatInquiries = await LivechatInquiry.findByVisitorIds([_id]).toArray();
await LivechatInquiry.removeByIds(livechatInquiries.map(({ _id }) => _id));
void notifyOnLivechatInquiryChanged(livechatInquiries, 'removed');
}
export async function getLivechatRoomGuestInfo(room: IOmnichannelRoom) {
const visitor = await LivechatVisitors.findOneEnabledById(room.v._id);
if (!visitor) {
throw new Error('error-invalid-visitor');
}
const agent = room.servedBy?._id
? await Users.findOneById<Pick<IUser, '_id' | 'customFields' | 'name' | 'username' | 'emails'>>(room.servedBy?._id, {
projection: { _id: 1, customFields: 1, name: 1, username: 1, emails: 1 },
})
: null;
const ua = new UAParser();
ua.setUA(visitor.userAgent || '');
const postData: ICRMData = {
_id: room._id,
label: room.fname || room.label, // using same field for compatibility
topic: room.topic,
createdAt: room.ts,
lastMessageAt: room.lm,
tags: room.tags,
customFields: room.livechatData,
visitor: {
_id: visitor._id,
token: visitor.token,
name: visitor.name,
username: visitor.username,
department: visitor.department,
ip: visitor.ip,
os: ua.getOS().name && `${ua.getOS().name} ${ua.getOS().version}`,
browser: ua.getBrowser().name && `${ua.getBrowser().name} ${ua.getBrowser().version}`,
customFields: visitor.livechatData,
},
};
if (agent) {
const customFields = parseAgentCustomFields(agent.customFields);
postData.agent = {
_id: agent._id,
username: agent.username,
name: agent.name,
...(customFields && { customFields }),
};
if (agent.emails && agent.emails.length > 0) {
postData.agent.email = agent.emails[0].address;
}
}
if (room.crmData) {
postData.crmData = room.crmData;
}
if (visitor.visitorEmails && visitor.visitorEmails.length > 0) {
postData.visitor.email = visitor.visitorEmails;
}
if (visitor.phone && visitor.phone.length > 0) {
postData.visitor.phone = visitor.phone;
}
return postData;
}
export async function notifyGuestStatusChanged(token: string, status: UserStatus) {
const [, inquiryVisitorStatus] = await Promise.all([
LivechatRooms.updateVisitorStatus(token, status),
LivechatInquiry.updateVisitorStatus(token, status),
]);
if (inquiryVisitorStatus.modifiedCount) {
void notifyOnLivechatInquiryChangedByToken(token, 'updated', { v: { status } });
}
}

View File

@@ -0,0 +1,140 @@
import { ILivechatAgentStatus } from '@rocket.chat/core-typings';
import type {
AtLeast,
ILivechatDepartment,
IOmnichannelRoom,
IOmnichannelRoomExtraData,
IOmnichannelRoomInfo,
IOmnichannelSource,
IUser,
SelectedAgent,
InquiryWithAgentInfo,
ILivechatInquiryRecord,
} from '@rocket.chat/core-typings';
import { LivechatContacts, LivechatDepartmentAgents, LivechatVisitors, Users } from '@rocket.chat/models';
import { makeFunction } from '@rocket.chat/patch-injection';
import { setUserStatusLivechat } from './utils';
import { sendToCRM } from '../../hooks/omnichannel/sendToCRM';
import { settings } from '../../settings';
import { callbacks } from '../callbacks';
import { notifyOnLivechatDepartmentAgentChangedByDepartmentId } from '../notifyListener';
export async function afterAgentUserActivated(user: IUser) {
if (!user.roles.includes('livechat-agent')) {
throw new Error('invalid-user-role');
}
callbacks.runAsync('livechat.onNewAgentCreated', user._id);
}
export async function afterAgentAdded<T extends AtLeast<IUser, '_id'>>(user: T): Promise<T> {
await setUserStatusLivechat(user._id, user.status !== 'offline' ? ILivechatAgentStatus.AVAILABLE : ILivechatAgentStatus.NOT_AVAILABLE);
callbacks.runAsync('livechat.onNewAgentCreated', user._id);
return user;
}
export async function afterRemoveAgent(user: AtLeast<IUser, '_id' | 'username'>) {
await callbacks.run('livechat.afterAgentRemoved', { agent: user });
return true;
}
export async function afterDepartmentArchived(department: AtLeast<ILivechatDepartment, '_id' | 'businessHourId'>) {
await LivechatDepartmentAgents.disableAgentsByDepartmentId(department._id);
void notifyOnLivechatDepartmentAgentChangedByDepartmentId(department._id);
await callbacks.run('livechat.afterDepartmentArchived', department);
}
export async function afterDepartmentUnarchived(department: AtLeast<ILivechatDepartment, '_id' | 'businessHourId'>) {
await LivechatDepartmentAgents.enableAgentsByDepartmentId(department._id);
void notifyOnLivechatDepartmentAgentChangedByDepartmentId(department._id);
}
export const checkDefaultAgentOnNewRoom = makeFunction(
async (defaultAgent?: SelectedAgent, _params?: { visitorId?: string; source?: IOmnichannelSource }) => defaultAgent,
);
export const beforeDelegateAgent = async (
agent: SelectedAgent | undefined,
{ department }: { department?: string } = {},
): Promise<SelectedAgent | null | undefined> => {
if (agent) {
return agent;
}
if (!settings.get('Livechat_assign_new_conversation_to_bot')) {
return null;
}
if (department) {
return LivechatDepartmentAgents.getNextBotForDepartment(department);
}
return Users.getNextBotAgent();
};
export const beforeNewRoom = makeFunction(
async (roomInfo: IOmnichannelRoomInfo, _extraData?: IOmnichannelRoomExtraData): Promise<Partial<IOmnichannelRoom>> => roomInfo,
);
export const onNewRoom = makeFunction(async (room: IOmnichannelRoom) => {
const {
_id,
v: { _id: guestId },
source,
contactId,
} = room;
const lastChat = {
_id,
ts: new Date(),
};
await Promise.all([
LivechatVisitors.setLastChatById(guestId, lastChat),
contactId
? LivechatContacts.updateLastChatById(
contactId,
{
visitorId: guestId,
source,
},
lastChat,
)
: undefined,
]);
if (settings.get('Livechat_webhook_on_start')) {
await sendToCRM('LivechatSessionStart', room);
}
});
export const afterTakeInquiry = makeFunction(
async ({ room }: { inquiry: InquiryWithAgentInfo; room: IOmnichannelRoom; agent: { agentId: string; username: string } }) => {
if (!settings.get('Livechat_webhook_on_chat_taken')) {
return;
}
await sendToCRM('LivechatSessionTaken', room);
},
);
export const afterInquiryQueued = makeFunction(async (_inquiry: ILivechatInquiryRecord) => {
return void 0;
});
export const afterRoomQueued = makeFunction((room: IOmnichannelRoom) => {
if (!settings.get('Livechat_webhook_on_chat_queued')) {
return;
}
return sendToCRM('LivechatSessionQueued', room);
});
export const beforeRouteChat = makeFunction(
async (inquiry: ILivechatInquiryRecord, _agent?: SelectedAgent | null): Promise<ILivechatInquiryRecord | null | undefined> => {
return inquiry;
},
);

View File

@@ -0,0 +1,24 @@
import './livechat';
import './startup';
import '../../hooks/omnichannel/leadCapture';
import '../../hooks/omnichannel/markRoomResponded';
import '../../hooks/omnichannel/offlineMessage';
import '../../hooks/omnichannel/offlineMessageToChannel';
import '../../hooks/omnichannel/saveAnalyticsData';
import '../../hooks/omnichannel/sendToCRM';
import '../../hooks/omnichannel/processRoomAbandonment';
import '../../hooks/omnichannel/saveLastVisitorMessageTs';
import '../../hooks/omnichannel/markRoomNotResponded';
import '../../hooks/omnichannel/sendEmailTranscriptOnClose';
import '../../hooks/omnichannel/saveLastMessageToInquiry';
import '../../hooks/omnichannel/afterUserActions';
import '../../hooks/omnichannel/afterAgentRemoved';
import '../../hooks/omnichannel/afterSaveOmnichannelMessage';
import './QueueManager';
import './RoutingManager';
import './routing/External';
import './routing/ManualSelection';
import './routing/AutoSelection';
import './stream/agentStatus';
import './sendMessageBySMS';
import '../../api/v1/omnichannel';

View File

@@ -0,0 +1,6 @@
import type { IMessage, IUser } from '@rocket.chat/core-typings';
import { Users } from '@rocket.chat/models';
export async function isMessageFromBot(message: IMessage): Promise<Pick<IUser, '_id' | 'roles'> | null> {
return Users.isUserInRole(message.u._id, 'bot');
}

View File

@@ -0,0 +1,73 @@
import url from 'node:url';
import jsdom from 'jsdom';
import mem from 'mem';
import { WebApp } from 'meteor/webapp';
import { addServerUrlToIndex } from './Assets';
import { settings } from '../../settings';
const indexHtmlWithServerURL = await Assets.getTextAsync('livechat/index.html');
function parseExtraAttributes(widgetData: string): string {
const liveChatAdditionalScripts = settings.get<string>('Livechat_AdditionalWidgetScripts');
const additionalClass = settings.get<string>('Livechat_WidgetLayoutClasses');
if (liveChatAdditionalScripts == null || additionalClass == null) {
return widgetData;
}
const domParser = new jsdom.JSDOM(widgetData);
const doc = domParser.window.document;
const head = doc.querySelector('head');
const body = doc.querySelector('body');
liveChatAdditionalScripts.split(',').forEach((script) => {
const scriptElement = doc.createElement('script');
scriptElement.src = script;
body?.appendChild(scriptElement);
});
additionalClass.split(',').forEach((css) => {
const linkElement = doc.createElement('link');
linkElement.rel = 'stylesheet';
linkElement.href = css;
head?.appendChild(linkElement);
});
return doc.documentElement.innerHTML;
}
const memoizedParseExtraAttributes = mem(parseExtraAttributes, {
maxAge: process.env.TEST_MODE === 'true' || process.env.TEST_MODE === 'api' ? 1 : 60000,
});
WebApp.connectHandlers.use('/livechat', (req, res, next) => {
if (!req.url) {
return next();
}
const reqUrl = url.parse(req.url);
if (reqUrl.pathname !== '/') {
return next();
}
res.setHeader('content-type', 'text/html; charset=utf-8');
const domainWhiteListSetting = settings.get<string>('Livechat_AllowedDomainsList');
let domainWhiteList = [];
if (req.headers.referer && domainWhiteListSetting.trim()) {
domainWhiteList = domainWhiteListSetting.split(',').map((domain) => domain.trim());
const referer = url.parse(req.headers.referer);
if (referer.host && !domainWhiteList.includes(referer.host)) {
res.setHeader('Content-Security-Policy', "frame-ancestors 'none'");
return next();
}
res.setHeader('Content-Security-Policy', `frame-ancestors ${referer.protocol}//${referer.host}`);
} else {
// TODO need to remove inline scripts from this route to be able to enable CSP here as well
res.removeHeader('Content-Security-Policy');
}
res.write(memoizedParseExtraAttributes(addServerUrlToIndex(indexHtmlWithServerURL || '')));
res.end();
});

View File

@@ -0,0 +1,88 @@
import type {
IOmnichannelRoom,
IUser,
ILivechatVisitor,
IMessage,
MessageAttachment,
IMessageInbox,
IOmnichannelAgent,
} from '@rocket.chat/core-typings';
type GenericCloseRoomParams = {
room: IOmnichannelRoom;
comment?: string;
forceClose?: boolean;
options?: {
clientAction?: boolean;
tags?: string[];
emailTranscript?:
| {
sendToVisitor: false;
}
| {
sendToVisitor: true;
requestData: NonNullable<IOmnichannelRoom['transcriptRequest']>;
};
pdfTranscript?: {
requestedBy: string;
};
};
};
export type CloseRoomParamsByUser = {
user: IUser | null;
} & GenericCloseRoomParams;
export type CloseRoomParamsByVisitor = {
visitor: ILivechatVisitor;
} & GenericCloseRoomParams;
export type CloseRoomParams = CloseRoomParamsByUser | CloseRoomParamsByVisitor;
type UploadedFile = {
_id: string;
name?: string;
type?: string;
size?: number;
description?: string;
identify?: { size: { width: number; height: number } };
format?: string;
};
export interface ILivechatMessage {
token: string;
_id: string;
rid: string;
msg: string;
file?: UploadedFile;
files?: UploadedFile[];
attachments?: MessageAttachment[];
alias?: string;
groupable?: boolean;
blocks?: IMessage['blocks'];
email?: IMessageInbox['email'];
}
export type ICRMData = {
_id: string;
label?: string;
topic?: string;
createdAt: Date;
lastMessageAt?: Date;
tags?: string[];
customFields?: IOmnichannelRoom['livechatData'];
visitor: Pick<ILivechatVisitor, '_id' | 'token' | 'name' | 'username' | 'department' | 'phone' | 'ip'> & {
email?: ILivechatVisitor['visitorEmails'];
os?: string;
browser?: string;
customFields: ILivechatVisitor['livechatData'];
};
agent?: Pick<IOmnichannelAgent, '_id' | 'username' | 'name' | 'customFields'> & {
email?: NonNullable<IOmnichannelAgent['emails']>[number]['address'];
};
crmData?: IOmnichannelRoom['crmData'];
};
export type AKeyOf<T> = {
[K in keyof T]?: T[K];
};

View File

@@ -0,0 +1,7 @@
import { Logger } from '@rocket.chat/logger';
export const callbackLogger = new Logger('[Omnichannel] Callback');
export const businessHourLogger = new Logger('Business Hour');
export const livechatLogger = new Logger('Livechat');
export const livechatContactsLogger = new Logger('Livechat Contacts');
export const webhooksLogger = new Logger('Webhooks');

View File

@@ -0,0 +1,153 @@
import dns from 'node:dns';
import * as util from 'node:util';
import type { ILivechatVisitor, AtLeast, IMessage, IUser, IOmnichannelRoomInfo, SelectedAgent } from '@rocket.chat/core-typings';
import { LivechatDepartment, Messages } from '@rocket.chat/models';
import { check, Match } from 'meteor/check';
import { Meteor } from 'meteor/meteor';
import type { ILivechatMessage } from './localTypes';
import { getRoom } from './rooms';
import { showConnecting } from './utils';
import { settings } from '../../settings';
import { callbacks } from '../callbacks';
import { deleteMessage as deleteMessageFunc } from '../messages/deleteMessage';
import { sendMessage as sendMessageFunc } from '../messages/sendMessage';
import { updateMessage as updateMessageFunc } from '../messages/updateMessage';
import * as Mailer from '../notifications/email/api';
const dnsResolveMx = util.promisify(dns.resolveMx);
type OfflineMessageData = {
message: string;
name: string;
email: string;
department?: string;
host?: string;
};
export async function sendOfflineMessage(data: OfflineMessageData) {
if (!settings.get('Livechat_display_offline_form')) {
throw new Error('error-offline-form-disabled');
}
const { message, name, email, department, host } = data;
if (!email) {
throw new Error('error-invalid-email');
}
const emailMessage = `${message}`.replace(/([^>\r\n]?)(\r\n|\n\r|\r|\n)/g, '$1<br>$2');
let html = '<h1>New livechat message</h1>';
if (host && host !== '') {
html = html.concat(`<p><strong>Sent from:</strong><a href='${host}'> ${host}</a></p>`);
}
html = html.concat(`
<p><strong>Visitor name:</strong> ${name}</p>
<p><strong>Visitor email:</strong> ${email}</p>
<p><strong>Message:</strong><br>${emailMessage}</p>`);
const fromEmail = settings.get<string>('From_Email').match(/\b[A-Z0-9._%+-]+@(?:[A-Z0-9-]+\.)+[A-Z]{2,4}\b/i);
let from: string;
if (fromEmail) {
from = fromEmail[0];
} else {
from = settings.get<string>('From_Email');
}
if (settings.get('Livechat_validate_offline_email')) {
const emailDomain = email.substr(email.lastIndexOf('@') + 1);
try {
await dnsResolveMx(emailDomain);
} catch (e) {
throw new Meteor.Error('error-invalid-email-address');
}
}
// TODO Block offline form if Livechat_offline_email is undefined
// (it does not make sense to have an offline form that does nothing)
// `this.sendEmail` will throw an error if the email is invalid
// thus this breaks livechat, since the "to" email is invalid, and that returns an [invalid email] error to the livechat client
let emailTo = settings.get<string>('Livechat_offline_email');
if (department && department !== '') {
const dep = await LivechatDepartment.findOneByIdOrName(department, { projection: { email: 1 } });
if (dep) {
emailTo = dep.email || emailTo;
}
}
const fromText = `${name} - ${email} <${from}>`;
const replyTo = `${name} <${email}>`;
const subject = `Livechat offline message from ${name}: ${`${emailMessage}`.substring(0, 20)}`;
await Mailer.send({
to: emailTo,
from: fromText,
replyTo,
subject,
html,
});
setImmediate(() => {
void callbacks.run('livechat.offlineMessage', data);
});
}
export async function updateMessage({ guest, message }: { guest: ILivechatVisitor; message: AtLeast<IMessage, '_id' | 'msg' | 'rid'> }) {
// TODO: Remove check
check(message, Match.ObjectIncluding({ _id: String }));
const originalMessage = await Messages.findOneById<Pick<IMessage, 'u' | '_id'>>(message._id, { projection: { u: 1 } });
if (!originalMessage?._id) {
return;
}
// TODO: shouldn't this happen inside updateMessageFunc?
const editAllowed = settings.get('Message_AllowEditing');
const editOwn = originalMessage.u && originalMessage.u._id === guest._id;
if (!editAllowed || !editOwn) {
throw new Error('error-action-not-allowed');
}
// TODO: Apps sends an `any` object and apparently we just check for _id being present
// while updateMessage expects AtLeast<id, msg, rid>
await updateMessageFunc(message, guest as unknown as IUser);
return true;
}
export async function deleteMessage({ guest, message }: { guest: ILivechatVisitor; message: IMessage }) {
const deleteAllowed = settings.get<boolean>('Message_AllowDeleting');
const editOwn = message.u && message.u._id === guest._id;
if (!deleteAllowed || !editOwn) {
throw new Error('error-action-not-allowed');
}
// TODO: we shouldn't do this :(
await deleteMessageFunc(message, guest as unknown as IUser);
return true;
}
export async function sendMessage({
guest,
message,
roomInfo,
agent,
}: {
guest: ILivechatVisitor;
message: ILivechatMessage;
roomInfo: IOmnichannelRoomInfo;
agent?: SelectedAgent;
}) {
const { room, newRoom } = await getRoom(guest, message, roomInfo, agent);
return {
...(await sendMessageFunc(guest, { ...message, token: guest.token, ...(guest.name && { alias: guest.name }) }, room)),
newRoom,
showConnecting: showConnecting(),
};
}

View File

@@ -0,0 +1,116 @@
import { api } from '@rocket.chat/core-services';
import type { UserStatus, IUser } from '@rocket.chat/core-typings';
import { LivechatDepartment, LivechatDepartmentAgents, LivechatRooms, Users } from '@rocket.chat/models';
import { removeEmpty } from '@rocket.chat/tools';
import { updateDepartmentAgents } from './Helper';
import { afterAgentAdded, afterRemoveAgent } from './hooks';
import { settings } from '../../settings';
import { callbacks } from '../callbacks';
import { addUserRolesAsync } from '../roles/addUserRoles';
import { removeUserFromRolesAsync } from '../roles/removeUserFromRoles';
export async function notifyAgentStatusChanged(userId: string, status?: UserStatus) {
if (!status) {
return;
}
void callbacks.runAsync('livechat.agentStatusChanged', { userId, status });
if (!settings.get('Livechat_show_agent_info')) {
return;
}
for await (const room of LivechatRooms.findOpenByAgent(userId, { projection: { _id: 1 } })) {
void api.broadcast('omnichannel.room', room._id, {
type: 'agentStatus',
status,
});
}
}
export async function addManager(username: string) {
const user = await Users.findOneByUsername(username, { projection: { _id: 1, username: 1 } });
if (!user) {
throw new Error('error-invalid-user');
}
if (await addUserRolesAsync(user._id, ['livechat-manager'])) {
return user;
}
return false;
}
export async function addAgent(username: string) {
// `status` is deliberately not projected: this endpoint has always turned the new agent available
// regardless of the user's presence, and `afterAgentAdded` relies on the field being absent to do so.
const user = await Users.findOneByUsername(username, { projection: { _id: 1, username: 1 } });
if (!user) {
throw new Error('error-invalid-user');
}
if (await addUserRolesAsync(user._id, ['livechat-agent'])) {
return afterAgentAdded(user);
}
return false;
}
export async function removeAgent(id: IUser['_id']) {
const user = await Users.findOneById<Pick<IUser, '_id' | 'username'>>(id, { projection: { _id: 1, username: 1 } });
if (!user) {
throw new Error('error-invalid-user');
}
const { _id } = user;
if (await removeUserFromRolesAsync(_id, ['livechat-agent'])) {
return afterRemoveAgent(user);
}
return false;
}
export async function removeManager(id: IUser['_id']) {
const user = await Users.findOneById<Pick<IUser, '_id' | 'username'>>(id, { projection: { _id: 1 } });
if (!user) {
throw new Error('error-invalid-user');
}
return removeUserFromRolesAsync(user._id, ['livechat-manager']);
}
export async function saveAgentInfo(_id: string, agentData: any, agentDepartments: string[]) {
await Users.setLivechatData(_id, removeEmpty(agentData));
const currentDepartmentsForAgent = await LivechatDepartmentAgents.findByAgentId(_id).toArray();
const toRemoveIds = currentDepartmentsForAgent
.filter((dept) => !agentDepartments.includes(dept.departmentId))
.map((dept) => dept.departmentId);
const toAddIds = agentDepartments.filter((d) => !currentDepartmentsForAgent.some((c) => c.departmentId === d));
await Promise.all(
await LivechatDepartment.findInIds([...toRemoveIds, ...toAddIds], {
projection: {
_id: 1,
enabled: 1,
},
})
.map((dep) => {
return updateDepartmentAgents(
dep._id,
{
...(toRemoveIds.includes(dep._id) ? { remove: [{ agentId: _id }] } : { upsert: [{ agentId: _id, count: 0, order: 0 }] }),
},
dep.enabled,
);
})
.toArray(),
);
return true;
}

View File

@@ -0,0 +1,6 @@
import type { IOutboundMessageProviderService } from '@rocket.chat/core-typings';
import { makeFunction } from '@rocket.chat/patch-injection';
export const getOutboundService = makeFunction((): IOutboundMessageProviderService => {
throw new Error('error-no-license');
});

View File

@@ -0,0 +1,63 @@
import type { ILivechatVisitor, IOmnichannelRoom, IUser } from '@rocket.chat/core-typings';
import { LivechatVisitors, Users } from '@rocket.chat/models';
import type { CloseRoomParams } from './localTypes';
import { settings } from '../../settings';
export const parseTranscriptRequest = async (
room: IOmnichannelRoom,
options: CloseRoomParams['options'],
visitor?: ILivechatVisitor,
user?: IUser,
): Promise<CloseRoomParams['options']> => {
const visitorDecideTranscript = settings.get<boolean>('Livechat_enable_transcript');
// visitor decides, no changes
if (visitorDecideTranscript) {
return options;
}
// send always is disabled, no changes
const sendAlways = settings.get<boolean>('Livechat_transcript_send_always');
if (!sendAlways) {
return options;
}
const visitorData =
visitor ||
(await LivechatVisitors.findOneById<Pick<ILivechatVisitor, 'visitorEmails'>>(room.v._id, { projection: { visitorEmails: 1 } }));
// no visitor, no changes
if (!visitorData) {
return options;
}
const visitorEmail = visitorData?.visitorEmails?.[0]?.address;
// visitor doesnt have email, no changes
if (!visitorEmail) {
return options;
}
// `as const` keeps the projection statically readable so the return type narrows;
// `utcOffset` is part of the `requestedBy` contract.
const defOptions = { projection: { _id: 1, username: 1, name: 1, utcOffset: 1 } } as const;
const requestedBy =
user ||
(room.servedBy && (await Users.findOneById(room.servedBy._id, defOptions))) ||
(await Users.findOneById('rocket.cat', defOptions));
// no user available for backing request, no changes
if (!requestedBy) {
return options;
}
return {
...options,
emailTranscript: {
sendToVisitor: true,
requestData: {
email: visitorEmail,
requestedAt: new Date(),
subject: '',
requestedBy,
},
},
};
};

View File

@@ -0,0 +1,23 @@
import type { IVisitorExternalIdentifier, ILivechatVisitor } from '@rocket.chat/core-typings';
import { LivechatVisitors } from '@rocket.chat/models';
type ResolveVisitorContactData = { phone: string } | { email: string };
type ResolveVisitorParams = {
appId: string;
externalId: Omit<IVisitorExternalIdentifier, 'appId'>;
contactData?: ResolveVisitorContactData;
};
export async function resolveVisitor({ appId, externalId, contactData }: ResolveVisitorParams): Promise<ILivechatVisitor | null> {
const visitorByExternalId = await LivechatVisitors.findOneByExternalId(externalId.entityId);
if (visitorByExternalId) {
return visitorByExternalId;
}
if (contactData && (('phone' in contactData && contactData.phone) || ('email' in contactData && contactData.email))) {
return LivechatVisitors.findOneVisitorByPhoneOrEmailAndAddExternalId(contactData, appId, externalId);
}
return null;
}

View File

@@ -0,0 +1,97 @@
import type { IUser, IOmnichannelRoom } from '@rocket.chat/core-typings';
import { LivechatDepartmentAgents, LivechatInquiry, LivechatRooms, LivechatDepartment } from '@rocket.chat/models';
import { RoutingManager } from './RoutingManager';
import { hasPermissionAsync } from '../authorization/hasPermission';
import { hasRoleAsync } from '../authorization/hasRole';
type OmnichannelRoomAccessValidator = (
room: IOmnichannelRoom,
user?: Pick<IUser, '_id'>,
extraData?: Record<string, any>,
) => boolean | Promise<boolean>;
export const validators: OmnichannelRoomAccessValidator[] = [
async function (_room, user) {
if (!user?._id) {
return false;
}
return hasPermissionAsync(user._id, 'view-livechat-rooms');
},
async function (room, user) {
if (!user?._id) {
return false;
}
const { _id: userId } = user;
const { servedBy: { _id: agentId } = {} } = room;
return userId === agentId || (!room.open && hasPermissionAsync(user._id, 'view-livechat-room-closed-by-another-agent'));
},
async function (room, _user, extraData) {
if (extraData?.rid) {
const dbRoom = await LivechatRooms.findOneById(extraData.rid);
if (dbRoom) {
room = dbRoom;
}
}
return extraData?.visitorToken && room.v && room.v.token === extraData.visitorToken && room.open === true;
},
async function (room, user) {
if (!user?._id) {
return false;
}
if (!RoutingManager.getConfig()?.previewRoom) {
return;
}
let departmentIds;
if (!(await hasRoleAsync(user._id, 'livechat-manager'))) {
const departmentAgents = (await LivechatDepartmentAgents.findByAgentId(user._id, { projection: { departmentId: 1 } }).toArray()).map(
(d) => d.departmentId,
);
departmentIds = (await LivechatDepartment.findEnabledInIds(departmentAgents, { projection: { _id: 1 } }).toArray()).map((d) => d._id);
}
const filter = {
rid: room._id,
$or: [
{
$and: [{ defaultAgent: { $exists: true } }, { 'defaultAgent.agentId': user._id }],
},
{
...(departmentIds && departmentIds.length > 0 && { department: { $in: departmentIds } }),
},
{
department: { $exists: false }, // No department == public queue
},
],
};
// TODO: findone filtering if the inquiry is queued instead of checking here
const inquiry = await LivechatInquiry.findOne(filter, { projection: { status: 1 } });
return inquiry && inquiry.status === 'queued';
},
async function (room, user) {
if (!room.departmentId || room.open || !user?._id) {
return;
}
const agentOfDepartment = await LivechatDepartmentAgents.findOneByAgentIdAndDepartmentId(user._id, room.departmentId, {
projection: { _id: 1 },
});
if (!agentOfDepartment) {
return;
}
return hasPermissionAsync(user._id, 'view-livechat-room-closed-same-department');
},
function (_room, user) {
// Check if user is rocket.cat
if (!user?._id) {
return false;
}
// This opens the ability for rocketcat to upload files to a livechat room without being included in it :)
// Worst case, someone manages to log in as rocketcat lol
return user._id === 'rocket.cat';
},
];

View File

@@ -0,0 +1,21 @@
import type { IAuthorizationLivechat } from '@rocket.chat/core-services';
import { ServiceClassInternal } from '@rocket.chat/core-services';
import type { IUser, IOmnichannelRoom } from '@rocket.chat/core-typings';
import { validators } from './roomAccessValidator.compatibility';
export class AuthorizationLivechat extends ServiceClassInternal implements IAuthorizationLivechat {
protected name = 'authorization-livechat';
protected override internal = true;
async canAccessRoom(room: IOmnichannelRoom, user?: Pick<IUser, '_id'>, extraData?: object): Promise<boolean> {
for (const validator of validators) {
if (await validator(room, user, extraData)) {
return true;
}
}
return false;
}
}

View File

@@ -0,0 +1,314 @@
import { AppEvents, Apps } from '@rocket.chat/apps';
import { Omnichannel } from '@rocket.chat/core-services';
import type {
ILivechatVisitor,
IMessage,
IOmnichannelRoomInfo,
SelectedAgent,
IOmnichannelRoomExtraData,
IOmnichannelRoom,
TransferData,
} from '@rocket.chat/core-typings';
import { isOmnichannelRoom } from '@rocket.chat/core-typings';
import {
LivechatRooms,
LivechatContacts,
Messages,
LivechatCustomField,
LivechatInquiry,
Rooms,
Subscriptions,
Users,
ReadReceipts,
ReadReceiptsArchive,
} from '@rocket.chat/models';
import { check } from 'meteor/check';
import { Meteor } from 'meteor/meteor';
import { normalizeTransferredByData } from './Helper';
import { QueueManager } from './QueueManager';
import { RoutingManager } from './RoutingManager';
import { Visitors } from './Visitors';
import { getRequiredDepartment } from './departmentsLib';
import { checkDefaultAgentOnNewRoom } from './hooks';
import { livechatLogger } from './logger';
import { saveTransferHistory } from './transfer';
import { i18n } from '../../../app/utils/lib/i18n';
import { trim } from '../../../lib/utils/stringUtils';
import { settings } from '../../settings';
import { hasPermissionAsync } from '../authorization/hasPermission';
import { callbacks } from '../callbacks';
import {
notifyOnLivechatInquiryChangedByRoom,
notifyOnSubscriptionChangedByRoomId,
notifyOnRoomChangedById,
notifyOnLivechatInquiryChanged,
notifyOnSubscriptionChanged,
notifyOnRoomChanged,
} from '../notifyListener';
export async function getRoom(
guest: ILivechatVisitor,
message: Pick<IMessage, 'rid' | 'msg' | 'token'>,
roomInfo: IOmnichannelRoomInfo,
agent?: SelectedAgent,
extraData?: IOmnichannelRoomExtraData,
) {
if (!settings.get('Livechat_enabled')) {
throw new Meteor.Error('error-omnichannel-is-disabled');
}
livechatLogger.debug({ msg: 'Attempting to find or create a room for visitor', visitorId: guest._id });
const room = await LivechatRooms.findOneById(message.rid);
if (room?.v._id && (await LivechatContacts.isChannelBlocked(Visitors.makeVisitorAssociation(room.v._id, room.source)))) {
throw new Error('error-contact-channel-blocked');
}
if (!room?.open) {
livechatLogger.debug({ msg: 'Last room for visitor closed. Creating new one', visitorId: guest._id });
}
if (!room?.open) {
return {
room: await createRoom({ visitor: guest, message: message.msg, roomInfo, agent, extraData }),
newRoom: true,
};
}
if (room.v.token !== guest.token) {
livechatLogger.debug({ msg: 'Visitor trying to access another visitor room', visitorId: guest._id });
throw new Meteor.Error('cannot-access-room');
}
return { room, newRoom: false };
}
export async function createRoom({
visitor,
message,
rid,
roomInfo,
agent,
extraData,
}: {
visitor: ILivechatVisitor;
message?: string;
rid?: string;
roomInfo: IOmnichannelRoomInfo;
agent?: SelectedAgent;
extraData?: IOmnichannelRoomExtraData;
}) {
if (!settings.get('Livechat_enabled')) {
throw new Meteor.Error('error-omnichannel-is-disabled');
}
if (await LivechatContacts.isChannelBlocked(Visitors.makeVisitorAssociation(visitor._id, roomInfo.source))) {
throw new Error('error-contact-channel-blocked');
}
const defaultAgent = await checkDefaultAgentOnNewRoom(agent, {
visitorId: visitor._id,
source: roomInfo.source,
});
// if no department selected verify if there is at least one active and pick the first
if (!defaultAgent && !visitor.department) {
const department = await getRequiredDepartment();
livechatLogger.debug({ msg: 'No department or default agent selected for visitor', visitorId: visitor._id });
if (department) {
livechatLogger.debug({ msg: 'Assigning visitor to department', visitorId: visitor._id, departmentId: department._id });
visitor.department = department._id;
}
}
// delegate room creation to QueueManager
livechatLogger.debug({ msg: 'Calling QueueManager to request a room for visitor', visitorId: visitor._id });
const room = await QueueManager.requestRoom({
guest: visitor,
message,
rid,
roomInfo,
agent: defaultAgent,
extraData,
});
livechatLogger.debug({ msg: 'Room obtained for visitor', visitorId: visitor._id, roomId: room._id });
await Messages.setRoomIdByToken(visitor.token, room._id);
return room;
}
export async function saveRoomInfo(
roomData: {
_id: string;
topic?: string;
tags?: string[];
livechatData?: { [k: string]: string };
// For priority and SLA, if the value is blank (ie ""), then system will remove the priority or SLA from the room
priorityId?: string;
slaId?: string;
},
guestData?: {
_id: string;
name?: string;
email?: string;
phone?: string;
livechatData?: { [k: string]: string };
},
userId?: string,
) {
livechatLogger.debug({ msg: 'Saving room information', roomId: roomData._id });
const { livechatData = {} } = roomData;
const customFields: Record<string, string> = {};
if ((!userId || (await hasPermissionAsync(userId, 'edit-livechat-room-customfields'))) && Object.keys(livechatData).length) {
const fields = LivechatCustomField.findByScope('room');
for await (const field of fields) {
if (!livechatData.hasOwnProperty(field._id)) {
continue;
}
const value = trim(livechatData[field._id]);
if (value !== '' && field.regexp !== undefined && field.regexp !== '') {
const regexp = new RegExp(field.regexp);
if (!regexp.test(value)) {
throw new Meteor.Error(i18n.t('error-invalid-custom-field-value', { field: field.label }));
}
}
customFields[field._id] = value;
}
roomData.livechatData = customFields;
livechatLogger.debug({
msg: 'About to update custom fields on room',
roomId: roomData._id,
customFieldCount: Object.keys(customFields).length,
});
}
await LivechatRooms.saveRoomById(roomData);
setImmediate(() => {
void Apps.self?.triggerEvent(AppEvents.IPostLivechatRoomSaved, roomData._id);
});
if (guestData?.name?.trim().length) {
const { _id: rid } = roomData;
const { name } = guestData;
const responses = await Promise.all([
Rooms.setFnameById(rid, name),
LivechatInquiry.setNameByRoomId(rid, name),
Subscriptions.updateDisplayNameByRoomId(rid, name),
]);
if (responses[1]?.modifiedCount) {
void notifyOnLivechatInquiryChangedByRoom(rid, 'updated', { name });
}
if (responses[2]?.modifiedCount) {
await notifyOnSubscriptionChangedByRoomId(rid);
}
}
void notifyOnRoomChangedById(roomData._id);
return true;
}
export async function returnRoomAsInquiry(room: IOmnichannelRoom, departmentId?: string, overrideTransferData: Partial<TransferData> = {}) {
livechatLogger.debug({ msg: 'Transferring room to queue', scope: departmentId ? 'department' : undefined, room });
if (!room.open) {
throw new Meteor.Error('room-closed', 'Room closed');
}
if (room.onHold) {
throw new Meteor.Error('error-room-onHold');
}
if (!(await Omnichannel.isWithinMACLimit(room))) {
throw new Meteor.Error('error-mac-limit-reached');
}
if (!room.servedBy) {
return false;
}
const user = await Users.findOneById(room.servedBy._id);
if (!user?._id) {
throw new Meteor.Error('error-invalid-user');
}
const inquiry = await LivechatInquiry.findOne({ rid: room._id });
if (!inquiry) {
return false;
}
// update inquiry's last message with room's last message to correctly display in the queue
// because we stop updating the inquiry when it's been taken
if (room.lastMessage) {
await LivechatInquiry.setLastMessageById(inquiry._id, room.lastMessage);
}
const transferredBy = normalizeTransferredByData(user, room);
livechatLogger.debug({ msg: 'Transferring room by user', roomId: room._id, transferredBy: transferredBy._id });
const transferData = { scope: 'queue' as const, departmentId, transferredBy, ...overrideTransferData };
try {
await saveTransferHistory(room, transferData);
await RoutingManager.unassignAgent(inquiry, departmentId);
} catch (err) {
livechatLogger.error({ err });
throw new Meteor.Error('error-returning-inquiry');
}
callbacks.runAsync('livechat:afterReturnRoomAsInquiry', { room });
return true;
}
export async function removeOmnichannelRoom(rid: string) {
livechatLogger.debug({ msg: 'Deleting room', roomId: rid });
check(rid, String);
const room = await LivechatRooms.findOneById(rid);
if (!room) {
throw new Meteor.Error('error-invalid-room', 'Invalid room');
}
if (!isOmnichannelRoom(room)) {
throw new Meteor.Error('error-this-is-not-a-livechat-room');
}
if (room.open) {
throw new Meteor.Error('error-room-is-not-closed');
}
const inquiry = await LivechatInquiry.findOneByRoomId(rid);
const result = await Promise.allSettled([
Messages.removeByRoomId(rid),
ReadReceipts.removeByRoomId(rid),
Subscriptions.removeByRoomId(rid, {
async onTrash(doc) {
void notifyOnSubscriptionChanged(doc, 'removed');
},
}),
LivechatInquiry.removeByRoomId(rid),
LivechatRooms.removeById(rid),
ReadReceiptsArchive.removeByRoomId(rid),
]);
if (result[3]?.status === 'fulfilled' && result[3].value?.deletedCount && inquiry) {
void notifyOnLivechatInquiryChanged(inquiry, 'removed');
}
if (result[4]?.status === 'fulfilled' && result[4].value?.deletedCount) {
void notifyOnRoomChanged(room, 'removed');
}
for (const r of result) {
if (r.status === 'rejected') {
livechatLogger.error({ msg: 'Error removing room', roomId: rid, err: r.reason });
throw new Meteor.Error('error-removing-room', 'Error removing room');
}
}
}

View File

@@ -0,0 +1,51 @@
import type { IRoutingMethod, RoutingMethodConfig, SelectedAgent } from '@rocket.chat/core-typings';
import { LivechatDepartmentAgents, Users } from '@rocket.chat/models';
import { settings } from '../../../settings';
import { callbacks } from '../../callbacks';
import { RoutingManager } from '../RoutingManager';
/* Auto Selection Queuing method:
*
* default method where the agent with the least number
* of open chats is paired with the incoming livechat
*/
class AutoSelection implements IRoutingMethod {
config: RoutingMethodConfig;
constructor() {
this.config = {
previewRoom: false,
showConnecting: false,
showQueue: false,
showQueueLink: true,
returnQueue: false,
enableTriggerAction: true,
autoAssignAgent: true,
};
}
async getNextAgent(department?: string, ignoreAgentId?: string): Promise<SelectedAgent | null | undefined> {
const extraQuery = await callbacks.run('livechat.applySimultaneousChatRestrictions', undefined, {
...(department ? { departmentId: department } : {}),
});
if (department) {
return LivechatDepartmentAgents.getNextAgentForDepartment(
department,
settings.get<boolean>('Livechat_enabled_when_agent_idle'),
ignoreAgentId,
extraQuery,
settings.get<boolean>('Livechat_accept_chats_with_no_agents'),
);
}
return Users.getNextAgent(
ignoreAgentId,
extraQuery,
settings.get<boolean>('Livechat_enabled_when_agent_idle'),
settings.get<boolean>('Livechat_accept_chats_with_no_agents'),
);
}
}
RoutingManager.registerMethod('Auto_Selection', AutoSelection);

View File

@@ -0,0 +1,81 @@
import type { IRoutingMethod, RoutingMethodConfig, SelectedAgent } from '@rocket.chat/core-typings';
import { Users } from '@rocket.chat/models';
import { serverFetch as fetch } from '@rocket.chat/server-fetch';
import { Meteor } from 'meteor/meteor';
import { settings } from '../../../settings';
import { SystemLogger } from '../../logger/system';
import { RoutingManager } from '../RoutingManager';
class ExternalQueue implements IRoutingMethod {
config: RoutingMethodConfig;
constructor() {
this.config = {
previewRoom: false,
showConnecting: false,
showQueue: false,
showQueueLink: false,
returnQueue: false,
enableTriggerAction: true,
autoAssignAgent: true,
};
}
async getNextAgent(department?: string, ignoreAgentId?: string): Promise<SelectedAgent | null | undefined> {
const promises = [];
for (let i = 0; i < 10; i++) {
promises.push(this.getAgentFromExternalQueue(department, ignoreAgentId));
}
try {
const results = (await Promise.all(promises)).filter(Boolean);
if (!results.length) {
throw new Meteor.Error('no-agent-online', 'Sorry, no online agents');
}
return results[0];
} catch (err) {
SystemLogger.error({ msg: 'Error requesting agent from external queue.', err });
throw err;
}
}
private async getAgentFromExternalQueue(department?: string, ignoreAgentId?: string): Promise<SelectedAgent | null | undefined> {
try {
const request = await fetch(`${settings.get('Livechat_External_Queue_URL')}`, {
headers: {
'User-Agent': 'RocketChat Server',
'Accept': 'application/json',
'X-RocketChat-Secret-Token': settings.get('Livechat_External_Queue_Token'),
},
params: {
...(department && { departmentId: department }),
...(ignoreAgentId && { ignoreAgentId }),
},
// // SECURITY: The URL is a value that is only configurable by admins/users with the right permissions. It's ok to disable it here.
ignoreSsrfValidation: true,
});
const result = (await request.json()) as { username?: string };
if (result?.username) {
const agent = await Users.findOneOnlineAgentByUserList(
result.username,
{},
settings.get<boolean>('Livechat_enabled_when_agent_idle'),
settings.get<boolean>('Livechat_accept_chats_with_no_agents'),
);
if (!agent?.username) {
return;
}
return {
agentId: agent._id,
username: agent.username,
};
}
} catch (err) {
SystemLogger.error({ msg: 'Error requesting agent from external queue.', err });
}
}
}
RoutingManager.registerMethod('External', ExternalQueue);

View File

@@ -0,0 +1,34 @@
import type { IRoutingMethod, RoutingMethodConfig, SelectedAgent } from '@rocket.chat/core-typings';
import { RoutingManager } from '../RoutingManager';
/* Manual Selection Queuing Method:
*
* An incoming livechat is created as an Inquiry
* which is picked up from an agent.
* An Inquiry is visible to all agents
*
* A room is still created with the initial message, but it is occupied by
* only the client until paired with an agent
*/
class ManualSelection implements IRoutingMethod {
config: RoutingMethodConfig;
constructor() {
this.config = {
previewRoom: true,
showConnecting: true,
showQueue: true,
showQueueLink: false,
returnQueue: true,
enableTriggerAction: false,
autoAssignAgent: false,
};
}
getNextAgent(_department?: string, _excludeAgent?: string): Promise<SelectedAgent | null | undefined> {
return Promise.resolve(undefined);
}
}
RoutingManager.registerMethod('Manual_Selection', ManualSelection);

View File

@@ -0,0 +1,79 @@
import { OmnichannelIntegration } from '@rocket.chat/core-services';
import { isEditedMessage } from '@rocket.chat/core-typings';
import { LivechatVisitors } from '@rocket.chat/models';
import { callbackLogger } from './logger';
import { settings } from '../../settings';
import { callbacks } from '../callbacks';
import { normalizeMessageFileUpload } from '../utils/functions/normalizeMessageFileUpload';
callbacks.add(
'afterOmnichannelSaveMessage',
async (message, { room }) => {
// skips this callback if the message was edited
if (isEditedMessage(message)) {
return message;
}
if (!settings.get('SMS_Enabled')) {
return message;
}
// only send the sms by SMS if it is a livechat room with SMS set to true
if (!(room.sms && room.v && room.v.token)) {
return message;
}
// if the message has a token, it was sent from the visitor, so ignore it
if (message.token) {
return message;
}
// if the message has a type means it is a special message (like the closing comment), so skips
if (message.t) {
return message;
}
const { rid, u: { _id: userId } = {} } = message;
let extraData = { rid, userId };
if (message.file) {
message = { ...(await normalizeMessageFileUpload(message)), ...{ _updatedAt: message._updatedAt } };
const { fileUpload } = message;
extraData = Object.assign({}, extraData, { fileUpload });
}
if (message.location) {
const { location } = message;
extraData = Object.assign({}, extraData, { location });
}
const service = settings.get<string>('SMS_Service');
const SMSService = await OmnichannelIntegration.getSmsService(service);
if (!SMSService) {
callbackLogger.debug('SMS Service is not configured, skipping SMS send');
return message;
}
const visitor = await LivechatVisitors.getVisitorByToken(room.v.token, { projection: { phone: 1, source: 1 } });
if (!visitor?.phone || visitor.phone.length === 0) {
return message;
}
try {
await SMSService.send(room.sms.from, visitor.phone[0].phoneNumber, message.msg, extraData);
callbackLogger.debug({
msg: 'SMS message sent',
phoneNumber: visitor.phone[0].phoneNumber,
service,
});
} catch (err) {
callbackLogger.error({ msg: 'Error sending SMS message', err });
}
return message;
},
callbacks.priority.LOW,
'sendMessageBySms',
);

View File

@@ -0,0 +1,262 @@
import { Message, Omnichannel } from '@rocket.chat/core-services';
import {
type IUser,
type MessageTypesValues,
type IOmnichannelSystemMessage,
type ILivechatVisitor,
type IOmnichannelRoom,
isFileAttachment,
isFileImageAttachment,
type AtLeast,
} from '@rocket.chat/core-typings';
import colors from '@rocket.chat/fuselage-tokens/dist/colors.json';
import { Logger } from '@rocket.chat/logger';
import { MessageTypes } from '@rocket.chat/message-types';
import { LivechatRooms, Messages, Uploads, Users } from '@rocket.chat/models';
import createDOMPurify from 'dompurify';
import { JSDOM } from 'jsdom';
import { Meteor } from 'meteor/meteor';
import moment from 'moment-timezone';
import { settings } from '../../settings';
import { callbacks } from '../callbacks';
import { i18n } from '../i18n';
import { FileUpload } from '../media/file-upload';
import * as Mailer from '../notifications/email/api';
import { getTimezone } from '../utils/lib/getTimezone';
const logger = new Logger('Livechat-SendTranscript');
const DOMPurify = createDOMPurify(new JSDOM('').window);
export async function sendTranscript({
token,
rid,
email,
subject,
user,
}: {
token: string;
rid: string;
email: string;
subject?: string;
user?: Pick<IUser, '_id' | 'name' | 'username' | 'utcOffset'> | null;
}): Promise<boolean> {
logger.debug({ msg: 'Sending conversation transcript', rid, token });
const room = await LivechatRooms.findOneById<Pick<IOmnichannelRoom, '_id' | 'v'>>(rid, { projection: { _id: 1, v: 1 } });
if (!room) {
throw new Error('error-invalid-room');
}
const visitor = room?.v as ILivechatVisitor;
if (token !== visitor?.token) {
throw new Error('error-invalid-visitor');
}
const userLanguage = settings.get<string>('Language') || 'en';
const timezone = getTimezone(user);
logger.debug({ msg: 'Transcript will be sent using timezone', timezone });
const showAgentInfo = settings.get<boolean>('Livechat_show_agent_info');
const showSystemMessages = settings.get<boolean>('Livechat_transcript_show_system_messages');
const closingMessage = await Messages.findLivechatClosingMessage(rid, { projection: { ts: 1 } });
const ignoredMessageTypes: MessageTypesValues[] = [
'livechat_navigation_history',
'livechat_transcript_history',
'command',
'livechat-close',
'livechat-started',
'livechat_video_call',
'omnichannel_priority_change_history',
];
const messages = Messages.findVisibleByRoomIdNotContainingTypesBeforeTs(
rid,
ignoredMessageTypes,
closingMessage?.ts ? new Date(closingMessage.ts) : new Date(),
showSystemMessages,
{
sort: { ts: 1 },
},
);
let html = '<div> <hr>';
const InvalidFileMessage = `<div style="background-color: ${colors.n100}; text-align: center; border-color: ${
colors.n250
}; border-width: 1px; border-style: solid; border-radius: 4px; padding-top: 8px; padding-bottom: 8px; margin-top: 4px;">${i18n.t(
'This_attachment_is_not_supported',
{ lng: userLanguage },
)}</div>`;
function escapeHtml(str: string): string {
if (typeof str !== 'string') return '';
return str
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#x27;')
.replace(/\//g, '&#x2F;');
}
for await (const message of messages) {
let author;
if (message.u._id === visitor._id) {
author = i18n.t('You', { lng: userLanguage });
} else {
author = showAgentInfo ? message.u.name || message.u.username : i18n.t('Agent', { lng: userLanguage });
}
const messageType = MessageTypes.getType(message);
let messageContent = messageType?.system
? DOMPurify.sanitize(`
<i>${messageType.text(i18n.cloneInstance({ interpolation: { escapeValue: false } }).t, message)}}</i>`)
: escapeHtml(message.msg);
let filesHTML = '';
if (message.attachments && message.attachments?.length > 0) {
messageContent = message.attachments[0].description || '';
escapeHtml(messageContent);
for await (const attachment of message.attachments) {
if (!isFileAttachment(attachment)) {
continue;
}
if (!isFileImageAttachment(attachment)) {
filesHTML += `<div>${escapeHtml(attachment.title || '')}${InvalidFileMessage}</div>`;
continue;
}
const file = message.files?.find((file) => file.name === attachment.title);
if (!file) {
filesHTML += `<div>${escapeHtml(attachment.title || '')}${InvalidFileMessage}</div>`;
continue;
}
const uploadedFile = await Uploads.findOneById(file._id);
if (!uploadedFile) {
filesHTML += `<div>${escapeHtml(file.name)}${InvalidFileMessage}</div>`;
continue;
}
const uploadedFileBuffer = await FileUpload.getBuffer(uploadedFile);
filesHTML += `<div style="color: ${colors.n700}; margin-top: 4px; flex-direction: column;">
<p>${escapeHtml(file.name)}</p>
<img src="data:${attachment.image_type};base64,${uploadedFileBuffer.toString('base64')}" style="width: 400px; max-height: 240px; object-fit: contain; object-position: 0;" />
</div>`;
}
}
const datetime = moment.tz(message.ts, timezone).locale(userLanguage).format('LLL');
const singleMessage = `
<p><strong>${escapeHtml(author)}</strong> <em>${escapeHtml(datetime)}</em></p>
<p>${messageContent}</p>
<p>${filesHTML}</p>
`;
html += singleMessage;
}
html = `${html}</div>`;
const fromEmail = settings.get<string>('From_Email').match(/\b[A-Z0-9._%+-]+@(?:[A-Z0-9-]+\.)+[A-Z]{2,4}\b/i);
let emailFromRegexp = '';
if (fromEmail) {
emailFromRegexp = fromEmail[0];
} else {
emailFromRegexp = settings.get<string>('From_Email');
}
// Some endpoints allow the caller to pass a different `subject` via parameter.
// IF subject is passed, we'll use that one and treat it as an override
// IF no subject is passed, we fallback to the setting `Livechat_transcript_email_subject`
// IF that is not configured, we fallback to 'Transcript of your livechat conversation', which is the default value
// As subject and setting value are user input, we don't translate them
const mailSubject =
subject ||
settings.get<string>('Livechat_transcript_email_subject') ||
i18n.t('Transcript_of_your_livechat_conversation', { lng: userLanguage });
await Mailer.send({
to: email,
from: emailFromRegexp,
replyTo: emailFromRegexp,
subject: mailSubject,
html,
});
setImmediate(() => {
void callbacks.run('livechat.sendTranscript', messages, email);
});
const requestData: IOmnichannelSystemMessage['requestData'] = {
type: 'user',
visitor,
user,
};
if (!user?.username) {
const cat = await Users.findOneById('rocket.cat', { projection: { _id: 1, username: 1, name: 1 } });
if (cat) {
requestData.user = cat;
requestData.type = 'visitor';
}
}
if (!requestData.user) {
logger.error('rocket.cat user not found');
throw new Error('No user provided and rocket.cat not found');
}
await Message.saveSystemMessage<IOmnichannelSystemMessage>('livechat_transcript_history', room._id, '', requestData.user, {
requestData,
});
return true;
}
export async function requestTranscript({
rid,
email,
subject,
user,
}: {
rid: string;
email: string;
subject: string;
user: AtLeast<IUser, '_id' | 'username' | 'utcOffset' | 'name'>;
}) {
// `v` is required by the MAC-limit check below
const room = await LivechatRooms.findOneById(rid, { projection: { _id: 1, open: 1, transcriptRequest: 1, v: 1 } });
if (!room?.open) {
throw new Meteor.Error('error-invalid-room', 'Invalid room');
}
if (room.transcriptRequest) {
throw new Meteor.Error('error-transcript-already-requested', 'Transcript already requested');
}
if (!(await Omnichannel.isWithinMACLimit(room))) {
throw new Error('error-mac-limit-reached');
}
const { _id, username, name, utcOffset } = user;
const transcriptRequest = {
requestedAt: new Date(),
requestedBy: {
_id,
username,
name,
utcOffset,
},
email,
subject,
};
await LivechatRooms.setEmailTranscriptRequestedByRoomId(rid, transcriptRequest);
return true;
}

View File

@@ -0,0 +1,90 @@
import type { ILivechatAgent, ILivechatDepartment, SelectedAgent } from '@rocket.chat/core-typings';
import { Users, LivechatDepartmentAgents, LivechatDepartment } from '@rocket.chat/models';
import type { FindCursor } from 'mongodb';
import { checkOnlineForDepartment, getOnlineForDepartment } from './departmentsLib';
import { livechatLogger } from './logger';
import { settings } from '../../settings';
export async function getOnlineAgents(department?: string, agent?: SelectedAgent | null): Promise<FindCursor<ILivechatAgent> | undefined> {
if (agent?.agentId) {
return Users.findOnlineAgents(
agent.agentId,
settings.get<boolean>('Livechat_enabled_when_agent_idle'),
settings.get<boolean>('Livechat_accept_chats_with_no_agents'),
);
}
if (department) {
return getOnlineForDepartment(department);
}
return Users.findOnlineAgents(
undefined,
settings.get<boolean>('Livechat_enabled_when_agent_idle'),
settings.get<boolean>('Livechat_accept_chats_with_no_agents'),
);
}
export async function online(department?: string, skipNoAgentSetting = false, skipFallbackCheck = false): Promise<boolean> {
livechatLogger.debug({ msg: 'Checking online agents', department });
if (!skipNoAgentSetting && settings.get('Livechat_accept_chats_with_no_agents')) {
livechatLogger.debug('Can accept without online agents: true');
return true;
}
if (settings.get('Livechat_assign_new_conversation_to_bot')) {
livechatLogger.debug({ msg: 'Fetching online bot agents for department', department });
// get & count where doing the same, but get was getting data, while count was only counting. We only need the count here
const botAgents = await countBotAgents(department);
if (botAgents) {
livechatLogger.debug({ msg: 'Found online bot agents', botAgents });
if (botAgents > 0) {
return true;
}
}
}
const agentsOnline = await checkOnlineAgents(department, undefined, skipFallbackCheck);
livechatLogger.debug({ msg: 'Online agents status', department, agentsOnline });
return agentsOnline;
}
export async function checkOnlineAgents(department?: string, agent?: { agentId: string }, skipFallbackCheck = false): Promise<boolean> {
if (agent?.agentId) {
return Users.checkOnlineAgents(
agent.agentId,
settings.get<boolean>('Livechat_enabled_when_agent_idle'),
settings.get<boolean>('Livechat_accept_chats_with_no_agents'),
);
}
if (department) {
const onlineForDep = await checkOnlineForDepartment(department);
if (onlineForDep || skipFallbackCheck) {
return !!onlineForDep;
}
const dep = await LivechatDepartment.findOneById<Pick<ILivechatDepartment, '_id' | 'fallbackForwardDepartment'>>(department, {
projection: { fallbackForwardDepartment: 1 },
});
if (!dep?.fallbackForwardDepartment) {
return !!onlineForDep;
}
return checkOnlineAgents(dep?.fallbackForwardDepartment);
}
return Users.checkOnlineAgents(
undefined,
settings.get<boolean>('Livechat_enabled_when_agent_idle'),
settings.get<boolean>('Livechat_accept_chats_with_no_agents'),
);
}
async function countBotAgents(department?: string) {
if (department) {
return LivechatDepartmentAgents.countBotsForDepartment(department);
}
return Users.countBotAgents();
}

View File

@@ -0,0 +1,60 @@
import { OmnichannelSortingMechanismSettingType } from '@rocket.chat/core-typings';
import { showConnecting } from './utils';
import { settings } from '../../settings';
export const getInquirySortMechanismSetting = (): OmnichannelSortingMechanismSettingType =>
settings.get<OmnichannelSortingMechanismSettingType>('Omnichannel_sorting_mechanism') || OmnichannelSortingMechanismSettingType.Timestamp;
export async function getInitSettings() {
const validSettings = [
'Livechat_title',
'Livechat_title_color',
'Livechat_enable_message_character_limit',
'Livechat_message_character_limit',
'Message_MaxAllowedSize',
'Livechat_enabled',
'Livechat_registration_form',
'Livechat_allow_switching_departments',
'Livechat_offline_title',
'Livechat_offline_title_color',
'Livechat_offline_message',
'Livechat_offline_success_message',
'Livechat_offline_form_unavailable',
'Livechat_display_offline_form',
'Omnichannel_call_provider',
'Language',
'Livechat_enable_transcript',
'Livechat_transcript_message',
'Livechat_fileupload_enabled',
'FileUpload_Enabled',
'Livechat_conversation_finished_message',
'Livechat_conversation_finished_text',
'Livechat_name_field_registration_form',
'Livechat_email_field_registration_form',
'Livechat_registration_form_message',
'Livechat_force_accept_data_processing_consent',
'Livechat_data_processing_consent_text',
'Livechat_show_agent_info',
'Livechat_clear_local_storage_when_chat_ended',
'Livechat_history_monitor_type',
'Livechat_hide_system_messages',
'Livechat_widget_position',
'Livechat_background',
'Assets_livechat_widget_logo',
'Livechat_hide_watermark',
'Omnichannel_allow_visitors_to_close_conversation',
'Livechat_hide_expand_chat',
] as const;
type SettingTypes = (typeof validSettings)[number] | 'Livechat_Show_Connecting';
const rcSettings = validSettings.reduce<Record<SettingTypes, string | boolean>>((acc, setting) => {
acc[setting] = settings.get(setting);
return acc;
}, {} as any);
rcSettings.Livechat_Show_Connecting = showConnecting();
return rcSettings;
}

View File

@@ -0,0 +1,148 @@
import type { IUser } from '@rocket.chat/core-typings';
import { ILivechatAgentStatus, isOmnichannelRoom } from '@rocket.chat/core-typings';
import { Logger } from '@rocket.chat/logger';
import { LivechatContacts, LivechatRooms } from '@rocket.chat/models';
import { registerGuest } from '@rocket.chat/omni-core';
import { validateEmail, wrapExceptions } from '@rocket.chat/tools';
import { Accounts } from 'meteor/accounts-base';
import { Meteor } from 'meteor/meteor';
import { businessHourManager } from './business-hour';
import { createDefaultBusinessHourIfNotExists } from './business-hour/Helper';
import { LivechatAgentActivityMonitor } from './statistics/LivechatAgentActivityMonitor';
import { setUserStatusLivechatIf } from './utils';
import { maybeMigrateLivechatRoom } from '../../api/lib/maybeMigrateLivechatRoom';
import { settings } from '../../settings';
import { hasPermissionAsync } from '../authorization/hasPermission';
import { callbacks } from '../callbacks';
import { beforeLeaveRoomCallback } from '../callbacks/beforeLeaveRoomCallback';
import { i18n } from '../i18n';
import { notifyOnUserChange } from '../notifyListener';
import { roomCoordinator } from '../rooms/roomCoordinator';
import './roomAccessValidator.internalService';
import { ContactMerger, type FieldAndValue } from './contacts/ContactMerger';
const logger = new Logger('LivechatStartup');
// TODO this patch is temporary because `ContactMerger` still a lot of dependencies, so it is not suitable to be moved to omni-core package
// TODO add tests covering the ContactMerger usage
registerGuest.patch(async (originalFn, newData, options) => {
const visitor = await originalFn(newData, options);
if (!visitor) {
return null;
}
const { name, phone, email, username } = newData;
const validatedEmail =
email &&
wrapExceptions(() => {
const trimmedEmail = email.trim().toLowerCase();
validateEmail(trimmedEmail);
return trimmedEmail;
}).suppress();
const fields = [
{ type: 'name', value: name },
{ type: 'phone', value: phone?.number },
{ type: 'email', value: validatedEmail },
{ type: 'username', value: username || visitor.username },
].filter((field) => Boolean(field.value)) as FieldAndValue[];
if (!fields.length) {
return null;
}
// If a visitor was updated who already had contacts, load up the contacts and update that information as well
const contacts = await LivechatContacts.findAllByVisitorId(visitor._id).toArray();
for await (const contact of contacts) {
await ContactMerger.mergeFieldsIntoContact({
fields,
contact,
conflictHandlingMode: contact.unknown ? 'overwrite' : 'conflict',
});
}
return visitor;
});
Meteor.startup(async () => {
roomCoordinator.setRoomFind('l', async (id) => maybeMigrateLivechatRoom(await LivechatRooms.findOneById(id)));
beforeLeaveRoomCallback.add(
(user, room) => {
if (!isOmnichannelRoom(room)) {
return;
}
throw new Meteor.Error(
i18n.t('You_cant_leave_a_livechat_room_Please_use_the_close_button', {
lng: user.language || settings.get('Language') || 'en',
}),
);
},
callbacks.priority.LOW,
'cant-leave-omnichannel-room',
);
callbacks.add(
'beforeJoinRoom',
async (user, room) => {
if (isOmnichannelRoom(room) && !(await hasPermissionAsync(user, 'view-l-room'))) {
throw new Meteor.Error('error-user-is-not-agent', 'User is not an Omnichannel Agent', {
method: 'beforeJoinRoom',
});
}
return user;
},
callbacks.priority.LOW,
'cant-join-omnichannel-room',
);
const monitor = new LivechatAgentActivityMonitor();
settings.watch<boolean>('Troubleshoot_Disable_Livechat_Activity_Monitor', async (value) => {
if (value) {
return monitor.stop();
}
await monitor.start();
});
await createDefaultBusinessHourIfNotExists();
settings.watch<boolean>(
'Livechat_enable_business_hours',
async (value) => {
logger.debug({ msg: 'Starting business hour manager', enabled: value });
if (value) {
await businessHourManager.startManager();
return;
}
await businessHourManager.stopManager();
},
process.env.TEST_MODE === 'true' || process.env.TEST_MODE === 'api'
? {
debounce: 10,
}
: undefined,
);
// Remove when accounts.onLogout is async
Accounts.onLogout(({ user }: { user?: IUser }) => {
if (!user?.roles?.includes('livechat-agent') || user?.roles?.includes('bot')) {
return;
}
void setUserStatusLivechatIf(user._id, ILivechatAgentStatus.NOT_AVAILABLE, {}, { livechatStatusSystemModified: true }).catch();
// TODO: Shouldn't this notifier be the same as the one inside setUserStatusLivechatIf?
void notifyOnUserChange({
id: user._id,
clientAction: 'updated',
diff: {
statusLivechat: ILivechatAgentStatus.NOT_AVAILABLE,
livechatStatusSystemModified: true,
},
});
});
});

View File

@@ -0,0 +1,166 @@
import type { ILivechatAgent, ISocketConnection } from '@rocket.chat/core-typings';
import { cronJobs } from '@rocket.chat/cron';
import { LivechatAgentActivity, Sessions, Users } from '@rocket.chat/models';
import { Meteor } from 'meteor/meteor';
import moment from 'moment';
import { callbacks } from '../../callbacks';
const formatDate = (dateTime = new Date()): { date: number } => ({
date: parseInt(moment(dateTime).format('YYYYMMDD')),
});
export class LivechatAgentActivityMonitor {
private _started: boolean;
private _name: string;
private scheduler = cronJobs;
constructor() {
this._started = false;
this._handleAgentStatusChanged = this._handleAgentStatusChanged.bind(this);
this._handleUserStatusLivechatChanged = this._handleUserStatusLivechatChanged.bind(this);
this._updateActiveSessions = this._updateActiveSessions.bind(this);
this._name = 'Livechat Agent Activity Monitor';
}
async start(): Promise<void> {
await this._setupListeners();
}
async stop(): Promise<void> {
if (!this.isRunning()) {
return;
}
await this.scheduler.remove(this._name);
this._started = false;
}
isRunning(): boolean {
return this._started;
}
async _setupListeners(): Promise<void> {
if (this.isRunning()) {
return;
}
await this._startMonitoring();
// TODO use service event socket.connected instead
Meteor.onConnection((connection: unknown) => this._handleMeteorConnection(connection as ISocketConnection));
callbacks.add('livechat.agentStatusChanged', this._handleAgentStatusChanged);
callbacks.add('livechat.setUserStatusLivechat', (...args) => {
return this._handleUserStatusLivechatChanged(...args);
});
this._started = true;
}
async _startMonitoring(): Promise<void> {
await this.scheduler.add(this._name, '0 0 * * *', () => this._updateActiveSessions());
}
async _updateActiveSessions(): Promise<void> {
const openLivechatAgentSessions = LivechatAgentActivity.findOpenSessions();
const today = moment(new Date());
const startedAt = new Date(today.year(), today.month(), today.date());
for await (const session of openLivechatAgentSessions) {
const startDate = moment(session.lastStartedAt);
const stoppedAt = new Date(startDate.year(), startDate.month(), startDate.date(), 23, 59, 59);
const data = { ...formatDate(startDate.toDate()), agentId: session.agentId };
const availableTime = moment(stoppedAt).diff(moment(new Date(session.lastStartedAt)), 'seconds');
await Promise.all([
LivechatAgentActivity.updateLastStoppedAt({ ...data, availableTime, lastStoppedAt: stoppedAt }),
LivechatAgentActivity.updateServiceHistory({ ...data, serviceHistory: { startedAt: session.lastStartedAt, stoppedAt } }),
]);
await this._createOrUpdateSession(session.agentId, startedAt);
}
}
async _handleMeteorConnection(connection: ISocketConnection): Promise<void> {
if (!this.isRunning()) {
return;
}
const session = await Sessions.findOneBySessionId(connection.id);
if (!session) {
return;
}
const user = await Users.findOneById<Pick<ILivechatAgent, '_id' | 'statusLivechat' | 'status'>>(session.userId, {
projection: { _id: 1, status: 1, statusLivechat: 1 },
});
if (user && user.status !== 'offline' && user.statusLivechat === 'available') {
await this._createOrUpdateSession(user._id);
}
connection.onClose(() => {
if (session) {
void this._updateSessionWhenAgentStop(session.userId);
}
});
}
async _handleAgentStatusChanged({ userId, status }: { userId: string; status: string }) {
if (!this.isRunning()) {
return;
}
const user = await Users.findOneById<Pick<ILivechatAgent, '_id' | 'statusLivechat'>>(userId, { projection: { statusLivechat: 1 } });
if (!user || user.statusLivechat !== 'available') {
return;
}
if (status !== 'offline') {
await this._createOrUpdateSession(userId);
} else {
await this._updateSessionWhenAgentStop(userId);
}
}
async _handleUserStatusLivechatChanged({ userId, status }: { userId: string; status: string }): Promise<void> {
if (!this.isRunning()) {
return;
}
const user = await Users.findOneById<Pick<ILivechatAgent, '_id' | 'status'>>(userId, { projection: { status: 1 } });
if (user && user.status === 'offline') {
return;
}
if (status === 'available') {
await this._createOrUpdateSession(userId);
}
if (status === 'not-available') {
await this._updateSessionWhenAgentStop(userId);
}
}
async _createOrUpdateSession(userId: string, lastStartedAt?: Date): Promise<void> {
const data = { ...formatDate(lastStartedAt), agentId: userId, lastStartedAt };
await LivechatAgentActivity.createOrUpdate(data);
}
async _updateSessionWhenAgentStop(agentId: string): Promise<void> {
const { date } = formatDate();
const livechatSession = await LivechatAgentActivity.findOneByAgendIdAndDate(agentId, date);
if (!livechatSession) {
return;
}
const stoppedAt = new Date();
const availableTime = moment(stoppedAt).diff(moment(new Date(livechatSession.lastStartedAt)), 'seconds');
await Promise.all([
LivechatAgentActivity.updateLastStoppedAt({ agentId, date, availableTime, lastStoppedAt: stoppedAt }),
LivechatAgentActivity.updateServiceHistory({
agentId,
date,
serviceHistory: { startedAt: livechatSession.lastStartedAt, stoppedAt },
}),
]);
}
}

View File

@@ -0,0 +1,86 @@
import { Logger } from '@rocket.chat/logger';
import { settings } from '../../../settings';
import { closeOpenChats } from '../closeRoom';
import { forwardOpenChats } from '../transfer';
const logger = new Logger('AgentStatusWatcher');
export let monitorAgents = false;
let actionTimeout = 60000;
let action = 'none';
let comment = '';
settings.watch('Livechat_agent_leave_action_timeout', (value) => {
if (typeof value !== 'number') {
return;
}
actionTimeout = value * 1000;
});
settings.watch('Livechat_agent_leave_action', (value) => {
monitorAgents = value !== 'none';
action = value as string;
});
settings.watch('Livechat_agent_leave_comment', (value) => {
if (typeof value !== 'string') {
return;
}
comment = value;
});
export const onlineAgents = {
users: new Set(),
queue: new Map(),
add(userId: string): void {
if (this.exists(userId)) {
return;
}
if (this.queue.has(userId)) {
clearTimeout(this.queue.get(userId));
this.queue.delete(userId);
}
this.users.add(userId);
},
remove(userId: string): void {
if (!this.exists(userId)) {
return;
}
this.users.delete(userId);
if (this.queue.has(userId)) {
clearTimeout(this.queue.get(userId));
}
this.queue.set(userId, setTimeout(this.runAgentLeaveAction, actionTimeout, userId));
},
exists(userId: string): boolean {
return this.users.has(userId);
},
runAgentLeaveAction: async (userId: string) => {
onlineAgents.users.delete(userId);
onlineAgents.queue.delete(userId);
try {
if (action === 'close') {
return await closeOpenChats(userId, comment);
}
if (action === 'forward') {
return await forwardOpenChats(userId);
}
} catch (e) {
logger.error({
msg: 'Cannot perform action',
action,
err: e,
});
}
},
};

View File

@@ -0,0 +1,65 @@
import { Omnichannel } from '@rocket.chat/core-services';
import { LivechatInquiry, LivechatRooms, Users } from '@rocket.chat/models';
import { Meteor } from 'meteor/meteor';
import { RoutingManager } from './RoutingManager';
import { isAgentAvailableToTakeContactInquiry } from './contacts/isAgentAvailableToTakeContactInquiry';
import { migrateVisitorIfMissingContact } from './contacts/migrateVisitorIfMissingContact';
import { settings } from '../../settings';
export const takeInquiry = async (
userId: string,
inquiryId: string,
options?: { clientAction: boolean; forwardingToDepartment?: { oldDepartmentId: string; transferData: any } },
): Promise<void> => {
const inquiry = await LivechatInquiry.findOneById(inquiryId);
if (!inquiry) {
throw new Meteor.Error('error-not-found', 'Inquiry not found', {
method: 'livechat:takeInquiry',
});
}
if (inquiry.status === 'taken') {
throw new Meteor.Error('error-inquiry-taken', 'Inquiry already taken', {
method: 'livechat:takeInquiry',
});
}
const user = await Users.findOneOnlineAgentById(
userId,
settings.get<boolean>('Livechat_enabled_when_agent_idle'),
settings.get<boolean>('Livechat_accept_chats_with_no_agents'),
{},
);
if (!user) {
throw new Meteor.Error('error-agent-status-service-offline', 'Agent status is offline or Omnichannel service is not active', {
method: 'livechat:takeInquiry',
...(process.env.TEST_MODE && {
Livechat_enabled_when_agent_idle: settings.get<boolean>('Livechat_enabled_when_agent_idle'),
Livechat_accept_chats_with_no_agents: settings.get<boolean>('Livechat_accept_chats_with_no_agents'),
user: await Users.findOneById(userId),
}),
});
}
const room = await LivechatRooms.findOneById(inquiry.rid);
if (!room || !(await Omnichannel.isWithinMACLimit(room))) {
throw new Meteor.Error('error-mac-limit-reached');
}
const contactId = room.contactId ?? (await migrateVisitorIfMissingContact(room.v._id, room.source));
if (contactId) {
const isAgentAvailableToTakeContactInquiryResult = await isAgentAvailableToTakeContactInquiry(inquiry.v._id, room.source, contactId);
if (!isAgentAvailableToTakeContactInquiryResult.value) {
throw new Meteor.Error(isAgentAvailableToTakeContactInquiryResult.error);
}
}
const agent = {
agentId: user._id,
username: user.username,
};
await RoutingManager.takeInquiry(inquiry, agent, options ?? {}, room);
};

View File

@@ -0,0 +1,54 @@
import { Message } from '@rocket.chat/core-services';
import { Users } from '@rocket.chat/models';
import { livechatLogger } from './logger';
import { settings } from '../../settings';
type PageInfo = { title: string; location: { href: string }; change: string };
export async function savePageHistory(token: string, roomId: string | undefined, pageInfo: PageInfo) {
livechatLogger.debug({
msg: `Saving page movement history for visitor with token ${token}`,
pageInfo,
roomId,
});
if (pageInfo.change !== settings.get<string>('Livechat_history_monitor_type')) {
return;
}
const user = await Users.findOneById('rocket.cat');
if (!user) {
throw new Error('error-invalid-user');
}
const pageTitle = pageInfo.title;
const pageUrl = pageInfo.location.href;
const extraData: {
navigation: {
page: PageInfo;
token: string;
};
expireAt?: number;
_hidden?: boolean;
} = {
navigation: {
page: pageInfo,
token,
},
};
if (!roomId) {
livechatLogger.warn({ msg: 'Saving page history without room id for visitor', token });
// keep history of unregistered visitors for 1 month
const keepHistoryMiliseconds = 2592000000;
extraData.expireAt = new Date().getTime() + keepHistoryMiliseconds;
}
if (!settings.get('Livechat_Visitor_navigation_as_a_message')) {
extraData._hidden = true;
}
// @ts-expect-error: Investigating on which case we won't receive a roomId and where that history is supposed to be stored
return Message.saveSystemMessage('livechat_navigation_history', roomId, `${pageTitle} - ${pageUrl}`, user, extraData);
}

View File

@@ -0,0 +1,85 @@
import { Message } from '@rocket.chat/core-services';
import type { ILivechatDepartment, ILivechatVisitor, IOmnichannelRoom, TransferData } from '@rocket.chat/core-typings';
import { Users, LivechatRooms, LivechatVisitors, LivechatDepartment } from '@rocket.chat/models';
import { check, Match } from 'meteor/check';
import { normalizeTransferredByData } from './Helper';
import { RoutingManager } from './RoutingManager';
import { livechatLogger } from './logger';
export async function saveTransferHistory(room: IOmnichannelRoom, transferData: TransferData) {
const { departmentId: previousDepartment } = room;
const { department: nextDepartment, transferredBy, transferredTo, scope, comment } = transferData;
check(
transferredBy,
Match.ObjectIncluding({
_id: String,
username: String,
name: Match.Maybe(String),
userType: String,
}),
);
const { _id, username } = transferredBy;
const scopeData = scope || (nextDepartment ? 'department' : 'agent');
livechatLogger.info({ msg: 'Storing new chat transfer', roomId: room._id, transferredBy: _id, scope: scopeData });
const transferMessage = {
...(transferData.transferredBy.userType === 'visitor' && { token: room.v.token }),
transferData: {
transferredBy,
ts: new Date(),
scope: scopeData,
comment,
...(previousDepartment && { previousDepartment }),
...(nextDepartment && { nextDepartment }),
...(transferredTo && { transferredTo }),
},
};
await Message.saveSystemMessageAndNotifyUser('livechat_transfer_history', room._id, '', { _id, username }, transferMessage);
}
export async function forwardOpenChats(userId: string) {
livechatLogger.debug({ msg: 'Transferring open chats for user', userId });
const user = await Users.findOneById(userId);
if (!user) {
throw new Error('error-invalid-user');
}
const { _id, username, name } = user;
for await (const room of LivechatRooms.findOpenByAgent(userId)) {
const guest = await LivechatVisitors.findOneEnabledById(room.v._id);
if (!guest) {
continue;
}
const transferredBy = normalizeTransferredByData({ _id, username, name }, room);
await transfer(room, guest, {
transferredBy,
departmentId: guest.department,
});
}
}
export async function transfer(room: IOmnichannelRoom, guest: ILivechatVisitor, transferData: TransferData) {
livechatLogger.debug({ msg: 'Transferring room', roomId: room._id, transferredBy: transferData?.transferredBy?._id });
if (room.onHold) {
throw new Error('error-room-onHold');
}
if (transferData.departmentId) {
const department = await LivechatDepartment.findOneById<Pick<ILivechatDepartment, 'name' | '_id'>>(transferData.departmentId, {
projection: { name: 1 },
});
if (!department) {
throw new Error('error-invalid-department');
}
transferData.department = department;
livechatLogger.debug({ msg: 'Transferring room to department', roomId: room._id, departmentId: transferData.department?._id });
}
return RoutingManager.transferRoom(room, guest, transferData);
}

View File

@@ -0,0 +1,62 @@
import { ILivechatAgentStatus } from '@rocket.chat/core-typings';
import type { ILivechatAgent, IUser } from '@rocket.chat/core-typings';
import { Users } from '@rocket.chat/models';
import type { Filter } from 'mongodb';
import { RoutingManager } from './RoutingManager';
import { businessHourManager } from './business-hour';
import type { AKeyOf } from './localTypes';
import { callbacks } from '../callbacks';
import { notifyOnUserChange } from '../notifyListener';
export function showConnecting() {
return RoutingManager.getConfig()?.showConnecting || false;
}
export async function setUserStatusLivechat(userId: string, status: ILivechatAgentStatus) {
const user = await Users.setLivechatStatus(userId, status);
// TODO: shouldnt this callback run if the modified count is > 0 too?
callbacks.runAsync('livechat.setUserStatusLivechat', { userId, status });
if (user.modifiedCount > 0) {
void notifyOnUserChange({
id: userId,
clientAction: 'updated',
diff: {
statusLivechat: status,
livechatStatusSystemModified: false,
},
});
}
return user;
}
export async function setUserStatusLivechatIf(
userId: string,
status: ILivechatAgentStatus,
condition?: Filter<IUser>,
fields?: AKeyOf<ILivechatAgent>,
) {
const result = await Users.setLivechatStatusIf(userId, status, condition, fields);
if (result.modifiedCount > 0) {
void notifyOnUserChange({
id: userId,
clientAction: 'updated',
diff: { ...fields, statusLivechat: status },
});
}
// TODO: shouldnt this callback run if the modified count is > 0 too?
callbacks.runAsync('livechat.setUserStatusLivechat', { userId, status });
return result;
}
export async function allowAgentChangeServiceStatus(statusLivechat: ILivechatAgentStatus, agentId: string) {
if (statusLivechat !== ILivechatAgentStatus.AVAILABLE) {
return true;
}
return businessHourManager.allowAgentChangeServiceStatus(agentId);
}

View File

@@ -0,0 +1,77 @@
import { serverFetch as fetch } from '@rocket.chat/server-fetch';
import type { Response } from '@rocket.chat/server-fetch';
import { webhooksLogger } from './logger';
import { settings } from '../../settings';
import { metrics } from '../metrics';
const isRetryable = (status: number): boolean => status >= 500 || status === 429;
export async function sendRequest(
postData: {
type: string;
[key: string]: any;
},
attempts = 5,
cb?: (response: Response) => Promise<void>,
) {
const timeout = settings.get<number>('Livechat_http_timeout');
const secretToken = settings.get<string>('Livechat_secret_token');
const webhookUrl = settings.get<string>('Livechat_webhookUrl');
try {
webhooksLogger.debug({ msg: 'Sending webhook request', postData });
const result = await fetch(webhookUrl, {
method: 'POST',
headers: {
...(secretToken && { 'X-RocketChat-Livechat-Token': secretToken }),
},
body: postData,
timeout,
// SECURITY: Webhooks can only be configured by users with enough privileges. It's ok to disable this check here.
ignoreSsrfValidation: true,
size: 10 * 1024 * 1024,
});
if (result.status === 200) {
metrics.totalLivechatWebhooksSuccess.inc();
metrics.totalLivechatWebhooksSuccessTotal.inc();
await cb?.(result);
return result;
}
if (!isRetryable(result.status)) {
webhooksLogger.error({
msg: 'Non-retryable error response from webhook',
webhookUrl,
status: result.status,
response: await result.text(),
});
metrics.totalLivechatWebhooksFailures.inc();
metrics.totalLivechatWebhooksFailuresTotal.inc();
return;
}
metrics.totalLivechatWebhooksFailures.inc();
metrics.totalLivechatWebhooksFailuresTotal.inc();
throw new Error(await result.text());
} catch (err) {
const retryAfter = timeout * 4;
webhooksLogger.debug({
msg: 'Error response on retry',
attempt: 6 - attempts,
err,
newAttemptAfterSeconds: retryAfter / 1000,
webhookUrl,
});
const remainingAttempts = attempts - 1;
// try 5 times after 20 seconds each
if (!remainingAttempts) {
webhooksLogger.error({ msg: 'Omnichannel webhook call failed. Max attempts reached' });
return;
}
setTimeout(async () => {
await sendRequest(postData, remainingAttempts, cb);
}, retryAfter);
}
}