snapshot of https://github.com/RocketChat/Rocket.Chat.git (develop, 2026-09-16; blobs > 131072B omitted)
Some checks failed
Create issues from TODOs / todos (push) Failing after 3s
Close inactive issues / close-issues (push) Has been cancelled
Deploy GitHub Pages / deploy-preview (push) Has been cancelled
CI / ⚙️ Variables Setup (push) Has been cancelled
CI / ⚙️ Test Guard (push) Has been cancelled
CI / 🚀 Notify external services - draft (push) Has been cancelled
CI / 📦 Build Packages (push) Has been cancelled
CI / 📦 Meteor Build (coverage) (push) Has been cancelled
CI / 📦 Meteor Build (production) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], fips) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Publish Docker Images (ghcr.io) (push) Has been cancelled
CI / 📦 Track Image Sizes (push) Has been cancelled
CI / 🔎 Code Check - Action Lint (push) Has been cancelled
CI / 🔎 Code Check (push) Has been cancelled
CI / 🔨 Test Storybook (push) Has been cancelled
CI / 🔨 Test Unit (push) Has been cancelled
CI / 🔨 Test API (CE) (push) Has been cancelled
CI / 🔨 Test API Livechat (CE) (push) Has been cancelled
CI / 🔨 Test UI (CE) (push) Has been cancelled
CI / 🔨 Test API (EE) (push) Has been cancelled
CI / 🔨 Test API Livechat (EE) (push) Has been cancelled
CI / 🔨 Test API Apps (deno-runtime - EE) (push) Has been cancelled
CI / 🔨 Test UI (EE) (push) Has been cancelled
CI / 🔨 Test API (FIPS) (push) Has been cancelled
CI / 🔨 Test API Livechat (FIPS) (push) Has been cancelled
CI / 🔨 Test UI (FIPS) (push) Has been cancelled
CI / 🔨 Test Federation Matrix (push) Has been cancelled
CI / 📊 Report Coverage (push) Has been cancelled
CI / ✅ Tests Done (push) Has been cancelled
CI / 🚀 Publish build assets (push) Has been cancelled
CI / 🚀 Publish Docker Images (DockerHub) (push) Has been cancelled
CI / 🚀 Notify external services (push) Has been cancelled
CI / Update Version Durability (push) Has been cancelled
Release / Release (push) Has been cancelled
Auto-close duplicate issues / auto-close-duplicates (push) Failing after 1s
Code scanning - action / CodeQL-Build (push) Failing after 1s
Some checks failed
Create issues from TODOs / todos (push) Failing after 3s
Close inactive issues / close-issues (push) Has been cancelled
Deploy GitHub Pages / deploy-preview (push) Has been cancelled
CI / ⚙️ Variables Setup (push) Has been cancelled
CI / ⚙️ Test Guard (push) Has been cancelled
CI / 🚀 Notify external services - draft (push) Has been cancelled
CI / 📦 Build Packages (push) Has been cancelled
CI / 📦 Meteor Build (coverage) (push) Has been cancelled
CI / 📦 Meteor Build (production) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], fips) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Publish Docker Images (ghcr.io) (push) Has been cancelled
CI / 📦 Track Image Sizes (push) Has been cancelled
CI / 🔎 Code Check - Action Lint (push) Has been cancelled
CI / 🔎 Code Check (push) Has been cancelled
CI / 🔨 Test Storybook (push) Has been cancelled
CI / 🔨 Test Unit (push) Has been cancelled
CI / 🔨 Test API (CE) (push) Has been cancelled
CI / 🔨 Test API Livechat (CE) (push) Has been cancelled
CI / 🔨 Test UI (CE) (push) Has been cancelled
CI / 🔨 Test API (EE) (push) Has been cancelled
CI / 🔨 Test API Livechat (EE) (push) Has been cancelled
CI / 🔨 Test API Apps (deno-runtime - EE) (push) Has been cancelled
CI / 🔨 Test UI (EE) (push) Has been cancelled
CI / 🔨 Test API (FIPS) (push) Has been cancelled
CI / 🔨 Test API Livechat (FIPS) (push) Has been cancelled
CI / 🔨 Test UI (FIPS) (push) Has been cancelled
CI / 🔨 Test Federation Matrix (push) Has been cancelled
CI / 📊 Report Coverage (push) Has been cancelled
CI / ✅ Tests Done (push) Has been cancelled
CI / 🚀 Publish build assets (push) Has been cancelled
CI / 🚀 Publish Docker Images (DockerHub) (push) Has been cancelled
CI / 🚀 Notify external services (push) Has been cancelled
CI / Update Version Durability (push) Has been cancelled
Release / Release (push) Has been cancelled
Auto-close duplicate issues / auto-close-duplicates (push) Failing after 1s
Code scanning - action / CodeQL-Build (push) Failing after 1s
This commit is contained in:
17
apps/meteor/server/lib/omnichannel/AnalyticsTyped.ts
Normal file
17
apps/meteor/server/lib/omnichannel/AnalyticsTyped.ts
Normal 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,
|
||||
});
|
||||
4
apps/meteor/server/lib/omnichannel/Assets.ts
Normal file
4
apps/meteor/server/lib/omnichannel/Assets.ts
Normal 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>`);
|
||||
};
|
||||
964
apps/meteor/server/lib/omnichannel/Helper.ts
Normal file
964
apps/meteor/server/lib/omnichannel/Helper.ts
Normal 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;
|
||||
};
|
||||
533
apps/meteor/server/lib/omnichannel/QueueManager.ts
Normal file
533
apps/meteor/server/lib/omnichannel/QueueManager.ts
Normal 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: [],
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
381
apps/meteor/server/lib/omnichannel/RoutingManager.ts
Normal file
381
apps/meteor/server/lib/omnichannel/RoutingManager.ts
Normal 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);
|
||||
}
|
||||
},
|
||||
};
|
||||
13
apps/meteor/server/lib/omnichannel/Visitors.ts
Normal file
13
apps/meteor/server/lib/omnichannel/Visitors.ts
Normal 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,
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
72
apps/meteor/server/lib/omnichannel/analytics/agents.ts
Normal file
72
apps/meteor/server/lib/omnichannel/analytics/agents.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
395
apps/meteor/server/lib/omnichannel/analytics/dashboards.ts
Normal file
395
apps/meteor/server/lib/omnichannel/analytics/dashboards.ts
Normal 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 });
|
||||
192
apps/meteor/server/lib/omnichannel/analytics/departments.ts
Normal file
192
apps/meteor/server/lib/omnichannel/analytics/departments.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
36
apps/meteor/server/lib/omnichannel/business-hour/Default.ts
Normal file
36
apps/meteor/server/lib/omnichannel/business-hour/Default.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
106
apps/meteor/server/lib/omnichannel/business-hour/Helper.ts
Normal file
106
apps/meteor/server/lib/omnichannel/business-hour/Helper.ts
Normal 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,
|
||||
},
|
||||
};
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -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),
|
||||
},
|
||||
};
|
||||
};
|
||||
99
apps/meteor/server/lib/omnichannel/business-hour/Single.ts
Normal file
99
apps/meteor/server/lib/omnichannel/business-hour/Single.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
}));
|
||||
};
|
||||
@@ -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])];
|
||||
};
|
||||
23
apps/meteor/server/lib/omnichannel/business-hour/index.ts
Normal file
23
apps/meteor/server/lib/omnichannel/business-hour/index.ts
Normal 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));
|
||||
});
|
||||
});
|
||||
87
apps/meteor/server/lib/omnichannel/closeLivechatRoom.ts
Normal file
87
apps/meteor/server/lib/omnichannel/closeLivechatRoom.ts
Normal 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,
|
||||
});
|
||||
};
|
||||
@@ -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);
|
||||
};
|
||||
297
apps/meteor/server/lib/omnichannel/closeRoom.ts
Normal file
297
apps/meteor/server/lib/omnichannel/closeRoom.ts
Normal 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);
|
||||
}
|
||||
36
apps/meteor/server/lib/omnichannel/conditionalLockAgent.ts
Normal file
36
apps/meteor/server/lib/omnichannel/conditionalLockAgent.ts
Normal 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);
|
||||
},
|
||||
};
|
||||
}
|
||||
336
apps/meteor/server/lib/omnichannel/contacts/ContactMerger.ts
Normal file
336
apps/meteor/server/lib/omnichannel/contacts/ContactMerger.ts
Normal 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',
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
52
apps/meteor/server/lib/omnichannel/contacts/createContact.ts
Normal file
52
apps/meteor/server/lib/omnichannel/contacts/createContact.ts
Normal 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 }),
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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()];
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
},
|
||||
);
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
51
apps/meteor/server/lib/omnichannel/contacts/getContacts.ts
Normal file
51
apps/meteor/server/lib/omnichannel/contacts/getContacts.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
}),
|
||||
);
|
||||
@@ -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;
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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)),
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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;
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
14
apps/meteor/server/lib/omnichannel/contacts/patchContact.ts
Normal file
14
apps/meteor/server/lib/omnichannel/contacts/patchContact.ts
Normal 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);
|
||||
};
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
120
apps/meteor/server/lib/omnichannel/contacts/registerContact.ts
Normal file
120
apps/meteor/server/lib/omnichannel/contacts/registerContact.ts
Normal 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;
|
||||
}
|
||||
@@ -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;
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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' });
|
||||
});
|
||||
});
|
||||
109
apps/meteor/server/lib/omnichannel/contacts/updateContact.ts
Normal file
109
apps/meteor/server/lib/omnichannel/contacts/updateContact.ts
Normal 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;
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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({});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
231
apps/meteor/server/lib/omnichannel/custom-fields.ts
Normal file
231
apps/meteor/server/lib/omnichannel/custom-fields.ts
Normal 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 || [])];
|
||||
}
|
||||
310
apps/meteor/server/lib/omnichannel/departmentsLib.ts
Normal file
310
apps/meteor/server/lib/omnichannel/departmentsLib.ts
Normal 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'),
|
||||
);
|
||||
}
|
||||
23
apps/meteor/server/lib/omnichannel/getRoomMessages.ts
Normal file
23
apps/meteor/server/lib/omnichannel/getRoomMessages.ts
Normal 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 },
|
||||
});
|
||||
}
|
||||
205
apps/meteor/server/lib/omnichannel/guests.ts
Normal file
205
apps/meteor/server/lib/omnichannel/guests.ts
Normal 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 } });
|
||||
}
|
||||
}
|
||||
140
apps/meteor/server/lib/omnichannel/hooks.ts
Normal file
140
apps/meteor/server/lib/omnichannel/hooks.ts
Normal 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;
|
||||
},
|
||||
);
|
||||
24
apps/meteor/server/lib/omnichannel/index.ts
Normal file
24
apps/meteor/server/lib/omnichannel/index.ts
Normal 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';
|
||||
6
apps/meteor/server/lib/omnichannel/isMessageFromBot.ts
Normal file
6
apps/meteor/server/lib/omnichannel/isMessageFromBot.ts
Normal 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');
|
||||
}
|
||||
73
apps/meteor/server/lib/omnichannel/livechat.ts
Normal file
73
apps/meteor/server/lib/omnichannel/livechat.ts
Normal 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();
|
||||
});
|
||||
88
apps/meteor/server/lib/omnichannel/localTypes.ts
Normal file
88
apps/meteor/server/lib/omnichannel/localTypes.ts
Normal 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];
|
||||
};
|
||||
7
apps/meteor/server/lib/omnichannel/logger.ts
Normal file
7
apps/meteor/server/lib/omnichannel/logger.ts
Normal 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');
|
||||
153
apps/meteor/server/lib/omnichannel/messages.ts
Normal file
153
apps/meteor/server/lib/omnichannel/messages.ts
Normal 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(),
|
||||
};
|
||||
}
|
||||
116
apps/meteor/server/lib/omnichannel/omni-users.ts
Normal file
116
apps/meteor/server/lib/omnichannel/omni-users.ts
Normal 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;
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
63
apps/meteor/server/lib/omnichannel/parseTranscriptRequest.ts
Normal file
63
apps/meteor/server/lib/omnichannel/parseTranscriptRequest.ts
Normal 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,
|
||||
},
|
||||
},
|
||||
};
|
||||
};
|
||||
23
apps/meteor/server/lib/omnichannel/resolveVisitor.ts
Normal file
23
apps/meteor/server/lib/omnichannel/resolveVisitor.ts
Normal 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;
|
||||
}
|
||||
@@ -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';
|
||||
},
|
||||
];
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
314
apps/meteor/server/lib/omnichannel/rooms.ts
Normal file
314
apps/meteor/server/lib/omnichannel/rooms.ts
Normal 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');
|
||||
}
|
||||
}
|
||||
}
|
||||
51
apps/meteor/server/lib/omnichannel/routing/AutoSelection.ts
Normal file
51
apps/meteor/server/lib/omnichannel/routing/AutoSelection.ts
Normal 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);
|
||||
81
apps/meteor/server/lib/omnichannel/routing/External.ts
Normal file
81
apps/meteor/server/lib/omnichannel/routing/External.ts
Normal 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);
|
||||
@@ -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);
|
||||
79
apps/meteor/server/lib/omnichannel/sendMessageBySMS.ts
Normal file
79
apps/meteor/server/lib/omnichannel/sendMessageBySMS.ts
Normal 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',
|
||||
);
|
||||
262
apps/meteor/server/lib/omnichannel/sendTranscript.ts
Normal file
262
apps/meteor/server/lib/omnichannel/sendTranscript.ts
Normal 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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''')
|
||||
.replace(/\//g, '/');
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
90
apps/meteor/server/lib/omnichannel/service-status.ts
Normal file
90
apps/meteor/server/lib/omnichannel/service-status.ts
Normal 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();
|
||||
}
|
||||
60
apps/meteor/server/lib/omnichannel/settings.ts
Normal file
60
apps/meteor/server/lib/omnichannel/settings.ts
Normal 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;
|
||||
}
|
||||
148
apps/meteor/server/lib/omnichannel/startup.ts
Normal file
148
apps/meteor/server/lib/omnichannel/startup.ts
Normal 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,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 },
|
||||
}),
|
||||
]);
|
||||
}
|
||||
}
|
||||
86
apps/meteor/server/lib/omnichannel/stream/agentStatus.ts
Normal file
86
apps/meteor/server/lib/omnichannel/stream/agentStatus.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
65
apps/meteor/server/lib/omnichannel/takeInquiry.ts
Normal file
65
apps/meteor/server/lib/omnichannel/takeInquiry.ts
Normal 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);
|
||||
};
|
||||
54
apps/meteor/server/lib/omnichannel/tracking.ts
Normal file
54
apps/meteor/server/lib/omnichannel/tracking.ts
Normal 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);
|
||||
}
|
||||
85
apps/meteor/server/lib/omnichannel/transfer.ts
Normal file
85
apps/meteor/server/lib/omnichannel/transfer.ts
Normal 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);
|
||||
}
|
||||
62
apps/meteor/server/lib/omnichannel/utils.ts
Normal file
62
apps/meteor/server/lib/omnichannel/utils.ts
Normal 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);
|
||||
}
|
||||
77
apps/meteor/server/lib/omnichannel/webhooks.ts
Normal file
77
apps/meteor/server/lib/omnichannel/webhooks.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user