Skip to content

Commit

Permalink
[NEW][ENTERPRISE] Automatic transfer of unanswered conversations to a…
Browse files Browse the repository at this point in the history
…nother 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>
  • Loading branch information
murtaza98 and renatobecker committed Jan 18, 2021
1 parent 768e709 commit f7caaf2
Show file tree
Hide file tree
Showing 15 changed files with 260 additions and 21 deletions.
6 changes: 3 additions & 3 deletions app/livechat/server/hooks/beforeGetNextAgent.js
Expand Up @@ -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');
6 changes: 3 additions & 3 deletions app/livechat/server/lib/RoutingManager.js
Expand Up @@ -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;
Expand Down
6 changes: 3 additions & 3 deletions app/livechat/server/lib/routing/AutoSelection.js
Expand Up @@ -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) {
Expand Down
8 changes: 6 additions & 2 deletions app/livechat/server/lib/routing/External.js
Expand Up @@ -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',
Expand Down
6 changes: 4 additions & 2 deletions app/models/server/models/LivechatDepartmentAgents.js
Expand Up @@ -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) {
Expand All @@ -70,6 +70,7 @@ export class LivechatDepartmentAgents extends Base {
username: {
$in: onlineUsernames,
},
...ignoreAgentId && { agentId: { $ne: ignoreAgentId } },
};

const sort = {
Expand Down Expand Up @@ -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) {
Expand All @@ -152,6 +153,7 @@ export class LivechatDepartmentAgents extends Base {
username: {
$in: botUsernames,
},
...ignoreAgentId && { agentId: { $ne: ignoreAgentId } },
};

const sort = {
Expand Down
39 changes: 39 additions & 0 deletions app/models/server/models/LivechatRooms.js
Expand Up @@ -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,
Expand Down
10 changes: 7 additions & 3 deletions app/models/server/models/Users.js
Expand Up @@ -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);
Expand All @@ -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();
Expand Down
4 changes: 2 additions & 2 deletions app/models/server/raw/Users.js
Expand Up @@ -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' },
Expand Down
84 changes: 84 additions & 0 deletions ee/app/livechat-enterprise/server/hooks/scheduleAutoTransfer.ts
@@ -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');
});
1 change: 1 addition & 0 deletions ee/app/livechat-enterprise/server/index.js
Expand Up @@ -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';
Expand Down
89 changes: 89 additions & 0 deletions ee/app/livechat-enterprise/server/lib/AutoTransferChatScheduler.ts
@@ -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();
});
Expand Up @@ -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;
}
Expand Down
12 changes: 12 additions & 0 deletions ee/app/livechat-enterprise/server/settings.js
Expand Up @@ -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', {
Expand Down

0 comments on commit f7caaf2

Please sign in to comment.