fix: use agent's first response time to calculate visitor's abandonment (#30072)

pull/30329/head^2
Murtaza Patrawala 2 years ago committed by GitHub
parent 6a3bc2c3d6
commit 61128364d6
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 7
      .changeset/tough-candles-heal.md
  2. 36
      apps/meteor/app/livechat/server/hooks/markRoomResponded.ts
  3. 33
      apps/meteor/ee/app/livechat-enterprise/server/hooks/setPredictedVisitorAbandonmentTime.ts
  4. 6
      apps/meteor/ee/app/livechat-enterprise/server/lib/Helper.ts
  5. 8
      apps/meteor/server/models/raw/LivechatRooms.ts
  6. 1
      apps/meteor/server/services/omnichannel-voip/service.ts
  7. 66
      apps/meteor/tests/end-to-end/api/livechat/18-rooms-ee.ts
  8. 18
      packages/core-typings/src/IRoom.ts
  9. 2
      packages/model-typings/src/models/ILivechatRoomsModel.ts

@ -0,0 +1,7 @@
---
"@rocket.chat/meteor": patch
"@rocket.chat/core-typings": patch
"@rocket.chat/model-typings": patch
---
Fixes a problem where the calculated time for considering the visitor abandonment was the first message from the visitor and not the visitor's reply to the agent.

@ -1,3 +1,4 @@
import type { IOmnichannelRoom } from '@rocket.chat/core-typings';
import { isOmnichannelRoom, isEditedMessage } from '@rocket.chat/core-typings';
import { LivechatRooms } from '@rocket.chat/models';
@ -15,28 +16,39 @@ callbacks.add(
return message;
}
// skips this callback if the message is a system message
if (message.t) {
return message;
}
// if the message has a token, it was sent by the visitor, so ignore it
if (message.token) {
return message;
}
if (room.responseBy) {
await LivechatRooms.setAgentLastMessageTs(room._id);
}
// check if room is yet awaiting for response
if (!(typeof room.t !== 'undefined' && room.t === 'l' && room.waitingResponse)) {
// check if room is yet awaiting for response from visitor
if (!room.waitingResponse) {
// case where agent sends second message or any subsequent message in a room before visitor responds to the first message
// in this case, we just need to update the lastMessageTs of the responseBy object
if (room.responseBy) {
await LivechatRooms.setAgentLastMessageTs(room._id);
}
return message;
}
await LivechatRooms.setResponseByRoomId(room._id, {
user: {
_id: message.u._id,
username: message.u.username,
},
});
// This is the first message from agent after visitor had last responded
const responseBy: IOmnichannelRoom['responseBy'] = room.responseBy || {
_id: message.u._id,
username: message.u.username,
firstResponseTs: new Date(message.ts),
lastMessageTs: new Date(message.ts),
};
// this unsets waitingResponse and sets responseBy object
await LivechatRooms.setResponseByRoomId(room._id, responseBy);
return message;
},
callbacks.priority.LOW,
callbacks.priority.HIGH,
'markRoomResponded',
);

@ -1,4 +1,7 @@
import type { IOmnichannelRoom } from '@rocket.chat/core-typings';
import { isEditedMessage, isOmnichannelRoom } from '@rocket.chat/core-typings';
import { LivechatRooms } from '@rocket.chat/models';
import moment from 'moment';
import { settings } from '../../../../../app/settings/server';
import { callbacks } from '../../../../../lib/callbacks';
@ -22,21 +25,33 @@ callbacks.add(
if (isEditedMessage(message)) {
return message;
}
// message valid only if it is a livechat room
if (!room.v?.token) {
return message;
}
// if the message has a type means it is a special message (like the closing comment), so skip it
if (message.t) {
return message;
}
const sentByAgent = !message.token;
if (sentByAgent) {
await setPredictedVisitorAbandonmentTime(room);
// message from visitor
if (message.token) {
return message;
}
const latestRoom = await LivechatRooms.findOneById<Pick<IOmnichannelRoom, '_id' | 'responseBy' | 'departmentId'>>(room._id, {
projection: {
_id: 1,
responseBy: 1,
departmentId: 1,
},
});
if (!latestRoom?.responseBy) {
return message;
}
if (moment(latestRoom.responseBy.firstResponseTs).isSame(moment(message.ts))) {
await setPredictedVisitorAbandonmentTime(latestRoom);
}
return message;
},
callbacks.priority.MEDIUM,
'save-visitor-inactivity',
); // This hook priority should always be less than the priority of hook "save-last-visitor-message-timestamp" bcs, the room.v.lastMessage property set there is being used here for determining visitor abandonment
); // This hook priority should always be less than the priority of hook "markRoomResponded" bcs, the room.responseBy.firstMessage property set there is being used here for determining visitor abandonment

