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 2s
298 lines
10 KiB
TypeScript
298 lines
10 KiB
TypeScript
import { Apps, AppEvents } from '@rocket.chat/apps';
|
|
import { Message } from '@rocket.chat/core-services';
|
|
import type { ILivechatDepartment, ILivechatInquiryRecord, IOmnichannelRoom, IOmnichannelRoomClosingInfo } from '@rocket.chat/core-typings';
|
|
import { isOmnichannelRoom } from '@rocket.chat/core-typings';
|
|
import { LivechatDepartment, LivechatInquiry, LivechatRooms, Subscriptions, Users } from '@rocket.chat/models';
|
|
import { applyDepartmentRestrictions } from '@rocket.chat/omni-core';
|
|
import type { ClientSession } from 'mongodb';
|
|
|
|
import type { CloseRoomParams, CloseRoomParamsByUser, CloseRoomParamsByVisitor } from './localTypes';
|
|
import { livechatLogger as logger } from './logger';
|
|
import { parseTranscriptRequest } from './parseTranscriptRequest';
|
|
import { client, shouldRetryTransaction } from '../../database/utils';
|
|
import { settings } from '../../settings';
|
|
import { callbacks } from '../callbacks';
|
|
import {
|
|
notifyOnLivechatInquiryChanged,
|
|
notifyOnRoomChanged,
|
|
notifyOnRoomChangedById,
|
|
notifyOnSubscriptionChanged,
|
|
} from '../notifyListener';
|
|
|
|
type ChatCloser = { _id: string; username: string | undefined };
|
|
|
|
const isRoomClosedByUserParams = (params: CloseRoomParams): params is CloseRoomParamsByUser =>
|
|
(params as CloseRoomParamsByUser).user !== undefined;
|
|
const isRoomClosedByVisitorParams = (params: CloseRoomParams): params is CloseRoomParamsByVisitor =>
|
|
(params as CloseRoomParamsByVisitor).visitor !== undefined;
|
|
|
|
export async function closeRoom(params: CloseRoomParams, attempts = 2): Promise<void> {
|
|
let newRoom: IOmnichannelRoom;
|
|
let chatCloser: ChatCloser;
|
|
let removedInquiryObj: ILivechatInquiryRecord | null;
|
|
|
|
const session = client.startSession();
|
|
try {
|
|
session.startTransaction();
|
|
const { room, closedBy, removedInquiry } = await doCloseRoom(params, session);
|
|
await session.commitTransaction();
|
|
|
|
newRoom = room;
|
|
chatCloser = closedBy;
|
|
removedInquiryObj = removedInquiry;
|
|
} catch (e) {
|
|
logger.error({ err: e, msg: 'Failed to close room', afterAttempts: attempts });
|
|
if (session.inTransaction()) {
|
|
await session.abortTransaction();
|
|
}
|
|
// Dont propagate transaction errors
|
|
if (shouldRetryTransaction(e)) {
|
|
if (attempts > 0) {
|
|
logger.debug({ msg: 'Retrying close room because of transient error', attemptsLeft: attempts });
|
|
return closeRoom(params, attempts - 1);
|
|
}
|
|
|
|
throw new Error('error-room-cannot-be-closed-try-again');
|
|
}
|
|
throw e;
|
|
} finally {
|
|
await session.endSession();
|
|
}
|
|
|
|
// Note: when reaching this point, the room has been closed
|
|
// Transaction is commited and so these messages can be sent here.
|
|
return afterRoomClosed(newRoom, chatCloser, removedInquiryObj, params);
|
|
}
|
|
|
|
async function afterRoomClosed(
|
|
newRoom: IOmnichannelRoom,
|
|
chatCloser: ChatCloser,
|
|
inquiry: ILivechatInquiryRecord | null,
|
|
params: CloseRoomParams,
|
|
): Promise<void> {
|
|
if (!chatCloser) {
|
|
// this should never happen
|
|
return;
|
|
}
|
|
// Note: we are okay with these messages being sent outside of the transaction. The process of sending a message
|
|
// is huge and involves multiple db calls. Making it transactionable this way would be really hard.
|
|
// And passing just _some_ actions to the transaction creates some deadlocks since messages are updated in the afterSaveMessages callbacks.
|
|
const transcriptRequested =
|
|
!!params.room.transcriptRequest || (!settings.get('Livechat_enable_transcript') && settings.get('Livechat_transcript_send_always'));
|
|
logger.debug({ msg: 'Sending closing message to room', roomId: newRoom._id });
|
|
await Message.saveSystemMessageAndNotifyUser('livechat-close', newRoom._id, params.comment ?? '', chatCloser, {
|
|
groupable: false,
|
|
transcriptRequested,
|
|
...(isRoomClosedByVisitorParams(params) && { token: params.visitor.token }),
|
|
});
|
|
|
|
if (settings.get('Livechat_enable_transcript') && !settings.get('Livechat_transcript_send_always')) {
|
|
await Message.saveSystemMessage('command', newRoom._id, 'promptTranscript', chatCloser);
|
|
}
|
|
|
|
logger.debug({ msg: 'Running callbacks for room', roomId: newRoom._id });
|
|
|
|
process.nextTick(() => {
|
|
/**
|
|
* @deprecated the `AppEvents.ILivechatRoomClosedHandler` event will be removed
|
|
* in the next major version of the Apps-Engine
|
|
*/
|
|
void Apps.self?.triggerEvent(AppEvents.ILivechatRoomClosedHandler, newRoom);
|
|
void Apps.self?.triggerEvent(AppEvents.IPostLivechatRoomClosed, newRoom);
|
|
});
|
|
|
|
const visitor = isRoomClosedByVisitorParams(params) ? params.visitor : undefined;
|
|
const opts = await parseTranscriptRequest(params.room, params.options, visitor);
|
|
if (process.env.TEST_MODE) {
|
|
await callbacks.run('livechat.closeRoom', {
|
|
room: newRoom,
|
|
options: opts,
|
|
});
|
|
} else {
|
|
callbacks.runAsync('livechat.closeRoom', {
|
|
room: newRoom,
|
|
options: opts,
|
|
});
|
|
}
|
|
|
|
void notifyOnRoomChangedById(newRoom._id);
|
|
if (inquiry) {
|
|
void notifyOnLivechatInquiryChanged(inquiry, 'removed');
|
|
}
|
|
|
|
logger.debug({ msg: 'Room was closed', roomId: newRoom._id });
|
|
}
|
|
|
|
async function doCloseRoom(
|
|
params: CloseRoomParams,
|
|
session: ClientSession,
|
|
): Promise<{ room: IOmnichannelRoom; closedBy: ChatCloser; removedInquiry: ILivechatInquiryRecord | null }> {
|
|
const { comment } = params;
|
|
const { room, forceClose } = params;
|
|
|
|
logger.debug({ msg: 'Attempting to close room', roomId: room._id, forceClose });
|
|
if (!room || !isOmnichannelRoom(room) || (!forceClose && !room.open)) {
|
|
logger.debug({ msg: 'Room is not open', roomId: room._id });
|
|
throw new Error('error-room-closed');
|
|
}
|
|
|
|
const commentRequired = settings.get('Livechat_request_comment_when_closing_conversation');
|
|
if (commentRequired && !comment?.trim()) {
|
|
throw new Error('error-comment-is-required');
|
|
}
|
|
|
|
const { updatedOptions: options } = await resolveChatTags(room, params.options);
|
|
logger.debug({ msg: 'Resolved chat tags for room', roomId: room._id });
|
|
|
|
const now = new Date();
|
|
const { _id: rid, servedBy } = room;
|
|
const serviceTimeDuration = servedBy && (now.getTime() - new Date(servedBy.ts).getTime()) / 1000;
|
|
|
|
const closeData: IOmnichannelRoomClosingInfo = {
|
|
closedAt: now,
|
|
chatDuration: (now.getTime() - new Date(room.ts).getTime()) / 1000,
|
|
...(serviceTimeDuration && { serviceTimeDuration }),
|
|
...options,
|
|
};
|
|
logger.debug({ msg: 'Room was closed', roomId: room._id, closedAt: closeData.closedAt, chatDuration: closeData.chatDuration });
|
|
|
|
if (isRoomClosedByUserParams(params)) {
|
|
const { user } = params;
|
|
logger.debug({ msg: 'Closing by user', userId: user?._id });
|
|
closeData.closer = 'user';
|
|
closeData.closedBy = {
|
|
_id: user?._id || '',
|
|
username: user?.username,
|
|
};
|
|
} else if (isRoomClosedByVisitorParams(params)) {
|
|
const { visitor } = params;
|
|
logger.debug({ msg: 'Closing by visitor', visitorId: params.visitor._id });
|
|
closeData.closer = 'visitor';
|
|
closeData.closedBy = {
|
|
_id: visitor._id,
|
|
username: visitor.username,
|
|
};
|
|
} else {
|
|
throw new Error('Error: Please provide details of the user or visitor who closed the room');
|
|
}
|
|
|
|
logger.debug({ msg: 'Updating DB for room with close data', roomId: room._id });
|
|
|
|
const inquiry = await LivechatInquiry.findOneByRoomId(rid, { session });
|
|
const removedInquiry = await LivechatInquiry.removeByRoomId(rid, { session });
|
|
if (!params.forceClose && removedInquiry && removedInquiry.deletedCount !== 1) {
|
|
throw new Error('Error removing inquiry');
|
|
}
|
|
if (removedInquiry.deletedCount) {
|
|
void notifyOnLivechatInquiryChanged(inquiry!, 'removed');
|
|
}
|
|
|
|
const updatedRoom = await LivechatRooms.closeRoomById(rid, closeData, { session });
|
|
if (!params.forceClose && updatedRoom?.modifiedCount !== 1) {
|
|
throw new Error('Error closing room');
|
|
}
|
|
|
|
const subs = await Subscriptions.countByRoomId(rid, { session });
|
|
if (subs) {
|
|
const removedSubs = await Subscriptions.removeByRoomId(rid, {
|
|
async onTrash(doc) {
|
|
void notifyOnSubscriptionChanged(doc, 'removed');
|
|
},
|
|
session,
|
|
});
|
|
|
|
if (!params.forceClose && removedSubs.deletedCount !== subs) {
|
|
throw new Error('Error removing subscriptions');
|
|
}
|
|
}
|
|
|
|
logger.debug({ msg: 'DB updated for room', roomId: room._id });
|
|
|
|
// Retrieve the closed room
|
|
const newRoom = await LivechatRooms.findOneById(rid, { session });
|
|
if (!newRoom) {
|
|
throw new Error('Error: Room not found');
|
|
}
|
|
|
|
void notifyOnRoomChanged(newRoom, 'updated');
|
|
return { room: newRoom, closedBy: closeData.closedBy, removedInquiry: inquiry };
|
|
}
|
|
|
|
async function resolveChatTags(
|
|
room: IOmnichannelRoom,
|
|
options: CloseRoomParams['options'] = {},
|
|
): Promise<{ updatedOptions: CloseRoomParams['options'] }> {
|
|
logger.debug({ msg: 'Resolving chat tags for room', roomId: room._id });
|
|
|
|
const concatUnique = (...arrays: (string[] | undefined)[]): string[] => [
|
|
...new Set(([] as string[]).concat(...arrays.filter((a): a is string[] => !!a))),
|
|
];
|
|
|
|
const { departmentId, tags: optionsTags } = room;
|
|
const { clientAction, tags: oldRoomTags } = options;
|
|
const roomTags = concatUnique(oldRoomTags, optionsTags);
|
|
|
|
if (!departmentId) {
|
|
return {
|
|
updatedOptions: {
|
|
...options,
|
|
...(roomTags.length && { tags: roomTags }),
|
|
},
|
|
};
|
|
}
|
|
|
|
const department = await LivechatDepartment.findOneById<Pick<ILivechatDepartment, 'requestTagBeforeClosingChat' | 'chatClosingTags'>>(
|
|
departmentId,
|
|
{
|
|
projection: { requestTagBeforeClosingChat: 1, chatClosingTags: 1 },
|
|
},
|
|
);
|
|
if (!department) {
|
|
return {
|
|
updatedOptions: {
|
|
...options,
|
|
...(roomTags.length && { tags: roomTags }),
|
|
},
|
|
};
|
|
}
|
|
|
|
const { requestTagBeforeClosingChat, chatClosingTags } = department;
|
|
const extraRoomTags = concatUnique(roomTags, chatClosingTags);
|
|
|
|
if (!requestTagBeforeClosingChat) {
|
|
return {
|
|
updatedOptions: {
|
|
...options,
|
|
...(extraRoomTags.length && { tags: extraRoomTags }),
|
|
},
|
|
};
|
|
}
|
|
|
|
const checkRoomTags = !clientAction || (roomTags && roomTags.length > 0);
|
|
const checkDepartmentTags = chatClosingTags && chatClosingTags.length > 0;
|
|
if (!checkRoomTags || !checkDepartmentTags) {
|
|
throw new Error('error-tags-must-be-assigned-before-closing-chat');
|
|
}
|
|
|
|
return {
|
|
updatedOptions: {
|
|
...options,
|
|
...(extraRoomTags.length && { tags: extraRoomTags }),
|
|
},
|
|
};
|
|
}
|
|
|
|
export async function closeOpenChats(userId: string, comment?: string) {
|
|
logger.debug({ msg: 'Closing open chats for user', userId });
|
|
const user = await Users.findOneById(userId);
|
|
|
|
const extraQuery = await applyDepartmentRestrictions({}, userId);
|
|
const openChats = LivechatRooms.findOpenByAgent(userId, extraQuery);
|
|
const promises: Promise<void>[] = [];
|
|
await openChats.forEach((room) => {
|
|
promises.push(closeRoom({ user, room, comment }));
|
|
});
|
|
|
|
await Promise.all(promises);
|
|
}
|