[NEW][Enterprise] Omnichannel On-Hold Queue (#20945)

* Add settings for on-hold feature

* Add On-Hold Section within Sidebar

* On hold room UI

* OnHold - Automatic

* Add Manual on-hold button UI

* OnHold - manual

- Start manual On-Hold timer after agent reply
- Stop manual On-Hold timer on visitor message
- Show manual On-Hold Option when timer expires

* Handle manual On-Hold event

* Add permissions to manual on-hold feature

* [New] Auto-Close On hold chats

* Routing chat when an on-hold get is resumed

* Apply suggestions from code review

* Apply suggestions from code review

* Add migration

* Add new endpoint - livechat/placeChatOnHold

* Move Resume Button login within livechatReadOnly file

* Remove timeout on Manual On-Hold feature

- From now on, the On-Hold option will appear within visitor Info panel, provided the agent has sent the last message

* Apply suggestions from code review

* Move resume On-Hold chat logic inside Queue Manager

* Apply suggestions from code review

Co-authored-by: Renato Becker <renato.augusto.becker@gmail.com>

* Apply suggestions from code review

* Use takeInquiry() to resume On-Hold chats

* Fix failing test case

* Minor improvements.

* [Regression] Omnichannel On Hold feature - handle following impacted events

- Returning chat to the queue
- Forwarding chats
- Closing chats

* Fix failing test cases

* Revert "[Regression] Omnichannel On hold Queue"

* Prevent on hold chat from being returned or forwarded

(cherry picked from commit 2edcb8234224ebad2b479282da3f9be6a5626db6)

* Move checks to Livechat methods

* Add releaseOnHoldChat() method

* Fix callback returning promise.

Co-authored-by: Renato Becker <renato.augusto.becker@gmail.com>
pull/20512/merge
Murtaza Patrawala 4 years ago committed by GitHub
parent 6acb8ed371
commit 297dc068ce
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 2
      app/authorization/server/startup.js
  2. 15
      app/livechat/client/views/app/livechatReadOnly.html
  3. 17
      app/livechat/client/views/app/livechatReadOnly.js
  4. 4
      app/livechat/client/views/app/tabbar/visitorInfo.html
  5. 30
      app/livechat/client/views/app/tabbar/visitorInfo.js
  6. 8
      app/livechat/server/lib/Livechat.js
  7. 2
      app/livechat/server/lib/QueueManager.js
  8. 13
      app/livechat/server/lib/RoutingManager.js
  9. 4
      app/livechat/server/methods/takeInquiry.js
  10. 13
      app/models/server/models/LivechatInquiry.js
  11. 16
      app/models/server/models/Subscriptions.js
  12. 2
      app/models/server/raw/Users.js
  13. 4
      app/theme/client/imports/components/message-box.css
  14. 2
      app/ui-message/client/messageBox/messageBox.html
  15. 9
      app/ui-message/client/messageBox/messageBox.js
  16. 13
      client/sidebar/hooks/useRoomList.ts
  17. 2
      definition/IRoom.ts
  18. 2
      definition/ISubscription.ts
  19. 1
      ee/app/livechat-enterprise/server/api/index.js
  20. 40
      ee/app/livechat-enterprise/server/api/rooms.js
  21. 36
      ee/app/livechat-enterprise/server/hooks/afterOnHold.ts
  22. 12
      ee/app/livechat-enterprise/server/hooks/afterOnHoldChatResumed.ts
  23. 6
      ee/app/livechat-enterprise/server/hooks/checkAgentBeforeTakeInquiry.js
  24. 2
      ee/app/livechat-enterprise/server/hooks/index.js
  25. 25
      ee/app/livechat-enterprise/server/hooks/onAgentAssignmentFailed.ts
  26. 3
      ee/app/livechat-enterprise/server/hooks/onCloseLivechat.js
  27. 36
      ee/app/livechat-enterprise/server/hooks/resumeOnHold.js
  28. 2
      ee/app/livechat-enterprise/server/hooks/setPredictedVisitorAbandonmentTime.js
  29. 4
      ee/app/livechat-enterprise/server/index.js
  30. 67
      ee/app/livechat-enterprise/server/lib/AutoCloseOnHoldScheduler.ts
  31. 8
      ee/app/livechat-enterprise/server/lib/Helper.js
  32. 29
      ee/app/livechat-enterprise/server/lib/LivechatEnterprise.js
  33. 21
      ee/app/livechat-enterprise/server/lib/VisitorInactivityMonitor.js
  34. 30
      ee/app/livechat-enterprise/server/methods/resumeOnHold.ts
  35. 2
      ee/app/livechat-enterprise/server/permissions.js
  36. 75
      ee/app/livechat-enterprise/server/settings.js
  37. 4
      ee/app/livechat-enterprise/server/startup.js
  38. 33
      ee/app/models/server/models/LivechatRooms.js
  39. 19
      packages/rocketchat-i18n/i18n/en.i18n.json
  40. 1
      server/modules/watchers/publishFields.ts
  41. 1
      server/startup/migrations/index.js
  42. 34
      server/startup/migrations/v219.js

@ -96,6 +96,8 @@ Meteor.startup(function() {
{ _id: 'view-livechat-rooms', roles: ['livechat-manager', 'admin'] },
{ _id: 'close-livechat-room', roles: ['livechat-agent', 'livechat-manager', 'admin'] },
{ _id: 'close-others-livechat-room', roles: ['livechat-manager', 'admin'] },
{ _id: 'on-hold-livechat-room', roles: ['livechat-agent', 'livechat-manager', 'admin'] },
{ _id: 'on-hold-others-livechat-room', roles: ['livechat-manager', 'admin'] },
{ _id: 'save-others-livechat-room-info', roles: ['livechat-manager'] },
{ _id: 'remove-closed-livechat-rooms', roles: ['livechat-manager', 'admin'] },
{ _id: 'view-livechat-analytics', roles: ['livechat-manager', 'admin'] },

@ -4,13 +4,20 @@
{{#if isPreparing}}
{{> loading}}
{{else}}
{{#if inquiryOpen}}
{{#if isOnHold}}
<div class="rc-message-box__join">
{{{_ "you_are_in_preview_mode_of_incoming_livechat"}}}
<button class="rc-button rc-button--primary rc-button--small rc-message-box__take-it-button js-take-it">{{_ "Take_it"}}</button>
{{{_ "chat_on_hold_due_to_inactivity"}}}
<button class="rc-button rc-button--primary rc-button--small rc-message-box__resume-it-button js-resume-it">{{_ "Resume"}}</button>
</div>
{{else}}
{{_ "room_is_read_only"}}
{{#if inquiryOpen}}
<div class="rc-message-box__join">
{{{_ "you_are_in_preview_mode_of_incoming_livechat"}}}
<button class="rc-button rc-button--primary rc-button--small rc-message-box__take-it-button js-take-it">{{_ "Take_it"}}</button>
</div>
{{else}}
{{_ "room_is_read_only"}}
{{/if}}
{{/if}}
{{/if}}
{{/if}}

@ -22,12 +22,16 @@ Template.livechatReadOnly.helpers({
showPreview() {
const config = Template.instance().routingConfig.get();
return config.previewRoom;
return config.previewRoom || Template.currentData().onHold;
},
isPreparing() {
return Template.instance().preparing.get();
},
isOnHold() {
return Template.currentData().onHold;
},
});
Template.livechatReadOnly.events({
@ -37,9 +41,18 @@ Template.livechatReadOnly.events({
const inquiry = instance.inquiry.get();
const { _id } = inquiry;
await call('livechat:takeInquiry', _id);
await call('livechat:takeInquiry', _id, { clientAction: true });
instance.loadInquiry(inquiry.rid);
},
async 'click .js-resume-it'(event, instance) {
event.preventDefault();
event.stopPropagation();
const room = instance.room.get();
await call('livechat:resumeOnHold', room._id, { clientAction: true });
},
});
Template.livechatReadOnly.onCreated(function() {

@ -89,6 +89,10 @@
{{#if canSendTranscript}}
<button class="button rc-button rc-button--secondary button-block send-transcript"><span><i class="icon-mail"></i> {{_ "Transcript"}}</span></button>
{{/if}}
{{#if canPlaceChatOnHold}}
<button class="button rc-button rc-button--secondary button-block on-hold"><span><i class="icon-pause"></i> {{_ "On_Hold_Chats"}}</span></button>
{{/if}}
</nav>
{{/if}}

@ -206,6 +206,11 @@ Template.visitorInfo.helpers({
return !room.email && hasPermission('send-omnichannel-chat-transcript');
},
canPlaceChatOnHold() {
const room = Template.instance().room.get();
return room.open && !room.onHold && room.servedBy && room.lastMessage && !room.lastMessage?.token && settings.get('Livechat_allow_manual_on_hold');
},
roomClosedDateTime() {
const { closedAt } = this;
return DateFormat.formatDateAndTime(closedAt);
@ -324,6 +329,31 @@ Template.visitorInfo.events({
instance.action.set('transcript');
},
'click .on-hold'(event) {
event.preventDefault();
modal.open({
title: t('Would_you_like_to_place_chat_on_hold'),
type: 'warning',
showCancelButton: true,
confirmButtonColor: '#3085d6',
cancelButtonColor: '#d33',
confirmButtonText: t('Yes'),
},
async () => {
const { success } = await APIClient.v1.post('livechat/room.onHold', { roomId: this.rid });
if (success) {
modal.open({
title: t('Chat_On_Hold'),
text: t('Chat_On_Hold_Successfully'),
type: 'success',
timer: 1500,
showConfirmButton: false,
});
}
});
},
});
Template.visitorInfo.onCreated(function() {

@ -589,6 +589,10 @@ export const Livechat = {
},
async transfer(room, guest, transferData) {
if (room.onHold) {
throw new Meteor.Error('error-room-onHold', 'Room On Hold', { method: 'livechat:transfer' });
}
if (transferData.departmentId) {
transferData.department = LivechatDepartment.findOneById(transferData.departmentId, { fields: { name: 1 } });
}
@ -606,6 +610,10 @@ export const Livechat = {
throw new Meteor.Error('room-closed', 'Room closed', { method: 'livechat:returnRoomAsInquiry' });
}
if (room.onHold) {
throw new Meteor.Error('error-room-onHold', 'Room On Hold', { method: 'livechat:returnRoomAsInquiry' });
}
if (!room.servedBy) {
return false;
}

@ -7,7 +7,7 @@ import { callbacks } from '../../../callbacks/server';
import { RoutingManager } from './RoutingManager';
const queueInquiry = async (room, inquiry, defaultAgent) => {
export const queueInquiry = async (room, inquiry, defaultAgent) => {
const inquiryAgent = RoutingManager.delegateAgent(defaultAgent, inquiry);
await callbacks.run('livechat.beforeRouteChat', inquiry, inquiryAgent);
inquiry = LivechatInquiry.findOneById(inquiry._id);

@ -13,7 +13,7 @@ import {
allowAgentSkipQueue,
} from './Helper';
import { callbacks } from '../../../callbacks/server';
import { LivechatRooms, Rooms, Messages, Users, LivechatInquiry } from '../../../models/server';
import { LivechatRooms, Rooms, Messages, Users, LivechatInquiry, Subscriptions } from '../../../models/server';
import { Apps, AppEvents } from '../../../apps/server';
export const RoutingManager = {
@ -110,7 +110,7 @@ export const RoutingManager = {
return true;
},
async takeInquiry(inquiry, agent) {
async takeInquiry(inquiry, agent, options = { clientAction: false }) {
check(agent, Match.ObjectIncluding({
agentId: String,
username: String,
@ -128,15 +128,20 @@ export const RoutingManager = {
return room;
}
if (room.servedBy && room.servedBy._id === agent.agentId) {
if (room.servedBy && room.servedBy._id === agent.agentId && !room.onHold) {
return room;
}
agent = await callbacks.run('livechat.checkAgentBeforeTakeInquiry', agent, inquiry);
agent = await callbacks.run('livechat.checkAgentBeforeTakeInquiry', { agent, inquiry, options });
if (!agent) {
await callbacks.run('livechat.onAgentAssignmentFailed', { inquiry, room, options });
return null;
}
if (room.onHold) {
Subscriptions.removeByRoomIdAndUserId(room._id, agent.agentId);
}
LivechatInquiry.takeInquiry(_id);
const inq = this.assignAgent(inquiry, agent);

@ -6,7 +6,7 @@ import { RoutingManager } from '../lib/RoutingManager';
import { userCanTakeInquiry } from '../lib/Helper';
Meteor.methods({
'livechat:takeInquiry'(inquiryId) {
'livechat:takeInquiry'(inquiryId, options) {
if (!Meteor.userId() || !hasPermission(Meteor.userId(), 'view-l-room')) {
throw new Meteor.Error('error-not-allowed', 'Not allowed', { method: 'livechat:takeInquiry' });
}
@ -27,6 +27,6 @@ Meteor.methods({
username: user.username,
};
return RoutingManager.takeInquiry(inquiry, agent);
return RoutingManager.takeInquiry(inquiry, agent, options);
},
});

@ -73,6 +73,19 @@ export class LivechatInquiry extends Base {
});
}
/*
* mark inquiry as ready
*/
readyInquiry(inquiryId) {
return this.update({
_id: inquiryId,
}, {
$set: {
status: 'ready',
},
});
}
changeDepartmentIdByRoomId(rid, department) {
const query = {
rid,

@ -1330,6 +1330,22 @@ export class Subscriptions extends Base {
return this.update(query, update, { multi: true });
}
setOnHold(roomId) {
return this.update(
{ rid: roomId },
{ $set: { onHold: true } },
{ multi: true },
);
}
unsetOnHold(roomId) {
return this.update(
{ rid: roomId },
{ $unset: { onHold: 1 } },
{ multi: true },
);
}
}
export default new Subscriptions('subscription', true);

@ -191,7 +191,7 @@ export class UsersRaw extends BaseRaw {
const aggregate = [
{ $match: { _id: userId, status: { $exists: true, $ne: 'offline' }, statusLivechat: 'available', roles: 'livechat-agent' } },
{ $lookup: { from: 'rocketchat_subscription', localField: '_id', foreignField: 'u._id', as: 'subs' } },
{ $project: { agentId: '$_id', username: 1, lastAssignTime: 1, lastRoutingTime: 1, 'queueInfo.chats': { $size: { $filter: { input: '$subs', as: 'sub', cond: { $eq: ['$$sub.t', 'l'] } } } } } },
{ $project: { agentId: '$_id', username: 1, lastAssignTime: 1, lastRoutingTime: 1, 'queueInfo.chats': { $size: { $filter: { input: '$subs', as: 'sub', cond: { $and: [{ $eq: ['$$sub.t', 'l'] }, { $eq: ['$$sub.open', true] }, { $ne: ['$$sub.onHold', true] }] } } } } } },
{ $sort: { 'queueInfo.chats': 1, lastAssignTime: 1, lastRoutingTime: 1, username: 1 } },
];

@ -270,6 +270,10 @@
margin: 0 0.5rem;
}
&__resume-it-button {
margin: 0 0.5rem;
}
&__cannot-send {
display: flex;
justify-content: space-between;

@ -84,7 +84,7 @@
{{#if isBlockedOrBlocker}}
{{_ "room_is_blocked"}}
{{else}}
{{> messageBoxReadOnly rid=rid isSubscribed=isSubscribed}}
{{> messageBoxReadOnly rid=rid isSubscribed=isSubscribed onHold=onHold }}
{{/if}}
</div>
{{/if}}

@ -120,7 +120,6 @@ Template.messageBox.onRendered(function() {
}
$input.on('dataChange', () => {
const messages = $input.data('reply') || [];
console.log('dataChange', messages);
this.replyMessageData.set(messages);
});
}
@ -214,6 +213,10 @@ Template.messageBox.helpers({
return false;
}
if (subscription?.onHold) {
return false;
}
const isReadOnly = roomTypes.readOnly(rid, Users.findOne({ _id: Meteor.userId() }, { fields: { username: 1 } }));
const isArchived = roomTypes.archived(rid) || (subscription && subscription.t === 'd' && subscription.archived);
@ -256,6 +259,10 @@ Template.messageBox.helpers({
isBlockedOrBlocker() {
return Template.instance().state.get('isBlockedOrBlocker');
},
onHold() {
const { rid, subscription } = Template.currentData();
return rid && !!subscription?.onHold;
},
isSubscribed() {
const { subscription } = Template.currentData();
return !!subscription;

@ -33,6 +33,7 @@ export const useRoomList = (): Array<ISubscription> => {
const direct = new Set();
const discussion = new Set();
const conversation = new Set();
const onHold = new Set();
rooms.forEach((room) => {
if (sidebarShowUnread && (room.alert || room.unread) && !room.hideUnreadStatus) {
@ -55,6 +56,10 @@ export const useRoomList = (): Array<ISubscription> => {
_private.add(room);
}
if (room.t === 'l' && room.onHold) {
return showOmnichannel && onHold.add(room);
}
if (room.t === 'l') {
return showOmnichannel && omnichannel.add(room);
}
@ -66,11 +71,13 @@ export const useRoomList = (): Array<ISubscription> => {
conversation.add(room);
});
const groups = new Map();
showOmnichannel && inquiries.enabled && groups.set('Omnichannel', []);
showOmnichannel && !inquiries.enabled && groups.set('Omnichannel', omnichannel);
showOmnichannel && (inquiries.enabled || onHold.size) && groups.set('Omnichannel', []);
showOmnichannel && !inquiries.enabled && !onHold.size && groups.set('Omnichannel', omnichannel);
showOmnichannel && inquiries.enabled && inquiries.queue.length && groups.set('Incoming_Livechats', inquiries.queue);
showOmnichannel && inquiries.enabled && omnichannel.size && groups.set('Open_Livechats', omnichannel);
showOmnichannel && (inquiries.enabled || onHold.size) && omnichannel.size && groups.set('Open_Livechats', omnichannel);
showOmnichannel && onHold.size && groups.set('On_Hold_Chats', onHold);
sidebarShowUnread && unread.size && groups.set('Unread', unread);
favoritesEnabled && favorite.size && groups.set('Favorites', favorite);
showDiscussion && discussion.size && groups.set('Discussions', discussion);

@ -38,6 +38,8 @@ export interface IRoom extends IRocketChatRecord {
balance: number;
}[];
};
onHold?: boolean;
}
export interface IDirectMessageRoom extends Omit<IRoom, 'default' | 'featured' | 'u' | 'name'> {

@ -31,6 +31,8 @@ export interface ISubscription extends IRocketChatRecord {
prid?: RoomID;
roles?: string[];
onHold?: boolean;
}
export interface ISubscriptionDirectMessage extends Omit<ISubscription, 'name'> {

@ -6,3 +6,4 @@ import './priorities';
import './tags';
import './units';
import './business-hours';
import './rooms';

@ -0,0 +1,40 @@
import { Meteor } from 'meteor/meteor';
import { API } from '../../../../../app/api/server';
import { hasPermission } from '../../../../../app/authorization';
import { Subscriptions, LivechatRooms } from '../../../../../app/models/server';
import { LivechatEnterprise } from '../lib/LivechatEnterprise';
API.v1.addRoute('livechat/room.onHold', { authRequired: true }, {
post() {
const { roomId } = this.bodyParams;
if (!roomId || roomId.trim() === '') {
return API.v1.failure('Invalid room Id');
}
if (!this.userId || !hasPermission(this.userId, 'on-hold-livechat-room')) {
return API.v1.failure('Not authorized');
}
const room = LivechatRooms.findOneById(roomId);
if (!room || room.t !== 'l') {
return API.v1.failure('Invalid room Id');
}
if (room.onHold) {
return API.v1.failure('Room is already On-Hold');
}
const user = Meteor.user();
const subscription = Subscriptions.findOneByRoomIdAndUserId(roomId, user._id, { _id: 1 });
if (!subscription && !hasPermission(this.userId, 'on-hold-others-livechat-room')) {
return API.v1.failure('Not authorized');
}
LivechatEnterprise.placeRoomOnHold(room);
return API.v1.success();
},
});

@ -0,0 +1,36 @@
import { TAPi18n } from 'meteor/rocketchat:tap-i18n';
import { callbacks } from '../../../../../app/callbacks/server';
import { settings } from '../../../../../app/settings/server';
import { AutoCloseOnHoldScheduler } from '../lib/AutoCloseOnHoldScheduler';
const DEFAULT_CLOSED_MESSAGE = TAPi18n.__('Closed_automatically');
let autoCloseOnHoldChatTimeout = 0;
let customCloseMessage = DEFAULT_CLOSED_MESSAGE;
const handleAfterOnHold = async (room: any = {}): Promise<any> => {
const { _id: rid } = room;
if (!rid) {
return;
}
if (!autoCloseOnHoldChatTimeout || autoCloseOnHoldChatTimeout <= 0) {
return;
}
await AutoCloseOnHoldScheduler.scheduleRoom(room._id, autoCloseOnHoldChatTimeout, customCloseMessage);
};
settings.get('Livechat_auto_close_on_hold_chats_timeout', (_, value) => {
autoCloseOnHoldChatTimeout = value as number;
if (!value || value <= 0) {
callbacks.remove('livechat:afterOnHold', 'livechat-auto-close-on-hold');
}
callbacks.add('livechat:afterOnHold', handleAfterOnHold, callbacks.priority.HIGH, 'livechat-auto-close-on-hold');
});
settings.get('Livechat_auto_close_on_hold_chats_custom_message', (_, value) => {
customCloseMessage = value as string || DEFAULT_CLOSED_MESSAGE;
});

@ -0,0 +1,12 @@
import { callbacks } from '../../../../../app/callbacks/server';
import { LivechatEnterprise } from '../lib/LivechatEnterprise';
const handleAfterOnHoldChatResumed = async (room: any): Promise<void> => {
if (!room || !room._id || !room.onHold) {
return;
}
LivechatEnterprise.releaseOnHoldChat(room);
};
callbacks.add('livechat:afterOnHoldChatResumed', handleAfterOnHoldChatResumed, callbacks.priority.HIGH, 'livechat-after-on-hold-chat-resumed');

@ -4,10 +4,9 @@ import { callbacks } from '../../../../../app/callbacks';
import { Users } from '../../../../../app/models/server/raw';
import { settings } from '../../../../../app/settings';
import { getMaxNumberSimultaneousChat } from '../lib/Helper';
import { RoutingManager } from '../../../../../app/livechat/server/lib/RoutingManager';
import { allowAgentSkipQueue } from '../../../../../app/livechat/server/lib/Helper';
callbacks.add('livechat.checkAgentBeforeTakeInquiry', async (agent, inquiry) => {
callbacks.add('livechat.checkAgentBeforeTakeInquiry', async ({ agent, inquiry, options }) => {
if (!settings.get('Livechat_waiting_queue')) {
return agent;
}
@ -36,8 +35,7 @@ callbacks.add('livechat.checkAgentBeforeTakeInquiry', async (agent, inquiry) =>
const { queueInfo: { chats = 0 } = {} } = user;
if (maxNumberSimultaneousChat <= chats) {
callbacks.run('livechat.onMaxNumberSimultaneousChatsReached', inquiry);
if (!RoutingManager.getConfig().autoAssignAgent) {
if (options.clientAction) {
throw new Meteor.Error('error-max-number-simultaneous-chats-reached', 'Not allowed');
}

@ -15,3 +15,5 @@ import './onLoadConfigApi';
import './onCloseLivechat';
import './onSaveVisitorInfo';
import './onBusinessHourStart';
import './onAgentAssignmentFailed';
import './afterOnHoldChatResumed';

@ -0,0 +1,25 @@
import { callbacks } from '../../../../../app/callbacks/server';
import { LivechatInquiry, Subscriptions, LivechatRooms } from '../../../../../app/models/server';
import { queueInquiry } from '../../../../../app/livechat/server/lib/QueueManager';
const handleOnAgentAssignmentFailed = async ({ inquiry, room }: { inquiry: any; room: any }): Promise<any> => {
if (!inquiry || !room || !room.onHold) {
return;
}
const { _id: roomId, servedBy } = room;
const { _id: inquiryId } = inquiry;
LivechatInquiry.readyInquiry(inquiryId);
LivechatInquiry.removeDefaultAgentById(inquiryId);
LivechatRooms.removeAgentByRoomId(roomId);
if (servedBy?._id) {
Subscriptions.removeByRoomIdAndUserId(roomId, servedBy._id);
}
const newInquiry = LivechatInquiry.findOneById(inquiryId);
await queueInquiry(room, newInquiry);
};
callbacks.add('livechat.onAgentAssignmentFailed', handleOnAgentAssignmentFailed, callbacks.priority.HIGH, 'livechat-agent-assignment-failed');

@ -2,8 +2,11 @@ import { callbacks } from '../../../../../app/callbacks';
import { settings } from '../../../../../app/settings';
import { dispatchWaitingQueueStatus } from '../lib/Helper';
import { RoutingManager } from '../../../../../app/livechat/server/lib/RoutingManager';
import { LivechatEnterprise } from '../lib/LivechatEnterprise';
const onCloseLivechat = (room) => {
Promise.await(LivechatEnterprise.releaseOnHoldChat(room));
if (!settings.get('Livechat_waiting_queue')) {
return room;
}

@ -0,0 +1,36 @@
import { Meteor } from 'meteor/meteor';
import { callbacks } from '../../../../../app/callbacks/server';
import { LivechatRooms } from '../../../../../app/models/server';
const handleAfterSaveMessage = (message, { _id: rid }) => {
// skips this callback if the message was edited
if (message.editedAt) {
return message;
}
// if the message has a type means it is a special message (like the closing comment), so skips
if (message.t) {
return message;
}
// Need to read the room every time, the room object is not updated
const room = LivechatRooms.findOneById(rid, { t: 1, v: 1, onHold: 1 });
if (!room) {
return message;
}
// message valid only if it is a livechat room
if (!(typeof room.t !== 'undefined' && room.t === 'l' && room.v && room.v.token)) {
return message;
}
// if a visitor sends a message in room which is On Hold
if (message.token && room.onHold) {
Meteor.call('livechat:resumeOnHold', rid, { clientAction: false });
}
return message;
};
callbacks.add('afterSaveMessage', handleAfterSaveMessage, callbacks.priority.HIGH, 'livechat-resume-on-hold');

@ -3,7 +3,7 @@ import { settings } from '../../../../../app/settings/server';
import { setPredictedVisitorAbandonmentTime } from '../lib/Helper';
callbacks.add('afterSaveMessage', function(message, room) {
if (!settings.get('Livechat_auto_close_abandoned_rooms') || settings.get('Livechat_visitor_inactivity_timeout') <= 0) {
if (!settings.get('Livechat_abandoned_rooms_action') || settings.get('Livechat_abandoned_rooms_action') === 'none' || settings.get('Livechat_visitor_inactivity_timeout') <= 0) {
return message;
}
// skips this callback if the message was edited

@ -11,6 +11,7 @@ import './methods/saveUnit';
import './methods/savePriority';
import './methods/removePriority';
import './methods/removeBusinessHour';
import './methods/resumeOnHold';
import LivechatUnit from '../../models/server/models/LivechatUnit';
import LivechatTag from '../../models/server/models/LivechatTag';
import LivechatUnitMonitors from '../../models/server/models/LivechatUnitMonitors';
@ -26,7 +27,10 @@ import './hooks/onLoadConfigApi';
import './hooks/onCloseLivechat';
import './hooks/onSaveVisitorInfo';
import './hooks/scheduleAutoTransfer';
import './hooks/resumeOnHold';
import './hooks/afterOnHold';
import './lib/routing/LoadBalancing';
import './lib/AutoCloseOnHoldScheduler';
import { onLicense } from '../../license/server';
import './business-hour';

@ -0,0 +1,67 @@
import Agenda from 'agenda';
import { MongoInternals } from 'meteor/mongo';
import { Meteor } from 'meteor/meteor';
import moment from 'moment';
import { Livechat } from '../../../../../app/livechat/server';
import { LivechatRooms, Users } from '../../../../../app/models/server';
const schedulerUser = Users.findOneById('rocket.cat');
const SCHEDULER_NAME = 'omnichannel_auto_close_on_hold_scheduler';
class AutoCloseOnHoldSchedulerClass {
scheduler: Agenda;
running: boolean;
public init(): void {
if (this.running) {
return;
}
this.scheduler = new Agenda({
mongo: (MongoInternals.defaultRemoteCollectionDriver().mongo as any).client.db(),
db: { collection: SCHEDULER_NAME },
defaultConcurrency: 1,
});
this.scheduler.start();
this.running = true;
}
public async scheduleRoom(roomId: string, timeout: number, comment: string): Promise<void> {
await this.unscheduleRoom(roomId);
const jobName = `${ SCHEDULER_NAME }-${ roomId }`;
const when = moment(new Date()).add(timeout, 's').toDate();
this.scheduler.define(jobName, this.executeJob.bind(this));
await this.scheduler.schedule(when, jobName, { roomId, comment });
}
public async unscheduleRoom(roomId: string): Promise<void> {
const jobName = `${ SCHEDULER_NAME }-${ roomId }`;
await this.scheduler.cancel({ name: jobName });
}
private async executeJob({ attrs: { data } }: any = {}): Promise<void> {
const { roomId, comment } = data;
const payload = {
user: schedulerUser,
room: LivechatRooms.findOneById(roomId),
comment,
options: {},
visitor: undefined,
};
Livechat.closeRoom(payload);
}
}
export const AutoCloseOnHoldScheduler = new AutoCloseOnHoldSchedulerClass();
Meteor.startup(() => {
AutoCloseOnHoldScheduler.init();
});

@ -124,7 +124,7 @@ export const processWaitingQueue = async (department) => {
};
export const setPredictedVisitorAbandonmentTime = (room) => {
if (!room.v || !room.v.lastMessageTs || !settings.get('Livechat_auto_close_abandoned_rooms')) {
if (!room.v || !room.v.lastMessageTs || !settings.get('Livechat_abandoned_rooms_action') || settings.get('Livechat_abandoned_rooms_action') === 'none') {
return;
}
@ -144,10 +144,10 @@ export const setPredictedVisitorAbandonmentTime = (room) => {
};
export const updatePredictedVisitorAbandonment = () => {
if (settings.get('Livechat_auto_close_abandoned_rooms')) {
LivechatRooms.findLivechat({ open: true }).forEach((room) => setPredictedVisitorAbandonmentTime(room));
} else {
if (!settings.get('Livechat_abandoned_rooms_action') || (settings.get('Livechat_abandoned_rooms_action') === 'none')) {
LivechatRooms.unsetPredictedVisitorAbandonment();
} else {
LivechatRooms.findLivechat({ open: true }).forEach((room) => setPredictedVisitorAbandonmentTime(room));
}
};

@ -5,11 +5,14 @@ import { Users } from '../../../../../app/models';
import { LivechatInquiry, OmnichannelQueue } from '../../../../../app/models/server/raw';
import LivechatUnit from '../../../models/server/models/LivechatUnit';
import LivechatTag from '../../../models/server/models/LivechatTag';
import { LivechatRooms, Subscriptions } from '../../../../../app/models/server';
import LivechatPriority from '../../../models/server/models/LivechatPriority';
import { addUserRoles, removeUserFromRoles } from '../../../../../app/authorization/server';
import { processWaitingQueue, removePriorityFromRooms, updateInquiryQueuePriority, updatePriorityInquiries, updateRoomPriorityHistory } from './Helper';
import { RoutingManager } from '../../../../../app/livechat/server/lib/RoutingManager';
import { settings } from '../../../../../app/settings/server';
import { callbacks } from '../../../../../app/callbacks';
import { AutoCloseOnHoldScheduler } from './AutoCloseOnHoldScheduler';
export const LivechatEnterprise = {
addMonitor(username) {
@ -163,6 +166,32 @@ export const LivechatEnterprise = {
updateInquiryQueuePriority(roomId, priority);
updateRoomPriorityHistory(roomId, user, priority);
},
placeRoomOnHold(room) {
const { _id: roomId, onHold } = room;
if (!roomId || onHold) {
return false;
}
LivechatRooms.setOnHold(roomId);
Subscriptions.setOnHold(roomId);
Meteor.defer(() => {
callbacks.run('livechat:afterOnHold', room);
});
return true;
},
async releaseOnHoldChat(room) {
const { _id: roomId, onHold } = room;
if (!roomId || !onHold) {
return;
}
await AutoCloseOnHoldScheduler.unscheduleRoom(roomId);
LivechatRooms.unsetAllOnHoldFieldsByRoomId(roomId);
Subscriptions.unsetOnHold(roomId);
},
};
const RACE_TIMEOUT = 1000;

@ -4,6 +4,7 @@ import { TAPi18n } from 'meteor/rocketchat:tap-i18n';
import { settings } from '../../../../../app/settings/server';
import { LivechatRooms, LivechatDepartment, Users } from '../../../../../app/models/server';
import { Livechat } from '../../../../../app/livechat/server/lib/Livechat';
import { LivechatEnterprise } from './LivechatEnterprise';
export class VisitorInactivityMonitor {
constructor() {
@ -77,11 +78,27 @@ export class VisitorInactivityMonitor {
});
}
placeRoomOnHold(room) {
LivechatEnterprise.placeRoomOnHold(room) && LivechatRooms.unsetPredictedVisitorAbandonmentByRoomId(room._id);
}
handleAbandonedRooms() {
if (!settings.get('Livechat_auto_close_abandoned_rooms')) {
const action = settings.get('Livechat_abandoned_rooms_action');
if (!action || action === 'none') {
return;
}
LivechatRooms.findAbandonedOpenRooms(new Date()).forEach((room) => this.closeRooms(room));
LivechatRooms.findAbandonedOpenRooms(new Date()).forEach((room) => {
switch (action) {
case 'close': {
this.closeRooms(room);
break;
}
case 'on-hold': {
this.placeRoomOnHold(room);
break;
}
}
});
this._initializeMessageCache();
}
}

@ -0,0 +1,30 @@
import { Meteor } from 'meteor/meteor';
import { LivechatRooms, LivechatInquiry } from '../../../../../app/models/server';
import { RoutingManager } from '../../../../../app/livechat/server/lib/RoutingManager';
import { callbacks } from '../../../../../app/callbacks/server';
Meteor.methods({
async 'livechat:resumeOnHold'(roomId, options = { clientAction: false }) {
const room = await LivechatRooms.findOneById(roomId);
if (!room || room.t !== 'l') {
throw new Meteor.Error('error-invalid-room', 'Invalid room', { method: 'livechat:resumeOnHold' });
}
if (!room.onHold) {
throw new Meteor.Error('room-closed', 'Room is not OnHold', { method: 'livechat:resumeOnHold' });
}
const { servedBy: { _id: agentId, username } } = room;
const inquiry = LivechatInquiry.findOneByRoomId(roomId, {});
if (!inquiry) {
throw new Meteor.Error('inquiry-not-found', 'Error! No inquiry found for this room', { method: 'livechat:resumeOnHold' });
}
await RoutingManager.takeInquiry(inquiry, { agentId, username }, options);
const updatedRoom = LivechatRooms.findOneById(roomId);
updatedRoom && Meteor.defer(() => callbacks.run('livechat:afterOnHoldChatResumed', updatedRoom));
},
});

@ -21,6 +21,8 @@ export const createPermissions = () => {
'view-livechat-rooms',
'close-livechat-room',
'close-others-livechat-room',
'on-hold-livechat-room',
'on-hold-others-livechat-room',
'save-others-livechat-room-info',
'remove-closed-livechat-rooms',
'view-livechat-analytics',

@ -53,24 +53,30 @@ export const createSettings = () => {
],
});
settings.add('Livechat_auto_close_abandoned_rooms', false, {
type: 'boolean',
settings.add('Livechat_abandoned_rooms_action', 'none', {
type: 'select',
group: 'Omnichannel',
section: 'Sessions',
i18nLabel: 'Enable_omnichannel_auto_close_abandoned_rooms',
values: [
{ key: 'none', i18nLabel: 'Do_Nothing' },
{ key: 'close', i18nLabel: 'Livechat_close_chat' },
{ key: 'on-hold', i18nLabel: 'Livechat_onHold_Chat' },
],
enterprise: true,
invalidValue: false,
public: true,
invalidValue: 'none',
modules: [
'livechat-enterprise',
],
});
settings.add('Livechat_abandoned_rooms_closed_custom_message', '', {
type: 'string',
group: 'Omnichannel',
section: 'Sessions',
i18nLabel: 'Livechat_abandoned_rooms_closed_custom_message',
enableQuery: { _id: 'Livechat_auto_close_abandoned_rooms', value: true },
enableQuery: { _id: 'Livechat_abandoned_rooms_action', value: 'close' },
enterprise: true,
invalidValue: '',
modules: [
@ -89,18 +95,6 @@ export const createSettings = () => {
],
});
settings.add('Livechat_auto_transfer_chat_timeout', 0, {
type: 'int',
group: 'Omnichannel',
section: 'Sessions',
i18nDescription: 'Livechat_auto_transfer_chat_timeout_description',
enterprise: true,
invalidValue: 0,
modules: [
'livechat-enterprise',
],
});
settings.addGroup('Omnichannel', function() {
this.section('Business_Hours', function() {
this.add('Livechat_business_hour_type', 'Single', {
@ -134,5 +128,52 @@ export const createSettings = () => {
],
});
settings.add('Livechat_auto_close_on_hold_chats_timeout', 3600, {
type: 'int',
group: 'Omnichannel',
section: 'Sessions',
enterprise: true,
invalidValue: 0,
modules: [
'livechat-enterprise',
],
});
settings.add('Livechat_auto_close_on_hold_chats_custom_message', '', {
type: 'string',
group: 'Omnichannel',
section: 'Sessions',
enableQuery: { _id: 'Livechat_auto_close_on_hold_chats_timeout', value: { $gte: 1 } },
enterprise: true,
invalidValue: '',
modules: [
'livechat-enterprise',
],
});
settings.add('Livechat_allow_manual_on_hold', false, {
type: 'boolean',
group: 'Omnichannel',
section: 'Sessions',
enterprise: true,
invalidValue: false,
public: true,
modules: [
'livechat-enterprise',
],
});
settings.add('Livechat_auto_transfer_chat_timeout', 0, {
type: 'int',
group: 'Omnichannel',
section: 'Sessions',
i18nDescription: 'Livechat_auto_transfer_chat_timeout_description',
enterprise: true,
invalidValue: 0,
modules: [
'livechat-enterprise',
],
});
Settings.addOptionValueById('Livechat_Routing_Method', { key: 'Load_Balancing', i18nLabel: 'Load_Balancing' });
};

@ -16,9 +16,9 @@ const businessHours = {
};
Meteor.startup(async function() {
settings.onload('Livechat_auto_close_abandoned_rooms', function(_, value) {
settings.onload('Livechat_abandoned_rooms_action', function(_, value) {
updatePredictedVisitorAbandonment();
if (!value) {
if (!value || value === 'none') {
return visitorActivityMonitor.stop();
}
visitorActivityMonitor.start();

@ -46,6 +46,20 @@ LivechatRooms.prototype.findAbandonedOpenRooms = function(date) {
});
};
LivechatRooms.prototype.setOnHold = function(roomId) {
return this.update(
{ _id: roomId },
{ $set: { onHold: true } },
);
};
LivechatRooms.prototype.unsetOnHold = function(roomId) {
return this.update(
{ _id: roomId },
{ $unset: { onHold: 1 } },
);
};
LivechatRooms.prototype.unsetPredictedVisitorAbandonment = function() {
return this.update({
open: true,
@ -57,6 +71,25 @@ LivechatRooms.prototype.unsetPredictedVisitorAbandonment = function() {
});
};
LivechatRooms.prototype.unsetPredictedVisitorAbandonmentByRoomId = function(roomId) {
return this.update({
_id: roomId,
}, {
$unset: { 'omnichannel.predictedVisitorAbandonmentAt': 1 },
});
};
LivechatRooms.prototype.unsetAllOnHoldFieldsByRoomId = function(roomId) {
return this.update({
_id: roomId,
}, {
$unset: {
'omnichannel.predictedVisitorAbandonmentAt': 1,
onHold: 1,
},
});
};
LivechatRooms.prototype.unsetPriorityById = function(priorityId) {
return this.update({
open: true,

@ -724,8 +724,12 @@
"Chat_closed_successfully": "Chat closed successfully",
"Chat_History": "Chat History",
"Chat_Now": "Chat Now",
"chat_on_hold_due_to_inactivity": "This chat is on-hold due to inactivity",
"Chat_On_Hold": "Chat On-Hold",
"Chat_On_Hold_Successfully": "This chat was successfully placed On-Hold",
"Chat_queued": "Chat Queued",
"Chat_removed": "Chat Removed",
"Chat_resumed": "Chat Resumed",
"Chat_start": "Chat Start",
"Chat_started": "Chat started",
"Chat_taken": "Chat Taken",
@ -1372,6 +1376,7 @@
"Displays_action_text": "Displays action text",
"Do_not_display_unread_counter": "Do not display any counter of this channel",
"Do_not_provide_this_code_to_anyone": "Do not provide this code to anyone.",
"Do_Nothing": "Do Nothing",
"Do_you_want_to_accept": "Do you want to accept?",
"Do_you_want_to_change_to_s_question": "Do you want to change to <strong>%s</strong>?",
"Document_Domain": "Document Domain",
@ -1644,6 +1649,7 @@
"error-role-in-use": "Cannot delete role because it's in use",
"error-role-name-required": "Role name is required",
"error-room-is-not-closed": "Room is not closed",
"error-room-onHold": "Error! Room is 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",
@ -2401,15 +2407,23 @@
"List_of_Direct_Messages": "List of Direct Messages",
"Omnichannel": "Omnichannel",
"Livechat": "Livechat",
"Livechat_abandoned_rooms_action": "How to handle Visitor Abandonment",
"Livechat_abandoned_rooms_closed_custom_message": "Custom message when room is automatically closed by visitor inactivity",
"Livechat_agents": "Omnichannel agents",
"Livechat_Agents": "Agents",
"Livechat_allow_manual_on_hold": "Allow agents to manually place chat On Hold",
"Livechat_allow_manual_on_hold_Description": "If enabled, the agent will get a new option to place a chat On Hold, provided the agent has sent the last message",
"Livechat_AllowedDomainsList": "Livechat Allowed Domains",
"Livechat_Appearance": "Livechat Appearance",
"Livechat_auto_close_on_hold_chats_custom_message": "Custom message for closed chats in On Hold queue",
"Livechat_auto_close_on_hold_chats_custom_message_Description": "Custom Message to be sent when a room in On-Hold queue gets automatically closed by the system",
"Livechat_auto_close_on_hold_chats_timeout": "How long to wait before closing a chat in On Hold Queue ?",
"Livechat_auto_close_on_hold_chats_timeout_Description": "Define how long the chat will remain in the On Hold queue until it's automatically closed by the system. Time in seconds",
"Livechat_auto_transfer_chat_timeout": "Timeout (in seconds) for automatic transfer of unanswered chats to another agent",
"Livechat_auto_transfer_chat_timeout_description": "This event takes place only when the chat has just started. After the first transfering for inactivity, the room is no longer monitored.",
"Livechat_auto_transfer_chat_timeout_Description": "This event takes place only when the chat has just started. After the first transfering for inactivity, the room is no longer monitored.",
"Livechat_business_hour_type": "Business Hour Type (Single or Multiple)",
"Livechat_chat_transcript_sent": "Chat transcript sent: __transcript__",
"Livechat_close_chat": "Close chat",
"Livechat_custom_fields_options_placeholder": "Comma-separated list used to select a pre-configured value. Spaces between elements are not accepted.",
"Livechat_custom_fields_public_description": "Public custom fields will be displayed in external applications, such as Livechat, etc.",
"Livechat_Dashboard": "Omnichannel Dashboard",
@ -2434,6 +2448,7 @@
"Livechat_offline": "Omnichannel offline",
"Livechat_offline_message_sent": "Livechat offline message sent",
"Livechat_OfflineMessageToChannel_enabled": "Send Livechat offline messages to a channel",
"Livechat_onHold_Chat": "Place chat On-Hold",
"Livechat_online": "Omnichannel on-line",
"Livechat_Queue": "Omnichannel Queue",
"Livechat_registration_form": "Registration Form",
@ -2933,6 +2948,7 @@
"Omnichannel_External_Frame_Encryption_JWK_Description": "If provided it will encrypt the user's token with the provided key and the external system will need to decrypt the data to access the token",
"Omnichannel_External_Frame_URL": "External frame URL",
"On": "On",
"On_Hold_Chats": "On Hold",
"online": "online",
"Online": "Online",
"Only_authorized_users_can_write_new_messages": "Only authorized users can write new messages",
@ -4233,6 +4249,7 @@
"Without_priority": "Without priority",
"Worldwide": "Worldwide",
"Would_you_like_to_return_the_inquiry": "Would you like to return the inquiry?",
"Would_you_like_to_place_chat_on_hold": "Would you like to place this chat On-Hold?",
"Yes": "Yes",
"Yes_archive_it": "Yes, archive it!",
"Yes_clear_all": "Yes, clear all!",

@ -37,6 +37,7 @@ export const subscriptionFields = {
tunreadGroup: 1,
tunreadUser: 1,
v: 1,
onHold: 1,
};
export const roomFields = {

@ -215,4 +215,5 @@ import './v215';
import './v216';
import './v217';
import './v218';
import './v219';
import './xrun';

@ -0,0 +1,34 @@
import { Migrations } from '../../../app/migrations/server';
import { Settings } from '../../../app/models/server';
Migrations.add({
version: 219,
up() {
const SettingIds = {
old: 'Livechat_auto_close_abandoned_rooms',
new: 'Livechat_abandoned_rooms_action',
};
const oldSetting = Settings.findOne({ _id: SettingIds.old });
if (!oldSetting) {
return;
}
const oldValue = oldSetting.value;
const newValue = oldValue && oldValue === true ? 'close' : 'none';
Settings.update({
_id: SettingIds.new,
}, {
$set: {
value: newValue,
},
});
Settings.remove({
_id: SettingIds.old,
});
},
});
Loading…
Cancel
Save