[NEW][ENTERPRISE] Automatic transfer of unanswered conversations to another agent (#20090)

* [Omnichannel] Auto transfer chat based on inactivity

* Updated Livechat.transfer() method to support a new param - ignoredUserId

- This will prevent the transfer to the same agent
- For Manual selection method, place the chat back to the queue, if the agents doesn't respond back within the set duration

* Apply suggestions from code review

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

* Apply suggestion from code review

* Fix merge conflict

* Apply suggestions from code review

* Fix PR review.

* cancel previous jobs b4 scheduling new one + minor improvements

* Use a dedicated variable to read setting value.

* [optimize] prevent cancelling job after each message sent

* Improve codebase.

* Remove unnecessary import.

* Add PT-BR translations.

* Fix class methods.

* Improve class code.

* Added final improvements to the codebase.

* remove unnused import files.

* Move hardcoded variables to const.

Co-authored-by: Renato Becker <renato.augusto.becker@gmail.com>
pull/20196/head^2
Murtaza Patrawala 5 years ago committed by GitHub
parent 768e709234
commit f7caaf207c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 6
      app/livechat/server/hooks/beforeGetNextAgent.js
  2. 6
      app/livechat/server/lib/RoutingManager.js
  3. 6
      app/livechat/server/lib/routing/AutoSelection.js
  4. 8
      app/livechat/server/lib/routing/External.js
  5. 6
      app/models/server/models/LivechatDepartmentAgents.js
  6. 39
      app/models/server/models/LivechatRooms.js
  7. 10
      app/models/server/models/Users.js
  8. 4
      app/models/server/raw/Users.js
  9. 84
      ee/app/livechat-enterprise/server/hooks/scheduleAutoTransfer.ts
  10. 1
      ee/app/livechat-enterprise/server/index.js
  11. 89
      ee/app/livechat-enterprise/server/lib/AutoTransferChatScheduler.ts
  12. 4
      ee/app/livechat-enterprise/server/lib/routing/LoadBalancing.js
  13. 12
      ee/app/livechat-enterprise/server/settings.js
  14. 4
      packages/rocketchat-i18n/i18n/en.i18n.json
  15. 2
      packages/rocketchat-i18n/i18n/pt-BR.i18n.json

@ -3,14 +3,14 @@ import { callbacks } from '../../../callbacks';
import { settings } from '../../../settings';
import { Users, LivechatDepartmentAgents } from '../../../models';
callbacks.add('livechat.beforeGetNextAgent', (department) => {
callbacks.add('livechat.beforeGetNextAgent', (department, ignoreAgentId) => {
if (!settings.get('Livechat_assign_new_conversation_to_bot')) {
return null;
}
if (department) {
return LivechatDepartmentAgents.getNextBotForDepartment(department);
return LivechatDepartmentAgents.getNextBotForDepartment(department, ignoreAgentId);
}
return Users.getNextBotAgent();
return Users.getNextBotAgent(ignoreAgentId);
}, callbacks.priority.HIGH, 'livechat-before-get-next-agent');

@ -37,11 +37,11 @@ export const RoutingManager = {
return this.getMethod().config || {};
},
async getNextAgent(department) {
let agent = callbacks.run('livechat.beforeGetNextAgent', department);
async getNextAgent(department, ignoreAgentId) {
let agent = callbacks.run('livechat.beforeGetNextAgent', department, ignoreAgentId);
if (!agent) {
agent = await this.getMethod().getNextAgent(department);
agent = await this.getMethod().getNextAgent(department, ignoreAgentId);
}
return agent;

@ -19,12 +19,12 @@ class AutoSelection {
};
}
getNextAgent(department) {
getNextAgent(department, ignoreAgentId) {
if (department) {
return LivechatDepartmentAgents.getNextAgentForDepartment(department);
return LivechatDepartmentAgents.getNextAgentForDepartment(department, ignoreAgentId);
}
return Users.getNextAgent();
return Users.getNextAgent(ignoreAgentId);
}
delegateAgent(agent) {

@ -18,10 +18,14 @@ class ExternalQueue {
};
}
getNextAgent(department) {
getNextAgent(department, ignoreAgentId) {
for (let i = 0; i < 10; i++) {
try {
const queryString = department ? `?departmentId=${ department }` : '';
let queryString = department ? `?departmentId=${ department }` : '';
if (ignoreAgentId) {
const ignoreAgentIdParam = `ignoreAgentId=${ ignoreAgentId }`;
queryString = queryString.startsWith('?') ? `${ queryString }&${ ignoreAgentIdParam }` : `?${ ignoreAgentIdParam }`;
}
const result = HTTP.call('GET', `${ settings.get('Livechat_External_Queue_URL') }${ queryString }`, {
headers: {
'User-Agent': 'RocketChat Server',

@ -54,7 +54,7 @@ export class LivechatDepartmentAgents extends Base {
this.remove({ departmentId });
}
getNextAgentForDepartment(departmentId) {
getNextAgentForDepartment(departmentId, ignoreAgentId) {
const agents = this.findByDepartmentId(departmentId).fetch();
if (agents.length === 0) {
@ -70,6 +70,7 @@ export class LivechatDepartmentAgents extends Base {
username: {
$in: onlineUsernames,
},
...ignoreAgentId && { agentId: { $ne: ignoreAgentId } },
};
const sort = {
@ -137,7 +138,7 @@ export class LivechatDepartmentAgents extends Base {
return this.find(query);
}
getNextBotForDepartment(departmentId) {
getNextBotForDepartment(departmentId, ignoreAgentId) {
const agents = this.findByDepartmentId(departmentId).fetch();
if (agents.length === 0) {
@ -152,6 +153,7 @@ export class LivechatDepartmentAgents extends Base {
username: {
$in: botUsernames,
},
...ignoreAgentId && { agentId: { $ne: ignoreAgentId } },
};
const sort = {

@ -652,6 +652,45 @@ export class LivechatRooms extends Base {
return this.update(query, update);
}
setAutoTransferredAtById(roomId) {
const query = {
_id: roomId,
};
const update = {
$set: {
autoTransferredAt: new Date(),
},
};
return this.update(query, update);
}
setAutoTransferOngoingById(roomId) {
const query = {
_id: roomId,
};
const update = {
$set: {
autoTransferOngoing: true,
},
};
return this.update(query, update);
}
unsetAutoTransferOngoingById(roomId) {
const query = {
_id: roomId,
};
const update = {
$unset: {
autoTransferOngoing: 1,
},
};
return this.update(query, update);
}
changeVisitorByRoomId(roomId, { _id, username, token }) {
const query = {
_id: roomId,

@ -174,8 +174,11 @@ export class Users extends Base {
return this.find(query);
}
getNextAgent() {
const query = queryStatusAgentOnline();
getNextAgent(ignoreAgentId) {
const extraFilters = {
...ignoreAgentId && { _id: { $ne: ignoreAgentId } },
};
const query = queryStatusAgentOnline(extraFilters);
const collectionObj = this.model.rawCollection();
const findAndModify = Meteor.wrapAsync(collectionObj.findAndModify, collectionObj);
@ -201,11 +204,12 @@ export class Users extends Base {
return null;
}
getNextBotAgent() {
getNextBotAgent(ignoreAgentId) {
const query = {
roles: {
$all: ['bot', 'livechat-agent'],
},
...ignoreAgentId && { _id: { $ne: ignoreAgentId } },
};
const collectionObj = this.model.rawCollection();

@ -133,9 +133,9 @@ export class UsersRaw extends BaseRaw {
return this.col.distinct('federation.origin', { federation: { $exists: true } });
}
async getNextLeastBusyAgent(department) {
async getNextLeastBusyAgent(department, ignoreAgentId) {
const aggregate = [
{ $match: { status: { $exists: true, $ne: 'offline' }, statusLivechat: 'available', roles: 'livechat-agent' } },
{ $match: { status: { $exists: true, $ne: 'offline' }, statusLivechat: 'available', roles: 'livechat-agent', ...ignoreAgentId && { _id: { $ne: ignoreAgentId } } } },
{ $lookup: {
from: 'rocketchat_subscription',
let: { id: '$_id' },

@ -0,0 +1,84 @@
import { AutoTransferChatScheduler } from '../lib/AutoTransferChatScheduler';
import { callbacks } from '../../../../../app/callbacks/server';
import { settings } from '../../../../../app/settings/server';
import { LivechatRooms } from '../../../../../app/models/server';
let autoTransferTimeout = 0;
const handleAfterTakeInquiryCallback = async (inquiry: any = {}): Promise<any> => {
const { rid } = inquiry;
if (!rid || !rid.trim()) {
return;
}
if (!autoTransferTimeout || autoTransferTimeout <= 0) {
return inquiry;
}
const room = LivechatRooms.findOneById(rid, { autoTransferredAt: 1, autoTransferOngoing: 1 });
if (!room || room.autoTransferredAt || room.autoTransferOngoing) {
return inquiry;
}
await AutoTransferChatScheduler.scheduleRoom(rid, autoTransferTimeout as number);
return inquiry;
};
const handleAfterSaveMessage = async (message: any = {}, room: any = {}): Promise<any> => {
const { _id: rid, t, autoTransferredAt, autoTransferOngoing } = room;
const { token } = message;
if (!autoTransferTimeout || autoTransferTimeout <= 0) {
return message;
}
if (!rid || !message || rid === '' || t !== 'l' || token) {
return message;
}
if (autoTransferredAt) {
return message;
}
if (!autoTransferOngoing) {
return message;
}
await AutoTransferChatScheduler.unscheduleRoom(rid);
return message;
};
const handleAfterCloseRoom = async (room: any = {}): Promise<any> => {
const { _id: rid, autoTransferredAt, autoTransferOngoing } = room;
if (!autoTransferTimeout || autoTransferTimeout <= 0) {
return room;
}
if (autoTransferredAt) {
return room;
}
if (!autoTransferOngoing) {
return room;
}
await AutoTransferChatScheduler.unscheduleRoom(rid);
return room;
};
settings.get('Livechat_auto_transfer_chat_timeout', function(_, value) {
autoTransferTimeout = value as number;
if (!autoTransferTimeout || autoTransferTimeout === 0) {
callbacks.remove('livechat.afterTakeInquiry', 'livechat-auto-transfer-job-inquiry');
callbacks.remove('afterSaveMessage', 'livechat-cancel-auto-transfer-job-after-message');
callbacks.remove('livechat.closeRoom', 'livechat-cancel-auto-transfer-on-close-room');
return;
}
callbacks.add('livechat.afterTakeInquiry', handleAfterTakeInquiryCallback, callbacks.priority.MEDIUM, 'livechat-auto-transfer-job-inquiry');
callbacks.add('afterSaveMessage', handleAfterSaveMessage, callbacks.priority.HIGH, 'livechat-cancel-auto-transfer-job-after-message');
callbacks.add('livechat.closeRoom', handleAfterCloseRoom, callbacks.priority.HIGH, 'livechat-cancel-auto-transfer-on-close-room');
});

@ -25,6 +25,7 @@ import './hooks/onCheckRoomParamsApi';
import './hooks/onLoadConfigApi';
import './hooks/onCloseLivechat';
import './hooks/onSaveVisitorInfo';
import './hooks/scheduleAutoTransfer';
import './lib/routing/LoadBalancing';
import { onLicense } from '../../license/server';
import './business-hour';

@ -0,0 +1,89 @@
import Agenda from 'agenda';
import { MongoInternals } from 'meteor/mongo';
import { Meteor } from 'meteor/meteor';
import { LivechatRooms, Users } from '../../../../../app/models/server';
import { Livechat } from '../../../../../app/livechat/server';
import { RoutingManager } from '../../../../../app/livechat/server/lib/RoutingManager';
import { forwardRoomToAgent } from '../../../../../app/livechat/server/lib/Helper';
const schedulerUser = Users.findOneById('rocket.cat');
const SCHEDULER_NAME = 'omnichannel_scheduler';
class AutoTransferChatSchedulerClass {
scheduler: Agenda;
running: boolean;
user: {};
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): Promise<void> {
await this.unscheduleRoom(roomId);
const jobName = `${ SCHEDULER_NAME }-${ roomId }`;
const when = new Date();
when.setSeconds(when.getSeconds() + timeout);
this.scheduler.define(jobName, this.executeJob.bind(this));
await this.scheduler.schedule(when, jobName, { roomId });
await LivechatRooms.setAutoTransferOngoingById(roomId);
}
public async unscheduleRoom(roomId: string): Promise<void> {
const jobName = `${ SCHEDULER_NAME }-${ roomId }`;
await LivechatRooms.unsetAutoTransferOngoingById(roomId);
await this.scheduler.cancel({ name: jobName });
}
private async transferRoom(roomId: string): Promise<boolean> {
const room = LivechatRooms.findOneById(roomId, { _id: 1, v: 1, servedBy: 1, open: 1, departmentId: 1 });
if (!room?.open || !room?.servedBy?._id) {
return false;
}
const { departmentId, servedBy: { _id: ignoreAgentId } } = room;
if (!RoutingManager.getConfig().autoAssignAgent) {
return Livechat.returnRoomAsInquiry(room._id, departmentId);
}
const agent = await RoutingManager.getNextAgent(departmentId, ignoreAgentId);
if (agent) {
return forwardRoomToAgent(room, { userId: agent.agentId, transferredBy: schedulerUser, transferredTo: agent });
}
return false;
}
private async executeJob({ attrs: { data } }: any = {}): Promise<void> {
const { roomId } = data;
if (await this.transferRoom(roomId)) {
LivechatRooms.setAutoTransferredAtById(roomId);
}
await this.unscheduleRoom(roomId);
}
}
export const AutoTransferChatScheduler = new AutoTransferChatSchedulerClass();
Meteor.startup(() => {
AutoTransferChatScheduler.init();
});

@ -19,8 +19,8 @@ class LoadBalancing {
};
}
async getNextAgent(department) {
const nextAgent = await Users.getNextLeastBusyAgent(department);
async getNextAgent(department, ignoreAgentId) {
const nextAgent = await Users.getNextLeastBusyAgent(department, ignoreAgentId);
if (!nextAgent) {
return;
}

@ -90,6 +90,18 @@ 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', {

@ -2339,6 +2339,8 @@
"Livechat_Agents": "Agents",
"Livechat_AllowedDomainsList": "Livechat Allowed Domains",
"Livechat_Appearance": "Livechat Appearance",
"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_business_hour_type": "Business Hour Type (Single or Multiple)",
"Livechat_chat_transcript_sent": "Chat transcript sent: __transcript__",
"Livechat_custom_fields_options_placeholder": "Comma-separated list used to select a pre-configured value. Spaces between elements are not accepted.",
@ -4198,4 +4200,4 @@
"Your_temporary_password_is_password": "Your temporary password is <strong>[password]</strong>.",
"Your_TOTP_has_been_reset": "Your Two Factor TOTP has been reset.",
"Your_workspace_is_ready": "Your workspace is ready to use 🎉"
}
}

@ -2024,6 +2024,8 @@
"Livechat_Agents": "Agentes",
"Livechat_AllowedDomainsList": "Domínios permitidos em Livechat",
"Livechat_Appearance": "Aparência do Livechat",
"Livechat_auto_transfer_chat_timeout": "Tempo limite (em segundos) para transferência automática de conversas não respondidas pelo agente",
"Livechat_auto_transfer_chat_timeout_description" : "Este evento ocorre apenas quando a conversa foi iniciada. Após a primeira transferência por inatividade a conversa não será mais monitorada.",
"Livechat_business_hour_type": "Tipo de Horário de expediente (Único ou Múltiplo)",
"Livechat_chat_transcript_sent": "Transcrição de bate-papo enviada: __transcript__",
"Livechat_custom_fields_options_placeholder": "Lista separada por vírgula usada para selecionar um valor pré-configurado. Espaços entre elementos não são aceitos.",

Loading…
Cancel
Save