From a2460efa82a20ef6ea5ed2ee85dc009c50d76795 Mon Sep 17 00:00:00 2001 From: Kevin Aleman Date: Tue, 25 Feb 2025 13:26:55 -0600 Subject: [PATCH] refactor: Break big `livechatTyped` into smaller files (#35324) --- .../app/apps/server/bridges/livechat.ts | 3 +- .../lib/server/functions/closeLivechatRoom.ts | 6 +- .../closeOmnichannelConversations.ts | 8 +- .../meteor/app/livechat/server/api/v1/room.ts | 3 +- .../app/livechat/server/api/v1/transcript.ts | 5 +- .../app/livechat/server/lib/LivechatTyped.ts | 325 +----------------- .../app/livechat/server/lib/closeRoom.ts | 289 ++++++++++++++++ .../app/livechat/server/lib/sendTranscript.ts | 45 ++- .../livechat/server/lib/stream/agentStatus.ts | 3 +- .../app/livechat/server/methods/closeRoom.ts | 4 +- .../server/api/lib/contacts.ts | 4 +- .../server/lib/AutoCloseOnHoldScheduler.ts | 4 +- .../server/lib/QueueInactivityMonitor.ts | 4 +- .../server/lib/VisitorInactivityMonitor.ts | 4 +- .../lib/AutoCloseOnHold.tests.ts | 2 +- .../server/lib/QueueInactivityMonitor.spec.ts | 12 +- .../functions/closeLivechatRoom.tests.ts | 4 +- 17 files changed, 370 insertions(+), 355 deletions(-) create mode 100644 apps/meteor/app/livechat/server/lib/closeRoom.ts diff --git a/apps/meteor/app/apps/server/bridges/livechat.ts b/apps/meteor/app/apps/server/bridges/livechat.ts index f2521d2f8cf..f1555bd3880 100644 --- a/apps/meteor/app/apps/server/bridges/livechat.ts +++ b/apps/meteor/app/apps/server/bridges/livechat.ts @@ -11,6 +11,7 @@ import { LivechatVisitors, LivechatRooms, LivechatDepartment, Users } from '@roc import { callbacks } from '../../../../lib/callbacks'; import { deasyncPromise } from '../../../../server/deasync/deasync'; import { Livechat as LivechatTyped } from '../../../livechat/server/lib/LivechatTyped'; +import { closeRoom } from '../../../livechat/server/lib/closeRoom'; import { getRoomMessages } from '../../../livechat/server/lib/getRoomMessages'; import type { ILivechatMessage } from '../../../livechat/server/lib/localTypes'; import { settings } from '../../../settings/server'; @@ -145,7 +146,7 @@ export class AppLivechatBridge extends LivechatBridge { ...(visitor && { visitor }), }; - await LivechatTyped.closeRoom(closeData); + await closeRoom(closeData); return true; } diff --git a/apps/meteor/app/lib/server/functions/closeLivechatRoom.ts b/apps/meteor/app/lib/server/functions/closeLivechatRoom.ts index 56b4b48ba29..339497b23b8 100644 --- a/apps/meteor/app/lib/server/functions/closeLivechatRoom.ts +++ b/apps/meteor/app/lib/server/functions/closeLivechatRoom.ts @@ -2,7 +2,7 @@ import type { IUser, IRoom, IOmnichannelRoom } from '@rocket.chat/core-typings'; import { LivechatRooms, Subscriptions } from '@rocket.chat/models'; import { hasPermissionAsync } from '../../../authorization/server/functions/hasPermission'; -import { Livechat } from '../../../livechat/server/lib/LivechatTyped'; +import { closeRoom } from '../../../livechat/server/lib/closeRoom'; import type { CloseRoomParams } from '../../../livechat/server/lib/localTypes'; export const closeLivechatRoom = async ( @@ -65,7 +65,7 @@ export const closeLivechatRoom = async ( }; if (forceClose) { - return Livechat.closeRoom({ + return closeRoom({ room, user, options, @@ -78,7 +78,7 @@ export const closeLivechatRoom = async ( throw new Error('error-room-already-closed'); } - return Livechat.closeRoom({ + return closeRoom({ room, user, options, diff --git a/apps/meteor/app/lib/server/functions/closeOmnichannelConversations.ts b/apps/meteor/app/lib/server/functions/closeOmnichannelConversations.ts index fc464f762a9..1c37a0ec84c 100644 --- a/apps/meteor/app/lib/server/functions/closeOmnichannelConversations.ts +++ b/apps/meteor/app/lib/server/functions/closeOmnichannelConversations.ts @@ -3,7 +3,7 @@ import { LivechatRooms } from '@rocket.chat/models'; import { callbacks } from '../../../../lib/callbacks'; import { i18n } from '../../../../server/lib/i18n'; -import { Livechat } from '../../../livechat/server/lib/LivechatTyped'; +import { closeRoom } from '../../../livechat/server/lib/closeRoom'; import { settings } from '../../../settings/server'; type SubscribedRooms = { @@ -13,7 +13,7 @@ type SubscribedRooms = { export const closeOmnichannelConversations = async (user: IUser, subscribedRooms: SubscribedRooms[]): Promise => { const extraQuery = await callbacks.run('livechat.applyRoomRestrictions', {}); - const roomsInfo = await LivechatRooms.findByIds( + const roomsInfo = LivechatRooms.findByIds( subscribedRooms.map(({ rid }) => rid), {}, extraQuery, @@ -22,8 +22,8 @@ export const closeOmnichannelConversations = async (user: IUser, subscribedRooms const comment = i18n.t('Agent_deactivated', { lng: language }); const promises: Promise[] = []; - await roomsInfo.forEach((room: any) => { - promises.push(Livechat.closeRoom({ user, room, comment })); + await roomsInfo.forEach((room) => { + promises.push(closeRoom({ user, room, comment })); }); await Promise.all(promises); diff --git a/apps/meteor/app/livechat/server/api/v1/room.ts b/apps/meteor/app/livechat/server/api/v1/room.ts index fe8aa43b663..6162380721b 100644 --- a/apps/meteor/app/livechat/server/api/v1/room.ts +++ b/apps/meteor/app/livechat/server/api/v1/room.ts @@ -24,6 +24,7 @@ import { closeLivechatRoom } from '../../../../lib/server/functions/closeLivecha import { settings as rcSettings } from '../../../../settings/server'; import { normalizeTransferredByData } from '../../lib/Helper'; import { Livechat as LivechatTyped } from '../../lib/LivechatTyped'; +import { closeRoom } from '../../lib/closeRoom'; import type { CloseRoomParams } from '../../lib/localTypes'; import { livechatLogger } from '../../lib/logger'; import { findGuest, findRoom, settings, findAgent, onCheckRoomParams } from '../lib/livechat'; @@ -180,7 +181,7 @@ API.v1.addRoute( } } - await LivechatTyped.closeRoom({ visitor, room, comment, options }); + await closeRoom({ visitor, room, comment, options }); return API.v1.success({ rid, comment }); }, diff --git a/apps/meteor/app/livechat/server/api/v1/transcript.ts b/apps/meteor/app/livechat/server/api/v1/transcript.ts index e46e841628f..dee730df846 100644 --- a/apps/meteor/app/livechat/server/api/v1/transcript.ts +++ b/apps/meteor/app/livechat/server/api/v1/transcript.ts @@ -5,8 +5,7 @@ import { isPOSTLivechatTranscriptParams, isPOSTLivechatTranscriptRequestParams } import { i18n } from '../../../../../server/lib/i18n'; import { API } from '../../../../api/server'; -import { Livechat } from '../../lib/LivechatTyped'; -import { sendTranscript } from '../../lib/sendTranscript'; +import { sendTranscript, requestTranscript } from '../../lib/sendTranscript'; API.v1.addRoute( 'livechat/transcript', @@ -66,7 +65,7 @@ API.v1.addRoute( throw new Error('error-invalid-user'); } - await Livechat.requestTranscript({ rid, email, subject, user }); + await requestTranscript({ rid, email, subject, user }); return API.v1.success(); }, diff --git a/apps/meteor/app/livechat/server/lib/LivechatTyped.ts b/apps/meteor/app/livechat/server/lib/LivechatTyped.ts index d85deb237b7..f15aa347ef7 100644 --- a/apps/meteor/app/livechat/server/lib/LivechatTyped.ts +++ b/apps/meteor/app/livechat/server/lib/LivechatTyped.ts @@ -1,8 +1,7 @@ import { Apps, AppEvents } from '@rocket.chat/apps'; -import { Message, VideoConf, api, Omnichannel } from '@rocket.chat/core-services'; +import { Message, VideoConf, api } from '@rocket.chat/core-services'; import type { IOmnichannelRoom, - IOmnichannelRoomClosingInfo, IUser, ILivechatVisitor, SelectedAgent, @@ -12,14 +11,13 @@ import type { AtLeast, TransferData, IOmnichannelAgent, - ILivechatInquiryRecord, UserStatus, IOmnichannelRoomInfo, IOmnichannelRoomExtraData, IOmnichannelSource, ILivechatContactVisitorAssociation, } from '@rocket.chat/core-typings'; -import { ILivechatAgentStatus, isOmnichannelRoom } from '@rocket.chat/core-typings'; +import { ILivechatAgentStatus } from '@rocket.chat/core-typings'; import { Logger, type MainLogger } from '@rocket.chat/logger'; import { LivechatDepartment, @@ -38,12 +36,11 @@ import { import { serverFetch as fetch } from '@rocket.chat/server-fetch'; import { Match, check } from 'meteor/check'; import { Meteor } from 'meteor/meteor'; -import type { Filter, ClientSession } from 'mongodb'; +import type { Filter } from 'mongodb'; import UAParser from 'ua-parser-js'; import { callbacks } from '../../../../lib/callbacks'; import { trim } from '../../../../lib/utils/stringUtils'; -import { client, shouldRetryTransaction } from '../../../../server/database/utils'; import { i18n } from '../../../../server/lib/i18n'; import { addUserRolesAsync } from '../../../../server/lib/roles/addUserRoles'; import { removeUserFromRolesAsync } from '../../../../server/lib/roles/removeUserFromRoles'; @@ -72,8 +69,7 @@ import { RoutingManager } from './RoutingManager'; import { Visitors, type RegisterGuestType } from './Visitors'; import { registerGuestData } from './contacts/registerGuestData'; import { getRequiredDepartment } from './departmentsLib'; -import type { CloseRoomParams, CloseRoomParamsByUser, CloseRoomParamsByVisitor, ILivechatMessage } from './localTypes'; -import { parseTranscriptRequest } from './parseTranscriptRequest'; +import type { ILivechatMessage } from './localTypes'; type AKeyOf = { [K in keyof T]?: T[K]; @@ -99,13 +95,6 @@ type ICRMData = { crmData?: IOmnichannelRoom['crmData']; }; -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; - class LivechatClass { logger: Logger; @@ -140,192 +129,6 @@ class LivechatClass { return agentsOnline; } - async closeRoom(params: CloseRoomParams, attempts = 2): Promise { - let newRoom: IOmnichannelRoom; - let chatCloser: ChatCloser; - let removedInquiryObj: ILivechatInquiryRecord | null; - - const session = client.startSession(); - try { - session.startTransaction(); - const { room, closedBy, removedInquiry } = await this.doCloseRoom(params, session); - await session.commitTransaction(); - - newRoom = room; - chatCloser = closedBy; - removedInquiryObj = removedInquiry; - } catch (e) { - this.logger.error({ err: e, msg: 'Failed to close room', afterAttempts: attempts }); - await session.abortTransaction(); - // Dont propagate transaction errors - if (shouldRetryTransaction(e)) { - if (attempts > 0) { - this.logger.debug(`Retrying close room because of transient error. Attempts left: ${attempts}`); - return this.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 this.afterRoomClosed(newRoom, chatCloser, removedInquiryObj, params); - } - - async afterRoomClosed( - newRoom: IOmnichannelRoom, - chatCloser: ChatCloser, - inquiry: ILivechatInquiryRecord | null, - params: CloseRoomParams, - ): Promise { - 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')); - this.logger.debug(`Sending closing message to room ${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); - } - - this.logger.debug(`Running callbacks for room ${newRoom._id}`); - - process.nextTick(() => { - /** - * @deprecated the `AppEvents.ILivechatRoomClosedHandler` event will be removed - * in the next major version of the Apps-Engine - */ - void Apps.self?.getBridges()?.getListenerBridge().livechatEvent(AppEvents.ILivechatRoomClosedHandler, newRoom); - void Apps.self?.getBridges()?.getListenerBridge().livechatEvent(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'); - } - - this.logger.debug(`Room ${newRoom._id} was closed`); - } - - async doCloseRoom( - params: CloseRoomParams, - session: ClientSession, - ): Promise<{ room: IOmnichannelRoom; closedBy: ChatCloser; removedInquiry: ILivechatInquiryRecord | null }> { - const { comment } = params; - const { room, forceClose } = params; - - this.logger.debug({ msg: `Attempting to close room`, roomId: room._id, forceClose }); - if (!room || !isOmnichannelRoom(room) || (!forceClose && !room.open)) { - this.logger.debug(`Room ${room._id} is not open`); - 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 this.resolveChatTags(room, params.options); - this.logger.debug(`Resolved chat tags for room ${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, - }; - this.logger.debug(`Room ${room._id} was closed at ${closeData.closedAt} (duration ${closeData.chatDuration})`); - - if (isRoomClosedByUserParams(params)) { - const { user } = params; - this.logger.debug(`Closing by user ${user?._id}`); - closeData.closer = 'user'; - closeData.closedBy = { - _id: user?._id || '', - username: user?.username, - }; - } else if (isRoomClosedByVisitorParams(params)) { - const { visitor } = params; - this.logger.debug(`Closing by visitor ${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'); - } - - this.logger.debug(`Updating DB for room ${room._id} with close data`); - - 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'); - } - - const updatedRoom = await LivechatRooms.closeRoomById(rid, closeData, { session }); - if (!params.forceClose && (!updatedRoom || 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'); - } - } - - this.logger.debug(`DB updated for room ${room._id}`); - - // Retrieve the closed room - const newRoom = await LivechatRooms.findOneById(rid, { session }); - if (!newRoom) { - throw new Error('Error: Room not found'); - } - - return { room: newRoom, closedBy: closeData.closedBy, removedInquiry: inquiry }; - } - private makeVisitorAssociation(visitorId: string, roomInfo: IOmnichannelSource): ILivechatContactVisitorAssociation { return { visitorId, @@ -515,70 +318,6 @@ class LivechatClass { return Users.countBotAgents(); } - private async resolveChatTags( - room: IOmnichannelRoom, - options: CloseRoomParams['options'] = {}, - ): Promise<{ updatedOptions: CloseRoomParams['options'] }> { - this.logger.debug(`Resolving chat tags for room ${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>( - 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 }), - }, - }; - } - async sendRequest( postData: { type: string; @@ -739,20 +478,6 @@ class LivechatClass { return true; } - async closeOpenChats(userId: string, comment?: string) { - this.logger.debug(`Closing open chats for user ${userId}`); - const user = await Users.findOneById(userId); - - const extraQuery = await callbacks.run('livechat.applyDepartmentRestrictions', {}, { userId }); - const openChats = LivechatRooms.findOpenByAgent(userId, extraQuery); - const promises: Promise[] = []; - await openChats.forEach((room) => { - promises.push(this.closeRoom({ user, room, comment })); - }); - - await Promise.all(promises); - } - async transfer(room: IOmnichannelRoom, guest: ILivechatVisitor, transferData: TransferData) { this.logger.debug(`Transfering room ${room._id} [Transfered by: ${transferData?.transferredBy?._id}]`); if (room.onHold) { @@ -1103,48 +828,6 @@ class LivechatClass { return result.modifiedCount; } - async requestTranscript({ - rid, - email, - subject, - user, - }: { - rid: string; - email: string; - subject: string; - user: AtLeast; - }) { - const room = await LivechatRooms.findOneById(rid, { projection: { _id: 1, open: 1, transcriptRequest: 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; - } - async afterRemoveAgent(user: AtLeast) { await callbacks.run('livechat.afterAgentRemoved', { agent: user }); return true; diff --git a/apps/meteor/app/livechat/server/lib/closeRoom.ts b/apps/meteor/app/livechat/server/lib/closeRoom.ts new file mode 100644 index 00000000000..3ff105051a0 --- /dev/null +++ b/apps/meteor/app/livechat/server/lib/closeRoom.ts @@ -0,0 +1,289 @@ +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 type { ClientSession } from 'mongodb'; + +import type { CloseRoomParams, CloseRoomParamsByUser, CloseRoomParamsByVisitor } from './localTypes'; +import { livechatLogger as logger } from './logger'; +import { parseTranscriptRequest } from './parseTranscriptRequest'; +import { callbacks } from '../../../../lib/callbacks'; +import { client, shouldRetryTransaction } from '../../../../server/database/utils'; +import { + notifyOnLivechatInquiryChanged, + notifyOnRoomChangedById, + notifyOnSubscriptionChanged, +} from '../../../lib/server/lib/notifyListener'; +import { settings } from '../../../settings/server'; + +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 { + 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 }); + await session.abortTransaction(); + // Dont propagate transaction errors + if (shouldRetryTransaction(e)) { + if (attempts > 0) { + logger.debug(`Retrying close room because of transient error. Attempts left: ${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 { + 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(`Sending closing message to room ${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(`Running callbacks for room ${newRoom._id}`); + + process.nextTick(() => { + /** + * @deprecated the `AppEvents.ILivechatRoomClosedHandler` event will be removed + * in the next major version of the Apps-Engine + */ + void Apps.self?.getBridges()?.getListenerBridge().livechatEvent(AppEvents.ILivechatRoomClosedHandler, newRoom); + void Apps.self?.getBridges()?.getListenerBridge().livechatEvent(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(`Room ${newRoom._id} was closed`); +} + +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(`Room ${room._id} is not open`); + 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(`Resolved chat tags for room ${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(`Room ${room._id} was closed at ${closeData.closedAt} (duration ${closeData.chatDuration})`); + + if (isRoomClosedByUserParams(params)) { + const { user } = params; + logger.debug(`Closing by user ${user?._id}`); + closeData.closer = 'user'; + closeData.closedBy = { + _id: user?._id || '', + username: user?.username, + }; + } else if (isRoomClosedByVisitorParams(params)) { + const { visitor } = params; + logger.debug(`Closing by visitor ${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(`Updating DB for room ${room._id} with close data`); + + 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'); + } + + const updatedRoom = await LivechatRooms.closeRoomById(rid, closeData, { session }); + if (!params.forceClose && (!updatedRoom || 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(`DB updated for room ${room._id}`); + + // Retrieve the closed room + const newRoom = await LivechatRooms.findOneById(rid, { session }); + if (!newRoom) { + throw new Error('Error: Room not found'); + } + + return { room: newRoom, closedBy: closeData.closedBy, removedInquiry: inquiry }; +} + +async function resolveChatTags( + room: IOmnichannelRoom, + options: CloseRoomParams['options'] = {}, +): Promise<{ updatedOptions: CloseRoomParams['options'] }> { + logger.debug(`Resolving chat tags for room ${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>( + 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(`Closing open chats for user ${userId}`); + const user = await Users.findOneById(userId); + + const extraQuery = await callbacks.run('livechat.applyDepartmentRestrictions', {}, { userId }); + const openChats = LivechatRooms.findOpenByAgent(userId, extraQuery); + const promises: Promise[] = []; + await openChats.forEach((room) => { + promises.push(closeRoom({ user, room, comment })); + }); + + await Promise.all(promises); +} diff --git a/apps/meteor/app/livechat/server/lib/sendTranscript.ts b/apps/meteor/app/livechat/server/lib/sendTranscript.ts index e701e8f5b86..9d4af9f8097 100644 --- a/apps/meteor/app/livechat/server/lib/sendTranscript.ts +++ b/apps/meteor/app/livechat/server/lib/sendTranscript.ts @@ -1,4 +1,4 @@ -import { Message } from '@rocket.chat/core-services'; +import { Message, Omnichannel } from '@rocket.chat/core-services'; import { type IUser, type MessageTypesValues, @@ -6,6 +6,7 @@ import { type ILivechatVisitor, isFileAttachment, isFileImageAttachment, + type AtLeast, } from '@rocket.chat/core-typings'; import colors from '@rocket.chat/fuselage-tokens/colors'; import { Logger } from '@rocket.chat/logger'; @@ -222,3 +223,45 @@ export async function sendTranscript({ return true; } + +export async function requestTranscript({ + rid, + email, + subject, + user, +}: { + rid: string; + email: string; + subject: string; + user: AtLeast; +}) { + const room = await LivechatRooms.findOneById(rid, { projection: { _id: 1, open: 1, transcriptRequest: 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; +} diff --git a/apps/meteor/app/livechat/server/lib/stream/agentStatus.ts b/apps/meteor/app/livechat/server/lib/stream/agentStatus.ts index 5ddd25e90bd..71bc21f6003 100644 --- a/apps/meteor/app/livechat/server/lib/stream/agentStatus.ts +++ b/apps/meteor/app/livechat/server/lib/stream/agentStatus.ts @@ -2,6 +2,7 @@ import { Logger } from '@rocket.chat/logger'; import { settings } from '../../../../settings/server'; import { Livechat } from '../LivechatTyped'; +import { closeOpenChats } from '../closeRoom'; const logger = new Logger('AgentStatusWatcher'); @@ -68,7 +69,7 @@ export const onlineAgents = { try { if (action === 'close') { - return await Livechat.closeOpenChats(userId, comment); + return await closeOpenChats(userId, comment); } if (action === 'forward') { diff --git a/apps/meteor/app/livechat/server/methods/closeRoom.ts b/apps/meteor/app/livechat/server/methods/closeRoom.ts index 19c8b270938..4d6daa5001c 100644 --- a/apps/meteor/app/livechat/server/methods/closeRoom.ts +++ b/apps/meteor/app/livechat/server/methods/closeRoom.ts @@ -5,7 +5,7 @@ import { Meteor } from 'meteor/meteor'; import { hasPermissionAsync } from '../../../authorization/server/functions/hasPermission'; import { methodDeprecationLogger } from '../../../lib/server/lib/deprecationWarningLogger'; -import { Livechat } from '../lib/LivechatTyped'; +import { closeRoom } from '../lib/closeRoom'; type CloseRoomOptions = { clientAction?: boolean; @@ -87,7 +87,7 @@ Meteor.methods({ }); } - await Livechat.closeRoom({ + await closeRoom({ user, room, comment, diff --git a/apps/meteor/ee/app/livechat-enterprise/server/api/lib/contacts.ts b/apps/meteor/ee/app/livechat-enterprise/server/api/lib/contacts.ts index 587a1704eee..14a2c9be8e5 100644 --- a/apps/meteor/ee/app/livechat-enterprise/server/api/lib/contacts.ts +++ b/apps/meteor/ee/app/livechat-enterprise/server/api/lib/contacts.ts @@ -2,7 +2,7 @@ import type { IUser, ILivechatContactVisitorAssociation } from '@rocket.chat/cor import { License } from '@rocket.chat/license'; import { LivechatContacts, LivechatRooms, LivechatVisitors } from '@rocket.chat/models'; -import { Livechat } from '../../../../../../app/livechat/server/lib/LivechatTyped'; +import { closeRoom } from '../../../../../../app/livechat/server/lib/closeRoom'; import { i18n } from '../../../../../../server/lib/i18n'; export async function changeContactBlockStatus({ block, visitor }: { visitor: ILivechatContactVisitorAssociation; block: boolean }) { @@ -32,5 +32,5 @@ export async function closeBlockedRoom(association: ILivechatContactVisitorAssoc return; } - return Livechat.closeRoom({ room, visitor, comment: i18n.t('close-blocked-room-comment'), user }); + return closeRoom({ room, visitor, comment: i18n.t('close-blocked-room-comment'), user }); } diff --git a/apps/meteor/ee/app/livechat-enterprise/server/lib/AutoCloseOnHoldScheduler.ts b/apps/meteor/ee/app/livechat-enterprise/server/lib/AutoCloseOnHoldScheduler.ts index eb7d1003ff1..b90d139e8d3 100644 --- a/apps/meteor/ee/app/livechat-enterprise/server/lib/AutoCloseOnHoldScheduler.ts +++ b/apps/meteor/ee/app/livechat-enterprise/server/lib/AutoCloseOnHoldScheduler.ts @@ -7,7 +7,7 @@ import { MongoInternals } from 'meteor/mongo'; import moment from 'moment'; import { schedulerLogger } from './logger'; -import { Livechat } from '../../../../../app/livechat/server/lib/LivechatTyped'; +import { closeRoom } from '../../../../../app/livechat/server/lib/closeRoom'; const SCHEDULER_NAME = 'omnichannel_auto_close_on_hold_scheduler'; @@ -82,7 +82,7 @@ export class AutoCloseOnHoldSchedulerClass { comment, }; - await Livechat.closeRoom(payload); + await closeRoom(payload); } private async getSchedulerUser(): Promise { diff --git a/apps/meteor/ee/app/livechat-enterprise/server/lib/QueueInactivityMonitor.ts b/apps/meteor/ee/app/livechat-enterprise/server/lib/QueueInactivityMonitor.ts index 83dd905e715..7b6996978e5 100644 --- a/apps/meteor/ee/app/livechat-enterprise/server/lib/QueueInactivityMonitor.ts +++ b/apps/meteor/ee/app/livechat-enterprise/server/lib/QueueInactivityMonitor.ts @@ -7,7 +7,7 @@ import { MongoInternals } from 'meteor/mongo'; import type { Db } from 'mongodb'; import { schedulerLogger } from './logger'; -import { Livechat } from '../../../../../app/livechat/server/lib/LivechatTyped'; +import { closeRoom } from '../../../../../app/livechat/server/lib/closeRoom'; import { settings } from '../../../../../app/settings/server'; import { i18n } from '../../../../../server/lib/i18n'; @@ -101,7 +101,7 @@ export class OmnichannelQueueInactivityMonitorClass { async closeRoomAction(room: IOmnichannelRoom): Promise { const comment = this.message; - return Livechat.closeRoom({ + return closeRoom({ comment, room, user: await this.getRocketCatUser(), diff --git a/apps/meteor/ee/app/livechat-enterprise/server/lib/VisitorInactivityMonitor.ts b/apps/meteor/ee/app/livechat-enterprise/server/lib/VisitorInactivityMonitor.ts index 7bfefe5db6e..7bd93d8f371 100644 --- a/apps/meteor/ee/app/livechat-enterprise/server/lib/VisitorInactivityMonitor.ts +++ b/apps/meteor/ee/app/livechat-enterprise/server/lib/VisitorInactivityMonitor.ts @@ -6,7 +6,7 @@ import { LivechatVisitors, LivechatRooms, LivechatDepartment, Users } from '@roc import { schedulerLogger } from './logger'; import { notifyOnRoomChangedById } from '../../../../../app/lib/server/lib/notifyListener'; -import { Livechat } from '../../../../../app/livechat/server/lib/LivechatTyped'; +import { closeRoom } from '../../../../../app/livechat/server/lib/closeRoom'; import { settings } from '../../../../../app/settings/server'; import { callbacks } from '../../../../../lib/callbacks'; import { i18n } from '../../../../../server/lib/i18n'; @@ -92,7 +92,7 @@ export class VisitorInactivityMonitor { if (room.departmentId) { comment = (await this._getDepartmentAbandonedCustomMessage(room.departmentId)) || comment; } - await Livechat.closeRoom({ + await closeRoom({ comment, room, user: this.user, diff --git a/apps/meteor/ee/tests/unit/apps/livechat-enterprise/lib/AutoCloseOnHold.tests.ts b/apps/meteor/ee/tests/unit/apps/livechat-enterprise/lib/AutoCloseOnHold.tests.ts index f998f351d20..4539f5e63fa 100644 --- a/apps/meteor/ee/tests/unit/apps/livechat-enterprise/lib/AutoCloseOnHold.tests.ts +++ b/apps/meteor/ee/tests/unit/apps/livechat-enterprise/lib/AutoCloseOnHold.tests.ts @@ -64,7 +64,7 @@ const mocks = { }, }, }, - '../../../../../app/livechat/server/lib/LivechatTyped': { Livechat: { closeRoom: mockLivechatCloseRoom } }, + '../../../../../app/livechat/server/lib/closeRoom': { closeRoom: mockLivechatCloseRoom }, './logger': { schedulerLogger: mockLogger }, '@rocket.chat/models': { LivechatRooms: mockLivechatRooms, diff --git a/apps/meteor/ee/tests/unit/apps/livechat-enterprise/server/lib/QueueInactivityMonitor.spec.ts b/apps/meteor/ee/tests/unit/apps/livechat-enterprise/server/lib/QueueInactivityMonitor.spec.ts index 383c7858638..526c981b83b 100644 --- a/apps/meteor/ee/tests/unit/apps/livechat-enterprise/server/lib/QueueInactivityMonitor.spec.ts +++ b/apps/meteor/ee/tests/unit/apps/livechat-enterprise/server/lib/QueueInactivityMonitor.spec.ts @@ -29,7 +29,7 @@ const mongoMock = { }), }, }; -const livechatMock = { Livechat: { closeRoom: sinon.stub() } }; +const livechatMock = { closeRoom: sinon.stub() }; const settingsMock = { settings: { get: sinon.stub() } }; const { OmnichannelQueueInactivityMonitorClass } = proxyquire @@ -41,7 +41,7 @@ const { OmnichannelQueueInactivityMonitorClass } = proxyquire '@rocket.chat/models': modelsMock, 'meteor/meteor': meteorMock, 'meteor/mongo': mongoMock, - '../../../../../app/livechat/server/lib/LivechatTyped': livechatMock, + '../../../../../app/livechat/server/lib/closeRoom': livechatMock, '../../../../../app/settings/server': settingsMock, '../../../../../server/lib/i18n': { i18n: { t: sinon.stub().returns('Closed automatically') } }, }); @@ -154,7 +154,7 @@ describe('OmnichannelQueueInactivityMonitorClass', () => { beforeEach(() => { modelsMock.LivechatInquiry.findOneById.reset(); modelsMock.LivechatRooms.findOneById.reset(); - livechatMock.Livechat.closeRoom.reset(); + livechatMock.closeRoom.reset(); }); it('should ignore the inquiry if its not in queue anymore', async () => { const qclass = new OmnichannelQueueInactivityMonitorClass(); @@ -162,7 +162,7 @@ describe('OmnichannelQueueInactivityMonitorClass', () => { await qclass.closeRoom({ attrs: { data: { inquiryId: 'inquiryId' } } }); expect(modelsMock.LivechatInquiry.findOneById.calledWith('inquiryId')).to.be.true; - expect(livechatMock.Livechat.closeRoom.notCalled).to.be.true; + expect(livechatMock.closeRoom.notCalled).to.be.true; }); it('should ignore an inquiry with no room', async () => { const qclass = new OmnichannelQueueInactivityMonitorClass(); @@ -172,7 +172,7 @@ describe('OmnichannelQueueInactivityMonitorClass', () => { expect(modelsMock.LivechatInquiry.findOneById.calledWith('inquiryId')).to.be.true; expect(modelsMock.LivechatRooms.findOneById.calledWith('roomId')).to.be.true; - expect(livechatMock.Livechat.closeRoom.notCalled).to.be.true; + expect(livechatMock.closeRoom.notCalled).to.be.true; }); it('should close a room', async () => { const qclass = new OmnichannelQueueInactivityMonitorClass(); @@ -185,7 +185,7 @@ describe('OmnichannelQueueInactivityMonitorClass', () => { expect(modelsMock.LivechatInquiry.findOneById.calledWith('inquiryId')).to.be.true; expect(modelsMock.LivechatRooms.findOneById.calledWith('roomId')).to.be.true; expect( - livechatMock.Livechat.closeRoom.calledWith( + livechatMock.closeRoom.calledWith( sinon.match({ comment: 'Closed automatically', room: { _id: 'roomId' }, diff --git a/apps/meteor/tests/unit/app/lib/server/functions/closeLivechatRoom.tests.ts b/apps/meteor/tests/unit/app/lib/server/functions/closeLivechatRoom.tests.ts index 307885f7a92..1fda43c3bb0 100644 --- a/apps/meteor/tests/unit/app/lib/server/functions/closeLivechatRoom.tests.ts +++ b/apps/meteor/tests/unit/app/lib/server/functions/closeLivechatRoom.tests.ts @@ -22,9 +22,7 @@ const livechatStub = { const hasPermissionStub = sinon.stub(); const { closeLivechatRoom } = proxyquire.noCallThru().load('../../../../../../app/lib/server/functions/closeLivechatRoom.ts', { - '../../../livechat/server/lib/LivechatTyped': { - Livechat: livechatStub, - }, + '../../../livechat/server/lib/closeRoom': livechatStub, '../../../authorization/server/functions/hasPermission': { hasPermissionAsync: hasPermissionStub, },