diff --git a/.changeset/funny-turtles-hunt.md b/.changeset/funny-turtles-hunt.md new file mode 100644 index 0000000000000..982dc542325ff --- /dev/null +++ b/.changeset/funny-turtles-hunt.md @@ -0,0 +1,6 @@ +--- +"@rocket.chat/meteor": minor +"@rocket.chat/i18n": minor +--- + +Adds a new setting that if enabled, will not count bot messages in the average response time metrics diff --git a/apps/meteor/app/livechat/server/hooks/markRoomResponded.ts b/apps/meteor/app/livechat/server/hooks/markRoomResponded.ts index 2360e3e1d5944..ca7ca8dfbd820 100644 --- a/apps/meteor/app/livechat/server/hooks/markRoomResponded.ts +++ b/apps/meteor/app/livechat/server/hooks/markRoomResponded.ts @@ -6,13 +6,20 @@ import moment from 'moment'; import { callbacks } from '../../../../lib/callbacks'; import { notifyOnLivechatInquiryChanged } from '../../../lib/server/lib/notifyListener'; +import { settings } from '../../../settings/server'; +import { isMessageFromBot } from '../lib/isMessageFromBot'; export async function markRoomResponded( message: IMessage, room: IOmnichannelRoom, roomUpdater: Updater, ): Promise { - if (isSystemMessage(message) || isEditedMessage(message) || isMessageFromVisitor(message)) { + if ( + isSystemMessage(message) || + isEditedMessage(message) || + isMessageFromVisitor(message) || + (settings.get('Omnichannel_Metrics_Ignore_Automatic_Messages') && (await isMessageFromBot(message))) + ) { return; } diff --git a/apps/meteor/app/livechat/server/hooks/saveAnalyticsData.ts b/apps/meteor/app/livechat/server/hooks/saveAnalyticsData.ts index 9553e9fe981b8..ce420afa1cb86 100644 --- a/apps/meteor/app/livechat/server/hooks/saveAnalyticsData.ts +++ b/apps/meteor/app/livechat/server/hooks/saveAnalyticsData.ts @@ -3,7 +3,9 @@ import type { IOmnichannelRoom } from '@rocket.chat/core-typings'; import { LivechatRooms } from '@rocket.chat/models'; import { callbacks } from '../../../../lib/callbacks'; +import { settings } from '../../../settings/server'; import { normalizeMessageFileUpload } from '../../../utils/server/functions/normalizeMessageFileUpload'; +import { isMessageFromBot } from '../lib/isMessageFromBot'; const getMetricValue = (metric: T | undefined, defaultValue: T): T => metric ?? defaultValue; const calculateTimeDifference = (startTime: T, now: Date): number => @@ -73,6 +75,10 @@ callbacks.add( if (isMessageFromVisitor(message)) { LivechatRooms.getAnalyticsUpdateQueryBySentByVisitor(room, message, roomUpdater); } else { + if (settings.get('Omnichannel_Metrics_Ignore_Automatic_Messages') && (await isMessageFromBot(message))) { + return message; + } + const analyticsData = getAnalyticsData(room, new Date()); LivechatRooms.getAnalyticsUpdateQueryBySentByAgent(room, message, analyticsData, roomUpdater); } diff --git a/apps/meteor/app/livechat/server/lib/isMessageFromBot.ts b/apps/meteor/app/livechat/server/lib/isMessageFromBot.ts new file mode 100644 index 0000000000000..8c53bf7c555d0 --- /dev/null +++ b/apps/meteor/app/livechat/server/lib/isMessageFromBot.ts @@ -0,0 +1,6 @@ +import type { IMessage } from '@rocket.chat/core-typings'; +import { Users } from '@rocket.chat/models'; + +export async function isMessageFromBot(message: IMessage): Promise { + return Users.isUserInRole(message.u._id, 'bot'); +} diff --git a/apps/meteor/server/settings/omnichannel.ts b/apps/meteor/server/settings/omnichannel.ts index 58e325c13004e..28c42fb755175 100644 --- a/apps/meteor/server/settings/omnichannel.ts +++ b/apps/meteor/server/settings/omnichannel.ts @@ -666,6 +666,14 @@ export const createOmniSettings = () => i18nLabel: 'Call_provider', enableQuery: omnichannelEnabledQuery, }); + + await this.add('Omnichannel_Metrics_Ignore_Automatic_Messages', false, { + type: 'boolean', + public: true, + group: 'Omnichannel', + section: 'Analytics', + i18nLabel: 'Omnichannel_Ignore_automatic_responses_for_performance_metrics', + }); }); await settingsRegistry.addGroup('SMS', async function () { await this.add('SMS_Enabled', false, { diff --git a/apps/meteor/tests/end-to-end/api/livechat/04-dashboards.ts b/apps/meteor/tests/end-to-end/api/livechat/04-dashboards.ts index 351abecff21c2..5facfbe965a7a 100644 --- a/apps/meteor/tests/end-to-end/api/livechat/04-dashboards.ts +++ b/apps/meteor/tests/end-to-end/api/livechat/04-dashboards.ts @@ -16,7 +16,7 @@ import { sendMessage, startANewLivechatRoomAndTakeIt, } from '../../../data/livechat/rooms'; -import { createAnOnlineAgent } from '../../../data/livechat/users'; +import { createAnOnlineAgent, createBotAgent } from '../../../data/livechat/users'; import { sleep } from '../../../data/livechat/utils'; import { removePermissionFromAllRoles, restorePermissionToRoles, updateEESetting, updateSetting } from '../../../data/permissions.helper'; import { deleteUser } from '../../../data/users.helper'; @@ -923,6 +923,7 @@ describe('LIVECHAT - dashboards', function () { describe('[livechat/analytics/agent-overview] - Average first response time', () => { let agent: { credentials: Credentials; user: IUser & { username: string } }; let forwardAgent: { credentials: Credentials; user: IUser & { username: string } }; + let botAgent: { credentials: Credentials; user: IUser }; let originalFirstResponseTimeInSeconds: number; let roomId: string; const firstDelayInSeconds = 4; @@ -931,9 +932,18 @@ describe('LIVECHAT - dashboards', function () { before(async () => { agent = await createAnOnlineAgent(); forwardAgent = await createAnOnlineAgent(); + botAgent = await createBotAgent(); + + await updateSetting('Omnichannel_Metrics_Ignore_Automatic_Messages', true); }); - after(async () => Promise.all([deleteUser(agent.user), deleteUser(forwardAgent.user)])); + after(async () => + Promise.all([ + deleteUser(agent.user), + deleteUser(forwardAgent.user), + updateSetting('Omnichannel_Metrics_Ignore_Automatic_Messages', false), + ]), + ); it('should return no average response time for an agent if no response has been sent in the period', async () => { await startANewLivechatRoomAndTakeIt({ agent: agent.credentials }); @@ -984,6 +994,34 @@ describe('LIVECHAT - dashboards', function () { expect(originalFirstResponseTimeInSeconds).to.be.greaterThanOrEqual(firstDelayInSeconds); }); + it("should not consider bot messages in agent's first response time metric if setting is enabled", async () => { + const response = await startANewLivechatRoomAndTakeIt({ agent: botAgent.credentials }); + + roomId = response.room._id; + + await sleep(firstDelayInSeconds * 1000); + + await sendAgentMessage(roomId, 'first response from agent', botAgent.credentials); + + const today = moment().startOf('day').format('YYYY-MM-DD'); + const result = await request + .get(api('livechat/analytics/agent-overview')) + .query({ from: today, to: today, name: 'Avg_first_response_time' }) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(result.body).to.have.property('success', true); + expect(result.body).to.have.property('head'); + expect(result.body).to.have.property('data'); + expect(result.body.data).to.be.an('array'); + + const agentData = result.body.data.find( + (agentOverviewData: { name: string; value: string }) => agentOverviewData.name === botAgent.user.username, + ); + expect(agentData).to.be.undefined; + }); + it('should correctly associate the first response time to the first agent who responded the room', async () => { const response = await startANewLivechatRoomAndTakeIt({ agent: forwardAgent.credentials }); roomId = response.room._id; @@ -1284,12 +1322,12 @@ describe('LIVECHAT - dashboards', function () { expect(result.body).to.be.an('array'); const expectedResult = [ - { title: 'Total_conversations', value: 15 }, - { title: 'Open_conversations', value: 12 }, + { title: 'Total_conversations', value: 16 }, + { title: 'Open_conversations', value: 13 }, { title: 'On_Hold_conversations', value: 1 }, // { title: 'Total_messages', value: 6 }, // { title: 'Busiest_day', value: moment().format('dddd') }, - { title: 'Conversations_per_day', value: '7.50' }, + { title: 'Conversations_per_day', value: '8.00' }, // { title: 'Busiest_time', value: '' }, ]; diff --git a/apps/meteor/tests/unit/app/livechat/server/lib/isMessageFromBot.spec.ts b/apps/meteor/tests/unit/app/livechat/server/lib/isMessageFromBot.spec.ts new file mode 100644 index 0000000000000..6449f3a52cb72 --- /dev/null +++ b/apps/meteor/tests/unit/app/livechat/server/lib/isMessageFromBot.spec.ts @@ -0,0 +1,35 @@ +import { expect } from 'chai'; +import p from 'proxyquire'; +import sinon from 'sinon'; + +import { createFakeMessage } from '../../../../../mocks/data'; + +const modelsMock = { + Users: { + isUserInRole: sinon.stub(), + }, +}; + +const { isMessageFromBot } = p.noCallThru().load('../../../../../../app/livechat/server/lib/isMessageFromBot', { + '@rocket.chat/models': modelsMock, +}); + +describe('isMessageFromBot', () => { + const mockMessage = createFakeMessage(); + + beforeEach(() => { + modelsMock.Users.isUserInRole.reset(); + }); + + it('Should return true if user has bot role', async () => { + modelsMock.Users.isUserInRole.resolves(true); + const result = await isMessageFromBot(mockMessage); + expect(result).to.be.true; + }); + + it('Should return false if user does not have bot role', async () => { + modelsMock.Users.isUserInRole.resolves(false); + const result = await isMessageFromBot(mockMessage); + expect(result).to.be.false; + }); +}); diff --git a/apps/meteor/tests/unit/server/livechat/hooks/markRoomResponded.spec.ts b/apps/meteor/tests/unit/server/livechat/hooks/markRoomResponded.spec.ts index 39dbff8013f64..61cd97784b34a 100644 --- a/apps/meteor/tests/unit/server/livechat/hooks/markRoomResponded.spec.ts +++ b/apps/meteor/tests/unit/server/livechat/hooks/markRoomResponded.spec.ts @@ -3,6 +3,8 @@ import { describe, it, beforeEach } from 'mocha'; import proxyquire from 'proxyquire'; import Sinon from 'sinon'; +import { createFakeMessage, createFakeUser } from '../../../../mocks/data'; + const models = { LivechatContacts: { isContactActiveOnPeriod: Sinon.stub(), markContactActiveForPeriod: Sinon.stub() }, LivechatInquiry: { markInquiryActiveForPeriod: Sinon.stub() }, @@ -13,10 +15,18 @@ const models = { }, }; -const { markRoomResponded } = proxyquire.load('../../../../../app/livechat/server/hooks/markRoomResponded.ts', { +const settingsGetMock = { + get: Sinon.stub(), +}; + +const isMessageFromBotMock = { isMessageFromBot: Sinon.stub() }; + +const { markRoomResponded } = proxyquire.noCallThru().load('../../../../../app/livechat/server/hooks/markRoomResponded.ts', { '../../../../lib/callbacks': { callbacks: { add: Sinon.stub(), priority: { HIGH: 'high' } } }, '../../../lib/server/lib/notifyListener': { notifyOnLivechatInquiryChanged: Sinon.stub() }, '@rocket.chat/models': models, + '../../../settings/server': { settings: settingsGetMock }, + '../lib/isMessageFromBot': isMessageFromBotMock, }); describe('markRoomResponded', () => { @@ -27,6 +37,8 @@ describe('markRoomResponded', () => { models.LivechatRooms.getVisitorActiveForPeriodUpdateQuery.reset(); models.LivechatRooms.getAgentLastMessageTsUpdateQuery.reset(); models.LivechatRooms.getResponseByRoomIdUpdateQuery.reset(); + + settingsGetMock.get.reset(); }); it('should return void if message is system message', async () => { @@ -66,6 +78,21 @@ describe('markRoomResponded', () => { expect(res).to.be.undefined; }); + it('should return void if message is from bot and setting is enabled', async () => { + settingsGetMock.get.withArgs('Omnichannel_Metrics_Ignore_Automatic_Messages').resolves(true); + + const user = createFakeUser({ roles: ['bot'] }); + + isMessageFromBotMock.isMessageFromBot.resolves(user); + + const message = createFakeMessage(); + const room = {}; + + const res = await markRoomResponded(message, room, user); + + expect(res).to.be.undefined; + }); + it('should try to mark visitor as active for current period', async () => { const message = {}; const room = { v: { _id: '1234' } }; diff --git a/packages/i18n/src/locales/en.i18n.json b/packages/i18n/src/locales/en.i18n.json index 643003307b35a..02d41601e3458 100644 --- a/packages/i18n/src/locales/en.i18n.json +++ b/packages/i18n/src/locales/en.i18n.json @@ -3393,6 +3393,7 @@ "Omnichannel_allow_force_close_conversations": "Force close conversation API", "Omnichannel_allow_force_close_conversations_Description": "Allow agents and managers to force close conversations via API.", "Omnichannel_allow_force_close_conversations_alert": "Only enable if your workspace has issues with rooms with invalid states.", + "Omnichannel_Ignore_automatic_responses_for_performance_metrics": "Ignore bots activities for performance metrics", "Livechat_Block_Unknown_Contacts": "Block unknown contacts", "Livechat_Block_Unknown_Contacts_Description": "Conversations from people who are not on the contact list will not be able to be taken.", "Livechat_Block_Unverified_Contacts": "Block unverified contacts",