diff --git a/.changeset/cool-coins-agree.md b/.changeset/cool-coins-agree.md new file mode 100644 index 0000000000000..d89d07c1fa384 --- /dev/null +++ b/.changeset/cool-coins-agree.md @@ -0,0 +1,7 @@ +--- +"@rocket.chat/meteor": patch +"@rocket.chat/model-typings": patch +"@rocket.chat/models": patch +--- + +Fixes a bug that caused routing algorithms to ignore the `Livechat_enabled_when_agent_idle` setting, effectively ignoring idle users from being assigned to inquiries. diff --git a/apps/meteor/app/livechat/server/lib/LivechatTyped.ts b/apps/meteor/app/livechat/server/lib/LivechatTyped.ts index 193f6cdbc60ac..d85deb237b711 100644 --- a/apps/meteor/app/livechat/server/lib/LivechatTyped.ts +++ b/apps/meteor/app/livechat/server/lib/LivechatTyped.ts @@ -433,7 +433,7 @@ class LivechatClass { async checkOnlineAgents(department?: string, agent?: { agentId: string }, skipFallbackCheck = false): Promise { if (agent?.agentId) { - return Users.checkOnlineAgents(agent.agentId); + return Users.checkOnlineAgents(agent.agentId, settings.get('Livechat_enabled_when_agent_idle')); } if (department) { @@ -452,7 +452,7 @@ class LivechatClass { return this.checkOnlineAgents(dep?.fallbackForwardDepartment); } - return Users.checkOnlineAgents(); + return Users.checkOnlineAgents(undefined, settings.get('Livechat_enabled_when_agent_idle')); } async removeRoom(rid: string) { diff --git a/apps/meteor/app/livechat/server/lib/getOnlineAgents.ts b/apps/meteor/app/livechat/server/lib/getOnlineAgents.ts index be92a7cfcc545..69b89cdc1c907 100644 --- a/apps/meteor/app/livechat/server/lib/getOnlineAgents.ts +++ b/apps/meteor/app/livechat/server/lib/getOnlineAgents.ts @@ -2,9 +2,11 @@ import type { ILivechatAgent, SelectedAgent } from '@rocket.chat/core-typings'; import { Users, LivechatDepartmentAgents } from '@rocket.chat/models'; import type { FindCursor } from 'mongodb'; +import { settings } from '../../../settings/server'; + export async function getOnlineAgents(department?: string, agent?: SelectedAgent | null): Promise | undefined> { if (agent?.agentId) { - return Users.findOnlineAgents(agent.agentId); + return Users.findOnlineAgents(agent.agentId, settings.get('Livechat_enabled_when_agent_idle')); } if (department) { @@ -20,5 +22,5 @@ export async function getOnlineAgents(department?: string, agent?: SelectedAgent return Users.findByIds([...new Set(agentIds)]); } - return Users.findOnlineAgents(); + return Users.findOnlineAgents(undefined, settings.get('Livechat_enabled_when_agent_idle')); } diff --git a/apps/meteor/app/livechat/server/lib/routing/AutoSelection.ts b/apps/meteor/app/livechat/server/lib/routing/AutoSelection.ts index f526280c757b4..ee3385879cc4c 100644 --- a/apps/meteor/app/livechat/server/lib/routing/AutoSelection.ts +++ b/apps/meteor/app/livechat/server/lib/routing/AutoSelection.ts @@ -38,7 +38,7 @@ class AutoSelection implements IRoutingMethod { ); } - return Users.getNextAgent(ignoreAgentId, extraQuery); + return Users.getNextAgent(ignoreAgentId, extraQuery, settings.get('Livechat_enabled_when_agent_idle')); } } diff --git a/apps/meteor/ee/app/livechat-enterprise/server/lib/routing/LoadBalancing.ts b/apps/meteor/ee/app/livechat-enterprise/server/lib/routing/LoadBalancing.ts index 638b1e95545bd..b909e00b27a54 100644 --- a/apps/meteor/ee/app/livechat-enterprise/server/lib/routing/LoadBalancing.ts +++ b/apps/meteor/ee/app/livechat-enterprise/server/lib/routing/LoadBalancing.ts @@ -1,6 +1,7 @@ import { Users } from '@rocket.chat/models'; import { RoutingManager } from '../../../../../../app/livechat/server/lib/RoutingManager'; +import { settings } from '../../../../../../app/settings/server'; import type { IRoutingManagerConfig } from '../../../../../../definition/IRoutingManagerConfig'; /* Load Balancing Queuing method: @@ -28,7 +29,11 @@ class LoadBalancing { } async getNextAgent(department?: string, ignoreAgentId?: string) { - const nextAgent = await Users.getNextLeastBusyAgent(department, ignoreAgentId); + const nextAgent = await Users.getNextLeastBusyAgent( + department, + ignoreAgentId, + settings.get('Livechat_enabled_when_agent_idle'), + ); if (!nextAgent) { return; } diff --git a/apps/meteor/ee/app/livechat-enterprise/server/lib/routing/LoadRotation.ts b/apps/meteor/ee/app/livechat-enterprise/server/lib/routing/LoadRotation.ts index a3e24296b29f5..b520a1b2a2a35 100644 --- a/apps/meteor/ee/app/livechat-enterprise/server/lib/routing/LoadRotation.ts +++ b/apps/meteor/ee/app/livechat-enterprise/server/lib/routing/LoadRotation.ts @@ -2,6 +2,7 @@ import type { IOmnichannelCustomAgent } from '@rocket.chat/core-typings'; import { Users } from '@rocket.chat/models'; import { RoutingManager } from '../../../../../../app/livechat/server/lib/RoutingManager'; +import { settings } from '../../../../../../app/settings/server'; import type { IRoutingManagerConfig } from '../../../../../../definition/IRoutingManagerConfig'; /* Load Rotation Queuing method: @@ -27,7 +28,11 @@ class LoadRotation { } public async getNextAgent(department?: string, ignoreAgentId?: string): Promise { - const nextAgent = await Users.getLastAvailableAgentRouted(department, ignoreAgentId); + const nextAgent = await Users.getLastAvailableAgentRouted( + department, + ignoreAgentId, + settings.get('Livechat_enabled_when_agent_idle'), + ); if (!nextAgent) { return; } diff --git a/apps/meteor/tests/data/livechat/rooms.ts b/apps/meteor/tests/data/livechat/rooms.ts index 0ca00556a02dc..9262db61b67d6 100644 --- a/apps/meteor/tests/data/livechat/rooms.ts +++ b/apps/meteor/tests/data/livechat/rooms.ts @@ -227,6 +227,14 @@ export const createManager = (overrideUsername?: string): Promise => { + await request + .post(api('livechat/agent.status')) + .set(overrideCredentials || credentials) + .send({ status }) + .expect(200); +}; + export const makeAgentAvailable = async (overrideCredentials?: Credentials): Promise => { await restorePermissionToRoles('view-l-room'); await request diff --git a/apps/meteor/tests/data/users.helper.ts b/apps/meteor/tests/data/users.helper.ts index ceeb671d3a4be..1022edef686ed 100644 --- a/apps/meteor/tests/data/users.helper.ts +++ b/apps/meteor/tests/data/users.helper.ts @@ -2,7 +2,7 @@ import type { Credentials } from '@rocket.chat/api-client'; import type { IUser } from '@rocket.chat/core-typings'; import { UserStatus } from '@rocket.chat/core-typings'; -import { api, credentials, request } from './api-data'; +import { api, credentials, methodCall, request } from './api-data'; import { password } from './user'; export type TestUser = TUser & { username: string; emails: string[] }; @@ -100,3 +100,29 @@ export const setUserStatus = (overrideCredentials = credentials, status = UserSt message: '', status, }); + +export const setUserAway = (overrideCredentials = credentials) => + request + .post(methodCall('UserPresence:away')) + .set(overrideCredentials) + .send({ + message: JSON.stringify({ + method: 'UserPresence:away', + params: [], + id: 'id', + msg: 'method', + }), + }); + +export const setUserOnline = (overrideCredentials = credentials) => + request + .post(methodCall('UserPresence:online')) + .set(overrideCredentials) + .send({ + message: JSON.stringify({ + method: 'UserPresence:online', + params: [], + id: 'id', + msg: 'method', + }), + }); diff --git a/apps/meteor/tests/e2e/omnichannel/omnichannel-livechat-api.spec.ts b/apps/meteor/tests/e2e/omnichannel/omnichannel-livechat-api.spec.ts index e14e3b43fbe30..18e57dbdfbb71 100644 --- a/apps/meteor/tests/e2e/omnichannel/omnichannel-livechat-api.spec.ts +++ b/apps/meteor/tests/e2e/omnichannel/omnichannel-livechat-api.spec.ts @@ -233,6 +233,7 @@ test.describe('OC - Livechat API', () => { await addAgentToDepartment(api, { department: departmentA, agentId: agent.data._id }); await addAgentToDepartment(api, { department: departmentB, agentId: agent2.data._id }); expect((await api.post('/settings/Livechat_offline_email', { value: 'test@testing.com' })).status()).toBe(200); + await api.post('/settings/Livechat_enabled_when_agent_idle', { value: false }); }); test.beforeEach(async ({ browser }, testInfo) => { @@ -266,6 +267,7 @@ test.describe('OC - Livechat API', () => { await expect((await api.post('/settings/Omnichannel_enable_department_removal', { value: true })).status()).toBe(200); await Promise.all([...departments.map((department) => department.delete())]); await expect((await api.post('/settings/Omnichannel_enable_department_removal', { value: false })).status()).toBe(200); + await api.post('/settings/Livechat_enabled_when_agent_idle', { value: true }); }); // clearBusinessUnit @@ -698,6 +700,7 @@ test.describe('OC - Livechat API', () => { test.beforeAll(async ({ api }) => { agent = await createAgent(api, 'user1'); expect((await api.post('/settings/Livechat_offline_email', { value: 'test@testing.com' })).status()).toBe(200); + await api.post('/settings/Livechat_enabled_when_agent_idle', { value: false }); }); test.beforeEach(async ({ browser }, testInfo) => { @@ -723,8 +726,9 @@ test.describe('OC - Livechat API', () => { await page.close(); }); - test.afterAll(async () => { + test.afterAll(async ({ api }) => { await agent.delete(); + await api.post('/settings/Livechat_enabled_when_agent_idle', { value: true }); }); test('OC - Livechat API - onChatMaximized & onChatMinimized', async () => { @@ -856,6 +860,7 @@ test.describe('OC - Livechat API', () => { const newVisitor = createFakeVisitor(); await poAuxContext.poHomeOmnichannel.sidenav.switchStatus('offline'); + await poAuxContext.poHomeOmnichannel.sidenav.switchOmnichannelStatus('offline'); const watchForTrigger = page.waitForFunction(() => window.onOfflineFormSubmit === true); diff --git a/apps/meteor/tests/end-to-end/api/livechat/24-routing.ts b/apps/meteor/tests/end-to-end/api/livechat/24-routing.ts index 5a35547460646..4a36699bfd29b 100644 --- a/apps/meteor/tests/end-to-end/api/livechat/24-routing.ts +++ b/apps/meteor/tests/end-to-end/api/livechat/24-routing.ts @@ -14,11 +14,12 @@ import { createLivechatRoom, getLivechatRoomInfo, makeAgentUnavailable, + switchLivechatStatus, } from '../../../data/livechat/rooms'; import { sleep } from '../../../data/livechat/utils'; import { updateSetting } from '../../../data/permissions.helper'; import { password } from '../../../data/user'; -import { createUser, deleteUser, login, setUserActiveStatus, setUserStatus } from '../../../data/users.helper'; +import { createUser, deleteUser, login, setUserActiveStatus, setUserAway, setUserStatus } from '../../../data/users.helper'; import { IS_EE } from '../../../e2e/config/constants'; (IS_EE ? describe : describe.skip)('Omnichannel - Routing', () => { @@ -271,7 +272,14 @@ import { IS_EE } from '../../../e2e/config/constants'; }); }); - after(async () => Promise.all([deleteUser(testUser.user), deleteUser(testUser2.user), deleteUser(testUser3.user)])); + after(async () => + Promise.all([ + deleteUser(testUser.user), + deleteUser(testUser2.user), + deleteUser(testUser3.user), + updateSetting('Livechat_enabled_when_agent_idle', true), + ]), + ); it('should route a room to an available agent', async () => { const visitor = await createVisitor(testDepartment._id); @@ -364,6 +372,29 @@ import { IS_EE } from '../../../e2e/config/constants'; const roomInfo = await getLivechatRoomInfo(room._id); expect(roomInfo.servedBy).to.be.undefined; }); + it('should not route to an idle user', async () => { + await setUserStatus(testUser.credentials, UserStatus.AWAY); + await setUserAway(testUser.credentials); + await setUserStatus(testUser3.credentials, UserStatus.AWAY); + await setUserAway(testUser3.credentials); + // Agent is available but should be ignored + await switchLivechatStatus('available', testUser.credentials); + + const visitor = await createVisitor(testDepartment._id); + const room = await createLivechatRoom(visitor.token); + + const roomInfo = await getLivechatRoomInfo(room._id); + expect(roomInfo.servedBy).to.be.undefined; + }); + it('should route to an idle user', async () => { + await updateSetting('Livechat_enabled_when_agent_idle', true); + + const visitor = await createVisitor(testDepartment._id); + const room = await createLivechatRoom(visitor.token); + + const roomInfo = await getLivechatRoomInfo(room._id); + expect(roomInfo.servedBy).to.be.an('object'); + }); it('should route to another available agent if contact manager is unavailable and Omnichannel_contact_manager_routing is enabled', async () => { await makeAgentAvailable(testUser.credentials); const visitor = await createVisitor(testDepartment._id, faker.person.fullName(), visitorEmail); @@ -505,6 +536,35 @@ import { IS_EE } from '../../../e2e/config/constants'; expect(roomInfo.servedBy).to.be.an('object'); expect(roomInfo.servedBy?._id).to.be.equal(testUser2.user._id); }); + it('should not route to an idle user', async () => { + await updateSetting('Livechat_enabled_when_agent_idle', false); + await setUserStatus(testUser.credentials, UserStatus.AWAY); + await setUserAway(testUser.credentials); + await setUserStatus(testUser2.credentials, UserStatus.AWAY); + await setUserAway(testUser2.credentials); + // Agent is available but should be ignored + await switchLivechatStatus('available', testUser.credentials); + + const visitor = await createVisitor(testDepartment._id); + const room = await createLivechatRoom(visitor.token); + + const roomInfo = await getLivechatRoomInfo(room._id); + expect(roomInfo.servedBy).to.be.undefined; + }); + it('should route to agents even if theyre idle when setting is enabled', async () => { + await updateSetting('Livechat_enabled_when_agent_idle', true); + await setUserStatus(testUser.credentials, UserStatus.AWAY); + await setUserStatus(testUser2.credentials, UserStatus.AWAY); + + const visitor = await createVisitor(testDepartment._id); + const room = await createLivechatRoom(visitor.token); + + await sleep(5000); + + const roomInfo = await getLivechatRoomInfo(room._id); + // Not checking who, just checking it's served + expect(roomInfo.servedBy).to.be.an('object'); + }); }); describe('Load Rotation', () => { before(async () => { @@ -594,5 +654,32 @@ import { IS_EE } from '../../../e2e/config/constants'; expect(roomInfo.servedBy).to.be.an('object'); expect(roomInfo.servedBy?._id).to.be.equal(testUser.user._id); }); + it('should not route to an idle user', async () => { + await updateSetting('Livechat_enabled_when_agent_idle', false); + await setUserStatus(testUser.credentials, UserStatus.AWAY); + await setUserAway(testUser.credentials); + await setUserStatus(testUser2.credentials, UserStatus.AWAY); + await setUserAway(testUser2.credentials); + // Agent is available but should be ignored + await switchLivechatStatus('available', testUser.credentials); + + const visitor = await createVisitor(testDepartment._id); + const room = await createLivechatRoom(visitor.token); + + const roomInfo = await getLivechatRoomInfo(room._id); + expect(roomInfo.servedBy).to.be.undefined; + }); + it('should route to agents even if theyre idle when setting is enabled', async () => { + await updateSetting('Livechat_enabled_when_agent_idle', true); + await setUserStatus(testUser.credentials, UserStatus.AWAY); + await setUserStatus(testUser2.credentials, UserStatus.AWAY); + + const visitor = await createVisitor(testDepartment._id); + const room = await createLivechatRoom(visitor.token); + + const roomInfo = await getLivechatRoomInfo(room._id); + // Not checking who, just checking it's served + expect(roomInfo.servedBy).to.be.an('object'); + }); }); }); diff --git a/packages/model-typings/src/models/IUsersModel.ts b/packages/model-typings/src/models/IUsersModel.ts index af7d498bb61fe..6a638ea2e2fda 100644 --- a/packages/model-typings/src/models/IUsersModel.ts +++ b/packages/model-typings/src/models/IUsersModel.ts @@ -98,10 +98,12 @@ export interface IUsersModel extends IBaseModel { getNextLeastBusyAgent( department: any, ignoreAgentId: any, + isEnabledWhenAgentIdle?: boolean, ): Promise<{ agentId: string; username: string; lastRoutingTime: Date; departments: any[]; count: number }>; getLastAvailableAgentRouted( department: any, ignoreAgentId: any, + isEnabledWhenAgentIdle?: boolean, ): Promise<{ agentId: string; username: string; lastRoutingTime: Date; departments: any[] }>; setLastRoutingTime(userId: any): Promise; @@ -263,9 +265,8 @@ export interface IUsersModel extends IBaseModel { }): Promise; findPersonalAccessTokenByTokenNameAndUserId(data: { userId: string; tokenName: string }): Promise; setOperator(userId: string, operator: boolean): Promise; - checkOnlineAgents(agentId?: string): Promise; - findOnlineAgents(agentId?: string): FindCursor; - countOnlineAgents(agentId: string): Promise; + checkOnlineAgents(agentId?: string, isLivechatEnabledWhenIdle?: boolean): Promise; + findOnlineAgents(agentId?: string, isLivechatEnabledWhenIdle?: boolean): FindCursor; findOneBotAgent(): Promise; findOneOnlineAgentById( agentId: string, @@ -274,7 +275,11 @@ export interface IUsersModel extends IBaseModel { ): Promise; findAgents(): FindCursor; countAgents(): Promise; - getNextAgent(ignoreAgentId?: string, extraQuery?: Filter): Promise<{ agentId: string; username: string } | null>; + getNextAgent( + ignoreAgentId?: string, + extraQuery?: Filter, + enabledWhenAgentIdle?: boolean, + ): Promise<{ agentId: string; username: string } | null>; getNextBotAgent(ignoreAgentId?: string): Promise<{ agentId: string; username: string } | null>; setLivechatStatus(userId: string, status: ILivechatAgentStatus): Promise; makeAgentUnavailableAndUnsetExtension(userId: string): Promise; diff --git a/packages/models/src/models/Users.js b/packages/models/src/models/Users.js index 03e4e333afb2f..d57a0dc3c35dd 100644 --- a/packages/models/src/models/Users.js +++ b/packages/models/src/models/Users.js @@ -483,16 +483,10 @@ export class UsersRaw extends BaseRaw { return this.col.distinct('federation.origin', { federation: { $exists: true } }); } - async getNextLeastBusyAgent(department, ignoreAgentId) { + async getNextLeastBusyAgent(department, ignoreAgentId, isEnabledWhenAgentIdle) { + const match = queryStatusAgentOnline({ ...(ignoreAgentId && { _id: { $ne: ignoreAgentId } }) }, isEnabledWhenAgentIdle); const aggregate = [ - { - $match: { - status: { $exists: true, $ne: 'offline' }, - statusLivechat: 'available', - roles: 'livechat-agent', - ...(ignoreAgentId && { _id: { $ne: ignoreAgentId } }), - }, - }, + { $match: match }, { $lookup: { from: 'rocketchat_subscription', @@ -549,16 +543,10 @@ export class UsersRaw extends BaseRaw { return agent; } - async getLastAvailableAgentRouted(department, ignoreAgentId) { + async getLastAvailableAgentRouted(department, ignoreAgentId, isEnabledWhenAgentIdle) { + const match = queryStatusAgentOnline({ ...(ignoreAgentId && { _id: { $ne: ignoreAgentId } }) }, isEnabledWhenAgentIdle); const aggregate = [ - { - $match: { - status: { $exists: true, $ne: 'offline' }, - statusLivechat: 'available', - roles: 'livechat-agent', - ...(ignoreAgentId && { _id: { $ne: ignoreAgentId } }), - }, - }, + { $match: match }, { $lookup: { from: 'rocketchat_livechat_department_agents', @@ -1690,27 +1678,20 @@ export class UsersRaw extends BaseRaw { return this.updateOne({ _id }, update); } - async checkOnlineAgents(agentId) { + async checkOnlineAgents(agentId, isLivechatEnabledWhenAgentIdle) { // TODO:: Create class Agent - const query = queryStatusAgentOnline(agentId && { _id: agentId }); + const query = queryStatusAgentOnline(agentId && { _id: agentId }, isLivechatEnabledWhenAgentIdle); return !!(await this.findOne(query)); } - findOnlineAgents(agentId) { + findOnlineAgents(agentId, isLivechatEnabledWhenAgentIdle) { // TODO:: Create class Agent - const query = queryStatusAgentOnline(agentId && { _id: agentId }); + const query = queryStatusAgentOnline(agentId && { _id: agentId }, isLivechatEnabledWhenAgentIdle); return this.find(query); } - countOnlineAgents(agentId) { - // TODO:: Create class Agent - const query = queryStatusAgentOnline(agentId && { _id: agentId }); - - return this.col.countDocuments(query); - } - findOneBotAgent() { // TODO:: Create class Agent const query = { @@ -1748,7 +1729,7 @@ export class UsersRaw extends BaseRaw { } // 2 - async getNextAgent(ignoreAgentId, extraQuery) { + async getNextAgent(ignoreAgentId, extraQuery, enabledWhenAgentIdle) { // TODO: Create class Agent // fetch all unavailable agents, and exclude them from the selection const unavailableAgents = (await this.getUnavailableAgents(null, extraQuery)).map((u) => u.username); @@ -1758,7 +1739,7 @@ export class UsersRaw extends BaseRaw { username: { $nin: unavailableAgents }, }; - const query = queryStatusAgentOnline(extraFilters); + const query = queryStatusAgentOnline(extraFilters, enabledWhenAgentIdle); const sort = { livechatCount: 1,