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> => { 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('Livechat_Require_Contact_Verification') === 'always') { await LivechatContacts.setChannelVerifiedStatus({ visitorId: _id, source }, false); } const contactId = await migrateVisitorIfMissingContact(_id, source); const contact = contactId && (await LivechatContacts.findOneEnabledById>(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; }; export const createLivechatRoom = async (room: InsertionModel, 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; 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, 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 = { 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; 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) => { 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) => { if (!customFields) { return; } const externalCustomFields = () => { const accountCustomFields = settings.get('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('Livechat_enabled_when_agent_idle'), settings.get('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('Livechat_enabled_when_agent_idle'), settings.get('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 >(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 = Omit & { [P in K]?: T[P] }; export const normalizeTransferredByData = ( transferredBy: MakePropertyOptional, 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 & { count?: number; order?: number })[]; remove?: Pick[]; }, 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; };