Files
upstream-mirror 30810eecd7
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
snapshot of https://github.com/RocketChat/Rocket.Chat.git (develop, 2026-09-16; blobs > 131072B omitted)
2026-09-16 01:21:02 +05:00

965 lines
26 KiB
TypeScript

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;
};