refactor: omnichannel on-hold feature (#28252)
parent
207a1916c9
commit
9da856cc67
@ -0,0 +1,9 @@ |
||||
--- |
||||
"@rocket.chat/meteor": patch |
||||
"@rocket.chat/core-services": patch |
||||
"@rocket.chat/core-typings": patch |
||||
"@rocket.chat/model-typings": patch |
||||
"@rocket.chat/rest-typings": patch |
||||
--- |
||||
|
||||
fix: Resume on-hold chat not working with max-chat's allowed per agent config |
||||
@ -1,20 +1,28 @@ |
||||
import type { IOmnichannelRoom } from '@rocket.chat/core-typings'; |
||||
|
||||
import { callbacks } from '../../../../../lib/callbacks'; |
||||
import { LivechatEnterprise } from '../lib/LivechatEnterprise'; |
||||
import { AutoCloseOnHoldScheduler } from '../lib/AutoCloseOnHoldScheduler'; |
||||
import { cbLogger } from '../lib/logger'; |
||||
|
||||
const handleAfterOnHoldChatResumed = async (room: any): Promise<void> => { |
||||
if (!room?._id || !room.onHold) { |
||||
cbLogger.debug('Skipping callback. No room provided or room is not on hold'); |
||||
return; |
||||
type IRoom = Pick<IOmnichannelRoom, '_id'>; |
||||
|
||||
const handleAfterOnHoldChatResumed = async (room: IRoom): Promise<IRoom> => { |
||||
if (!room?._id) { |
||||
cbLogger.debug('Skipping callback. No room provided'); |
||||
return room; |
||||
} |
||||
|
||||
cbLogger.debug(`Removing current on hold timers for room ${room._id}`); |
||||
void LivechatEnterprise.releaseOnHoldChat(room); |
||||
const { _id: roomId } = room; |
||||
|
||||
cbLogger.debug(`Removing current on hold timers for room ${roomId}`); |
||||
await AutoCloseOnHoldScheduler.unscheduleRoom(roomId); |
||||
|
||||
return room; |
||||
}; |
||||
|
||||
callbacks.add( |
||||
'livechat:afterOnHoldChatResumed', |
||||
(room) => handleAfterOnHoldChatResumed(room), |
||||
handleAfterOnHoldChatResumed, |
||||
callbacks.priority.HIGH, |
||||
'livechat-after-on-hold-chat-resumed', |
||||
); |
||||
|
||||
@ -1,24 +1,46 @@ |
||||
import type { IOmnichannelRoom } from '@rocket.chat/core-typings'; |
||||
import { LivechatRooms, Subscriptions } from '@rocket.chat/models'; |
||||
|
||||
import { callbacks } from '../../../../../lib/callbacks'; |
||||
import { settings } from '../../../../../app/settings/server'; |
||||
import { debouncedDispatchWaitingQueueStatus } from '../lib/Helper'; |
||||
import { LivechatEnterprise } from '../lib/LivechatEnterprise'; |
||||
import { callbackLogger } from '../../../../../app/livechat/server/lib/callbackLogger'; |
||||
import { AutoCloseOnHoldScheduler } from '../lib/AutoCloseOnHoldScheduler'; |
||||
|
||||
callbacks.add( |
||||
'livechat.closeRoom', |
||||
async (params) => { |
||||
const { room } = params; |
||||
type LivechatCloseCallbackParams = { |
||||
room: IOmnichannelRoom; |
||||
}; |
||||
|
||||
await LivechatEnterprise.releaseOnHoldChat(room); |
||||
const onCloseLivechat = async (params: LivechatCloseCallbackParams) => { |
||||
const { |
||||
room, |
||||
room: { _id: roomId }, |
||||
} = params; |
||||
|
||||
if (!settings.get('Livechat_waiting_queue')) { |
||||
return params; |
||||
} |
||||
callbackLogger.debug(`[onCloseLivechat] clearing onHold related data for room ${roomId}`); |
||||
|
||||
const { departmentId } = room || {}; |
||||
debouncedDispatchWaitingQueueStatus(departmentId); |
||||
await Promise.all([ |
||||
LivechatRooms.unsetOnHoldByRoomId(roomId), |
||||
Subscriptions.unsetOnHoldByRoomId(roomId), |
||||
AutoCloseOnHoldScheduler.unscheduleRoom(roomId), |
||||
]); |
||||
|
||||
callbackLogger.debug(`[onCloseLivechat] clearing onHold related data for room ${roomId} completed`); |
||||
|
||||
if (!settings.get('Livechat_waiting_queue')) { |
||||
return params; |
||||
}, |
||||
} |
||||
|
||||
const { departmentId } = room || {}; |
||||
callbackLogger.debug(`[onCloseLivechat] dispatching waiting queue status for department ${departmentId}`); |
||||
debouncedDispatchWaitingQueueStatus(departmentId); |
||||
|
||||
return params; |
||||
}; |
||||
|
||||
callbacks.add( |
||||
'livechat.closeRoom', |
||||
(params: LivechatCloseCallbackParams) => onCloseLivechat(params), |
||||
callbacks.priority.HIGH, |
||||
'livechat-waiting-queue-monitor-close-room', |
||||
); |
||||
|
||||
@ -1,46 +1,68 @@ |
||||
import { Meteor } from 'meteor/meteor'; |
||||
import { isOmnichannelRoom, isEditedMessage } from '@rocket.chat/core-typings'; |
||||
import { LivechatRooms } from '@rocket.chat/models'; |
||||
import type { ILivechatVisitor, IMessage, IOmnichannelRoom, IRoom, IUser } from '@rocket.chat/core-typings'; |
||||
import { isEditedMessage, isOmnichannelRoom } from '@rocket.chat/core-typings'; |
||||
import { LivechatRooms, LivechatVisitors, Users } from '@rocket.chat/models'; |
||||
import { OmnichannelEEService } from '@rocket.chat/core-services'; |
||||
|
||||
import { callbacks } from '../../../../../lib/callbacks'; |
||||
import { callbackLogger } from '../../../../../app/livechat/server/lib/callbackLogger'; |
||||
import { i18n } from '../../../../../server/lib/i18n'; |
||||
|
||||
callbacks.add( |
||||
'afterSaveMessage', |
||||
async (message, roomParams) => { |
||||
// skips this callback if the message was edited
|
||||
if (isEditedMessage(message)) { |
||||
return message; |
||||
} |
||||
const resumeOnHoldCommentAndUser = async (room: IOmnichannelRoom): Promise<{ comment: string; resumedBy: IUser }> => { |
||||
const { |
||||
v: { _id: visitorId }, |
||||
_id: rid, |
||||
} = room; |
||||
const visitor = await LivechatVisitors.findOneById<Pick<ILivechatVisitor, 'name' | 'username'>>(visitorId, { |
||||
projection: { name: 1, username: 1 }, |
||||
}); |
||||
if (!visitor) { |
||||
callbackLogger.error(`[afterSaveMessage] Visitor Not found for room ${rid} while trying to resume on hold`); |
||||
throw new Error('Visitor not found while trying to resume on hold'); |
||||
} |
||||
|
||||
// if the message has a type means it is a special message (like the closing comment), so skips
|
||||
if (message.t) { |
||||
return message; |
||||
} |
||||
const guest = visitor.name || visitor.username; |
||||
|
||||
if (!isOmnichannelRoom(roomParams)) { |
||||
return message; |
||||
} |
||||
const resumeChatComment = i18n.t('Omnichannel_on_hold_chat_automatically', { guest }); |
||||
|
||||
const { _id: rid, t: roomType, v: roomVisitor } = roomParams; |
||||
const resumedBy = await Users.findOneById('rocket.cat'); |
||||
if (!resumedBy) { |
||||
callbackLogger.error(`[afterSaveMessage] User Not found for room ${rid} while trying to resume on hold`); |
||||
throw new Error(`User not found while trying to resume on hold`); |
||||
} |
||||
|
||||
// message valid only if it is a livechat room
|
||||
if (!(typeof roomType !== 'undefined' && roomType === 'l' && roomVisitor && roomVisitor.token)) { |
||||
return message; |
||||
} |
||||
return { comment: resumeChatComment, resumedBy }; |
||||
}; |
||||
|
||||
const handleAfterSaveMessage = async (message: IMessage, room: IRoom) => { |
||||
if (isEditedMessage(message) || message.t || !isOmnichannelRoom(room)) { |
||||
return message; |
||||
} |
||||
|
||||
const { _id: rid, v: roomVisitor } = room; |
||||
|
||||
// Need to read the room every time, the room object is not updated
|
||||
const room = await LivechatRooms.findOneById(rid, { projection: { t: 1, v: 1, onHold: 1 } }); |
||||
if (!room) { |
||||
if (!roomVisitor?._id) { |
||||
return message; |
||||
} |
||||
|
||||
// Need to read the room every time, the room object is not updated
|
||||
const updatedRoom = await LivechatRooms.findOneById(rid); |
||||
if (!updatedRoom) { |
||||
return message; |
||||
} |
||||
|
||||
if (message.token && room.onHold) { |
||||
callbackLogger.debug(`[afterSaveMessage] Room ${rid} is on hold, resuming it now since visitor sent a message`); |
||||
|
||||
try { |
||||
const { comment: resumeChatComment, resumedBy } = await resumeOnHoldCommentAndUser(updatedRoom); |
||||
await OmnichannelEEService.resumeRoomOnHold(updatedRoom, resumeChatComment, resumedBy); |
||||
} catch (error) { |
||||
callbackLogger.error(`[afterSaveMessage] Error while resuming room ${rid} on hold: Error: `, error); |
||||
return message; |
||||
} |
||||
} |
||||
|
||||
// if a visitor sends a message in room which is On Hold
|
||||
if (message.token && room.onHold) { |
||||
await Meteor.callAsync('livechat:resumeOnHold', rid, { clientAction: false }); |
||||
} |
||||
return message; |
||||
}; |
||||
|
||||
return message; |
||||
}, |
||||
callbacks.priority.HIGH, |
||||
'livechat-resume-on-hold', |
||||
); |
||||
callbacks.add('afterSaveMessage', handleAfterSaveMessage, callbacks.priority.HIGH, 'livechat-resume-on-hold'); |
||||
|
||||
@ -0,0 +1,180 @@ |
||||
import { ServiceClassInternal, Message } from '@rocket.chat/core-services'; |
||||
import type { IOmnichannelEEService } from '@rocket.chat/core-services'; |
||||
import { isOmnichannelRoom } from '@rocket.chat/core-typings'; |
||||
import type { IOmnichannelRoom, IUser, ILivechatInquiryRecord, IOmnichannelSystemMessage } from '@rocket.chat/core-typings'; |
||||
import { LivechatRooms, Subscriptions, LivechatInquiry } from '@rocket.chat/models'; |
||||
|
||||
import { Logger } from '../../../../../app/logger/server'; |
||||
import { callbacks } from '../../../../../lib/callbacks'; |
||||
import { RoutingManager } from '../../../../../app/livechat/server/lib/RoutingManager'; |
||||
import { dispatchAgentDelegated } from '../../../../../app/livechat/server/lib/Helper'; |
||||
import { queueInquiry } from '../../../../../app/livechat/server/lib/QueueManager'; |
||||
|
||||
export class OmnichannelEE extends ServiceClassInternal implements IOmnichannelEEService { |
||||
protected name = 'omnichannel-ee'; |
||||
|
||||
protected internal = true; |
||||
|
||||
logger: Logger; |
||||
|
||||
constructor() { |
||||
super(); |
||||
this.logger = new Logger('OmnichannelEE'); |
||||
} |
||||
|
||||
async placeRoomOnHold( |
||||
room: Pick<IOmnichannelRoom, '_id' | 't' | 'open' | 'onHold'>, |
||||
comment: string, |
||||
onHoldBy: Pick<IUser, '_id' | 'username' | 'name'>, |
||||
) { |
||||
this.logger.debug(`Attempting to place room ${room._id} on hold by user ${onHoldBy?._id}`); |
||||
|
||||
const { _id: roomId } = room; |
||||
|
||||
if (!room || !isOmnichannelRoom(room)) { |
||||
throw new Error('error-invalid-room'); |
||||
} |
||||
if (!room.open) { |
||||
throw new Error('error-room-already-closed'); |
||||
} |
||||
if (room.onHold) { |
||||
throw new Error('error-room-is-already-on-hold'); |
||||
} |
||||
if (room.lastMessage?.token) { |
||||
throw new Error('error-contact-sent-last-message-so-cannot-place-on-hold'); |
||||
} |
||||
if (!room.servedBy) { |
||||
throw new Error('error-unserved-rooms-cannot-be-placed-onhold'); |
||||
} |
||||
|
||||
await Promise.all([ |
||||
LivechatRooms.setOnHoldByRoomId(roomId), |
||||
Subscriptions.setOnHoldByRoomId(roomId), |
||||
Message.saveSystemMessage<IOmnichannelSystemMessage>('omnichannel_placed_chat_on_hold', roomId, '', onHoldBy, { comment }), |
||||
]); |
||||
|
||||
await callbacks.run('livechat:afterOnHold', room); |
||||
|
||||
this.logger.debug(`Room ${room._id} set on hold successfully`); |
||||
} |
||||
|
||||
async resumeRoomOnHold( |
||||
room: Pick<IOmnichannelRoom, '_id' | 't' | 'open' | 'onHold' | 'servedBy'>, |
||||
comment: string, |
||||
resumeBy: Pick<IUser, '_id' | 'username' | 'name'>, |
||||
clientAction = false, |
||||
) { |
||||
this.logger.debug(`Attempting to resume room ${room._id} on hold by user ${resumeBy?._id}`); |
||||
|
||||
if (!room || !isOmnichannelRoom(room)) { |
||||
throw new Error('error-invalid-room'); |
||||
} |
||||
|
||||
if (!room.open) { |
||||
throw new Error('This_conversation_is_already_closed'); |
||||
} |
||||
|
||||
if (!room.onHold) { |
||||
throw new Error('error-room-not-on-hold'); |
||||
} |
||||
|
||||
const { _id: roomId, servedBy } = room; |
||||
|
||||
if (!servedBy) { |
||||
this.logger.error(`No serving agent found for room ${roomId}`); |
||||
throw new Error('error-room-not-served'); |
||||
} |
||||
|
||||
const inquiry = await LivechatInquiry.findOneByRoomId(roomId, {}); |
||||
if (!inquiry) { |
||||
this.logger.error(`No inquiry found for room ${roomId}`); |
||||
throw new Error('error-invalid-inquiry'); |
||||
} |
||||
|
||||
await this.attemptToAssignRoomToServingAgentElseQueueIt({ |
||||
room, |
||||
inquiry, |
||||
servingAgent: servedBy, |
||||
clientAction, |
||||
}); |
||||
|
||||
await Promise.all([ |
||||
LivechatRooms.unsetOnHoldByRoomId(roomId), |
||||
Subscriptions.unsetOnHoldByRoomId(roomId), |
||||
Message.saveSystemMessage<IOmnichannelSystemMessage>('omnichannel_on_hold_chat_resumed', roomId, '', resumeBy, { comment }), |
||||
]); |
||||
|
||||
await callbacks.run('livechat:afterOnHoldChatResumed', room); |
||||
|
||||
this.logger.debug(`Room ${room._id} resumed successfully`); |
||||
} |
||||
|
||||
private async attemptToAssignRoomToServingAgentElseQueueIt({ |
||||
room, |
||||
inquiry, |
||||
servingAgent, |
||||
clientAction, |
||||
}: { |
||||
room: Pick<IOmnichannelRoom, '_id'>; |
||||
inquiry: ILivechatInquiryRecord; |
||||
servingAgent: NonNullable<IOmnichannelRoom['servedBy']>; |
||||
clientAction: boolean; |
||||
}) { |
||||
try { |
||||
const agent = { |
||||
agentId: servingAgent._id, |
||||
username: servingAgent.username, |
||||
}; |
||||
|
||||
await callbacks.run('livechat.checkAgentBeforeTakeInquiry', { |
||||
agent, |
||||
inquiry, |
||||
options: {}, |
||||
}); |
||||
|
||||
return; |
||||
} catch (e) { |
||||
this.logger.debug(`Agent ${servingAgent._id} is not available to take the inquiry ${inquiry._id}`, e); |
||||
if (clientAction) { |
||||
// if the action was triggered by the client, we should throw the error
|
||||
// so the client can handle it and show the error message to the user
|
||||
throw e; |
||||
} |
||||
} |
||||
|
||||
this.logger.debug(`Attempting to queue inquiry ${inquiry._id}`); |
||||
|
||||
await this.removeCurrentAgentFromRoom({ room, inquiry }); |
||||
|
||||
const { _id: inquiryId } = inquiry; |
||||
const newInquiry = await LivechatInquiry.findOneById(inquiryId); |
||||
|
||||
await queueInquiry(newInquiry); |
||||
|
||||
this.logger.debug('Room queued successfully'); |
||||
} |
||||
|
||||
private async removeCurrentAgentFromRoom({ |
||||
room, |
||||
inquiry, |
||||
}: { |
||||
room: Pick<IOmnichannelRoom, '_id'>; |
||||
inquiry: ILivechatInquiryRecord; |
||||
}): Promise<void> { |
||||
this.logger.debug(`Attempting to remove current agent from room ${room._id}`); |
||||
|
||||
const { _id: roomId } = room; |
||||
|
||||
const { _id: inquiryId } = inquiry; |
||||
|
||||
await Promise.all([ |
||||
LivechatRooms.removeAgentByRoomId(roomId), |
||||
LivechatInquiry.queueInquiryAndRemoveDefaultAgent(inquiryId), |
||||
RoutingManager.removeAllRoomSubscriptions(room), |
||||
]); |
||||
|
||||
await dispatchAgentDelegated(roomId, null); |
||||
|
||||
this.logger.debug(`Current agent removed from room ${room._id} successfully`); |
||||
} |
||||
} |
||||
@ -0,0 +1,18 @@ |
||||
import type { IOmnichannelRoom, IUser } from '@rocket.chat/core-typings'; |
||||
|
||||
import type { IServiceClass } from './ServiceClass'; |
||||
|
||||
export interface IOmnichannelEEService extends IServiceClass { |
||||
placeRoomOnHold( |
||||
room: Pick<IOmnichannelRoom, '_id' | 't' | 'open' | 'onHold'>, |
||||
comment: string, |
||||
onHoldBy: Pick<IUser, '_id' | 'username' | 'name'>, |
||||
): Promise<void>; |
||||
|
||||
resumeRoomOnHold( |
||||
room: Pick<IOmnichannelRoom, '_id' | 't' | 'open' | 'onHold' | 'servedBy'>, |
||||
comment: string, |
||||
resumeBy: Pick<IUser, '_id' | 'username' | 'name'>, |
||||
clientAction?: boolean, |
||||
): Promise<void>; |
||||
} |
||||
Loading…
Reference in new issue