[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 <renato.augusto.becker@gmail.com>
pull/17141/head
Marcos Spessatto Defendi 6 years ago committed by GitHub
parent c180a53698
commit 6a94e3c9e5
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 24
      app/livechat/server/hooks/markRoomNotResponded.js
  2. 2
      app/livechat/server/hooks/processRoomAbandonment.js
  3. 1
      app/livechat/server/index.js
  4. 17
      app/models/server/models/LivechatRooms.js
  5. 12
      ee/app/livechat-enterprise/client/views/app/customTemplates/livechatDepartmentCustomFieldsForm.html
  6. 2
      ee/app/livechat-enterprise/server/hooks/afterForwardChatToDepartment.js
  7. 26
      ee/app/livechat-enterprise/server/hooks/setPredictedVisitorAbandonmentTime.js
  8. 1
      ee/app/livechat-enterprise/server/index.js
  9. 33
      ee/app/livechat-enterprise/server/lib/Helper.js
  10. 87
      ee/app/livechat-enterprise/server/lib/VisitorInactivityMonitor.js
  11. 15
      ee/app/livechat-enterprise/server/settings.js
  12. 15
      ee/app/livechat-enterprise/server/startup.js
  13. 34
      ee/app/models/server/models/LivechatRooms.js
  14. 5
      ee/i18n/en.i18n.json
  15. 5
      ee/i18n/pt-BR.i18n.json

@ -0,0 +1,24 @@
import { callbacks } from '../../../callbacks';
import { LivechatRooms } from '../../../models';
callbacks.add('afterSaveMessage', function(message, room) {
// skips this callback if the message was edited
if (!message || message.editedAt) {
return message;
}
// if the message has not a token, it was sent by the agent, so ignore it
if (!message.token) {
return message;
}
// check if room is yet awaiting for response
if (typeof room.t !== 'undefined' && room.t === 'l' && room.waitingResponse) {
return message;
}
LivechatRooms.setNotResponseByRoomId(room._id);
return message;
}, callbacks.priority.LOW, 'markRoomNotResponded');

@ -58,5 +58,5 @@ callbacks.add('livechat.closeRoom', (room) => {
return;
}
const secondsSinceLastAgentResponse = getSecondsSinceLastAgentResponse(room, agentLastMessage);
LivechatRooms.setVisitorInactivityInSecondsByRoomId(room._id, secondsSinceLastAgentResponse);
LivechatRooms.setVisitorInactivityInSecondsById(room._id, secondsSinceLastAgentResponse);
}, callbacks.priority.HIGH, 'process-room-abandonment');

@ -17,6 +17,7 @@ import './hooks/sendToCRM';
import './hooks/sendToFacebook';
import './hooks/processRoomAbandonment';
import './hooks/saveLastVisitorMessageTs';
import './hooks/markRoomNotResponded';
import './methods/addAgent';
import './methods/addManager';
import './methods/changeLivechatStatus';

