Skip to content

Commit 6a94e3c

Browse files
Marcos Spessatto Defendirenatobecker
andauthored
[NEW][ENTERPRISE] Auto close abandoned Omnichannel rooms (#17055)
* close/freeze automatically inactive omnichannel rooms * Apply suggestions from review * Apply suggestions from review * Apply suggestions from review * Apply suggestions from review * Set livechat rooms as waiting response after visitor's messages * Remove unnecessary console.log calls. Co-authored-by: Renato Becker <[email protected]>
1 parent c180a53 commit 6a94e3c

File tree

15 files changed

+274
-5
lines changed

15 files changed

+274
-5
lines changed
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
2+
import { callbacks } from '../../../callbacks';
3+
import { LivechatRooms } from '../../../models';
4+
5+
callbacks.add('afterSaveMessage', function(message, room) {
6+
// skips this callback if the message was edited
7+
if (!message || message.editedAt) {
8+
return message;
9+
}
10+
11+
// if the message has not a token, it was sent by the agent, so ignore it
12+
if (!message.token) {
13+
return message;
14+
}
15+
16+
// check if room is yet awaiting for response
17+
if (typeof room.t !== 'undefined' && room.t === 'l' && room.waitingResponse) {
18+
return message;
19+
}
20+
21+
LivechatRooms.setNotResponseByRoomId(room._id);
22+
23+
return message;
24+
}, callbacks.priority.LOW, 'markRoomNotResponded');

app/livechat/server/hooks/processRoomAbandonment.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,5 +58,5 @@ callbacks.add('livechat.closeRoom', (room) => {
5858
return;
5959
}
6060
const secondsSinceLastAgentResponse = getSecondsSinceLastAgentResponse(room, agentLastMessage);
61-
LivechatRooms.setVisitorInactivityInSecondsByRoomId(room._id, secondsSinceLastAgentResponse);
61+
LivechatRooms.setVisitorInactivityInSecondsById(room._id, secondsSinceLastAgentResponse);
6262
}, callbacks.priority.HIGH, 'process-room-abandonment');

app/livechat/server/index.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import './hooks/sendToCRM';
1717
import './hooks/sendToFacebook';
1818
import './hooks/processRoomAbandonment';
1919
import './hooks/saveLastVisitorMessageTs';
20+
import './hooks/markRoomNotResponded';
2021
import './methods/addAgent';
2122
import './methods/addManager';
2223
import './methods/changeLivechatStatus';

app/models/server/models/LivechatRooms.js

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ export class LivechatRooms extends Base {
1515
this.tryEnsureIndex({ 'metrics.chatDuration': 1 }, { sparse: true });
1616
this.tryEnsureIndex({ 'metrics.serviceTimeDuration': 1 }, { sparse: true });
1717
this.tryEnsureIndex({ 'metrics.visitorInactivity': 1 }, { sparse: true });
18+
this.tryEnsureIndex({ 'omnichannel.predictedVisitorAbandonmentAt': 1 }, { sparse: true });
1819
}
1920

2021
findLivechat(filter = {}, offset = 0, limit = 20) {
@@ -286,6 +287,20 @@ export class LivechatRooms extends Base {
286287
});
287288
}
288289

