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 { 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 { 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>( 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[] = []; await openChats.forEach((room) => { promises.push(closeRoom({ user, room, comment })); }); await Promise.all(promises); }