@ -15,6 +15,7 @@ export class LivechatRooms extends Base {
this.tryEnsureIndex({ 'metrics.chatDuration': 1 }, { sparse: true });
this.tryEnsureIndex({ 'metrics.serviceTimeDuration': 1 }, { sparse: true });
this.tryEnsureIndex({ 'metrics.visitorInactivity': 1 }, { sparse: true });
this.tryEnsureIndex({ 'omnichannel.predictedVisitorAbandonmentAt': 1 }, { sparse: true });
}
findLivechat(filter = {}, offset = 0, limit = 20) {
@ -286,6 +287,20 @@ export class LivechatRooms extends Base {
});
}
setNotResponseByRoomId(roomId) {
return this.update({
_id: roomId,
t: 'l',
}, {
$set: {
waitingResponse: true,
},
$unset: {
responseBy: 1,
},
});
}
setAgentLastMessageTs(roomId) {
return this.update({
_id: roomId,
@ -566,7 +581,7 @@ export class LivechatRooms extends Base {
return this.update(query, update);
}
setVisitorInactivityInSecondsByRoomId(roomId, visitorInactivity) {
setVisitorInactivityInSecondsById(roomId, visitorInactivity) {
const query = {
_id: roomId,
};

@ -5,6 +5,18 @@
<input type="number" class="rc-input__element customFormField" name="maxNumberSimultaneousChat" value="{{department.maxNumberSimultaneousChat}}" placeholder="{{_ "Max_number_of_chats_per_agent"}}" />
</div>
</div>
<div class="input-line">
<label>{{_ "How_long_to_wait_to_consider_visitor_abandonment"}}</label>
<div>
<input type="number" class="rc-input__element customFormField" name="visitorInactivityTimeoutInSeconds" value="{{department.visitorInactivityTimeoutInSeconds}}" placeholder="{{_ "Number_in_seconds"}}" />
</div>
</div>
<div class="input-line">
<label>{{_ "Livechat_abandoned_rooms_closed_custom_message"}}</label>
<div>
<input type="text" class="rc-input__element customFormField" name="abandonedRoomsCloseCustomMessage" value="{{department.abandonedRoomsCloseCustomMessage}}" placeholder="{{_ "Enter_a_custom_message"}}" />
</div>
</div>
<div class="input-line">
<label>{{_ "Waiting_queue_message"}}</label>
<div>

@ -1,6 +1,7 @@
import { callbacks } from '../../../../../app/callbacks/server';
import LivechatRooms from '../../../../../app/models/server/models/LivechatRooms';
import LivechatDepartment from '../../../../../app/models/server/models/LivechatDepartment';
import { setPredictedVisitorAbandonmentTime } from '../lib/Helper';
callbacks.add('livechat.afterForwardChatToDepartment', (options) => {
const { rid, newDepartmentId } = options;
@ -9,6 +10,7 @@ callbacks.add('livechat.afterForwardChatToDepartment', (options) => {
if (!room) {
return;
}
setPredictedVisitorAbandonmentTime(room);
const department = LivechatDepartment.findOneById(newDepartmentId, { fields: { ancestors: 1 } });
if (!department) {

@ -0,0 +1,26 @@
import { callbacks } from '../../../../../app/callbacks/server';
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) {
return message;
}
// skips this callback if the message was edited
if (message.editedAt) {
return false;
}
// message valid only if it is a livechat room
if (!(typeof room.t !== 'undefined' && room.t === 'l' && room.v && room.v.token)) {
return false;
}
// if the message has a type means it is a special message (like the closing comment), so skips
if (message.t) {
return false;
}
const sentByAgent = !message.token;
if (sentByAgent) {
setPredictedVisitorAbandonmentTime(room);
}
return message;
}, callbacks.priority.HIGH, 'save-visitor-inactivity');

@ -3,6 +3,7 @@ import { Meteor } from 'meteor/meteor';
import './hooks/addDepartmentAncestors';
import './hooks/afterForwardChatToDepartment';
import './hooks/beforeListTags';
import './hooks/setPredictedVisitorAbandonmentTime';
import './methods/addMonitor';
import './methods/getUnitsFromUserRoles';
import './methods/removeMonitor';

@ -1,8 +1,9 @@
import { Meteor } from 'meteor/meteor';
import { Match, check } from 'meteor/check';
import moment from 'moment';
import { hasRole } from '../../../../../app/authorization';
import { LivechatDepartment, Users, LivechatInquiry } from '../../../../../app/models/server';
import { LivechatDepartment, Users, LivechatInquiry, LivechatRooms } from '../../../../../app/models/server';
import { Rooms as RoomRaw } from '../../../../../app/models/server/raw';
import { settings } from '../../../../../app/settings';
import { Livechat } from '../../../../../app/livechat/server/lib/Livechat';
@ -64,7 +65,7 @@ export const normalizeQueueInfo = async ({ position, queueInfo, department }) =>
queueInfo = await getQueueInfo(department);
}
const { message, numberMostRecentChats, statistics: { avgChatDuration } = { } } = queueInfo;
const { message, numberMostRecentChats, statistics: { avgChatDuration } = {} } = queueInfo;
const spot = position + 1;
const estimatedWaitTimeSeconds = getSpotEstimatedWaitTime(spot, numberMostRecentChats, avgChatDuration);
return { spot, message, estimatedWaitTimeSeconds };
@ -137,3 +138,31 @@ export const allowAgentSkipQueue = (agent) => {
return settings.get('Livechat_assign_new_conversation_to_bot') && hasRole(agent.agentId, 'bot');
};
export const setPredictedVisitorAbandonmentTime = (room) => {
if (!room.v || !room.v.lastMessageTs || !settings.get('Livechat_auto_close_abandoned_rooms')) {
return;
}
let secondsToAdd = settings.get('Livechat_visitor_inactivity_timeout');
const department = room.departmentId && LivechatDepartment.findOneById(room.departmentId);
if (department && department.visitorInactivityTimeoutInSeconds) {
secondsToAdd = department.visitorInactivityTimeoutInSeconds;
}
if (secondsToAdd <= 0) {
return;
}
const willBeAbandonedAt = moment(room.v.lastMessageTs).add(Number(secondsToAdd), 'seconds').toDate();
LivechatRooms.setPredictedVisitorAbandonment(room._id, willBeAbandonedAt);
};
export const updatePredictedVisitorAbandonment = () => {
if (settings.get('Livechat_auto_close_abandoned_rooms')) {
LivechatRooms.findLivechat({ open: true }).forEach((room) => setPredictedVisitorAbandonmentTime(room));
} else {
LivechatRooms.unsetPredictedVisitorAbandonment();
}
};

@ -0,0 +1,87 @@
import { SyncedCron } from 'meteor/littledata:synced-cron';
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';
export class VisitorInactivityMonitor {
constructor() {
this._started = false;
this._name = 'Omnichannel Visitor Inactivity Monitor';
this.messageCache = new Map();
this.userToPerformAutomaticClosing;
}
start() {
this._startMonitoring();
this._initializeMessageCache();
this.userToPerformAutomaticClosing = Users.findOneById('rocket.cat');
}
_startMonitoring() {
if (this.isRunning()) {
return;
}
const everyMinute = '* * * * *';
SyncedCron.add({
name: this._name,
schedule: (parser) => parser.cron(everyMinute),
job: () => {
this.handleAbandonedRooms();
},
});
this._started = true;
}
stop() {
if (!this.isRunning()) {
return;
}
SyncedCron.remove(this._name);
this._started = false;
}
isRunning() {
return this._started;
}
_initializeMessageCache() {
this.messageCache.clear();
this.messageCache.set('default', settings.get('Livechat_abandoned_rooms_closed_custom_message') || TAPi18n.__('Closed_automatically'));
}
_getDepartmentAbandonedCustomMessage(departmentId) {
if (this.messageCache.has('departmentId')) {
return this.messageCache.get('departmentId');
}
const department = LivechatDepartment.findOneById(departmentId);
if (!department) {
return;
}
this.messageCache.set(department._id, department.abandonedRoomsCloseCustomMessage);
return department.abandonedRoomsCloseCustomMessage;
}
closeRooms(room) {
let comment = this.messageCache.get('default');
if (room.departmentId) {
comment = this._getDepartmentAbandonedCustomMessage(room.departmentId) || comment;
}
Livechat.closeRoom({
comment,
room,
user: this.userToPerformAutomaticClosing,
});
}
handleAbandonedRooms() {
if (!settings.get('Livechat_auto_close_abandoned_rooms')) {
return;
}
LivechatRooms.findAbandonedOpenRooms(new Date()).forEach((room) => this.closeRooms(room));
this._initializeMessageCache();
}
}

@ -36,5 +36,20 @@ export const createSettings = () => {
enableQuery: { _id: 'Livechat_waiting_queue', value: true },
});
settings.add('Livechat_auto_close_abandoned_rooms', false, {
type: 'boolean',
group: 'Omnichannel',
section: 'Sessions',
i18nLabel: 'Enable_omnichannel_auto_close_abandoned_rooms',
});
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 },
});
Settings.addOptionValueById('Livechat_Routing_Method', { key: 'Load_Balancing', i18nLabel: 'Load_Balancing' });
};

@ -1,11 +1,24 @@
import { Meteor } from 'meteor/meteor';
import { settings } from '../../../../app/settings';
import { checkWaitingQueue } from './lib/Helper';
import { checkWaitingQueue, updatePredictedVisitorAbandonment } from './lib/Helper';
import { VisitorInactivityMonitor } from './lib/VisitorInactivityMonitor';
import './lib/query.helper';
const visitorActivityMonitor = new VisitorInactivityMonitor();
Meteor.startup(function() {
settings.onload('Livechat_maximum_chats_per_agent', function(/* key, value */) {
checkWaitingQueue();
});
settings.onload('Livechat_auto_close_abandoned_rooms', function(_, value) {
updatePredictedVisitorAbandonment();
if (!value) {
return visitorActivityMonitor.stop();
}
visitorActivityMonitor.start();
});
settings.onload('Livechat_visitor_inactivity_timeout', function() {
updatePredictedVisitorAbandonment();
});
});

@ -23,4 +23,38 @@ overwriteClassOnLicense('livechat-enterprise', LivechatRooms, {
},
});
LivechatRooms.prototype.setPredictedVisitorAbandonment = function(roomId, willBeAbandonedAt) {
const query = {
_id: roomId,
};
const update = {
$set: {
'omnichannel.predictedVisitorAbandonmentAt': willBeAbandonedAt,
},
};
return this.update(query, update);
};
LivechatRooms.prototype.findAbandonedOpenRooms = function(date) {
return this.find({
'omnichannel.predictedVisitorAbandonmentAt': { $lte: date },
waitingResponse: { $exists: false },
closedAt: { $exists: false },
open: true,
});
};
LivechatRooms.prototype.unsetPredictedVisitorAbandonment = function() {
return this.update({
open: true,
t: 'l',
}, {
$unset: { 'omnichannel.predictedVisitorAbandonmentAt': 1 },
}, {
multi: true,
});
};
export default LivechatRooms;

