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
382 lines
12 KiB
TypeScript
382 lines
12 KiB
TypeScript
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);
|
|
}
|
|
},
|
|
};
|