290+
setNotResponseByRoomId(roomId) {
291+
return this.update({
292+
_id: roomId,
293+
t: 'l',
294+
}, {
295+
$set: {
296+
waitingResponse: true,
297+
},
298+
$unset: {
299+
responseBy: 1,
300+
},
301+
});
302+
}
303+
289304
setAgentLastMessageTs(roomId) {
290305
return this.update({
291306
_id: roomId,
@@ -566,7 +581,7 @@ export class LivechatRooms extends Base {
566581
return this.update(query, update);
567582
}
568583

569-
setVisitorInactivityInSecondsByRoomId(roomId, visitorInactivity) {
584+
setVisitorInactivityInSecondsById(roomId, visitorInactivity) {
570585
const query = {
571586
_id: roomId,
572587
};

ee/app/livechat-enterprise/client/views/app/customTemplates/livechatDepartmentCustomFieldsForm.html

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,18 @@
55
<input type="number" class="rc-input__element customFormField" name="maxNumberSimultaneousChat" value="{{department.maxNumberSimultaneousChat}}" placeholder="{{_ "Max_number_of_chats_per_agent"}}" />
66
</div>
77
</div>
8+
<div class="input-line">
9+
<label>{{_ "How_long_to_wait_to_consider_visitor_abandonment"}}</label>
10+
<div>
11+
<input type="number" class="rc-input__element customFormField" name="visitorInactivityTimeoutInSeconds" value="{{department.visitorInactivityTimeoutInSeconds}}" placeholder="{{_ "Number_in_seconds"}}" />
12+
</div>
13+
</div>
14+
<div class="input-line">
15+
<label>{{_ "Livechat_abandoned_rooms_closed_custom_message"}}</label>
16+
<div>
17+
<input type="text" class="rc-input__element customFormField" name="abandonedRoomsCloseCustomMessage" value="{{department.abandonedRoomsCloseCustomMessage}}" placeholder="{{_ "Enter_a_custom_message"}}" />
18+
</div>
19+
</div>
820
<div class="input-line">
921
<label>{{_ "Waiting_queue_message"}}</label>
1022
<div>

ee/app/livechat-enterprise/server/hooks/afterForwardChatToDepartment.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { callbacks } from '../../../../../app/callbacks/server';
22
import LivechatRooms from '../../../../../app/models/server/models/LivechatRooms';
33
import LivechatDepartment from '../../../../../app/models/server/models/LivechatDepartment';
4+
import { setPredictedVisitorAbandonmentTime } from '../lib/Helper';
45

56
callbacks.add('livechat.afterForwardChatToDepartment', (options) => {
67
const { rid, newDepartmentId } = options;
@@ -9,6 +10,7 @@ callbacks.add('livechat.afterForwardChatToDepartment', (options) => {
910
if (!room) {
1011
return;
1112
}
13+
setPredictedVisitorAbandonmentTime(room);
1214

1315
const department = LivechatDepartment.findOneById(newDepartmentId, { fields: { ancestors: 1 } });
1416
if (!department) {
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import { callbacks } from '../../../../../app/callbacks/server';
2+
import { settings } from '../../../../../app/settings/server';
3+
import { setPredictedVisitorAbandonmentTime } from '../lib/Helper';
4+
5+
callbacks.add('afterSaveMessage', function(message, room) {
6+
if (!settings.get('Livechat_auto_close_abandoned_rooms') || settings.get('Livechat_visitor_inactivity_timeout') <= 0) {
7+
return message;
8+
}
9+
// skips this callback if the message was edited
10+
if (message.editedAt) {
11+
return false;
12+
}
13+
// message valid only if it is a livechat room
14+
if (!(typeof room.t !== 'undefined' && room.t === 'l' && room.v && room.v.token)) {
15+
return false;
16+
}
17+
// if the message has a type means it is a special message (like the closing comment), so skips
18+
if (message.t) {
19+
return false;
20+
}
21+
const sentByAgent = !message.token;
22+
if (sentByAgent) {
23+
setPredictedVisitorAbandonmentTime(room);
24+
}
25+
return message;
26+
}, callbacks.priority.HIGH, 'save-visitor-inactivity');

ee/app/livechat-enterprise/server/index.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { Meteor } from 'meteor/meteor';
33
import './hooks/addDepartmentAncestors';
44
import './hooks/afterForwardChatToDepartment';
55
import './hooks/beforeListTags';
6+
import './hooks/setPredictedVisitorAbandonmentTime';
67
import './methods/addMonitor';
78
import './methods/getUnitsFromUserRoles';
89
import './methods/removeMonitor';

ee/app/livechat-enterprise/server/lib/Helper.js

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
import { Meteor } from 'meteor/meteor';
22
import { Match, check } from 'meteor/check';
3+
import moment from 'moment';
34

45
import { hasRole } from '../../../../../app/authorization';
5-
import { LivechatDepartment, Users, LivechatInquiry } from '../../../../../app/models/server';
6+
import { LivechatDepartment, Users, LivechatInquiry, LivechatRooms } from '../../../../../app/models/server';
67
import { Rooms as RoomRaw } from '../../../../../app/models/server/raw';
78
import { settings } from '../../../../../app/settings';
89
import { Livechat } from '../../../../../app/livechat/server/lib/Livechat';
@@ -64,7 +65,7 @@ export const normalizeQueueInfo = async ({ position, queueInfo, department }) =>
6465
queueInfo = await getQueueInfo(department);
6566
}
6667

67-
const { message, numberMostRecentChats, statistics: { avgChatDuration } = { } } = queueInfo;
68+
const { message, numberMostRecentChats, statistics: { avgChatDuration } = {} } = queueInfo;
6869
const spot = position + 1;
6970
const estimatedWaitTimeSeconds = getSpotEstimatedWaitTime(spot, numberMostRecentChats, avgChatDuration);
7071
return { spot, message, estimatedWaitTimeSeconds };
@@ -137,3 +138,31 @@ export const allowAgentSkipQueue = (agent) => {
137138

138139
return settings.get('Livechat_assign_new_conversation_to_bot') && hasRole(agent.agentId, 'bot');
139140
};
141+
142+
export const setPredictedVisitorAbandonmentTime = (room) => {
143+
if (!room.v || !room.v.lastMessageTs || !settings.get('Livechat_auto_close_abandoned_rooms')) {
144+
return;
145+
}
146+
147+
let secondsToAdd = settings.get('Livechat_visitor_inactivity_timeout');
148+
149+
const department = room.departmentId && LivechatDepartment.findOneById(room.departmentId);
150+
if (department && department.visitorInactivityTimeoutInSeconds) {
151+
secondsToAdd = department.visitorInactivityTimeoutInSeconds;
152+
}
153+
154+
if (secondsToAdd <= 0) {
155+
return;
156+
}
157+
158+
const willBeAbandonedAt = moment(room.v.lastMessageTs).add(Number(secondsToAdd), 'seconds').toDate();
159+
LivechatRooms.setPredictedVisitorAbandonment(room._id, willBeAbandonedAt);
160+
};
161+
162+
export const updatePredictedVisitorAbandonment = () => {
163+
if (settings.get('Livechat_auto_close_abandoned_rooms')) {
164+
LivechatRooms.findLivechat({ open: true }).forEach((room) => setPredictedVisitorAbandonmentTime(room));
165+
} else {
166+
LivechatRooms.unsetPredictedVisitorAbandonment();
167+
}
168+
};
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
import { SyncedCron } from 'meteor/littledata:synced-cron';
2+
import { TAPi18n } from 'meteor/rocketchat:tap-i18n';
3+
4+
import { settings } from '../../../../../app/settings/server';
5+
import { LivechatRooms, LivechatDepartment, Users } from '../../../../../app/models/server';
6+
import { Livechat } from '../../../../../app/livechat/server/lib/Livechat';
7+
8+
export class VisitorInactivityMonitor {
9+
constructor() {
10+
this._started = false;
11+
this._name = 'Omnichannel Visitor Inactivity Monitor';
12+
this.messageCache = new Map();
13+
this.userToPerformAutomaticClosing;
14+
}
15+
16+
start() {
17+
this._startMonitoring();
18+
this._initializeMessageCache();
19+
this.userToPerformAutomaticClosing = Users.findOneById('rocket.cat');
20+
}
21+
22+
_startMonitoring() {
23+
if (this.isRunning()) {
24+
return;
25+
}
26+
const everyMinute = '* * * * *';
27+
SyncedCron.add({
28+
name: this._name,
29+
schedule: (parser) => parser.cron(everyMinute),
30+
job: () => {
31+
this.handleAbandonedRooms();
32+
},
33+
});
34+
this._started = true;
35+
}
36+
37+
stop() {
38+
if (!this.isRunning()) {
39+
return;
40+
}
41+
42+
SyncedCron.remove(this._name);
43+
44+
this._started = false;
45+
}
46+
47+
isRunning() {
48+
return this._started;
49+
}
50+
51+
_initializeMessageCache() {
52+
this.messageCache.clear();
53+
this.messageCache.set('default', settings.get('Livechat_abandoned_rooms_closed_custom_message') || TAPi18n.__('Closed_automatically'));
54+
}
55+
56+
_getDepartmentAbandonedCustomMessage(departmentId) {
57+
if (this.messageCache.has('departmentId')) {
58+
return this.messageCache.get('departmentId');
59+
}
60+
const department = LivechatDepartment.findOneById(departmentId);
61+
if (!department) {
62+
return;
63+
}
64+
this.messageCache.set(department._id, department.abandonedRoomsCloseCustomMessage);
65+
return department.abandonedRoomsCloseCustomMessage;
66+
}
67+
68+
closeRooms(room) {
69+
let comment = this.messageCache.get('default');
70+
if (room.departmentId) {
71+
comment = this._getDepartmentAbandonedCustomMessage(room.departmentId) || comment;
72+
}
73+
Livechat.closeRoom({
74+
comment,
75+
room,
76+
user: this.userToPerformAutomaticClosing,
77+
});
78+
}
79+
80+
handleAbandonedRooms() {
81+
if (!settings.get('Livechat_auto_close_abandoned_rooms')) {
82+
return;
83+
}
84+
LivechatRooms.findAbandonedOpenRooms(new Date()).forEach((room) => this.closeRooms(room));
85+
this._initializeMessageCache();
86+
}
87+
}

0 commit comments

Comments
 (0)