@ -6,8 +6,11 @@
"Canned Responses": "Canned Responses",
"Canned_Response_Removed": "Canned Response Removed",
"Canned_Responses_Enable": "Enable Canned Responses",
"Closed_automatically": "Closed automatically by the system",
"Edit_Tag": "Edit Tag",
"Edit_Unit": "Edit Unit",
"Enable_omnichannel_auto_close_abandoned_rooms": "Enable automatic closing of rooms abandoned by the visitor",
"Enter_a_custom_message": "Enter a custom message",
"Enterprise_License": "Enterprise License",
"Enterprise_License_Description": "If your workspace is registered and license is provided by Rocket.Chat Cloud you don't need to manually update the license here.",
"Failed_to_add_monitor": "Failed to add monitor",
@ -22,6 +25,7 @@
"LDAP_Roles_To_Rocket_Chat_Roles_Description": "Role mapping in object format where the object key must be the LDAP role and the object value must be an array of RC roles. Example: { 'ldapRole': ['rcRole', 'anotherRCRole'] }",
"LDAP_Validate_Roles_For_Each_Login": "Validate mapping for each login",
"LDAP_Validate_Roles_For_Each_Login_Description": "If the validation should occurs for each login (Be careful with this setting because it will overwrite the user roles in each login, otherwise this will be validated only at the moment of user creation).",
"Livechat_abandoned_rooms_closed_custom_message": "Custom message when room is automatically closed by visitor inactivity",
"Livechat_Monitors": "Monitors",
"Livechat_monitors": "Livechat monitors",
"Max_number_of_chats_per_agent": "Max. number of simultaneous chats",
@ -35,6 +39,7 @@
"New_Tag": "New Tag",
"New_Unit": "New Unit",
"No_Canned_Responses": "No Canned Responses",
"Number_in_seconds": "Number in seconds",
"Number_of_most_recent_chats_estimate_wait_time": "Number of recent chats to calculate estimate wait time",
"Number_of_most_recent_chats_estimate_wait_time_description": "This number defines the number of last served rooms that will be used to calculate queue wait times.",
"Others": "Others",

