Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

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

Merged
merged 27 commits into from Jan 18, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
1222137
[Omnichannel] Auto transfer chat based on inactivity
murtaza98 Jan 7, 2021
1996b93
Updated Livechat.transfer() method to support a new param - ignoredUs…
murtaza98 Jan 9, 2021
93f366b
Merge branch 'develop' into livechat/auto-transfer-feature
murtaza98 Jan 9, 2021
4cd4285
Merge branch 'develop' into livechat/auto-transfer-feature
renatobecker Jan 11, 2021
6c38c24
Merge branch 'develop' into livechat/auto-transfer-feature
renatobecker Jan 13, 2021
845cf1c
Apply suggestions from code review
murtaza98 Jan 14, 2021
6e7e970
Apply suggestion from code review
murtaza98 Jan 14, 2021
18c0dfd
Merge branch 'develop' into livechat/auto-transfer-feature
renatobecker Jan 14, 2021
a97b29b
Fix merge conflict
murtaza98 Jan 14, 2021
af44eab
Merge branch 'livechat/auto-transfer-feature' of https://github.com/R…
murtaza98 Jan 14, 2021
f386196
Apply suggestions from code review
murtaza98 Jan 14, 2021
b312e5a
Fix PR review.
renatobecker Jan 15, 2021
8b33c73
cancel previous jobs b4 scheduling new one + minor improvements
murtaza98 Jan 15, 2021
0a539d5
Merge branch 'livechat/auto-transfer-feature' of https://github.com/R…
murtaza98 Jan 15, 2021
8b8bb86
Use a dedicated variable to read setting value.
renatobecker Jan 15, 2021
f52c9ad
[optimize] prevent cancelling job after each message sent
murtaza98 Jan 15, 2021
b7e5d36
Merge branch 'livechat/auto-transfer-feature' of https://github.com/R…
murtaza98 Jan 15, 2021
6a8068e
Improve codebase.
renatobecker Jan 15, 2021
2799708
Remove unnecessary import.
renatobecker Jan 15, 2021
ae0d4e4
Add PT-BR translations.
renatobecker Jan 15, 2021
8119bb1
Fix class methods.
renatobecker Jan 17, 2021
29076a7
Merge branch 'develop' into livechat/auto-transfer-feature
renatobecker Jan 18, 2021
9ae0940
Merge branch 'livechat/auto-transfer-feature' of https://github.com/R…
renatobecker Jan 18, 2021
3fa9453
Improve class code.
renatobecker Jan 18, 2021
1bd7117
Added final improvements to the codebase.
renatobecker Jan 18, 2021
6561b19
remove unnused import files.
renatobecker Jan 18, 2021
54a4907
Move hardcoded variables to const.
renatobecker Jan 18, 2021
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
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);
murtaza98 marked this conversation as resolved.
Show resolved Hide resolved
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, {
murtaza98 marked this conversation as resolved.
Show resolved Hide resolved
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