diff --git a/apps/meteor/app/apps/server/bridges/livechat.ts b/apps/meteor/app/apps/server/bridges/livechat.ts index f1555bd38805f..2d5bc1c9fc25d 100644 --- a/apps/meteor/app/apps/server/bridges/livechat.ts +++ b/apps/meteor/app/apps/server/bridges/livechat.ts @@ -14,6 +14,7 @@ import { Livechat as LivechatTyped } from '../../../livechat/server/lib/Livechat import { closeRoom } from '../../../livechat/server/lib/closeRoom'; import { getRoomMessages } from '../../../livechat/server/lib/getRoomMessages'; import type { ILivechatMessage } from '../../../livechat/server/lib/localTypes'; +import { updateMessage } from '../../../livechat/server/lib/messages'; import { settings } from '../../../settings/server'; declare module '@rocket.chat/apps/dist/converters/IAppMessagesConverter' { @@ -89,7 +90,7 @@ export class AppLivechatBridge extends LivechatBridge { }; // @ts-expect-error IVisitor vs ILivechatVisitor :( - await LivechatTyped.updateMessage(data); + await updateMessage(data); } protected async createRoom( diff --git a/apps/meteor/app/livechat/server/api/v1/message.ts b/apps/meteor/app/livechat/server/api/v1/message.ts index b7eb6e1f684a0..56b7321a0957c 100644 --- a/apps/meteor/app/livechat/server/api/v1/message.ts +++ b/apps/meteor/app/livechat/server/api/v1/message.ts @@ -18,6 +18,7 @@ import { loadMessageHistory } from '../../../../lib/server/functions/loadMessage import { settings } from '../../../../settings/server'; import { normalizeMessageFileUpload } from '../../../../utils/server/functions/normalizeMessageFileUpload'; import { Livechat as LivechatTyped } from '../../lib/LivechatTyped'; +import { updateMessage, deleteMessage } from '../../lib/messages'; import { findGuest, findRoom, normalizeHttpHeaderData } from '../lib/livechat'; API.v1.addRoute( @@ -128,12 +129,13 @@ API.v1.addRoute( throw new Error('invalid-room'); } + // TODO: projection const msg = await Messages.findOneById(_id); if (!msg) { throw new Error('invalid-message'); } - const result = await LivechatTyped.updateMessage({ + const result = await updateMessage({ guest, message: { _id: msg._id, msg: this.bodyParams.msg, rid: msg.rid }, }); @@ -175,7 +177,7 @@ API.v1.addRoute( throw new Error('invalid-message'); } - const result = await LivechatTyped.deleteMessage({ guest, message }); + const result = await deleteMessage({ guest, message }); if (result) { return API.v1.success({ message: { diff --git a/apps/meteor/app/livechat/server/hooks/offlineMessage.ts b/apps/meteor/app/livechat/server/hooks/offlineMessage.ts index fb440cc853b82..cf0f16af709a5 100644 --- a/apps/meteor/app/livechat/server/hooks/offlineMessage.ts +++ b/apps/meteor/app/livechat/server/hooks/offlineMessage.ts @@ -1,6 +1,6 @@ import { callbacks } from '../../../../lib/callbacks'; import { settings } from '../../../settings/server'; -import { Livechat } from '../lib/LivechatTyped'; +import { sendRequest } from '../lib/webhooks'; callbacks.add( 'livechat.offlineMessage', @@ -19,7 +19,7 @@ callbacks.add( message: data.message, }; - await Livechat.sendRequest(postData); + await sendRequest(postData); }, callbacks.priority.MEDIUM, 'livechat-send-email-offline-message', diff --git a/apps/meteor/app/livechat/server/hooks/sendToCRM.ts b/apps/meteor/app/livechat/server/hooks/sendToCRM.ts index b3624bd3ecf63..4189f84cbfd8a 100644 --- a/apps/meteor/app/livechat/server/hooks/sendToCRM.ts +++ b/apps/meteor/app/livechat/server/hooks/sendToCRM.ts @@ -6,6 +6,7 @@ import { callbacks } from '../../../../lib/callbacks'; import { settings } from '../../../settings/server'; import { normalizeMessageFileUpload } from '../../../utils/server/functions/normalizeMessageFileUpload'; import { Livechat as LivechatTyped } from '../lib/LivechatTyped'; +import { sendRequest } from '../lib/webhooks'; type AdditionalFields = | Record @@ -139,7 +140,7 @@ async function sendToCRM( const additionalData = getAdditionalFieldsByType(type, room); const responseData = Object.assign(postData, additionalData); - const response = await LivechatTyped.sendRequest(responseData); + const response = await sendRequest(responseData); if (response) { const responseData = await response.text(); diff --git a/apps/meteor/app/livechat/server/lib/LivechatTyped.ts b/apps/meteor/app/livechat/server/lib/LivechatTyped.ts index f15aa347ef70a..7b918e0b172e8 100644 --- a/apps/meteor/app/livechat/server/lib/LivechatTyped.ts +++ b/apps/meteor/app/livechat/server/lib/LivechatTyped.ts @@ -18,7 +18,7 @@ import type { ILivechatContactVisitorAssociation, } from '@rocket.chat/core-typings'; import { ILivechatAgentStatus } from '@rocket.chat/core-typings'; -import { Logger, type MainLogger } from '@rocket.chat/logger'; +import { Logger } from '@rocket.chat/logger'; import { LivechatDepartment, LivechatInquiry, @@ -33,7 +33,6 @@ import { LivechatCustomField, LivechatContacts, } from '@rocket.chat/models'; -import { serverFetch as fetch } from '@rocket.chat/server-fetch'; import { Match, check } from 'meteor/check'; import { Meteor } from 'meteor/meteor'; import type { Filter } from 'mongodb'; @@ -47,8 +46,6 @@ import { removeUserFromRolesAsync } from '../../../../server/lib/roles/removeUse import { canAccessRoomAsync } from '../../../authorization/server'; import { hasPermissionAsync } from '../../../authorization/server/functions/hasPermission'; import { hasRoleAsync } from '../../../authorization/server/functions/hasRole'; -import { FileUpload } from '../../../file-upload/server'; -import { deleteMessage } from '../../../lib/server/functions/deleteMessage'; import { sendMessage } from '../../../lib/server/functions/sendMessage'; import { updateMessage } from '../../../lib/server/functions/updateMessage'; import { @@ -60,7 +57,6 @@ import { notifyOnSubscriptionChangedByRoomId, notifyOnSubscriptionChanged, } from '../../../lib/server/lib/notifyListener'; -import { metrics } from '../../../metrics/server'; import { settings } from '../../../settings/server'; import { businessHourManager } from '../business-hour'; import { parseAgentCustomFields, updateDepartmentAgents, normalizeTransferredByData } from './Helper'; @@ -70,6 +66,7 @@ import { Visitors, type RegisterGuestType } from './Visitors'; import { registerGuestData } from './contacts/registerGuestData'; import { getRequiredDepartment } from './departmentsLib'; import type { ILivechatMessage } from './localTypes'; +import { cleanGuestHistory } from './tracking'; type AKeyOf = { [K in keyof T]?: T[K]; @@ -98,11 +95,8 @@ type ICRMData = { class LivechatClass { logger: Logger; - webhookLogger: MainLogger; - constructor() { this.logger = new Logger('Livechat'); - this.webhookLogger = this.logger.section('Webhook'); } async online(department?: string, skipNoAgentSetting = false, skipFallbackCheck = false): Promise { @@ -318,50 +312,6 @@ class LivechatClass { return Users.countBotAgents(); } - async sendRequest( - postData: { - type: string; - [key: string]: any; - }, - attempts = 10, - ) { - if (!attempts) { - Livechat.logger.error({ msg: 'Omnichannel webhook call failed. Max attempts reached' }); - return; - } - const timeout = settings.get('Livechat_http_timeout'); - const secretToken = settings.get('Livechat_secret_token'); - const webhookUrl = settings.get('Livechat_webhookUrl'); - try { - Livechat.webhookLogger.debug({ msg: 'Sending webhook request', postData }); - const result = await fetch(webhookUrl, { - method: 'POST', - headers: { - ...(secretToken && { 'X-RocketChat-Livechat-Token': secretToken }), - }, - body: postData, - timeout, - }); - - if (result.status === 200) { - metrics.totalLivechatWebhooksSuccess.inc(); - return result; - } - - metrics.totalLivechatWebhooksFailures.inc(); - throw new Error(await result.text()); - } catch (err) { - const retryAfter = timeout * 4; - Livechat.webhookLogger.error({ msg: `Error response on ${11 - attempts} try ->`, err }); - // try 10 times after 20 seconds each - attempts - 1 && - Livechat.webhookLogger.warn({ msg: `Webhook call failed. Retrying`, newAttemptAfterSeconds: retryAfter / 1000, webhookUrl }); - setTimeout(async () => { - await Livechat.sendRequest(postData, attempts - 1); - }, retryAfter); - } - } - async saveAgentInfo(_id: string, agentData: any, agentDepartments: string[]) { check(_id, String); check(agentData, Object); @@ -456,28 +406,6 @@ class LivechatClass { }); } - async updateMessage({ guest, message }: { guest: ILivechatVisitor; message: AtLeast }) { - check(message, Match.ObjectIncluding({ _id: String })); - - const originalMessage = await Messages.findOneById>(message._id, { projection: { u: 1 } }); - if (!originalMessage?._id) { - return; - } - - const editAllowed = settings.get('Message_AllowEditing'); - const editOwn = originalMessage.u && originalMessage.u._id === guest._id; - - if (!editAllowed || !editOwn) { - throw new Error('error-action-not-allowed'); - } - - // TODO: Apps sends an `any` object and apparently we just check for _id being present - // while updateMessage expects AtLeast - await updateMessage(message, guest as unknown as IUser); - - return true; - } - async transfer(room: IOmnichannelRoom, guest: ILivechatVisitor, transferData: TransferData) { this.logger.debug(`Transfering room ${room._id} [Transfered by: ${transferData?.transferredBy?._id}]`); if (room.onHold) { @@ -604,52 +532,10 @@ class LivechatClass { throw new Error('error-invalid-guest'); } - await this.cleanGuestHistory(guest); + await cleanGuestHistory(guest); return LivechatVisitors.disableById(_id); } - async cleanGuestHistory(guest: ILivechatVisitor) { - const { token } = guest; - - // This shouldn't be possible, but just in case - if (!token) { - throw new Error('error-invalid-guest'); - } - - const cursor = LivechatRooms.findByVisitorToken(token); - for await (const room of cursor) { - await Promise.all([ - Subscriptions.removeByRoomId(room._id, { - async onTrash(doc) { - void notifyOnSubscriptionChanged(doc, 'removed'); - }, - }), - FileUpload.removeFilesByRoomId(room._id), - Messages.removeByRoomId(room._id), - ReadReceipts.removeByRoomId(room._id), - ]); - } - - await LivechatRooms.removeByVisitorToken(token); - - const livechatInquiries = await LivechatInquiry.findIdsByVisitorToken(token).toArray(); - await LivechatInquiry.removeByIds(livechatInquiries.map(({ _id }) => _id)); - void notifyOnLivechatInquiryChanged(livechatInquiries, 'removed'); - } - - async deleteMessage({ guest, message }: { guest: ILivechatVisitor; message: IMessage }) { - const deleteAllowed = settings.get('Message_AllowDeleting'); - const editOwn = message.u && message.u._id === guest._id; - - if (!deleteAllowed || !editOwn) { - throw new Error('error-action-not-allowed'); - } - - await deleteMessage(message, guest as unknown as IUser); - - return true; - } - async setUserStatusLivechatIf(userId: string, status: ILivechatAgentStatus, condition?: Filter, fields?: AKeyOf) { const result = await Users.setLivechatStatusIf(userId, status, condition, fields); diff --git a/apps/meteor/app/livechat/server/lib/logger.ts b/apps/meteor/app/livechat/server/lib/logger.ts index a468818cfd101..7c79c56208264 100644 --- a/apps/meteor/app/livechat/server/lib/logger.ts +++ b/apps/meteor/app/livechat/server/lib/logger.ts @@ -4,3 +4,4 @@ export const callbackLogger = new Logger('[Omnichannel] Callback'); export const businessHourLogger = new Logger('Business Hour'); export const livechatLogger = new Logger('Livechat'); export const livechatContactsLogger = new Logger('Livechat Contacts'); +export const webhooksLogger = new Logger('Webhooks'); diff --git a/apps/meteor/app/livechat/server/lib/messages.ts b/apps/meteor/app/livechat/server/lib/messages.ts index 0f5c460e3c288..6a9fa8b81a6f8 100644 --- a/apps/meteor/app/livechat/server/lib/messages.ts +++ b/apps/meteor/app/livechat/server/lib/messages.ts @@ -1,9 +1,12 @@ import dns from 'dns'; import * as util from 'util'; -import { LivechatDepartment } from '@rocket.chat/models'; +import type { ILivechatVisitor, AtLeast, IMessage, IUser } from '@rocket.chat/core-typings'; +import { LivechatDepartment, Messages } from '@rocket.chat/models'; import { callbacks } from '../../../../lib/callbacks'; +import { deleteMessage as deleteMessageFunc } from '../../../lib/server/functions/deleteMessage'; +import { updateMessage as updateMessageFunc } from '../../../lib/server/functions/updateMessage'; import * as Mailer from '../../../mailer/server/api'; import { settings } from '../../../settings/server'; @@ -89,3 +92,41 @@ async function sendEmail(from: string, to: string, replyTo: string, subject: str html, }); } + +export async function updateMessage({ guest, message }: { guest: ILivechatVisitor; message: AtLeast }) { + // TODO: Remove check + check(message, Match.ObjectIncluding({ _id: String })); + + const originalMessage = await Messages.findOneById>(message._id, { projection: { u: 1 } }); + if (!originalMessage?._id) { + return; + } + + // TODO: shouldn't this happen inside updateMessageFunc? + const editAllowed = settings.get('Message_AllowEditing'); + const editOwn = originalMessage.u && originalMessage.u._id === guest._id; + + if (!editAllowed || !editOwn) { + throw new Error('error-action-not-allowed'); + } + + // TODO: Apps sends an `any` object and apparently we just check for _id being present + // while updateMessage expects AtLeast + await updateMessageFunc(message, guest as unknown as IUser); + + return true; +} + +export async function deleteMessage({ guest, message }: { guest: ILivechatVisitor; message: IMessage }) { + const deleteAllowed = settings.get('Message_AllowDeleting'); + const editOwn = message.u && message.u._id === guest._id; + + if (!deleteAllowed || !editOwn) { + throw new Error('error-action-not-allowed'); + } + + // TODO: we shouldn't do this :( + await deleteMessageFunc(message, guest as unknown as IUser); + + return true; +} diff --git a/apps/meteor/app/livechat/server/lib/tracking.ts b/apps/meteor/app/livechat/server/lib/tracking.ts index bfbcf9912212e..5e21bb4c38e46 100644 --- a/apps/meteor/app/livechat/server/lib/tracking.ts +++ b/apps/meteor/app/livechat/server/lib/tracking.ts @@ -1,7 +1,10 @@ import { Message } from '@rocket.chat/core-services'; -import { Users } from '@rocket.chat/models'; +import type { ILivechatVisitor } from '@rocket.chat/core-typings'; +import { LivechatInquiry, LivechatRooms, Messages, ReadReceipts, Subscriptions, Users } from '@rocket.chat/models'; import { livechatLogger } from './logger'; +import { FileUpload } from '../../../file-upload/server'; +import { notifyOnSubscriptionChanged, notifyOnLivechatInquiryChanged } from '../../../lib/server/lib/notifyListener'; import { settings } from '../../../settings/server'; type PageInfo = { title: string; location: { href: string }; change: string }; @@ -52,3 +55,32 @@ export async function savePageHistory(token: string, roomId: string | undefined, // @ts-expect-error: Investigating on which case we won't receive a roomId and where that history is supposed to be stored return Message.saveSystemMessage('livechat_navigation_history', roomId, `${pageTitle} - ${pageUrl}`, user, extraData); } + +export async function cleanGuestHistory(guest: ILivechatVisitor) { + const { token } = guest; + + // This shouldn't be possible, but just in case + if (!token) { + throw new Error('error-invalid-guest'); + } + + const cursor = LivechatRooms.findByVisitorToken(token); + for await (const room of cursor) { + await Promise.all([ + Subscriptions.removeByRoomId(room._id, { + async onTrash(doc) { + void notifyOnSubscriptionChanged(doc, 'removed'); + }, + }), + FileUpload.removeFilesByRoomId(room._id), + Messages.removeByRoomId(room._id), + ReadReceipts.removeByRoomId(room._id), + ]); + } + + await LivechatRooms.removeByVisitorToken(token); + + const livechatInquiries = await LivechatInquiry.findIdsByVisitorToken(token).toArray(); + await LivechatInquiry.removeByIds(livechatInquiries.map(({ _id }) => _id)); + void notifyOnLivechatInquiryChanged(livechatInquiries, 'removed'); +} diff --git a/apps/meteor/app/livechat/server/lib/webhooks.ts b/apps/meteor/app/livechat/server/lib/webhooks.ts new file mode 100644 index 0000000000000..57bb41af8ab65 --- /dev/null +++ b/apps/meteor/app/livechat/server/lib/webhooks.ts @@ -0,0 +1,48 @@ +import { serverFetch as fetch } from '@rocket.chat/server-fetch'; + +import { webhooksLogger } from './logger'; +import { metrics } from '../../../metrics/server'; +import { settings } from '../../../settings/server'; + +export async function sendRequest( + postData: { + type: string; + [key: string]: any; + }, + attempts = 10, +) { + if (!attempts) { + webhooksLogger.error({ msg: 'Omnichannel webhook call failed. Max attempts reached' }); + return; + } + const timeout = settings.get('Livechat_http_timeout'); + const secretToken = settings.get('Livechat_secret_token'); + const webhookUrl = settings.get('Livechat_webhookUrl'); + try { + webhooksLogger.debug({ msg: 'Sending webhook request', postData }); + const result = await fetch(webhookUrl, { + method: 'POST', + headers: { + ...(secretToken && { 'X-RocketChat-Livechat-Token': secretToken }), + }, + body: postData, + timeout, + }); + + if (result.status === 200) { + metrics.totalLivechatWebhooksSuccess.inc(); + return result; + } + + metrics.totalLivechatWebhooksFailures.inc(); + throw new Error(await result.text()); + } catch (err) { + const retryAfter = timeout * 4; + webhooksLogger.error({ msg: `Error response on ${11 - attempts} try ->`, err }); + // try 10 times after 20 seconds each + attempts - 1 && webhooksLogger.warn({ msg: `Webhook call failed. Retrying`, newAttemptAfterSeconds: retryAfter / 1000, webhookUrl }); + setTimeout(async () => { + await sendRequest(postData, attempts - 1); + }, retryAfter); + } +} diff --git a/apps/meteor/tests/unit/app/livechat/server/hooks/sendToCRM.tests.ts b/apps/meteor/tests/unit/app/livechat/server/hooks/sendToCRM.tests.ts index 074c7047ccafa..70fbc42bc77dd 100644 --- a/apps/meteor/tests/unit/app/livechat/server/hooks/sendToCRM.tests.ts +++ b/apps/meteor/tests/unit/app/livechat/server/hooks/sendToCRM.tests.ts @@ -18,9 +18,8 @@ const { sendMessageType, isOmnichannelNavigationMessage, isOmnichannelClosingMes '../../../utils/server/functions/normalizeMessageFileUpload': { normalizeMessageFileUpload: (data: any) => data, }, - '../lib/LivechatTyped': { - Livechat: {}, - }, + '../lib/webhooks': {}, + '../lib/LivechatTyped': { Livechat: {} }, }); describe('[OC] Send TO CRM', () => {