@ -2,8 +2,11 @@
"error-max-number-simultaneous-chats-reached": "O número máximo de bate-papos simultâneos por agente foi atingido.",
"Add_monitor": "Adicionar Monitor",
"Available_departments": "Departamentos disponíveis",
"Closed_automatically": "Fechado automaticamente pelo sistema",
"Edit_Tag": "Editar Tag",
"Edit_Unit": "Editar Unidade",
"Enable_omnichannel_auto_close_abandoned_rooms": "Habilitar o fechamento automático de salas abandonadas pelo visitante",
"Enter_a_custom_message": "Digite uma mensagem customizada",
"Enterprise_License": "Licença Enterprise",
"Enterprise_License_Description": "Se você registrou seu workspace e a licença foi fornecida pelo Rocket.Chat Cloud você não precisa atualizar a licença manualmente aqui.",
"Invalid_Department": "Departamento inválido",
@ -16,6 +19,7 @@
"LDAP_Roles_To_Rocket_Chat_Roles_Description": "Mapeamento dos papéis que deve ser em formato de objeto onde a chave do objeto precisa ser o nome do papel LDAP e o valor deve ser um array de papéis Rocket.Chat. Exemplo: { 'ldapRole': ['rcRole', 'anotherRCRole'] }",
"LDAP_Validate_Roles_For_Each_Login": "Validar o mapeamento em cada login",
"LDAP_Validate_Roles_For_Each_Login_Description": " Se a validação deve ser feita a cada login(Tenha cuidado com essa configuração, pois ela vai sobrescrever os papéis de usuário a cada login, caso esteja desabilitado, a validação será feita apenas no momento da criação do usuário).",
"Livechat_abandoned_rooms_closed_custom_message": "Mensagem customizada para usar quando a sala for automaticamente fechada por abandono do visitante",
"Livechat_Monitors": "Monitores",
"Livechat_monitors": "Monitores de Livechat",
"Max_number_of_chats_per_agent": "Número máximo de atendimentos simultâneos",
@ -26,6 +30,7 @@
"Monitors": "Monitores",
"New_Tag": "Nova Tag",
"New_Unit": "Nova Unidade",
"Number_in_seconds": "Number in seconds",
"Number_of_most_recent_chats_estimate_wait_time": "Número de chats recentes para cálculo de tempo na fila",
"Number_of_most_recent_chats_estimate_wait_time_description": "Este numero define a quantidade de últimas salas atendidas que serão usadas para calculo de estimativa de tempo de espera da fila.",
"Others": "Outros",

Loading…
Cancel
Save