fix: System messages are counted as agents' first responses in livechat rooms (#32846)

pull/31459/merge
Matheus Barbosa Silva 1 year ago committed by GitHub
parent eb5e60ef7c
commit 7937ff741a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 6
      .changeset/rotten-camels-pretend.md
  2. 6
      apps/meteor/app/livechat/server/hooks/markRoomResponded.ts
  3. 4
      apps/meteor/app/livechat/server/hooks/saveAnalyticsData.ts
  4. 5
      apps/meteor/app/livechat/server/lib/AnalyticsTyped.ts
  5. 4
      apps/meteor/tests/data/livechat/rooms.ts
  6. 201
      apps/meteor/tests/end-to-end/api/livechat/04-dashboards.ts
  7. 182
      packages/core-typings/src/IMessage/IMessage.ts

@ -0,0 +1,6 @@
---
"@rocket.chat/meteor": patch
"@rocket.chat/core-typings": patch
---
Fixed issue with system messages being counted as agents' first responses in livechat rooms (which caused the "best first response time" and "average first response time" metrics to be unreliable for all agents)

@ -1,5 +1,5 @@
import type { IOmnichannelRoom, IMessage } from '@rocket.chat/core-typings';
import { isEditedMessage, isMessageFromVisitor } from '@rocket.chat/core-typings';
import { isEditedMessage, isMessageFromVisitor, isSystemMessage } from '@rocket.chat/core-typings';
import type { Updater } from '@rocket.chat/models';
import { LivechatRooms, LivechatVisitors, LivechatInquiry } from '@rocket.chat/models';
import moment from 'moment';
@ -12,7 +12,7 @@ export async function markRoomResponded(
room: IOmnichannelRoom,
roomUpdater: Updater<IOmnichannelRoom>,
): Promise<IOmnichannelRoom['responseBy'] | undefined> {
if (message.t || isEditedMessage(message) || isMessageFromVisitor(message)) {
if (isSystemMessage(message) || isEditedMessage(message) || isMessageFromVisitor(message)) {
return;
}
@ -62,7 +62,7 @@ export async function markRoomResponded(
callbacks.add(
'afterOmnichannelSaveMessage',
async (message, { room, roomUpdater }) => {
if (!message || message.t || isEditedMessage(message) || isMessageFromVisitor(message)) {
if (!message || isEditedMessage(message) || isMessageFromVisitor(message) || isSystemMessage(message)) {
return;
}

@ -1,4 +1,4 @@
import { isEditedMessage, isMessageFromVisitor } from '@rocket.chat/core-typings';
import { isEditedMessage, isMessageFromVisitor, isSystemMessage } from '@rocket.chat/core-typings';
import type { IOmnichannelRoom } from '@rocket.chat/core-typings';
import { LivechatRooms } from '@rocket.chat/models';
@ -62,7 +62,7 @@ const getAnalyticsData = (room: IOmnichannelRoom, now: Date): Record<string, str
callbacks.add(
'afterOmnichannelSaveMessage',
async (message, { room, roomUpdater }) => {
if (!message || isEditedMessage(message)) {
if (!message || isEditedMessage(message) || isSystemMessage(message)) {
return message;
}

@ -1,7 +1,10 @@
import { OmnichannelAnalytics } from '@rocket.chat/core-services';
import mem from 'mem';
export const getAgentOverviewDataCached = mem(OmnichannelAnalytics.getAgentOverviewData, { maxAge: 60000, cacheKey: JSON.stringify });
export const getAgentOverviewDataCached = mem(OmnichannelAnalytics.getAgentOverviewData, {
maxAge: process.env.TEST_MODE === 'true' ? 1 : 60000,
cacheKey: JSON.stringify,
});
// Agent overview data on realtime is cached for 5 seconds
// while the data on the overview page is cached for 1 minute
export const getAnalyticsOverviewDataCached = mem(OmnichannelAnalytics.getAnalyticsOverviewData, {

@ -240,11 +240,11 @@ export const uploadFile = (roomId: string, visitorToken: string): Promise<IMessa
};
// Sends a message using sendMessage method from agent
export const sendAgentMessage = (roomId: string, msg?: string): Promise<IMessage> => {
export const sendAgentMessage = (roomId: string, msg?: string, userCredentials: Credentials = credentials): Promise<IMessage> => {
return new Promise((resolve, reject) => {
void request
.post(methodCall('sendMessage'))
.set(credentials)
.set(userCredentials)
.send({
message: JSON.stringify({
method: 'sendMessage',

@ -3,7 +3,7 @@ import type { Credentials } from '@rocket.chat/api-client';
import type { ILivechatDepartment, IUser } from '@rocket.chat/core-typings';
import { Random } from '@rocket.chat/random';
import { expect } from 'chai';
import { before, describe, it } from 'mocha';
import { before, after, describe, it } from 'mocha';
import moment from 'moment';
import type { Response } from 'supertest';
@ -19,6 +19,7 @@ import {
import { createAnOnlineAgent } from '../../../data/livechat/users';
import { sleep } from '../../../data/livechat/utils';
import { removePermissionFromAllRoles, restorePermissionToRoles, updateSetting } from '../../../data/permissions.helper';
import { deleteUser } from '../../../data/users.helper';
import { IS_EE } from '../../../e2e/config/constants';
describe('LIVECHAT - dashboards', function () {
@ -777,6 +778,198 @@ describe('LIVECHAT - dashboards', function () {
});
});
describe('[livechat/analytics/agent-overview] - Average first response time', () => {
let agent: { credentials: Credentials; user: IUser & { username: string } };
let originalFirstResponseTimeInSeconds: number;
let roomId: string;
const firstDelayInSeconds = 4;
const secondDelayInSeconds = 8;
before(async () => {
agent = await createAnOnlineAgent();
});
after(async () => {
await deleteUser(agent.user);
});
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 });
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');
expect(result.body.data).to.not.deep.include({ name: agent.user.username });
});
it("should not consider system messages in agents' first response time metric", async () => {
const response = await startANewLivechatRoomAndTakeIt({ agent: agent.credentials });
roomId = response.room._id;
await sleep(firstDelayInSeconds * 1000);
await sendAgentMessage(roomId, 'first response from agent', agent.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 === agent.user.username,
);
expect(agentData).to.not.be.undefined;
expect(agentData).to.have.property('name', agent.user.username);
expect(agentData).to.have.property('value');
originalFirstResponseTimeInSeconds = moment.duration(agentData.value).asSeconds();
expect(originalFirstResponseTimeInSeconds).to.be.greaterThanOrEqual(firstDelayInSeconds);
});
it('should correctly calculate the average time of first responses for an agent', async () => {
const response = await startANewLivechatRoomAndTakeIt({ agent: agent.credentials });
roomId = response.room._id;
await sleep(secondDelayInSeconds * 1000);
await sendAgentMessage(roomId, 'first response from agent', agent.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').that.is.not.empty;
const agentData = result.body.data.find(
(agentOverviewData: { name: string; value: string }) => agentOverviewData.name === agent.user.username,
);
expect(agentData).to.not.be.undefined;
expect(agentData).to.have.property('name', agent.user.username);
expect(agentData).to.have.property('value');
const averageFirstResponseTimeInSeconds = moment.duration(agentData.value).asSeconds();
expect(averageFirstResponseTimeInSeconds).to.be.greaterThan(originalFirstResponseTimeInSeconds);
expect(averageFirstResponseTimeInSeconds).to.be.greaterThanOrEqual((firstDelayInSeconds + secondDelayInSeconds) / 2);
expect(averageFirstResponseTimeInSeconds).to.be.lessThan(secondDelayInSeconds);
});
});
describe('[livechat/analytics/agent-overview] - Best first response time', () => {
let agent: { credentials: Credentials; user: IUser & { username: string } };
let originalBestFirstResponseTimeInSeconds: number;
let roomId: string;
before(async () => {
agent = await createAnOnlineAgent();
});
after(() => deleteUser(agent.user));
it('should return no best response time for an agent if no response has been sent in the period', async () => {
await startANewLivechatRoomAndTakeIt({ agent: agent.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: 'Best_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');
expect(result.body.data).to.not.deep.include({ name: agent.user.username });
});
it("should not consider system messages in agents' best response time metric", async () => {
const response = await startANewLivechatRoomAndTakeIt({ agent: agent.credentials });
roomId = response.room._id;
const delayInSeconds = 4;
await sleep(delayInSeconds * 1000);
await sendAgentMessage(roomId, 'first response from agent', agent.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: 'Best_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').that.is.not.empty;
const agentData = result.body.data.find(
(agentOverviewData: { name: string; value: string }) => agentOverviewData.name === agent.user.username,
);
expect(agentData).to.not.be.undefined;
expect(agentData).to.have.property('name', agent.user.username);
expect(agentData).to.have.property('value');
originalBestFirstResponseTimeInSeconds = moment.duration(agentData.value).asSeconds();
expect(originalBestFirstResponseTimeInSeconds).to.be.greaterThanOrEqual(delayInSeconds);
});
it('should correctly calculate the best first response time for an agent and there are multiple first responses in the period', async () => {
const response = await startANewLivechatRoomAndTakeIt({ agent: agent.credentials });
roomId = response.room._id;
const delayInSeconds = 6;
await sleep(delayInSeconds * 1000);
await sendAgentMessage(roomId, 'first response from agent', agent.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: 'Best_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 === agent.user.username,
);
expect(agentData).to.not.be.undefined;
expect(agentData).to.have.property('name', agent.user.username);
expect(agentData).to.have.property('value');
const bestFirstResponseTimeInSeconds = moment.duration(agentData.value).asSeconds();
expect(bestFirstResponseTimeInSeconds).to.be.equal(originalBestFirstResponseTimeInSeconds);
});
});
describe('livechat/analytics/overview', () => {
it('should return an "unauthorized error" when the user does not have the necessary permission', async () => {
await removePermissionFromAllRoles('view-livechat-manager');
@ -835,12 +1028,12 @@ describe('LIVECHAT - dashboards', function () {
expect(result.body).to.be.an('array');
const expectedResult = [
{ title: 'Total_conversations', value: 7 },
{ title: 'Open_conversations', value: 4 },
{ title: 'Total_conversations', value: 13 },
{ title: 'Open_conversations', value: 10 },
{ title: 'On_Hold_conversations', value: 1 },
// { title: 'Total_messages', value: 6 },
// { title: 'Busiest_day', value: moment().format('dddd') },
{ title: 'Conversations_per_day', value: '3.50' },
{ title: 'Conversations_per_day', value: '6.50' },
// { title: 'Busiest_time', value: '' },
];

@ -22,90 +22,95 @@ export type MessageUrl = {
parsedUrl?: Pick<UrlWithStringQuery, 'host' | 'hash' | 'pathname' | 'protocol' | 'port' | 'query' | 'search' | 'hostname'>;
};
type VoipMessageTypesValues =
| 'voip-call-started'
| 'voip-call-declined'
| 'voip-call-on-hold'
| 'voip-call-unhold'
| 'voip-call-ended'
| 'voip-call-duration'
| 'voip-call-wrapup'
| 'voip-call-ended-unexpectedly';
type TeamMessageTypes =
| 'removed-user-from-team'
| 'added-user-to-team'
| 'ult'
| 'user-converted-to-team'
| 'user-converted-to-channel'
| 'user-removed-room-from-team'
| 'user-deleted-room-from-team'
| 'user-added-room-to-team'
| 'ujt';
type LivechatMessageTypes =
| 'livechat_navigation_history'
| 'livechat_transfer_history'
| 'omnichannel_priority_change_history'
| 'omnichannel_sla_change_history'
| 'livechat_transcript_history'
| 'livechat_video_call'
| 'livechat_transfer_history_fallback'
| 'livechat-close'
| 'livechat_webrtc_video_call'
| 'livechat-started';
type OmnichannelTypesValues = 'omnichannel_placed_chat_on_hold' | 'omnichannel_on_hold_chat_resumed';
type OtrMessageTypeValues = 'otr' | 'otr-ack';
export type OtrSystemMessages = 'user_joined_otr' | 'user_requested_otr_key_refresh' | 'user_key_refreshed_successfully';
export type MessageTypesValues =
| 'e2e'
| 'uj'
| 'ul'
| 'ru'
| 'au'
| 'mute_unmute'
| 'r'
| 'ut'
| 'wm'
| 'rm'
| 'subscription-role-added'
| 'subscription-role-removed'
| 'room-archived'
| 'room-unarchived'
| 'room_changed_privacy'
| 'room_changed_description'
| 'room_changed_announcement'
| 'room_changed_avatar'
| 'room_changed_topic'
| 'room_e2e_enabled'
| 'room_e2e_disabled'
| 'user-muted'
| 'user-unmuted'
| 'room-removed-read-only'
| 'room-set-read-only'
| 'room-allowed-reacting'
| 'room-disallowed-reacting'
| 'command'
| 'videoconf'
| 'message_pinned'
| 'message_pinned_e2e'
| 'new-moderator'
| 'moderator-removed'
| 'new-owner'
| 'owner-removed'
| 'new-leader'
| 'leader-removed'
| 'discussion-created'
| LivechatMessageTypes
| TeamMessageTypes
| VoipMessageTypesValues
| OmnichannelTypesValues
| OtrMessageTypeValues
| OtrSystemMessages;
const VoipMessageTypesValues = [
'voip-call-started',
'voip-call-declined',
'voip-call-on-hold',
'voip-call-unhold',
'voip-call-ended',
'voip-call-duration',
'voip-call-wrapup',
'voip-call-ended-unexpectedly',
] as const;
const TeamMessageTypesValues = [
'removed-user-from-team',
'added-user-to-team',
'ult',
'user-converted-to-team',
'user-converted-to-channel',
'user-removed-room-from-team',
'user-deleted-room-from-team',
'user-added-room-to-team',
'ujt',
] as const;
const LivechatMessageTypesValues = [
'livechat_navigation_history',
'livechat_transfer_history',
'livechat_transcript_history',
'livechat_video_call',
'livechat_transfer_history_fallback',
'livechat-close',
'livechat_webrtc_video_call',
'livechat-started',
'omnichannel_priority_change_history',
'omnichannel_sla_change_history',
'omnichannel_placed_chat_on_hold',
'omnichannel_on_hold_chat_resumed',
] as const;
const OtrMessageTypeValues = ['otr', 'otr-ack'] as const;
const OtrSystemMessagesValues = ['user_joined_otr', 'user_requested_otr_key_refresh', 'user_key_refreshed_successfully'] as const;
export type OtrSystemMessages = (typeof OtrSystemMessagesValues)[number];
const MessageTypes = [
'e2e',
'uj',
'ul',
'ru',
'au',
'mute_unmute',
'r',
'ut',
'wm',
'rm',
'subscription-role-added',
'subscription-role-removed',
'room-archived',
'room-unarchived',
'room_changed_privacy',
'room_changed_description',
'room_changed_announcement',
'room_changed_avatar',
'room_changed_topic',
'room_e2e_enabled',
'room_e2e_disabled',
'user-muted',
'user-unmuted',
'room-removed-read-only',
'room-set-read-only',
'room-allowed-reacting',
'room-disallowed-reacting',
'command',
'videoconf',
'message_pinned',
'message_pinned_e2e',
'new-moderator',
'moderator-removed',
'new-owner',
'owner-removed',
'new-leader',
'leader-removed',
'discussion-created',
...TeamMessageTypesValues,
...LivechatMessageTypesValues,
...VoipMessageTypesValues,
...OtrMessageTypeValues,
...OtrSystemMessagesValues,
] as const;
export type MessageTypesValues = (typeof MessageTypes)[number];
export type TokenType = 'code' | 'inlinecode' | 'bold' | 'italic' | 'strike' | 'link';
export type Token = {
@ -231,9 +236,9 @@ export interface IMessage extends IRocketChatRecord {
};
}
export type MessageSystem = {
t: 'system';
};
export interface ISystemMessage extends IMessage {
t: MessageTypesValues;
}
export interface IEditedMessage extends IMessage {
editedAt: Date;
@ -249,6 +254,9 @@ export const isEditedMessage = (message: IMessage): message is IEditedMessage =>
'_id' in (message as IEditedMessage).editedBy &&
typeof (message as IEditedMessage).editedBy._id === 'string';
export const isSystemMessage = (message: IMessage): message is ISystemMessage =>
message.t !== undefined && MessageTypes.includes(message.t);
export const isDeletedMessage = (message: IMessage): message is IEditedMessage => isEditedMessage(message) && message.t === 'rm';
export const isMessageFromMatrixFederation = (message: IMessage): boolean =>
'federation' in message && Boolean(message.federation?.eventId);

Loading…
Cancel
Save