From f7caaf207c4c7d9c9bd9af89ef35f430724acff0 Mon Sep 17 00:00:00 2001 From: Murtaza Patrawala <34130764+murtaza98@users.noreply.github.com> Date: Mon, 18 Jan 2021 17:11:24 +0530 Subject: [PATCH] [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 * 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 --- .../server/hooks/beforeGetNextAgent.js | 6 +- app/livechat/server/lib/RoutingManager.js | 6 +- .../server/lib/routing/AutoSelection.js | 6 +- app/livechat/server/lib/routing/External.js | 8 +- .../server/models/LivechatDepartmentAgents.js | 6 +- app/models/server/models/LivechatRooms.js | 39 ++++++++ app/models/server/models/Users.js | 10 ++- app/models/server/raw/Users.js | 4 +- .../server/hooks/scheduleAutoTransfer.ts | 84 +++++++++++++++++ ee/app/livechat-enterprise/server/index.js | 1 + .../server/lib/AutoTransferChatScheduler.ts | 89 +++++++++++++++++++ .../server/lib/routing/LoadBalancing.js | 4 +- ee/app/livechat-enterprise/server/settings.js | 12 +++ packages/rocketchat-i18n/i18n/en.i18n.json | 4 +- packages/rocketchat-i18n/i18n/pt-BR.i18n.json | 2 + 15 files changed, 260 insertions(+), 21 deletions(-) create mode 100644 ee/app/livechat-enterprise/server/hooks/scheduleAutoTransfer.ts create mode 100644 ee/app/livechat-enterprise/server/lib/AutoTransferChatScheduler.ts diff --git a/app/livechat/server/hooks/beforeGetNextAgent.js b/app/livechat/server/hooks/beforeGetNextAgent.js index 6151c771f8ce..091a7cfb9c0b 100644 --- a/app/livechat/server/hooks/beforeGetNextAgent.js +++ b/app/livechat/server/hooks/beforeGetNextAgent.js @@ -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'); diff --git a/app/livechat/server/lib/RoutingManager.js b/app/livechat/server/lib/RoutingManager.js index 93b9d1565cf5..0cba8fc3fe89 100644 --- a/app/livechat/server/lib/RoutingManager.js +++ b/app/livechat/server/lib/RoutingManager.js @@ -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; diff --git a/app/livechat/server/lib/routing/AutoSelection.js b/app/livechat/server/lib/routing/AutoSelection.js index 4157159f169d..3e615f420015 100644 --- a/app/livechat/server/lib/routing/AutoSelection.js +++ b/app/livechat/server/lib/routing/AutoSelection.js @@ -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) { diff --git a/app/livechat/server/lib/routing/External.js b/app/livechat/server/lib/routing/External.js index 4cb86e82d995..1323f63ad4a2 100644 --- a/app/livechat/server/lib/routing/External.js +++ b/app/livechat/server/lib/routing/External.js @@ -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', diff --git a/app/models/server/models/LivechatDepartmentAgents.js b/app/models/server/models/LivechatDepartmentAgents.js index d18cb831c8ef..48e27eff0615 100644 --- a/app/models/server/models/LivechatDepartmentAgents.js +++ b/app/models/server/models/LivechatDepartmentAgents.js @@ -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 = { diff --git a/app/models/server/models/LivechatRooms.js b/app/models/server/models/LivechatRooms.js index dcaeff7d7721..a763bbee23dd 100644 --- a/app/models/server/models/LivechatRooms.js +++ b/app/models/server/models/LivechatRooms.js @@ -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, diff --git a/app/models/server/models/Users.js b/app/models/server/models/Users.js index 00538da0b98d..36826d1465a1 100644 --- a/app/models/server/models/Users.js +++ b/app/models/server/models/Users.js @@ -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(); diff --git a/app/models/server/raw/Users.js b/app/models/server/raw/Users.js index 83fcd70f84b9..5893c5ed7a84 100644 --- a/app/models/server/raw/Users.js +++ b/app/models/server/raw/Users.js @@ -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' }, diff --git a/ee/app/livechat-enterprise/server/hooks/scheduleAutoTransfer.ts b/ee/app/livechat-enterprise/server/hooks/scheduleAutoTransfer.ts new file mode 100644 index 000000000000..9fae8fab804d --- /dev/null +++ b/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 => { + 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 => { + 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 => { + 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'); +}); diff --git a/ee/app/livechat-enterprise/server/index.js b/ee/app/livechat-enterprise/server/index.js index 4dd422dbb92f..14e92a66cb9e 100644 --- a/ee/app/livechat-enterprise/server/index.js +++ b/ee/app/livechat-enterprise/server/index.js @@ -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'; diff --git a/ee/app/livechat-enterprise/server/lib/AutoTransferChatScheduler.ts b/ee/app/livechat-enterprise/server/lib/AutoTransferChatScheduler.ts new file mode 100644 index 000000000000..f95557528d2f --- /dev/null +++ b/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 { + 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 { + const jobName = `${ SCHEDULER_NAME }-${ roomId }`; + + await LivechatRooms.unsetAutoTransferOngoingById(roomId); + await this.scheduler.cancel({ name: jobName }); + } + + private async transferRoom(roomId: string): Promise { + 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 { + const { roomId } = data; + + if (await this.transferRoom(roomId)) { + LivechatRooms.setAutoTransferredAtById(roomId); + } + + await this.unscheduleRoom(roomId); + } +} + +export const AutoTransferChatScheduler = new AutoTransferChatSchedulerClass(); + +Meteor.startup(() => { + AutoTransferChatScheduler.init(); +}); diff --git a/ee/app/livechat-enterprise/server/lib/routing/LoadBalancing.js b/ee/app/livechat-enterprise/server/lib/routing/LoadBalancing.js index 7bac91ec8c13..770ae5b97a73 100644 --- a/ee/app/livechat-enterprise/server/lib/routing/LoadBalancing.js +++ b/ee/app/livechat-enterprise/server/lib/routing/LoadBalancing.js @@ -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; } diff --git a/ee/app/livechat-enterprise/server/settings.js b/ee/app/livechat-enterprise/server/settings.js index ee5b5aa41a6f..29c0ea99db90 100644 --- a/ee/app/livechat-enterprise/server/settings.js +++ b/ee/app/livechat-enterprise/server/settings.js @@ -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', { diff --git a/packages/rocketchat-i18n/i18n/en.i18n.json b/packages/rocketchat-i18n/i18n/en.i18n.json index ce2a83c4e69c..3a8f5351099a 100644 --- a/packages/rocketchat-i18n/i18n/en.i18n.json +++ b/packages/rocketchat-i18n/i18n/en.i18n.json @@ -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 [password].", "Your_TOTP_has_been_reset": "Your Two Factor TOTP has been reset.", "Your_workspace_is_ready": "Your workspace is ready to use 🎉" -} \ No newline at end of file +} diff --git a/packages/rocketchat-i18n/i18n/pt-BR.i18n.json b/packages/rocketchat-i18n/i18n/pt-BR.i18n.json index 76b039d91a3a..9e37416bd646 100644 --- a/packages/rocketchat-i18n/i18n/pt-BR.i18n.json +++ b/packages/rocketchat-i18n/i18n/pt-BR.i18n.json @@ -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.",