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 12 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');
19 changes: 16 additions & 3 deletions app/livechat/server/lib/RoutingManager.js
Expand Up @@ -13,6 +13,7 @@ import {
import { callbacks } from '../../../callbacks/server';
import { LivechatRooms, Rooms, Messages, Users, LivechatInquiry } from '../../../models/server';
import { Apps, AppEvents } from '../../../apps/server';
import { Livechat } from './Livechat';

export const RoutingManager = {
methodName: null,
Expand All @@ -37,11 +38,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 Expand Up @@ -151,6 +152,18 @@ export const RoutingManager = {
},

async transferRoom(room, guest, transferData) {
if (transferData.ignoreAgentId) {
const agent = await RoutingManager.getNextAgent(transferData.departmentId, transferData.ignoreAgentId);
if (agent) {
transferData.userId = agent.agentId;
transferData.transferredTo = agent;
return forwardRoomToAgent(room, transferData);
}
if (!RoutingManager.getConfig().autoAssignAgent) {
return Livechat.returnRoomAsInquiry(room._id, room.departmentId);
}
}

if (transferData.departmentId) {
return forwardRoomToDepartment(room, guest, transferData);
}
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
13 changes: 13 additions & 0 deletions app/models/server/models/LivechatRooms.js
Expand Up @@ -652,6 +652,19 @@ 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);
}

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
57 changes: 57 additions & 0 deletions ee/app/livechat-enterprise/server/hooks/scheduleAutoTransfer.ts
@@ -0,0 +1,57 @@
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 });
if (!room || room.autoTransferredAt) {
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 } = room;
const { token } = message;

if (!rid || !message || rid === '' || t !== 'l' || token) {
return message;
}

if (autoTransferredAt) {
return message;
}

// TODO: We can't call this process all time, if need to know wheter the room transfer is scheduled or not
await AutoTransferChatScheduler.unscheduleRoom(rid);
murtaza98 marked this conversation as resolved.
Show resolved Hide resolved

return message;
};


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');
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');
});
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
82 changes: 82 additions & 0 deletions ee/app/livechat-enterprise/server/lib/AutoTransferChatScheduler.ts
@@ -0,0 +1,82 @@
import Agenda from 'agenda';
import { MongoInternals } from 'meteor/mongo';
import { Meteor } from 'meteor/meteor';
import { TAPi18n } from 'meteor/rocketchat:tap-i18n';

import { LivechatRooms, LivechatVisitors, Users } from '../../../../../app/models/server';
import { Livechat } from '../../../../../app/livechat/server';
import { settings } from '../../../../../app/settings/server';


class AutoTransferChatSchedulerClass {
scheduler: Agenda;

running: boolean;

public init(): void {
if (this.running) {
return;
}

this.scheduler = new Agenda({
mongo: (MongoInternals.defaultRemoteCollectionDriver().mongo as any).client.db(),
db: { collection: 'livechat_scheduler' },
// this ensures the same job doesn't get executed multiple times in a cluster
defaultConcurrency: 1,
});
this.scheduler.start();
this.running = true;
}

public async scheduleRoom(roomId: string, timeout: number): Promise<void> {
const jobName = `livechat-auto-transfer-${ roomId }`;
const when = this.addMinutesToDate(new Date(), timeout);

this.scheduler.define(jobName, this.autoTransferVisitorJob);

await this.scheduler.schedule(when, jobName, { roomId });
}

public async unscheduleRoom(roomId: string): Promise<void> {
const jobName = `livechat-auto-transfer-${ roomId }`;

await this.scheduler.cancel({ name: jobName });
}

private async autoTransferVisitorJob({ attrs: { data } }: any = {}): Promise<void> {
const { roomId } = data;
const schedulerUser: any = Users.findOneById('rocket.cat');

try {
const room = await LivechatRooms.findOneById(roomId, { _id: 1, v: 1, servedBy: 1, open: 1, departmentId: 1 });
const timeout = await settings.get('Livechat_auto_transfer_chat_timeout');
const { departmentId, v: { token }, servedBy: { _id: ignoreAgentId, username } = { _id: null, username: null } } = room;

const guest = await LivechatVisitors.getVisitorByToken(token, {});
const transferData = {
ignoreAgentId,
departmentId,
transferredBy: schedulerUser,
comment: TAPi18n.__('Livechat_auto_transfer_chat_message', { username, timeout }),
};
try {
murtaza98 marked this conversation as resolved.
Show resolved Hide resolved
await LivechatRooms.setAutoTransferredAtById(roomId);
await Livechat.transfer(room, guest, transferData);
} catch (err) {
console.error(`Error occurred while transferring chat. Details: ${ err.message }`);
}
} catch (err) {
console.error(err);
}
}

private addMinutesToDate(date: Date, minutes: number): Date {
return new Date(date.getTime() + minutes * 1000 * 60);
}
}

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
5 changes: 4 additions & 1 deletion packages/rocketchat-i18n/i18n/en.i18n.json
Expand Up @@ -2338,6 +2338,9 @@
"Livechat_Agents": "Agents",
"Livechat_AllowedDomainsList": "Livechat Allowed Domains",
"Livechat_Appearance": "Livechat Appearance",
"Livechat_auto_transfer_chat_message": "The chat was transferred because __username__ had not replied for __timeout__ minute(s)",
"Livechat_auto_transfer_chat_timeout": "Timeout (in minutes) 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.",
Expand Down Expand Up @@ -4196,4 +4199,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 🎉"
}
}