@ -144,9 +144,9 @@ const dispatchWaitingQueueStatus = async (department?: string) => {
// but we don't need to notify _each_ change that takes place, just their final position
export const debouncedDispatchWaitingQueueStatus = memoizeDebounce(dispatchWaitingQueueStatus, 1200);
export const setPredictedVisitorAbandonmentTime = async (room: IOmnichannelRoom) => {
export const setPredictedVisitorAbandonmentTime = async (room: Pick<IOmnichannelRoom, '_id' | 'responseBy' | 'departmentId'>) => {
if (
!room.v?.lastMessageTs ||
!room.responseBy?.firstResponseTs ||
!settings.get('Livechat_abandoned_rooms_action') ||
settings.get('Livechat_abandoned_rooms_action') === 'none'
) {
@ -164,7 +164,7 @@ export const setPredictedVisitorAbandonmentTime = async (room: IOmnichannelRoom)
return;
}
const willBeAbandonedAt = moment(room.v.lastMessageTs).add(Number(secondsToAdd), 'seconds').toDate();
const willBeAbandonedAt = moment(room.responseBy.firstResponseTs).add(Number(secondsToAdd), 'seconds').toDate();
await LivechatRooms.setPredictedVisitorAbandonmentByRoomId(room._id, willBeAbandonedAt);
};

@ -1986,7 +1986,7 @@ export class LivechatRoomsRaw extends BaseRaw<IOmnichannelRoom> implements ILive
return this.find(query, options);
}
setResponseByRoomId(roomId: string, response: { user: { _id: string; username: string } }) {
setResponseByRoomId(roomId: string, responseBy: IOmnichannelRoom['responseBy']) {
return this.updateOne(
{
_id: roomId,
@ -1994,11 +1994,7 @@ export class LivechatRoomsRaw extends BaseRaw<IOmnichannelRoom> implements ILive
},
{
$set: {
responseBy: {
_id: response.user._id,
username: response.user.username,
lastMessageTs: new Date(),
},
responseBy,
},
$unset: {
waitingResponse: 1,

@ -173,7 +173,6 @@ export class OmnichannelVoipService extends ServiceClassInternal implements IOmn
uids: [],
autoTranslateLanguage: '',
responseBy: '',
livechatData: '',
u: {
_id: agent.agentId,

@ -1,4 +1,4 @@
import type { IUser } from '@rocket.chat/core-typings';
import type { IOmnichannelRoom, IUser } from '@rocket.chat/core-typings';
import { expect } from 'chai';
import { after, before, describe, it } from 'mocha';
@ -254,4 +254,68 @@ import { IS_EE } from '../../../e2e/config/constants';
expect(updatedRoom).to.not.have.property('onHold');
});
});
describe('visitor abandonment feature', () => {
let room: IOmnichannelRoom;
before(async () => {
await updateSetting('Livechat_abandoned_rooms_action', 'Livechat_close_chat');
await updateSetting('Livechat_visitor_inactivity_timeout', 60);
});
it('should set predictedVisitorAbandonmentAt when agent sends a message', async () => {
const { room: newRoom } = await startANewLivechatRoomAndTakeIt();
room = newRoom;
await sendAgentMessage(room._id);
const updatedRoom = await getLivechatRoomInfo(room._id);
const lastMessageTs = updatedRoom.responseBy?.lastMessageTs;
const firstResponseTs = updatedRoom.responseBy?.firstResponseTs;
const predictedVisitorAbandonmentAt = updatedRoom.omnichannel?.predictedVisitorAbandonmentAt;
expect(predictedVisitorAbandonmentAt).to.not.be.undefined;
expect(lastMessageTs).to.not.be.undefined;
expect(firstResponseTs).to.not.be.undefined;
// expect predictedVisitorAbandonmentAt to be 60 seconds after lastMessageTs
const lastMessageTsDate = new Date(lastMessageTs as Date);
const predictedVisitorAbandonmentAtDate = new Date(predictedVisitorAbandonmentAt as Date);
const firstResponseTsDate = new Date(firstResponseTs as Date);
expect(predictedVisitorAbandonmentAtDate.getTime()).to.be.equal(lastMessageTsDate.getTime() + 60000);
expect(firstResponseTsDate.getTime()).to.be.equal(lastMessageTsDate.getTime());
});
it('should not update predictedVisitorAbandonmentAt when agent sends yet another message', async () => {
await sendAgentMessage(room._id);
const updatedRoom = await getLivechatRoomInfo(room._id);
const lastMessageTs = updatedRoom.responseBy?.lastMessageTs;
const firstResponseTs = updatedRoom.responseBy?.firstResponseTs;
const predictedVisitorAbandonmentAt = updatedRoom.omnichannel?.predictedVisitorAbandonmentAt;
expect(predictedVisitorAbandonmentAt).to.not.be.undefined;
expect(lastMessageTs).to.not.be.undefined;
// expect predictedVisitorAbandonmentAt to be 60 seconds after first message
const lastMessageTsDate = new Date(lastMessageTs as Date);
const predictedVisitorAbandonmentAtDate = new Date(predictedVisitorAbandonmentAt as Date);
const firstResponseTsDate = new Date(firstResponseTs as Date);
expect(predictedVisitorAbandonmentAtDate.getTime()).to.be.equal(firstResponseTsDate.getTime() + 60000);
// lastMessageTs should be updated
expect(lastMessageTsDate.getTime()).to.not.be.equal(firstResponseTsDate.getTime());
expect(lastMessageTsDate.getTime()).to.be.greaterThan(firstResponseTsDate.getTime());
});
after(async () => {
await updateSetting('Livechat_abandoned_rooms_action', 'none');
await updateSetting('Livechat_visitor_inactivity_timeout', 3600);
});
});
});

@ -193,8 +193,24 @@ export interface IOmnichannelGenericRoom extends Omit<IRoom, 'default' | 'featur
metrics?: {
serviceTimeDuration?: number;
};
// set to true when the room is waiting for a response from the visitor
waitingResponse: any;
responseBy: any;
// contains information about the last response from an agent
responseBy?: {
_id: string;
username: string;
// when the agent first responded to the visitor after the latest message from visitor
// this will reset when the visitor sends a new message
firstResponseTs: Date;
// when the agent last responded to the visitor
// This is almost the same as firstResponseTs, but here we hold the timestamp of the last response
// and it gets updated after each message from agent
// So if an agent sends multiple messages to visitor, then firstResponseTs will store timestamp
// of their first reply, and lastMessageTs will store timestamp of their latest response
lastMessageTs: Date;
};
livechatData: any;
queuedAt?: Date;

@ -196,7 +196,7 @@ export interface ILivechatRoomsModel extends IBaseModel<IOmnichannelRoom> {
options?: FindOptions<IOmnichannelRoom>,
extraQuery?: Filter<IOmnichannelRoom>,
): FindCursor<IOmnichannelRoom>;
setResponseByRoomId(roomId: string, response: { user: { _id: string; username: string } }): Promise<UpdateResult>;
setResponseByRoomId(roomId: string, responseBy: IOmnichannelRoom['responseBy']): Promise<UpdateResult>;
setNotResponseByRoomId(roomId: string): Promise<UpdateResult>;
setAgentLastMessageTs(roomId: string): Promise<UpdateResult>;
saveAnalyticsDataByRoomId(

Loading…
Cancel
Save