refactor: omnichannel on-hold feature (#28252)

pull/29493/head^2
Murtaza Patrawala 3 years ago committed by GitHub
parent 207a1916c9
commit 9da856cc67
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 9
      .changeset/moody-teachers-cheer.md
  2. 3
      apps/meteor/app/livechat/server/lib/Helper.js
  3. 6
      apps/meteor/app/livechat/server/lib/QueueManager.js
  4. 4
      apps/meteor/app/livechat/server/lib/RoutingManager.js
  5. 13
      apps/meteor/client/views/room/components/body/composer/ComposerOmnichannel/hooks/useResumeChatOnHoldMutation.ts
  6. 69
      apps/meteor/ee/app/livechat-enterprise/server/api/rooms.ts
  7. 6
      apps/meteor/ee/app/livechat-enterprise/server/hooks/afterOnHold.ts
  8. 24
      apps/meteor/ee/app/livechat-enterprise/server/hooks/afterOnHoldChatResumed.ts
  9. 22
      apps/meteor/ee/app/livechat-enterprise/server/hooks/onAgentAssignmentFailed.ts
  10. 46
      apps/meteor/ee/app/livechat-enterprise/server/hooks/onCloseLivechat.ts
  11. 90
      apps/meteor/ee/app/livechat-enterprise/server/hooks/resumeOnHold.ts
  12. 23
      apps/meteor/ee/app/livechat-enterprise/server/hooks/scheduleAutoTransfer.ts
  13. 1
      apps/meteor/ee/app/livechat-enterprise/server/index.ts
  14. 41
      apps/meteor/ee/app/livechat-enterprise/server/lib/AutoTransferChatScheduler.ts
  15. 34
      apps/meteor/ee/app/livechat-enterprise/server/lib/LivechatEnterprise.ts
  16. 4
      apps/meteor/ee/app/livechat-enterprise/server/lib/VisitorInactivityMonitor.ts
  17. 33
      apps/meteor/ee/app/livechat-enterprise/server/methods/resumeOnHold.ts
  18. 180
      apps/meteor/ee/app/livechat-enterprise/server/services/omnichannel.internalService.ts
  19. 2
      apps/meteor/ee/server/startup/services.ts
  20. 4
      apps/meteor/lib/callbacks.ts
  21. 6
      apps/meteor/packages/rocketchat-i18n/i18n/en.i18n.json
  22. 8
      apps/meteor/server/models/raw/Subscriptions.ts
  23. 8
      apps/meteor/tests/data/livechat/rooms.ts
  24. 4
      apps/meteor/tests/end-to-end/api/livechat/00-rooms.ts
  25. 145
      apps/meteor/tests/end-to-end/api/livechat/18-rooms-ee.ts
  26. 3
      packages/core-services/src/index.ts
  27. 18
      packages/core-services/src/types/IOmnichannelEEService.ts
  28. 2
      packages/core-typings/src/IRoom.ts
  29. 1
      packages/model-typings/src/models/IMessagesModel.ts
  30. 3
      packages/model-typings/src/models/ISubscriptionsModel.ts
  31. 20
      packages/rest-typings/src/v1/omnichannel.ts

@ -0,0 +1,9 @@
---
"@rocket.chat/meteor": patch
"@rocket.chat/core-services": patch
"@rocket.chat/core-typings": patch
"@rocket.chat/model-typings": patch
"@rocket.chat/rest-typings": patch
---
fix: Resume on-hold chat not working with max-chat's allowed per agent config

@ -382,7 +382,6 @@ export const forwardRoomToAgent = async (room, transferData) => {
if (oldServedBy && servedBy._id !== oldServedBy._id) {
await RoutingManager.removeAllRoomSubscriptions(room, servedBy);
}
await Message.saveSystemMessage('uj', rid, servedBy.username, servedBy);
setImmediate(() => {
Apps.triggerEvent(AppEvents.IPostLivechatRoomTransferred, {
@ -505,7 +504,7 @@ export const forwardRoomToDepartment = async (room, guest, transferData) => {
await LivechatRooms.removeAgentByRoomId(rid);
await dispatchAgentDelegated(rid, null);
const newInquiry = await LivechatInquiry.findOneById(inquiry._id);
await queueInquiry(room, newInquiry);
await queueInquiry(newInquiry);
logger.debug(`Inquiry ${inquiry._id} queued succesfully`);
}

@ -14,7 +14,7 @@ export const saveQueueInquiry = async (inquiry) => {
await callbacks.run('livechat.afterInquiryQueued', inquiry);
};
export const queueInquiry = async (room, inquiry, defaultAgent) => {
export const queueInquiry = async (inquiry, defaultAgent) => {
const inquiryAgent = await RoutingManager.delegateAgent(defaultAgent, inquiry);
logger.debug(`Delegating inquiry with id ${inquiry._id} to agent ${defaultAgent?.username}`);
@ -70,7 +70,7 @@ export const QueueManager = {
await LivechatRooms.updateRoomCount();
await queueInquiry(room, inquiry, agent);
await queueInquiry(inquiry, agent);
logger.debug(`Inquiry ${inquiry._id} queued`);
const newRoom = await LivechatRooms.findOneById(rid);
@ -126,7 +126,7 @@ export const QueueManager = {
const inquiry = await LivechatInquiry.findOneById(await createLivechatInquiry({ rid, name, guest, message, extraData: { source } }));
logger.debug(`Generated inquiry for visitor ${v._id} with id ${inquiry._id} [Not queued]`);
await queueInquiry(room, inquiry, defaultAgent);
await queueInquiry(inquiry, defaultAgent);
logger.debug(`Inquiry ${inquiry._id} queued`);
return room;

@ -106,7 +106,7 @@ export const RoutingManager = {
const user = await Users.findOneById(agent.agentId);
const room = await LivechatRooms.findOneById(rid);
await Message.saveSystemMessage('command', rid, 'connected', user);
await Promise.all([Message.saveSystemMessage('command', rid, 'connected', user), Message.saveSystemMessage('uj', rid, '', user)]);
await dispatchAgentDelegated(rid, agent.agentId);
logger.debug(`Agent ${agent.agentId} assigned to inquriy ${inquiry._id}. Instances notified`);
@ -176,7 +176,7 @@ export const RoutingManager = {
return room;
}
if (room.servedBy && room.servedBy._id === agent.agentId && !room.onHold) {
if (room.servedBy && room.servedBy._id === agent.agentId) {
logger.debug(`Cannot take Inquiry ${inquiry._id}: Already taken by agent ${room.servedBy._id}`);
return room;
}

@ -1,18 +1,20 @@
import type { IRoom } from '@rocket.chat/core-typings';
import { useMethod } from '@rocket.chat/ui-contexts';
import { useEndpoint, useToastMessageDispatch } from '@rocket.chat/ui-contexts';
import type { UseMutationOptions, UseMutationResult } from '@tanstack/react-query';
import { useMutation, useQueryClient } from '@tanstack/react-query';
export const useResumeChatOnHoldMutation = (
options?: Omit<UseMutationOptions<void, Error, IRoom['_id']>, 'mutationFn'>,
): UseMutationResult<void, Error, IRoom['_id']> => {
const resumeChatOnHold = useMethod('livechat:resumeOnHold');
const resumeChatOnHold = useEndpoint('POST', '/v1/livechat/room.resumeOnHold');
const dispatchToastMessage = useToastMessageDispatch();
const queryClient = useQueryClient();
return useMutation(
async (rid) => {
await resumeChatOnHold(rid, { clientAction: true });
async (roomId) => {
await resumeChatOnHold({ roomId });
},
{
...options,
@ -22,6 +24,9 @@ export const useResumeChatOnHoldMutation = (
await queryClient.invalidateQueries(['subscriptions', { rid }]);
return options?.onSuccess?.(data, rid, context);
},
onError: (error) => {
dispatchToastMessage({ type: 'error', message: error });
},
},
);
};

@ -1,56 +1,77 @@
import { Meteor } from 'meteor/meteor';
import { isPOSTLivechatRoomPriorityParams } from '@rocket.chat/rest-typings';
import { isLivechatRoomOnHoldProps, isLivechatRoomResumeOnHoldProps, isPOSTLivechatRoomPriorityParams } from '@rocket.chat/rest-typings';
import { LivechatRooms, Subscriptions } from '@rocket.chat/models';
import type { IOmnichannelRoom } from '@rocket.chat/core-typings';
import { OmnichannelEEService } from '@rocket.chat/core-services';
import { API } from '../../../../../app/api/server';
import { hasPermissionAsync } from '../../../../../app/authorization/server/functions/hasPermission';
import { LivechatEnterprise } from '../lib/LivechatEnterprise';
import { removePriorityFromRoom, updateRoomPriority } from './lib/priorities';
import { i18n } from '../../../../../server/lib/i18n';
API.v1.addRoute(
'livechat/room.onHold',
{ authRequired: true, permissionsRequired: ['on-hold-livechat-room'] },
{ authRequired: true, permissionsRequired: ['on-hold-livechat-room'], validateParams: isLivechatRoomOnHoldProps },
{
async post() {
const { roomId } = this.bodyParams;
if (!roomId || roomId.trim() === '') {
return API.v1.failure('Invalid room Id');
}
const room = await LivechatRooms.findOneById(roomId);
if (!room || room.t !== 'l') {
return API.v1.failure('Invalid room Id');
}
type Room = Pick<IOmnichannelRoom, '_id' | 't' | 'open' | 'onHold' | 'lastMessage' | 'servedBy'>;
if (room.lastMessage?.token) {
return API.v1.failure('You cannot place chat on-hold, when the Contact has sent the last message');
const room = await LivechatRooms.findOneById<Room>(roomId, {
projection: { _id: 1, t: 1, open: 1, onHold: 1, lastMessage: 1, servedBy: 1 },
});
if (!room) {
throw new Error('error-invalid-room');
}
if (room.onHold) {
return API.v1.failure('Room is already On-Hold');
const subscription = await Subscriptions.findOneByRoomIdAndUserId(roomId, this.userId, { projection: { _id: 1 } });
if (!subscription && !(await hasPermissionAsync(this.userId, 'on-hold-others-livechat-room'))) {
throw new Error('Not_authorized');
}
if (!room.open) {
return API.v1.failure('Room cannot be placed on hold after being closed');
const onHoldBy = { _id: this.userId, username: this.user.username, name: this.user.name };
const comment = i18n.t('Omnichannel_On_Hold_manually', {
user: onHoldBy.name || `@${onHoldBy.username}`,
});
await OmnichannelEEService.placeRoomOnHold(room, comment, this.user);
return API.v1.success();
},
},
);
API.v1.addRoute(
'livechat/room.resumeOnHold',
{ authRequired: true, permissionsRequired: ['view-l-room'], validateParams: isLivechatRoomResumeOnHoldProps },
{
async post() {
const { roomId } = this.bodyParams;
if (!roomId || roomId.trim() === '') {
throw new Error('invalid-param');
}
const user = await Meteor.userAsync();
if (!user) {
return API.v1.failure('Invalid user');
type Room = Pick<IOmnichannelRoom, '_id' | 't' | 'open' | 'onHold' | 'servedBy'>;
const room = await LivechatRooms.findOneById<Room>(roomId, {
projection: { t: 1, open: 1, onHold: 1, servedBy: 1 },
});
if (!room) {
throw new Error('error-invalid-room');
}
const subscription = await Subscriptions.findOneByRoomIdAndUserId(roomId, user._id, { projection: { _id: 1 } });
const subscription = await Subscriptions.findOneByRoomIdAndUserId(roomId, this.userId, { projection: { _id: 1 } });
if (!subscription && !(await hasPermissionAsync(this.userId, 'on-hold-others-livechat-room'))) {
return API.v1.failure('Not authorized');
throw new Error('Not_authorized');
}
const onHoldBy = { _id: user._id, username: user.username, name: (user as any).name };
const { name, username, _id: userId } = this.user;
const onHoldBy = { _id: userId, username, name };
const comment = i18n.t('Omnichannel_On_Hold_manually', {
user: onHoldBy.name || `@${onHoldBy.username}`,
});
await LivechatEnterprise.placeRoomOnHold(room, comment, onHoldBy);
await OmnichannelEEService.resumeRoomOnHold(room, comment, this.user, true);
return API.v1.success();
},

@ -1,3 +1,5 @@
import type { IOmnichannelRoom } from '@rocket.chat/core-typings';
import { callbacks } from '../../../../../lib/callbacks';
import { settings } from '../../../../../app/settings/server';
import { AutoCloseOnHoldScheduler } from '../lib/AutoCloseOnHoldScheduler';
@ -6,7 +8,7 @@ import { i18n } from '../../../../../server/lib/i18n';
let autoCloseOnHoldChatTimeout = 0;
const handleAfterOnHold = async (room: any = {}): Promise<any> => {
const handleAfterOnHold = async (room: Pick<IOmnichannelRoom, '_id'>): Promise<any> => {
const { _id: rid } = room;
if (!rid) {
cbLogger.debug('Skipping callback. No room provided');
@ -24,7 +26,7 @@ const handleAfterOnHold = async (room: any = {}): Promise<any> => {
i18n.t('Closed_automatically_because_chat_was_onhold_for_seconds', {
onHoldTime: autoCloseOnHoldChatTimeout,
});
await AutoCloseOnHoldScheduler.scheduleRoom(room._id, autoCloseOnHoldChatTimeout, closeComment);
await AutoCloseOnHoldScheduler.scheduleRoom(rid, autoCloseOnHoldChatTimeout, closeComment);
};
settings.watch<number>('Livechat_auto_close_on_hold_chats_timeout', (value) => {

@ -1,20 +1,28 @@
import type { IOmnichannelRoom } from '@rocket.chat/core-typings';
import { callbacks } from '../../../../../lib/callbacks';
import { LivechatEnterprise } from '../lib/LivechatEnterprise';
import { AutoCloseOnHoldScheduler } from '../lib/AutoCloseOnHoldScheduler';
import { cbLogger } from '../lib/logger';
const handleAfterOnHoldChatResumed = async (room: any): Promise<void> => {
if (!room?._id || !room.onHold) {
cbLogger.debug('Skipping callback. No room provided or room is not on hold');
return;
type IRoom = Pick<IOmnichannelRoom, '_id'>;
const handleAfterOnHoldChatResumed = async (room: IRoom): Promise<IRoom> => {
if (!room?._id) {
cbLogger.debug('Skipping callback. No room provided');
return room;
}
cbLogger.debug(`Removing current on hold timers for room ${room._id}`);
void LivechatEnterprise.releaseOnHoldChat(room);
const { _id: roomId } = room;
cbLogger.debug(`Removing current on hold timers for room ${roomId}`);
await AutoCloseOnHoldScheduler.unscheduleRoom(roomId);
return room;
};
callbacks.add(
'livechat:afterOnHoldChatResumed',
(room) => handleAfterOnHoldChatResumed(room),
handleAfterOnHoldChatResumed,
callbacks.priority.HIGH,
'livechat-after-on-hold-chat-resumed',
);

@ -1,10 +1,6 @@
import { Subscriptions, LivechatInquiry, LivechatRooms } from '@rocket.chat/models';
import { callbacks } from '../../../../../lib/callbacks';
import { queueInquiry } from '../../../../../app/livechat/server/lib/QueueManager';
import { settings } from '../../../../../app/settings/server';
import { cbLogger } from '../lib/logger';
import { dispatchAgentDelegated } from '../../../../../app/livechat/server/lib/Helper';
const handleOnAgentAssignmentFailed = async ({
inquiry,
@ -23,24 +19,6 @@ const handleOnAgentAssignmentFailed = async ({
return;
}
if (room.onHold) {
cbLogger.debug('Room is on hold. Removing current assignations before queueing again');
const { _id: roomId } = room;
const { _id: inquiryId } = inquiry;
await LivechatInquiry.queueInquiryAndRemoveDefaultAgent(inquiryId);
await LivechatRooms.removeAgentByRoomId(roomId);
await Subscriptions.removeByRoomId(roomId);
await dispatchAgentDelegated(roomId, null);
const newInquiry = await LivechatInquiry.findOneById(inquiryId);
await queueInquiry(room, newInquiry);
cbLogger.debug('Room queued successfully');
return;
}
if (!settings.get('Livechat_waiting_queue')) {
cbLogger.debug('Skipping callback. Queue disabled by setting');
return;

@ -1,24 +1,46 @@
import type { IOmnichannelRoom } from '@rocket.chat/core-typings';
import { LivechatRooms, Subscriptions } from '@rocket.chat/models';
import { callbacks } from '../../../../../lib/callbacks';
import { settings } from '../../../../../app/settings/server';
import { debouncedDispatchWaitingQueueStatus } from '../lib/Helper';
import { LivechatEnterprise } from '../lib/LivechatEnterprise';
import { callbackLogger } from '../../../../../app/livechat/server/lib/callbackLogger';
import { AutoCloseOnHoldScheduler } from '../lib/AutoCloseOnHoldScheduler';
callbacks.add(
'livechat.closeRoom',
async (params) => {
const { room } = params;
type LivechatCloseCallbackParams = {
room: IOmnichannelRoom;
};
await LivechatEnterprise.releaseOnHoldChat(room);
const onCloseLivechat = async (params: LivechatCloseCallbackParams) => {
const {
room,
room: { _id: roomId },
} = params;
if (!settings.get('Livechat_waiting_queue')) {
return params;
}
callbackLogger.debug(`[onCloseLivechat] clearing onHold related data for room ${roomId}`);
const { departmentId } = room || {};
debouncedDispatchWaitingQueueStatus(departmentId);
await Promise.all([
LivechatRooms.unsetOnHoldByRoomId(roomId),
Subscriptions.unsetOnHoldByRoomId(roomId),
AutoCloseOnHoldScheduler.unscheduleRoom(roomId),
]);
callbackLogger.debug(`[onCloseLivechat] clearing onHold related data for room ${roomId} completed`);
if (!settings.get('Livechat_waiting_queue')) {
return params;
},
}
const { departmentId } = room || {};
callbackLogger.debug(`[onCloseLivechat] dispatching waiting queue status for department ${departmentId}`);
debouncedDispatchWaitingQueueStatus(departmentId);
return params;
};
callbacks.add(
'livechat.closeRoom',
(params: LivechatCloseCallbackParams) => onCloseLivechat(params),
callbacks.priority.HIGH,
'livechat-waiting-queue-monitor-close-room',
);

@ -1,46 +1,68 @@
import { Meteor } from 'meteor/meteor';
import { isOmnichannelRoom, isEditedMessage } from '@rocket.chat/core-typings';
import { LivechatRooms } from '@rocket.chat/models';
import type { ILivechatVisitor, IMessage, IOmnichannelRoom, IRoom, IUser } from '@rocket.chat/core-typings';
import { isEditedMessage, isOmnichannelRoom } from '@rocket.chat/core-typings';
import { LivechatRooms, LivechatVisitors, Users } from '@rocket.chat/models';
import { OmnichannelEEService } from '@rocket.chat/core-services';
import { callbacks } from '../../../../../lib/callbacks';
import { callbackLogger } from '../../../../../app/livechat/server/lib/callbackLogger';
import { i18n } from '../../../../../server/lib/i18n';
callbacks.add(
'afterSaveMessage',
async (message, roomParams) => {
// skips this callback if the message was edited
if (isEditedMessage(message)) {
return message;
}
const resumeOnHoldCommentAndUser = async (room: IOmnichannelRoom): Promise<{ comment: string; resumedBy: IUser }> => {
const {
v: { _id: visitorId },
_id: rid,
} = room;
const visitor = await LivechatVisitors.findOneById<Pick<ILivechatVisitor, 'name' | 'username'>>(visitorId, {
projection: { name: 1, username: 1 },
});
if (!visitor) {
callbackLogger.error(`[afterSaveMessage] Visitor Not found for room ${rid} while trying to resume on hold`);
throw new Error('Visitor not found while trying to resume on hold');
}
// if the message has a type means it is a special message (like the closing comment), so skips
if (message.t) {
return message;
}
const guest = visitor.name || visitor.username;
if (!isOmnichannelRoom(roomParams)) {
return message;
}
const resumeChatComment = i18n.t('Omnichannel_on_hold_chat_automatically', { guest });
const { _id: rid, t: roomType, v: roomVisitor } = roomParams;
const resumedBy = await Users.findOneById('rocket.cat');
if (!resumedBy) {
callbackLogger.error(`[afterSaveMessage] User Not found for room ${rid} while trying to resume on hold`);
throw new Error(`User not found while trying to resume on hold`);
}
// message valid only if it is a livechat room
if (!(typeof roomType !== 'undefined' && roomType === 'l' && roomVisitor && roomVisitor.token)) {
return message;
}
return { comment: resumeChatComment, resumedBy };
};
const handleAfterSaveMessage = async (message: IMessage, room: IRoom) => {
if (isEditedMessage(message) || message.t || !isOmnichannelRoom(room)) {
return message;
}
const { _id: rid, v: roomVisitor } = room;
// Need to read the room every time, the room object is not updated
const room = await LivechatRooms.findOneById(rid, { projection: { t: 1, v: 1, onHold: 1 } });
if (!room) {
if (!roomVisitor?._id) {
return message;
}
// Need to read the room every time, the room object is not updated
const updatedRoom = await LivechatRooms.findOneById(rid);
if (!updatedRoom) {
return message;
}
if (message.token && room.onHold) {
callbackLogger.debug(`[afterSaveMessage] Room ${rid} is on hold, resuming it now since visitor sent a message`);
try {
const { comment: resumeChatComment, resumedBy } = await resumeOnHoldCommentAndUser(updatedRoom);
await OmnichannelEEService.resumeRoomOnHold(updatedRoom, resumeChatComment, resumedBy);
} catch (error) {
callbackLogger.error(`[afterSaveMessage] Error while resuming room ${rid} on hold: Error: `, error);
return message;
}
}
// if a visitor sends a message in room which is On Hold
if (message.token && room.onHold) {
await Meteor.callAsync('livechat:resumeOnHold', rid, { clientAction: false });
}
return message;
};
return message;
},
callbacks.priority.HIGH,
'livechat-resume-on-hold',
);
callbacks.add('afterSaveMessage', handleAfterSaveMessage, callbacks.priority.HIGH, 'livechat-resume-on-hold');

@ -1,4 +1,5 @@
import type { IMessage, IOmnichannelRoom } from '@rocket.chat/core-typings';
import type { IMessage, IOmnichannelRoom, IRoom } from '@rocket.chat/core-typings';
import { isOmnichannelRoom } from '@rocket.chat/core-typings';
import { LivechatRooms } from '@rocket.chat/models';
import { AutoTransferChatScheduler } from '../lib/AutoTransferChatScheduler';
@ -16,7 +17,7 @@ let autoTransferTimeout = 0;
const handleAfterTakeInquiryCallback = async (inquiry: any = {}): Promise<any> => {
const { rid } = inquiry;
if (!rid || !rid.trim()) {
if (!rid?.trim()) {
cbLogger.debug('Skipping callback. Invalid room id');
return;
}
@ -43,15 +44,25 @@ const handleAfterTakeInquiryCallback = async (inquiry: any = {}): Promise<any> =
return inquiry;
};
const handleAfterSaveMessage = async (message: any = {}, room: any = {}): Promise<IMessage> => {
const { _id: rid, t, autoTransferredAt, autoTransferOngoing } = room;
const { token } = message;
const handleAfterSaveMessage = async (message: IMessage, room: IRoom | undefined): Promise<IMessage> => {
if (!room || !isOmnichannelRoom(room)) {
return message;
}
const { _id: rid, autoTransferredAt, autoTransferOngoing } = room;
const { token, t: messageType } = message;
if (messageType) {
// ignore system messages
return message;
}
if (!autoTransferTimeout || autoTransferTimeout <= 0) {
return message;
}
if (!rid || !message || rid === '' || t !== 'l' || token) {
if (!message || token) {
// ignore messages from visitors
return message;
}

@ -17,7 +17,6 @@ import './hooks/checkAgentBeforeTakeInquiry';
import './hooks/handleNextAgentPreferredEvents';
import './hooks/onCheckRoomParamsApi';
import './hooks/onLoadConfigApi';
import './hooks/onCloseLivechat';
import './hooks/onSaveVisitorInfo';
import './hooks/scheduleAutoTransfer';
import './hooks/resumeOnHold';

@ -69,7 +69,7 @@ class AutoTransferChatSchedulerClass {
await this.scheduler.cancel({ name: jobName });
}
private async transferRoom(roomId: string): Promise<boolean> {
private async transferRoom(roomId: string): Promise<void> {
this.logger.debug(`Transferring room ${roomId}`);
const room = await LivechatRooms.findOneById(roomId, {
_id: 1,
@ -79,7 +79,7 @@ class AutoTransferChatSchedulerClass {
departmentId: 1,
});
if (!room?.open || !room?.servedBy?._id) {
return false;
throw new Error('Room is not open or is not being served by an agent');
}
const {
@ -91,37 +91,42 @@ class AutoTransferChatSchedulerClass {
if (!RoutingManager.getConfig().autoAssignAgent) {
this.logger.debug(`Auto-assign agent is disabled, returning room ${roomId} as inquiry`);
return Livechat.returnRoomAsInquiry(room._id, departmentId, {
await Livechat.returnRoomAsInquiry(room._id, departmentId, {
scope: 'autoTransferUnansweredChatsToQueue',
comment: timeoutDuration,
transferredBy: await this.getSchedulerUser(),
});
return;
}
const agent = await RoutingManager.getNextAgent(departmentId, ignoreAgentId);
if (agent) {
this.logger.debug(`Transferring room ${roomId} to agent ${agent.agentId}`);
return forwardRoomToAgent(room, {
userId: agent.agentId,
transferredBy: await this.getSchedulerUser(),
transferredTo: agent,
scope: 'autoTransferUnansweredChatsToAgent',
comment: timeoutDuration,
});
if (!agent) {
this.logger.error(`No agent found to transfer room ${room._id} which hasn't been answered in ${timeoutDuration} seconds`);
return;
}
this.logger.debug(`No agent found to transfer room ${roomId}`);
return false;
this.logger.debug(`Transferring room ${roomId} to agent ${agent.agentId}`);
await forwardRoomToAgent(room, {
userId: agent.agentId,
transferredBy: await this.getSchedulerUser(),
transferredTo: agent,
scope: 'autoTransferUnansweredChatsToAgent',
comment: timeoutDuration,
});
}
private async executeJob({ attrs: { data } }: any = {}): Promise<void> {
const { roomId } = data;
if (await this.transferRoom(roomId)) {
await LivechatRooms.setAutoTransferredAtById(roomId);
}
try {
await this.transferRoom(roomId);
await this.unscheduleRoom(roomId);
await Promise.all([LivechatRooms.setAutoTransferredAtById(roomId), this.unscheduleRoom(roomId)]);
} catch (error) {
this.logger.error(`Error while executing job ${SCHEDULER_NAME} for room ${roomId}:`, error);
}
}
}

@ -3,17 +3,14 @@ import { Match, check } from 'meteor/check';
import {
LivechatInquiry,
Users,
LivechatRooms,
LivechatDepartment as LivechatDepartmentRaw,
OmnichannelServiceLevelAgreements,
LivechatTag,
LivechatUnitMonitors,
LivechatUnit,
} from '@rocket.chat/models';
import { Message } from '@rocket.chat/core-services';
import type {
IOmnichannelBusinessUnit,
IOmnichannelRoom,
IOmnichannelServiceLevelAgreements,
LivechatDepartmentDTO,
InquiryWithAgentInfo,
@ -27,9 +24,7 @@ import { processWaitingQueue, updateSLAInquiries } from './Helper';
import { removeSLAFromRooms } from './SlaHelper';
import { RoutingManager } from '../../../../../app/livechat/server/lib/RoutingManager';
import { settings } from '../../../../../app/settings/server';
import { logger, queueLogger } from './logger';
import { callbacks } from '../../../../../lib/callbacks';
import { AutoCloseOnHoldScheduler } from './AutoCloseOnHoldScheduler';
import { queueLogger } from './logger';
import { getInquirySortMechanismSetting } from '../../../../../app/livechat/server/lib/settings';
export const LivechatEnterprise = {
@ -191,33 +186,6 @@ export const LivechatEnterprise = {
await removeSLAFromRooms(_id);
},
async placeRoomOnHold(room: IOmnichannelRoom, comment: string, onHoldBy: { _id: string; username?: string; name?: string }) {
logger.debug(`Attempting to place room ${room._id} on hold by user ${onHoldBy?._id}`);
const { _id: roomId, onHold } = room;
if (!roomId || onHold) {
logger.debug(`Room ${roomId} invalid or already on hold. Skipping`);
return false;
}
await LivechatRooms.setOnHoldByRoomId(roomId);
await Message.saveSystemMessage('omnichannel_placed_chat_on_hold', roomId, '', onHoldBy, { comment });
await callbacks.run('livechat:afterOnHold', room);
logger.debug(`Room ${room._id} set on hold succesfully`);
return true;
},
async releaseOnHoldChat(room: IOmnichannelRoom) {
const { _id: roomId, onHold } = room;
if (!roomId || !onHold) {
return;
}
await AutoCloseOnHoldScheduler.unscheduleRoom(roomId);
await LivechatRooms.unsetOnHoldAndPredictedVisitorAbandonmentByRoomId(roomId);
},
/**
* @param {string|null} _id - The department id
* @param {Partial<import('@rocket.chat/core-typings').ILivechatDepartment>} departmentData

@ -1,10 +1,10 @@
import type { IOmnichannelRoom, IUser } from '@rocket.chat/core-typings';
import { LivechatVisitors, LivechatRooms, LivechatDepartment, Users } from '@rocket.chat/models';
import { OmnichannelEEService } from '@rocket.chat/core-services';
import { cronJobs } from '@rocket.chat/cron';
import { settings } from '../../../../../app/settings/server';
import { Livechat } from '../../../../../app/livechat/server/lib/LivechatTyped';
import { LivechatEnterprise } from './LivechatEnterprise';
import { i18n } from '../../../../../server/lib/i18n';
import { callbacks } from '../../../../../lib/callbacks';
import { schedulerLogger } from './logger';
@ -123,7 +123,7 @@ export class VisitorInactivityMonitor {
const comment = i18n.t('Omnichannel_On_Hold_due_to_inactivity', { guest, timeout });
const result = await Promise.allSettled([
LivechatEnterprise.placeRoomOnHold(room, comment, this.user),
OmnichannelEEService.placeRoomOnHold(room, comment, this.user),
LivechatRooms.unsetPredictedVisitorAbandonmentByRoomId(room._id),
]);
this.logger.debug(`Room ${room._id} placed on hold`);

@ -1,11 +1,13 @@
import { Meteor } from 'meteor/meteor';
import type { ILivechatVisitor } from '@rocket.chat/core-typings';
import type { ILivechatVisitor, IOmnichannelSystemMessage } from '@rocket.chat/core-typings';
import { isOmnichannelRoom } from '@rocket.chat/core-typings';
import { LivechatVisitors, LivechatInquiry, LivechatRooms, Users } from '@rocket.chat/models';
import { Message } from '@rocket.chat/core-services';
import type { ServerMethods } from '@rocket.chat/ui-contexts';
import { Message } from '@rocket.chat/core-services';
import { RoutingManager } from '../../../../../app/livechat/server/lib/RoutingManager';
import { callbacks } from '../../../../../lib/callbacks';
import { methodDeprecationLogger } from '../../../../../app/lib/server/lib/deprecationWarningLogger';
import { i18n } from '../../../../../server/lib/i18n';
async function resolveOnHoldCommentInfo(options: { clientAction: boolean }, room: any, onHoldChatResumedBy: any): Promise<string> {
@ -38,8 +40,12 @@ declare module '@rocket.chat/ui-contexts' {
Meteor.methods<ServerMethods>({
async 'livechat:resumeOnHold'(roomId, options = { clientAction: false }) {
methodDeprecationLogger.warn(
'Method "livechat:resumeOnHold" is deprecated and will be removed in next major version. Please use "livechat/room.resumeOnHold" API instead.',
);
const room = await LivechatRooms.findOneById(roomId);
if (!room || room.t !== 'l') {
if (!room || !isOmnichannelRoom(room)) {
throw new Meteor.Error('error-invalid-room', 'Invalid room', {
method: 'livechat:resumeOnHold',
});
@ -58,19 +64,30 @@ Meteor.methods<ServerMethods>({
});
}
const { servedBy: { _id: agentId, username } = {} } = room;
if (!room.servedBy) {
throw new Meteor.Error('error-unserved-rooms-cannot-be-placed-onhold', 'Error! Un-served rooms cannot be placed OnHold', {
method: 'livechat:resumeOnHold',
});
}
const {
servedBy: { _id: agentId, username },
} = room;
await RoutingManager.takeInquiry(inquiry, { agentId, username }, options);
const onHoldChatResumedBy = options.clientAction ? await Meteor.userAsync() : await Users.findOneById('rocket.cat');
if (!onHoldChatResumedBy) {
throw new Meteor.Error('error-invalid-user', 'Invalid user', { method: 'livechat:resumeOnHold' });
throw new Meteor.Error('error-invalid-user', 'Invalid user', {
method: 'livechat:resumeOnHold',
});
}
const comment = await resolveOnHoldCommentInfo(options, room, onHoldChatResumedBy);
await Message.saveSystemMessage('omnichannel_on_hold_chat_resumed', roomId, '', onHoldChatResumedBy, { comment });
await Message.saveSystemMessage<IOmnichannelSystemMessage>('omnichannel_on_hold_chat_resumed', roomId, '', onHoldChatResumedBy, {
comment,
});
const updatedRoom = await LivechatRooms.findOneById(roomId);
updatedRoom && setImmediate(() => callbacks.run('livechat:afterOnHoldChatResumed', updatedRoom));
setImmediate(() => callbacks.run('livechat:afterOnHoldChatResumed', room));
},
});

@ -0,0 +1,180 @@
import { ServiceClassInternal, Message } from '@rocket.chat/core-services';
import type { IOmnichannelEEService } from '@rocket.chat/core-services';
import { isOmnichannelRoom } from '@rocket.chat/core-typings';
import type { IOmnichannelRoom, IUser, ILivechatInquiryRecord, IOmnichannelSystemMessage } from '@rocket.chat/core-typings';
import { LivechatRooms, Subscriptions, LivechatInquiry } from '@rocket.chat/models';
import { Logger } from '../../../../../app/logger/server';
import { callbacks } from '../../../../../lib/callbacks';
import { RoutingManager } from '../../../../../app/livechat/server/lib/RoutingManager';
import { dispatchAgentDelegated } from '../../../../../app/livechat/server/lib/Helper';
import { queueInquiry } from '../../../../../app/livechat/server/lib/QueueManager';
export class OmnichannelEE extends ServiceClassInternal implements IOmnichannelEEService {
protected name = 'omnichannel-ee';
protected internal = true;
logger: Logger;
constructor() {
super();
this.logger = new Logger('OmnichannelEE');
}
async placeRoomOnHold(
room: Pick<IOmnichannelRoom, '_id' | 't' | 'open' | 'onHold'>,
comment: string,
onHoldBy: Pick<IUser, '_id' | 'username' | 'name'>,
) {
this.logger.debug(`Attempting to place room ${room._id} on hold by user ${onHoldBy?._id}`);
const { _id: roomId } = room;
if (!room || !isOmnichannelRoom(room)) {
throw new Error('error-invalid-room');
}
if (!room.open) {
throw new Error('error-room-already-closed');
}
if (room.onHold) {
throw new Error('error-room-is-already-on-hold');
}
if (room.lastMessage?.token) {
throw new Error('error-contact-sent-last-message-so-cannot-place-on-hold');
}
if (!room.servedBy) {
throw new Error('error-unserved-rooms-cannot-be-placed-onhold');
}
await Promise.all([
LivechatRooms.setOnHoldByRoomId(roomId),
Subscriptions.setOnHoldByRoomId(roomId),
Message.saveSystemMessage<IOmnichannelSystemMessage>('omnichannel_placed_chat_on_hold', roomId, '', onHoldBy, { comment }),
]);
await callbacks.run('livechat:afterOnHold', room);
this.logger.debug(`Room ${room._id} set on hold successfully`);
}
async resumeRoomOnHold(
room: Pick<IOmnichannelRoom, '_id' | 't' | 'open' | 'onHold' | 'servedBy'>,
comment: string,
resumeBy: Pick<IUser, '_id' | 'username' | 'name'>,
clientAction = false,
) {
this.logger.debug(`Attempting to resume room ${room._id} on hold by user ${resumeBy?._id}`);
if (!room || !isOmnichannelRoom(room)) {
throw new Error('error-invalid-room');
}
if (!room.open) {
throw new Error('This_conversation_is_already_closed');
}
if (!room.onHold) {
throw new Error('error-room-not-on-hold');
}
const { _id: roomId, servedBy } = room;
if (!servedBy) {
this.logger.error(`No serving agent found for room ${roomId}`);
throw new Error('error-room-not-served');
}
const inquiry = await LivechatInquiry.findOneByRoomId(roomId, {});
if (!inquiry) {
this.logger.error(`No inquiry found for room ${roomId}`);
throw new Error('error-invalid-inquiry');
}
await this.attemptToAssignRoomToServingAgentElseQueueIt({
room,
inquiry,
servingAgent: servedBy,
clientAction,
});
await Promise.all([
LivechatRooms.unsetOnHoldByRoomId(roomId),
Subscriptions.unsetOnHoldByRoomId(roomId),
Message.saveSystemMessage<IOmnichannelSystemMessage>('omnichannel_on_hold_chat_resumed', roomId, '', resumeBy, { comment }),
]);
await callbacks.run('livechat:afterOnHoldChatResumed', room);
this.logger.debug(`Room ${room._id} resumed successfully`);
}
private async attemptToAssignRoomToServingAgentElseQueueIt({
room,
inquiry,
servingAgent,
clientAction,
}: {
room: Pick<IOmnichannelRoom, '_id'>;
inquiry: ILivechatInquiryRecord;
servingAgent: NonNullable<IOmnichannelRoom['servedBy']>;
clientAction: boolean;
}) {
try {
const agent = {
agentId: servingAgent._id,
username: servingAgent.username,
};
await callbacks.run('livechat.checkAgentBeforeTakeInquiry', {
agent,
inquiry,
options: {},
});
return;
} catch (e) {
this.logger.debug(`Agent ${servingAgent._id} is not available to take the inquiry ${inquiry._id}`, e);
if (clientAction) {
// if the action was triggered by the client, we should throw the error
// so the client can handle it and show the error message to the user
throw e;
}
}
this.logger.debug(`Attempting to queue inquiry ${inquiry._id}`);
await this.removeCurrentAgentFromRoom({ room, inquiry });
const { _id: inquiryId } = inquiry;
const newInquiry = await LivechatInquiry.findOneById(inquiryId);
await queueInquiry(newInquiry);
this.logger.debug('Room queued successfully');
}
private async removeCurrentAgentFromRoom({
room,
inquiry,
}: {
room: Pick<IOmnichannelRoom, '_id'>;
inquiry: ILivechatInquiryRecord;
}): Promise<void> {
this.logger.debug(`Attempting to remove current agent from room ${room._id}`);
const { _id: roomId } = room;
const { _id: inquiryId } = inquiry;
await Promise.all([
LivechatRooms.removeAgentByRoomId(roomId),
LivechatInquiry.queueInquiryAndRemoveDefaultAgent(inquiryId),
RoutingManager.removeAllRoomSubscriptions(room),
]);
await dispatchAgentDelegated(roomId, null);
this.logger.debug(`Current agent removed from room ${room._id} successfully`);
}
}

@ -6,6 +6,7 @@ import { MessageReadsService } from '../local-services/message-reads/service';
import { InstanceService } from '../local-services/instance/service';
import { LicenseService } from '../../app/license/server/license.internalService';
import { isRunningMs } from '../../../server/lib/isRunningMs';
import { OmnichannelEE } from '../../app/livechat-enterprise/server/services/omnichannel.internalService';
import { FederationService } from '../../../server/services/federation/service';
import { FederationServiceEE } from '../local-services/federation/service';
import { isEnterprise, onLicense } from '../../app/license/server';
@ -15,6 +16,7 @@ api.registerService(new EnterpriseSettings());
api.registerService(new LDAPEEService());
api.registerService(new LicenseService());
api.registerService(new MessageReadsService());
api.registerService(new OmnichannelEE());
// when not running micro services we want to start up the instance intercom
if (!isRunningMs()) {

@ -125,8 +125,8 @@ type ChainedCallbackSignatures = {
'beforeSaveMessage': (message: IMessage, room?: IRoom) => IMessage;
'afterCreateUser': (user: IUser) => IUser;
'afterDeleteRoom': (rid: IRoom['_id']) => IRoom['_id'];
'livechat:afterOnHold': (room: IRoom) => IRoom;
'livechat:afterOnHoldChatResumed': (room: IRoom) => IRoom;
'livechat:afterOnHold': (room: Pick<IOmnichannelRoom, '_id'>) => Pick<IOmnichannelRoom, '_id'>;
'livechat:afterOnHoldChatResumed': (room: Pick<IOmnichannelRoom, '_id'>) => Pick<IOmnichannelRoom, '_id'>;
'livechat:onTransferFailure': (params: { room: IRoom; guest: ILivechatVisitor; transferData: { [k: string]: string | any } }) => {
room: IRoom;
guest: ILivechatVisitor;

@ -2028,6 +2028,8 @@
"error-room-already-closed": "Room is already closed",
"error-room-is-not-closed": "Room is not closed",
"error-room-onHold": "Error! Room is On Hold",
"error-room-is-already-on-hold": "Error! Room is already On Hold",
"error-room-not-on-hold": "Error! Room is not On Hold",
"error-selected-agent-room-agent-are-same": "The selected agent and the room agent are the same",
"error-starring-message": "Message could not be stared",
"error-tags-must-be-assigned-before-closing-chat": "Tag(s) must be assigned before closing the chat",
@ -2055,6 +2057,8 @@
"error-you-are-last-owner": "You are the last owner. Please set new owner before leaving the room.",
"error-saving-sla": "An error ocurred while saving the SLA",
"error-duplicated-sla": "An SLA with the same name or due time already exists",
"error-contact-sent-last-message-so-cannot-place-on-hold": "You cannot place chat on-hold, when the Contact has sent the last message",
"error-unserved-rooms-cannot-be-placed-onhold": "Room cannot be placed on hold before being served",
"You_do_not_have_permission_to_do_this": "You do not have permission to do this",
"Errors_and_Warnings": "Errors and Warnings",
"Esc_to": "Esc to",
@ -3055,7 +3059,7 @@
"Livechat_OfflineMessageToChannel_enabled": "Send Livechat offline messages to a channel",
"Omnichannel_on_hold_chat_resumed": "On Hold Chat Resumed: {{comment}}",
"Omnichannel_on_hold_chat_automatically": "The chat was automatically resumed from On Hold upon receiving a new message from {{guest}}",
"Omnichannel_on_hold_chat_manually": "The chat was manually resumed from On Hold by {{user}}",
"Omnichannel_on_hold_chat_resumed_manually": "The chat was manually resumed from On Hold by {{user}}",
"Omnichannel_On_Hold_due_to_inactivity": "The chat was automatically placed On Hold because we haven't received any reply from {{guest}} in {{timeout}} seconds",
"Omnichannel_On_Hold_manually": "The chat was manually placed On Hold by {{user}}",
"Omnichannel_onHold_Chat": "Place chat On-Hold",

@ -539,6 +539,14 @@ export class SubscriptionsRaw extends BaseRaw<ISubscription> implements ISubscri
return this.updateOne(query, { $unset: { E2ESuggestedKey: 1 } });
}
setOnHoldByRoomId(rid: string): Promise<UpdateResult> {
return this.updateOne({ rid }, { $set: { onHold: true } });
}
unsetOnHoldByRoomId(rid: string): Promise<UpdateResult> {
return this.updateOne({ rid }, { $unset: { onHold: 1 } });
}
findByRoomIds(roomIds: string[]): FindCursor<ISubscription> {
const query = {
rid: {

@ -319,3 +319,11 @@ export const startANewLivechatRoomAndTakeIt = async ({
return { room, visitor };
};
export const placeRoomOnHold = async (roomId: string): Promise<void> => {
await request
.post(api('livechat/room.onHold'))
.set(credentials)
.send({ roomId })
.expect(200);
}

@ -1152,7 +1152,7 @@ describe('LIVECHAT - rooms', function () {
expect(body).to.have.property('success', true);
expect(body).to.have.property('messages');
expect(body.messages).to.be.an('array');
expect(body.messages.length <= 3).to.be.true;
expect(body.messages.length <= 4).to.be.true;
expect(body.messages[0]).to.have.property('msg', 'Hello');
expect(body.messages[1]).to.have.property('t');
});
@ -1204,7 +1204,7 @@ describe('LIVECHAT - rooms', function () {
expect(body).to.have.property('success', true);
expect(body).to.have.property('messages').that.is.an('array');
expect(body.messages.length <= 3).to.be.true;
expect(body.messages.length <= 4).to.be.true;
expect(body.messages[0]).to.have.property('msg', 'Hello');
expect(body.messages[1]).to.have.property('t');
});

@ -1,10 +1,25 @@
/* eslint-env mocha */
import type { IUser } from '@rocket.chat/core-typings';
import { expect } from 'chai';
import { getCredentials, api, request, credentials } from '../../../data/api-data';
import { createVisitor, createLivechatRoom, sendMessage, closeOmnichannelRoom } from '../../../data/livechat/rooms';
import {
createVisitor,
createLivechatRoom,
sendMessage,
sendAgentMessage,
placeRoomOnHold,
getLivechatRoomInfo,
startANewLivechatRoomAndTakeIt,
makeAgentAvailable,
createAgent,
closeOmnichannelRoom,
} from '../../../data/livechat/rooms';
import { sleep } from '../../../data/livechat/utils';
import { updatePermission, updateSetting } from '../../../data/permissions.helper';
import { password } from '../../../data/user';
import { createUser, login } from '../../../data/users.helper';
import { IS_EE } from '../../../e2e/config/constants';
(IS_EE ? describe : describe.skip)('[EE] LIVECHAT - rooms', function () {
@ -12,8 +27,24 @@ import { IS_EE } from '../../../e2e/config/constants';
before((done) => getCredentials(done));
let agent2: { user: IUser; credentials: { 'X-Auth-Token': string; 'X-User-Id': string } };
before(async () => {
await updateSetting('Livechat_enabled', true);
await updateSetting('Livechat_Routing_Method', 'Manual_Selection');
await createAgent();
await makeAgentAvailable();
});
before(async () => {
const user: IUser = await createUser();
const userCredentials = await login(user.username, password);
await createAgent(user.username);
agent2 = {
user,
credentials: userCredentials,
};
});
describe('livechat/room.onHold', () => {
@ -28,9 +59,10 @@ import { IS_EE } from '../../../e2e/config/constants';
.expect(403);
expect(response.body.success).to.be.false;
await updatePermission('on-hold-livechat-room', ['livechat-manager', 'livechat-monitor', 'livechat-agent', 'admin']);
});
it('should fail if roomId is invalid', async () => {
await updatePermission('on-hold-livechat-room', ['admin']);
const response = await request
.post(api('livechat/room.onHold'))
.set(credentials)
@ -40,6 +72,7 @@ import { IS_EE } from '../../../e2e/config/constants';
.expect(400);
expect(response.body.success).to.be.false;
expect(response.body.error).to.be.equal('error-invalid-room');
});
it('should fail if room is an empty string', async () => {
const response = await request
@ -51,6 +84,7 @@ import { IS_EE } from '../../../e2e/config/constants';
.expect(400);
expect(response.body.success).to.be.false;
expect(response.body.error).to.be.equal('error-invalid-room');
});
it('should fail if room is not a livechat room', async () => {
const response = await request
@ -62,6 +96,7 @@ import { IS_EE } from '../../../e2e/config/constants';
.expect(400);
expect(response.body.success).to.be.false;
expect(response.body.error).to.be.equal('error-invalid-room');
});
it('should fail if visitor is awaiting response (visitor sent last message)', async () => {
const visitor = await createVisitor();
@ -77,6 +112,7 @@ import { IS_EE } from '../../../e2e/config/constants';
.expect(400);
expect(response.body.success).to.be.false;
expect(response.body.error).to.be.equal('error-contact-sent-last-message-so-cannot-place-on-hold');
});
it('should fail if room is closed', async () => {
const visitor = await createVisitor();
@ -92,26 +128,26 @@ import { IS_EE } from '../../../e2e/config/constants';
.expect(400);
expect(response.body.success).to.be.false;
expect(response.body.error).to.be.equal('error-room-already-closed');
});
it('should fail if user is not serving the chat and doesnt have on-hold-others-livechat-room permission', async () => {
await updatePermission('on-hold-others-livechat-room', []);
const visitor = await createVisitor();
const room = await createLivechatRoom(visitor.token);
const { room } = await startANewLivechatRoomAndTakeIt();
await sendAgentMessage(room._id);
const response = await request
.post(api('livechat/room.onHold'))
.set(credentials)
.set(agent2.credentials)
.send({
roomId: room._id,
})
.expect(400);
expect(response.body.success).to.be.false;
expect(response.body.error).to.be.equal('Not_authorized');
});
it('should put room on hold', async () => {
await updatePermission('on-hold-others-livechat-room', ['admin', 'livechat-manager']);
const visitor = await createVisitor();
const room = await createLivechatRoom(visitor.token);
const { room } = await startANewLivechatRoomAndTakeIt();
await sendAgentMessage(room._id);
const response = await request
.post(api('livechat/room.onHold'))
@ -122,6 +158,97 @@ import { IS_EE } from '../../../e2e/config/constants';
.expect(200);
expect(response.body.success).to.be.true;
const updatedRoom = await getLivechatRoomInfo(room._id);
expect(updatedRoom.onHold).to.be.true;
});
});
describe('livechat/room.resumeOnHold', () => {
it('should fail if user doesnt have view-l-room permission', async () => {
await updatePermission('view-l-room', []);
const response = await request
.post(api('livechat/room.resumeOnHold'))
.set(credentials)
.send({
roomId: 'invalid-room-id',
})
.expect(403);
expect(response.body.success).to.be.false;
});
it('should fail if roomId is invalid', async () => {
await updatePermission('view-l-room', ['admin', 'livechat-manager', 'livechat-agent']);
const response = await request
.post(api('livechat/room.resumeOnHold'))
.set(credentials)
.send({
roomId: 'invalid-room-id',
})
.expect(400);
expect(response.body.success).to.be.false;
expect(response.body.error).to.be.equal('error-invalid-room');
});
it('should fail if room is not a livechat room', async () => {
const response = await request
.post(api('livechat/room.resumeOnHold'))
.set(credentials)
.send({
roomId: 'GENERAL',
})
.expect(400);
expect(response.body.success).to.be.false;
expect(response.body.error).to.be.equal('error-invalid-room');
});
it('should fail if room is not on hold', async () => {
const { room } = await startANewLivechatRoomAndTakeIt();
const response = await request
.post(api('livechat/room.resumeOnHold'))
.set(credentials)
.send({
roomId: room._id,
})
.expect(400);
expect(response.body.success).to.be.false;
expect(response.body.error).to.be.equal('error-room-not-on-hold');
});
it('should resume room on hold', async () => {
const { room } = await startANewLivechatRoomAndTakeIt();
await sendAgentMessage(room._id);
await placeRoomOnHold(room._id);
const response = await request
.post(api('livechat/room.resumeOnHold'))
.set(credentials)
.send({
roomId: room._id,
})
.expect(200);
expect(response.body.success).to.be.true;
const updatedRoom = await getLivechatRoomInfo(room._id);
expect(updatedRoom).to.not.have.property('onHold');
});
it('should resume chat automatically if visitor sent a message', async () => {
const { room, visitor } = await startANewLivechatRoomAndTakeIt();
await sendAgentMessage(room._id);
await placeRoomOnHold(room._id);
await sendMessage(room._id, 'test', visitor.token);
// wait for the room to be resumed since that logic is within callbacks
await sleep(500);
const updatedRoom = await getLivechatRoomInfo(room._id);
expect(updatedRoom).to.not.have.property('onHold');
});
});
});

@ -41,6 +41,7 @@ import type { IQueueWorkerService, HealthAggResult } from './types/IQueueWorkerS
import type { ITranslationService } from './types/ITranslationService';
import type { IMessageService } from './types/IMessageService';
import type { ISettingsService } from './types/ISettingsService';
import type { IOmnichannelEEService } from './types/IOmnichannelEEService';
import type { IOmnichannelIntegrationService } from './types/IOmnichannelIntegrationService';
export { asyncLocalStorage } from './lib/asyncLocalStorage';
@ -113,6 +114,7 @@ export {
ITranslationService,
IMessageService,
ISettingsService,
IOmnichannelEEService,
IOmnichannelIntegrationService,
};
@ -146,6 +148,7 @@ export const Settings = proxifyWithWait<ISettingsService>('settings');
export const OmnichannelIntegration = proxifyWithWait<IOmnichannelIntegrationService>('omnichannel-integration');
export const Federation = proxifyWithWait<IFederationService>('federation');
export const FederationEE = proxifyWithWait<IFederationServiceEE>('federation-enterprise');
export const OmnichannelEEService = proxifyWithWait<IOmnichannelEEService>('omnichannel-ee');
// Calls without wait. Means that the service is optional and the result may be an error
// of service/method not available

@ -0,0 +1,18 @@
import type { IOmnichannelRoom, IUser } from '@rocket.chat/core-typings';
import type { IServiceClass } from './ServiceClass';
export interface IOmnichannelEEService extends IServiceClass {
placeRoomOnHold(
room: Pick<IOmnichannelRoom, '_id' | 't' | 'open' | 'onHold'>,
comment: string,
onHoldBy: Pick<IUser, '_id' | 'username' | 'name'>,
): Promise<void>;
resumeRoomOnHold(
room: Pick<IOmnichannelRoom, '_id' | 't' | 'open' | 'onHold' | 'servedBy'>,
comment: string,
resumeBy: Pick<IUser, '_id' | 'username' | 'name'>,
clientAction?: boolean,
): Promise<void>;
}

@ -302,7 +302,7 @@ export type IOmnichannelRoomClosingInfo = Pick<IOmnichannelGenericRoom, 'closer'
chatDuration: number;
};
export const isOmnichannelRoom = (room: IRoom): room is IOmnichannelRoom & IRoom => room.t === 'l';
export const isOmnichannelRoom = (room: Pick<IRoom, 't'>): room is IOmnichannelRoom & IRoom => room.t === 'l';
export const isVoipRoom = (room: IRoom): room is IVoipRoom & IRoom => room.t === 'v';

@ -94,6 +94,7 @@ export interface IMessagesModel extends IBaseModel<IMessage> {
): FindCursor<IMessage>;
findLivechatClosingMessage(rid: IRoom['_id'], options?: FindOptions<IMessage>): Promise<IMessage | null>;
setReactions(messageId: string, reactions: IMessage['reactions']): Promise<UpdateResult>;
keepHistoryForToken(token: string): Promise<UpdateResult | Document>;
setRoomIdByToken(token: string, rid: string): Promise<UpdateResult | Document>;

@ -90,6 +90,9 @@ export interface ISubscriptionsModel extends IBaseModel<ISubscription> {
unsetGroupE2ESuggestedKey(_id: string): Promise<UpdateResult | Document>;
setOnHoldByRoomId(roomId: string): Promise<UpdateResult>;
unsetOnHoldByRoomId(roomId: string): Promise<UpdateResult>;
updateUnreadAlertById(_id: string, unreadAlert: ISubscription['unreadAlert']): Promise<UpdateResult>;
updateNotificationsPrefById(
_id: string,

@ -71,6 +71,23 @@ const LivechatRoomOnHoldSchema = {
export const isLivechatRoomOnHoldProps = ajv.compile<LivechatRoomOnHold>(LivechatRoomOnHoldSchema);
type LivechatRoomResumeOnHold = {
roomId: IRoom['_id'];
};
const LivechatRoomResumeOnHoldSchema = {
type: 'object',
properties: {
roomId: {
type: 'string',
},
},
required: ['roomId'],
additionalProperties: false,
};
export const isLivechatRoomResumeOnHoldProps = ajv.compile<LivechatRoomResumeOnHold>(LivechatRoomResumeOnHoldSchema);
type LivechatDepartmentId = {
onlyMyDepartments?: booleanString;
includeAgents?: booleanString;
@ -2970,6 +2987,9 @@ export type OmnichannelEndpoints = {
'/v1/livechat/room.onHold': {
POST: (params: LivechatRoomOnHold) => void;
};
'/v1/livechat/room.resumeOnHold': {
POST: (params: LivechatRoomResumeOnHold) => void;
};
'/v1/livechat/room.join': {
GET: (params: LiveChatRoomJoin) => void;
};

Loading…
Cancel
Save