From 7726d683741f2aa98f5effcc95e735754b792f36 Mon Sep 17 00:00:00 2001 From: Matheus Barbosa Silva <36537004+matheusbsilva137@users.noreply.github.com> Date: Thu, 17 Oct 2024 21:38:27 -0300 Subject: [PATCH] feat(sci): Restrict livechat visitors to their source type scope (#33569) --- .changeset/fuzzy-pans-share.md | 9 ++ .../app/apps/server/bridges/livechat.ts | 37 +++++++- .../app/apps/server/converters/visitors.js | 21 +++++ .../app/livechat/imports/server/rest/sms.ts | 23 ++++- .../livechat/imports/server/rest/upload.ts | 12 ++- .../app/livechat/server/api/lib/livechat.ts | 24 ++++- .../app/livechat/server/api/v1/message.ts | 27 ++++-- .../app/livechat/server/lib/LivechatTyped.ts | 5 +- .../server/methods/sendMessageLivechat.ts | 22 +++-- .../app/livechat/server/sendMessageBySMS.ts | 14 ++- .../ee/server/apps/communication/uikit.ts | 3 +- .../EmailInbox/EmailInbox_Incoming.ts | 18 +++- .../server/models/raw/LivechatVisitors.ts | 70 +++++++++++++- .../tests/end-to-end/api/livechat/00-rooms.ts | 94 +++++++++++++++---- .../api/livechat/06-integrations.ts | 52 +++++++++- .../end-to-end/api/livechat/11-livechat.ts | 31 ++++++ .../src/converters/IAppVisitorsConverter.ts | 2 + packages/core-typings/src/ILivechatVisitor.ts | 2 + .../src/models/ILivechatVisitorsModel.ts | 28 +++++- 19 files changed, 440 insertions(+), 54 deletions(-) create mode 100644 .changeset/fuzzy-pans-share.md diff --git a/.changeset/fuzzy-pans-share.md b/.changeset/fuzzy-pans-share.md new file mode 100644 index 0000000000000..e689cb28df731 --- /dev/null +++ b/.changeset/fuzzy-pans-share.md @@ -0,0 +1,9 @@ +--- +"@rocket.chat/meteor": minor +"@rocket.chat/apps": minor +"@rocket.chat/core-typings": minor +"@rocket.chat/model-typings": minor +--- + +Adds a `source` field to livechat visitors, which stores the channel (eg API, widget, SMS, email-inbox, app) that's been used by the visitor to send messages. +Uses the new `source` field to assure each visitor is linked to a single source, so that each connection through a distinct channel creates a new visitor. diff --git a/apps/meteor/app/apps/server/bridges/livechat.ts b/apps/meteor/app/apps/server/bridges/livechat.ts index 821d1fdd60d53..8e9a2780820bb 100644 --- a/apps/meteor/app/apps/server/bridges/livechat.ts +++ b/apps/meteor/app/apps/server/bridges/livechat.ts @@ -52,6 +52,21 @@ export class AppLivechatBridge extends LivechatBridge { const appMessage = (await this.orch.getConverters().get('messages').convertAppMessage(message)) as IMessage | undefined; const livechatMessage = appMessage as ILivechatMessage | undefined; + if (guest) { + const visitorSource = { + type: OmnichannelSourceType.APP, + id: appId, + alias: this.orch.getManager()?.getOneById(appId)?.getNameSlug(), + }; + const fullVisitor = await LivechatVisitors.findOneEnabledByIdAndSource({ + _id: guest._id, + sourceFilter: { 'source.type': visitorSource.type, 'source.id': visitorSource.id, 'source.alias': visitorSource.alias }, + }); + if (!fullVisitor?.source) { + await LivechatVisitors.setSourceById(guest._id, visitorSource); + } + } + const msg = await LivechatTyped.sendMessage({ guest: guest as ILivechatVisitor, message: livechatMessage as ILivechatMessage, @@ -286,7 +301,7 @@ export class AppLivechatBridge extends LivechatBridge { } return Promise.all( - (await LivechatVisitors.findEnabled(query).toArray()).map( + (await LivechatVisitors.findEnabledBySource({ 'source.type': OmnichannelSourceType.APP, 'source.id': appId }, query).toArray()).map( async (visitor) => visitor && this.orch.getConverters()?.get('visitors').convertVisitor(visitor), ), ); @@ -295,7 +310,7 @@ export class AppLivechatBridge extends LivechatBridge { protected async findVisitorById(id: string, appId: string): Promise { this.orch.debugLog(`The App ${appId} is looking for livechat visitors.`); - return this.orch.getConverters()?.get('visitors').convertById(id); + return this.orch.getConverters()?.get('visitors').convertByIdAndSource(id, appId); } protected async findVisitorByEmail(email: string, appId: string): Promise { @@ -304,7 +319,9 @@ export class AppLivechatBridge extends LivechatBridge { return this.orch .getConverters() ?.get('visitors') - .convertVisitor(await LivechatVisitors.findOneGuestByEmailAddress(email)); + .convertVisitor( + await LivechatVisitors.findOneGuestByEmailAddressAndSource(email, { 'source.type': OmnichannelSourceType.APP, 'source.id': appId }), + ); } protected async findVisitorByToken(token: string, appId: string): Promise { @@ -313,7 +330,12 @@ export class AppLivechatBridge extends LivechatBridge { return this.orch .getConverters() ?.get('visitors') - .convertVisitor(await LivechatVisitors.getVisitorByToken(token, {})); + .convertVisitor( + await LivechatVisitors.getVisitorByTokenAndSource({ + token, + sourceFilter: { 'source.type': OmnichannelSourceType.APP, 'source.id': appId }, + }), + ); } protected async findVisitorByPhoneNumber(phoneNumber: string, appId: string): Promise { @@ -322,7 +344,12 @@ export class AppLivechatBridge extends LivechatBridge { return this.orch .getConverters() ?.get('visitors') - .convertVisitor(await LivechatVisitors.findOneVisitorByPhone(phoneNumber)); + .convertVisitor( + await LivechatVisitors.findOneVisitorByPhoneAndSource(phoneNumber, { + 'source.type': OmnichannelSourceType.APP, + 'source.id': appId, + }), + ); } protected async findDepartmentByIdOrName(value: string, appId: string): Promise { diff --git a/apps/meteor/app/apps/server/converters/visitors.js b/apps/meteor/app/apps/server/converters/visitors.js index 32864e3e900e8..c70d7905bdbd5 100644 --- a/apps/meteor/app/apps/server/converters/visitors.js +++ b/apps/meteor/app/apps/server/converters/visitors.js @@ -1,3 +1,4 @@ +import { OmnichannelSourceType } from '@rocket.chat/core-typings'; import { LivechatVisitors } from '@rocket.chat/models'; import { transformMappedData } from './transformMappedData'; @@ -14,12 +15,30 @@ export class AppVisitorsConverter { return this.convertVisitor(visitor); } + async convertByIdAndSource(id, appId) { + const visitor = await LivechatVisitors.findOneEnabledByIdAndSource({ + _id: id, + sourceFilter: { 'source.type': OmnichannelSourceType.APP, 'source.id': appId }, + }); + + return this.convertVisitor(visitor); + } + async convertByToken(token) { const visitor = await LivechatVisitors.getVisitorByToken(token); return this.convertVisitor(visitor); } + async convertByTokenAndSource(token, appId) { + const visitor = await LivechatVisitors.getVisitorByTokenAndSource({ + token, + sourceFilter: { 'source.type': OmnichannelSourceType.APP, 'source.id': appId }, + }); + + return this.convertVisitor(visitor); + } + async convertVisitor(visitor) { if (!visitor) { return undefined; @@ -37,6 +56,7 @@ export class AppVisitorsConverter { livechatData: 'livechatData', status: 'status', contactId: 'contactId', + source: 'source', }; return transformMappedData(visitor, map); @@ -56,6 +76,7 @@ export class AppVisitorsConverter { livechatData: visitor.livechatData, status: visitor.status || 'online', contactId: visitor.contactId, + source: visitor.source, ...(visitor.visitorEmails && { visitorEmails: visitor.visitorEmails }), ...(visitor.department && { department: visitor.department }), }; diff --git a/apps/meteor/app/livechat/imports/server/rest/sms.ts b/apps/meteor/app/livechat/imports/server/rest/sms.ts index 15f08cdc1e83b..3004423c64ba9 100644 --- a/apps/meteor/app/livechat/imports/server/rest/sms.ts +++ b/apps/meteor/app/livechat/imports/server/rest/sms.ts @@ -6,6 +6,7 @@ import type { MessageAttachment, ServiceData, FileAttachmentProps, + IOmnichannelSource, } from '@rocket.chat/core-typings'; import { OmnichannelSourceType } from '@rocket.chat/core-typings'; import { Logger } from '@rocket.chat/logger'; @@ -55,10 +56,24 @@ const defineDepartment = async (idOrName?: string) => { return department?._id; }; -const defineVisitor = async (smsNumber: string, targetDepartment?: string) => { - const visitor = await LivechatVisitors.findOneVisitorByPhone(smsNumber); - let data: { token: string; department?: string } = { +const defineVisitor = async (smsNumber: string, serviceName: string, destination: string, targetDepartment?: string) => { + const visitorSource: IOmnichannelSource = { + type: OmnichannelSourceType.SMS, + alias: serviceName, + }; + + const visitor = await LivechatVisitors.findOneVisitorByPhoneAndSource( + smsNumber, + { + 'source.type': visitorSource.type, + 'source.alias': visitorSource.alias, + }, + { projection: { token: 1 } }, + ); + visitorSource.destination = destination; + let data: { token: string; source: IOmnichannelSource; department?: string } = { token: visitor?.token || Random.id(), + source: visitorSource, }; if (!visitor) { @@ -117,7 +132,7 @@ API.v1.addRoute('livechat/sms-incoming/:service', { targetDepartment = await defineDepartment(smsDepartment); } - const visitor = await defineVisitor(sms.from, targetDepartment); + const visitor = await defineVisitor(sms.from, service, sms.to, targetDepartment); if (!visitor) { return API.v1.success(SMSService.error(new Error('Invalid visitor'))); } diff --git a/apps/meteor/app/livechat/imports/server/rest/upload.ts b/apps/meteor/app/livechat/imports/server/rest/upload.ts index 14db8f20afcf2..30bb686fe06b0 100644 --- a/apps/meteor/app/livechat/imports/server/rest/upload.ts +++ b/apps/meteor/app/livechat/imports/server/rest/upload.ts @@ -1,7 +1,9 @@ +import { OmnichannelSourceType } from '@rocket.chat/core-typings'; import { LivechatVisitors, LivechatRooms } from '@rocket.chat/models'; import filesize from 'filesize'; import { API } from '../../../../api/server'; +import { isWidget } from '../../../../api/server/helpers/isWidget'; import { getUploadFormData } from '../../../../api/server/lib/getUploadFormData'; import { FileUpload } from '../../../../file-upload/server'; import { settings } from '../../../../settings/server'; @@ -13,6 +15,7 @@ API.v1.addRoute('livechat/upload/:rid', { if (!this.request.headers['x-visitor-token']) { return API.v1.unauthorized(); } + const sourceType = isWidget(this.request.headers) ? OmnichannelSourceType.WIDGET : OmnichannelSourceType.API; const canUpload = settings.get('Livechat_fileupload_enabled') && settings.get('FileUpload_Enabled'); @@ -23,7 +26,10 @@ API.v1.addRoute('livechat/upload/:rid', { } const visitorToken = this.request.headers['x-visitor-token']; - const visitor = await LivechatVisitors.getVisitorByToken(visitorToken as string, {}); + const visitor = await LivechatVisitors.getVisitorByTokenAndSource({ + token: visitorToken as string, + sourceFilter: { 'source.type': sourceType }, + }); if (!visitor) { return API.v1.unauthorized(); @@ -76,6 +82,10 @@ API.v1.addRoute('livechat/upload/:rid', { return API.v1.failure('Invalid file'); } + if (!visitor.source) { + await LivechatVisitors.setSourceById(visitor._id, { type: sourceType }); + } + uploadedFile.description = fields.description; delete fields.description; diff --git a/apps/meteor/app/livechat/server/api/lib/livechat.ts b/apps/meteor/app/livechat/server/api/lib/livechat.ts index 01c4d9736c66a..03aca9da02f30 100644 --- a/apps/meteor/app/livechat/server/api/lib/livechat.ts +++ b/apps/meteor/app/livechat/server/api/lib/livechat.ts @@ -1,4 +1,11 @@ -import type { ILivechatAgent, ILivechatDepartment, ILivechatTrigger, ILivechatVisitor, IOmnichannelRoom } from '@rocket.chat/core-typings'; +import type { + ILivechatAgent, + ILivechatDepartment, + ILivechatTrigger, + ILivechatVisitor, + IOmnichannelRoom, + OmnichannelSourceType, +} from '@rocket.chat/core-typings'; import { License } from '@rocket.chat/license'; import { EmojiCustom, LivechatTrigger, LivechatVisitors, LivechatRooms, LivechatDepartment } from '@rocket.chat/models'; import { Meteor } from 'meteor/meteor'; @@ -62,6 +69,21 @@ export function findGuest(token: string): Promise { }); } +export function findGuestBySource(token: string, sourceType: OmnichannelSourceType): Promise { + const projection = { + name: 1, + username: 1, + token: 1, + visitorEmails: 1, + department: 1, + activity: 1, + contactId: 1, + source: 1, + }; + + return LivechatVisitors.getVisitorByTokenAndSource({ token, sourceFilter: { 'source.type': sourceType } }, { projection }); +} + export function findGuestWithoutActivity(token: string): Promise { return LivechatVisitors.getVisitorByToken(token, { projection: { name: 1, username: 1, token: 1, visitorEmails: 1, department: 1 } }); } diff --git a/apps/meteor/app/livechat/server/api/v1/message.ts b/apps/meteor/app/livechat/server/api/v1/message.ts index b7eb6e1f684a0..bde332af5db42 100644 --- a/apps/meteor/app/livechat/server/api/v1/message.ts +++ b/apps/meteor/app/livechat/server/api/v1/message.ts @@ -1,3 +1,4 @@ +import type { IOmnichannelSource } from '@rocket.chat/core-typings'; import { OmnichannelSourceType } from '@rocket.chat/core-typings'; import { LivechatVisitors, LivechatRooms, Messages } from '@rocket.chat/models'; import { Random } from '@rocket.chat/random'; @@ -18,7 +19,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 { findGuest, findRoom, normalizeHttpHeaderData } from '../lib/livechat'; +import { findGuest, findGuestBySource, findRoom, normalizeHttpHeaderData } from '../lib/livechat'; API.v1.addRoute( 'livechat/message', @@ -26,8 +27,9 @@ API.v1.addRoute( { async post() { const { token, rid, agent, msg } = this.bodyParams; + const sourceType = isWidget(this.request.headers) ? OmnichannelSourceType.WIDGET : OmnichannelSourceType.API; - const guest = await findGuest(token); + const guest = await findGuestBySource(token, sourceType); if (!guest) { throw new Error('invalid-token'); } @@ -48,6 +50,10 @@ API.v1.addRoute( throw new Error('message-length-exceeds-character-limit'); } + if (!guest.source) { + await LivechatVisitors.setSourceById(guest._id, { type: sourceType }); + } + const _id = this.bodyParams._id || Random.id(); const sendMessage = { @@ -61,7 +67,7 @@ API.v1.addRoute( agent, roomInfo: { source: { - type: isWidget(this.request.headers) ? OmnichannelSourceType.WIDGET : OmnichannelSourceType.API, + type: sourceType, }, }, }; @@ -250,8 +256,12 @@ API.v1.addRoute( { async post() { const visitorToken = this.bodyParams.visitor.token; + const sourceType = isWidget(this.request.headers) ? OmnichannelSourceType.WIDGET : OmnichannelSourceType.API; - const visitor = await LivechatVisitors.getVisitorByToken(visitorToken, {}); + const visitor = await LivechatVisitors.getVisitorByTokenAndSource( + { token: visitorToken, sourceFilter: { 'source.type': sourceType } }, + {}, + ); let rid: string; if (visitor) { const extraQuery = await callbacks.run('livechat.applyRoomRestrictions', {}); @@ -261,11 +271,16 @@ API.v1.addRoute( } else { rid = Random.id(); } + + if (!visitor.source) { + await LivechatVisitors.setSourceById(visitor._id, { type: sourceType }); + } } else { rid = Random.id(); - const guest: typeof this.bodyParams.visitor & { connectionData?: unknown } = this.bodyParams.visitor; + const guest: typeof this.bodyParams.visitor & { connectionData?: unknown; source?: IOmnichannelSource } = this.bodyParams.visitor; guest.connectionData = normalizeHttpHeaderData(this.request.headers); + guest.source = { type: sourceType }; const visitor = await LivechatTyped.registerGuest(guest); if (!visitor) { @@ -290,7 +305,7 @@ API.v1.addRoute( }, roomInfo: { source: { - type: isWidget(this.request.headers) ? OmnichannelSourceType.WIDGET : OmnichannelSourceType.API, + type: sourceType, }, }, }; diff --git a/apps/meteor/app/livechat/server/lib/LivechatTyped.ts b/apps/meteor/app/livechat/server/lib/LivechatTyped.ts index e521ac98fe711..447a25987476d 100644 --- a/apps/meteor/app/livechat/server/lib/LivechatTyped.ts +++ b/apps/meteor/app/livechat/server/lib/LivechatTyped.ts @@ -81,7 +81,7 @@ import { isDepartmentCreationAvailable } from './isDepartmentCreationAvailable'; import type { CloseRoomParams, CloseRoomParamsByUser, CloseRoomParamsByVisitor } from './localTypes'; import { parseTranscriptRequest } from './parseTranscriptRequest'; -type RegisterGuestType = Partial> & { +type RegisterGuestType = Partial> & { id?: string; connectionData?: any; email?: string; @@ -654,6 +654,7 @@ class LivechatClass { username, connectionData, status = UserStatus.ONLINE, + source, }: RegisterGuestType): Promise { check(token, String); check(id, Match.Maybe(String)); @@ -663,6 +664,7 @@ class LivechatClass { const visitorDataToUpdate: Partial & { userAgent?: string; ip?: string; host?: string } = { token, status, + source, ...(phone?.number ? { phone: [{ phoneNumber: phone.number }] } : {}), ...(name ? { name } : {}), }; @@ -708,6 +710,7 @@ class LivechatClass { visitorDataToUpdate.username = username || (await LivechatVisitors.getNextVisitorUsername()); visitorDataToUpdate.status = status; visitorDataToUpdate.ts = new Date(); + visitorDataToUpdate.source = source; if (settings.get('Livechat_Allow_collect_and_store_HTTP_header_informations') && Livechat.isValidObject(connectionData)) { Livechat.logger.debug(`Saving connection data for visitor ${token}`); diff --git a/apps/meteor/app/livechat/server/methods/sendMessageLivechat.ts b/apps/meteor/app/livechat/server/methods/sendMessageLivechat.ts index 6fac80397906f..badf4149081f6 100644 --- a/apps/meteor/app/livechat/server/methods/sendMessageLivechat.ts +++ b/apps/meteor/app/livechat/server/methods/sendMessageLivechat.ts @@ -42,19 +42,27 @@ export const sendMessageLivechat = async ({ }), ); - const guest = await LivechatVisitors.getVisitorByToken(token, { - projection: { - name: 1, - username: 1, - department: 1, - token: 1, + const guest = await LivechatVisitors.getVisitorByTokenAndSource( + { token, sourceFilter: { 'source.type': { $in: [OmnichannelSourceType.API, OmnichannelSourceType.WIDGET] } } }, + { + projection: { + name: 1, + username: 1, + department: 1, + token: 1, + source: 1, + }, }, - }); + ); if (!guest) { throw new Meteor.Error('invalid-token'); } + if (!guest.source) { + await LivechatVisitors.setSourceById(guest._id, { type: OmnichannelSourceType.API }); + } + if (settings.get('Livechat_enable_message_character_limit') && msg.length > parseInt(settings.get('Livechat_message_character_limit'))) { throw new Meteor.Error('message-length-exceeds-character-limit'); } diff --git a/apps/meteor/app/livechat/server/sendMessageBySMS.ts b/apps/meteor/app/livechat/server/sendMessageBySMS.ts index c7f88646158b9..57013508673eb 100644 --- a/apps/meteor/app/livechat/server/sendMessageBySMS.ts +++ b/apps/meteor/app/livechat/server/sendMessageBySMS.ts @@ -1,5 +1,6 @@ import { OmnichannelIntegration } from '@rocket.chat/core-services'; -import { isEditedMessage } from '@rocket.chat/core-typings'; +import type { IOmnichannelSource } from '@rocket.chat/core-typings'; +import { isEditedMessage, OmnichannelSourceType } from '@rocket.chat/core-typings'; import { LivechatVisitors } from '@rocket.chat/models'; import { callbacks } from '../../../lib/callbacks'; @@ -55,11 +56,20 @@ callbacks.add( return message; } - const visitor = await LivechatVisitors.getVisitorByToken(room.v.token, { projection: { phone: 1 } }); + const visitorSource: IOmnichannelSource = { type: OmnichannelSourceType.SMS, alias: service }; + const visitor = await LivechatVisitors.getVisitorByTokenAndSource( + { token: room.v.token, sourceFilter: { 'source.type': visitorSource.type, 'source.alias': visitorSource.alias } }, + { projection: { phone: 1, source: 1 } }, + ); if (!visitor?.phone || visitor.phone.length === 0) { return message; } + visitorSource.destination = visitor.phone[0].phoneNumber; + + if (!visitor.source) { + await LivechatVisitors.setSourceById(visitor._id, visitorSource); + } try { await SMSService.send(room.sms.from, visitor.phone[0].phoneNumber, message.msg, extraData); diff --git a/apps/meteor/ee/server/apps/communication/uikit.ts b/apps/meteor/ee/server/apps/communication/uikit.ts index 0392076704d72..87bb37965d075 100644 --- a/apps/meteor/ee/server/apps/communication/uikit.ts +++ b/apps/meteor/ee/server/apps/communication/uikit.ts @@ -61,9 +61,10 @@ router.use(authenticationMiddleware({ rejectUnauthorized: false })); router.use(async (req: Request, res, next) => { const { 'x-visitor-token': visitorToken } = req.headers; + const { id: appId } = req.params; if (visitorToken) { - req.body.visitor = await Apps.getConverters()?.get('visitors').convertByToken(visitorToken); + req.body.visitor = await Apps.getConverters()?.get('visitors').convertByTokenAndSource(visitorToken, appId); } if (!req.user && !req.body.visitor) { diff --git a/apps/meteor/server/features/EmailInbox/EmailInbox_Incoming.ts b/apps/meteor/server/features/EmailInbox/EmailInbox_Incoming.ts index 7ecb8f309b29d..907f84998d062 100644 --- a/apps/meteor/server/features/EmailInbox/EmailInbox_Incoming.ts +++ b/apps/meteor/server/features/EmailInbox/EmailInbox_Incoming.ts @@ -24,8 +24,15 @@ type FileAttachment = VideoAttachmentProps & ImageAttachmentProps & AudioAttachm const language = settings.get('Language') || 'en'; const t = i18n.getFixedT(language); -async function getGuestByEmail(email: string, name: string, department = ''): Promise { - const guest = await LivechatVisitors.findOneGuestByEmailAddress(email); +async function getGuestByEmail(email: string, name: string, inbox: string, department = ''): Promise { + const guest = await LivechatVisitors.findOneGuestByEmailAddressAndSource( + email, + { + 'source.type': OmnichannelSourceType.EMAIL, + 'source.id': inbox, + }, + { projection: { department: 1, token: 1, source: 1 } }, + ); if (guest) { if (guest.department !== department) { @@ -37,6 +44,10 @@ async function getGuestByEmail(email: string, name: string, department = ''): Pr await LivechatTyped.setDepartmentForGuest({ token: guest.token, department }); return LivechatVisitors.findOneEnabledById(guest._id, {}); } + if (!guest.source) { + const source = { type: OmnichannelSourceType.EMAIL, id: inbox, alias: 'email-inbox' }; + await LivechatVisitors.setSourceById(guest._id, source); + } return guest; } @@ -45,6 +56,7 @@ async function getGuestByEmail(email: string, name: string, department = ''): Pr name: name || email, email, department, + source: { type: OmnichannelSourceType.EMAIL, id: inbox, alias: 'email-inbox' }, }); if (!livechatVisitor) { @@ -105,7 +117,7 @@ export async function onEmailReceived(email: ParsedMail, inbox: string, departme const references = typeof email.references === 'string' ? [email.references] : email.references; const initialRef = [email.messageId, email.inReplyTo].filter(Boolean) as string[]; const thread = (references?.length ? references : []).flatMap((t: string) => t.split(',')).concat(initialRef); - const guest = await getGuestByEmail(email.from.value[0].address, email.from.value[0].name, department); + const guest = await getGuestByEmail(email.from.value[0].address, email.from.value[0].name, inbox, department); if (!guest) { logger.error(`No visitor found for ${email.from.value[0].address}`); diff --git a/apps/meteor/server/models/raw/LivechatVisitors.ts b/apps/meteor/server/models/raw/LivechatVisitors.ts index 396b728159ff6..1c371449fd93a 100644 --- a/apps/meteor/server/models/raw/LivechatVisitors.ts +++ b/apps/meteor/server/models/raw/LivechatVisitors.ts @@ -22,6 +22,7 @@ import { ObjectId } from 'mongodb'; import { notifyOnSettingChanged } from '../../../app/lib/server/lib/notifyListener'; import { BaseRaw } from './BaseRaw'; +const emptySourceFilter = { source: { $exists: false } }; export class LivechatVisitorsRaw extends BaseRaw implements ILivechatVisitorsModel { constructor(db: Db, trash?: Collection>) { super(db, 'livechat_visitor', trash); @@ -49,6 +50,19 @@ export class LivechatVisitorsRaw extends BaseRaw implements IL return this.findOne(query); } + findOneVisitorByPhoneAndSource( + phone: string, + sourceFilter: Filter, + options?: FindOptions, + ): Promise { + const query = { + 'phone.phoneNumber': phone, + ...(sourceFilter ? { $or: [sourceFilter, emptySourceFilter] } : emptySourceFilter), + }; + + return this.findOne(query, options); + } + findOneGuestByEmailAddress(emailAddress: string): Promise { const query = { 'visitorEmails.address': String(emailAddress).toLowerCase(), @@ -57,6 +71,19 @@ export class LivechatVisitorsRaw extends BaseRaw implements IL return this.findOne(query); } + findOneGuestByEmailAddressAndSource( + emailAddress: string, + sourceFilter: Filter, + options?: FindOptions, + ): Promise { + const query = { + 'visitorEmails.address': emailAddress.toLowerCase(), + ...(sourceFilter ? { $or: [sourceFilter, emptySourceFilter] } : emptySourceFilter), + }; + + return this.findOne(query, options); + } + /** * Find visitors by _id * @param {string} token - Visitor token @@ -69,10 +96,15 @@ export class LivechatVisitorsRaw extends BaseRaw implements IL return this.find(query, options); } - findEnabled(query: Filter, options?: FindOptions): FindCursor { + findEnabledBySource( + sourceFilter: Filter, + query: Filter, + options?: FindOptions, + ): FindCursor { return this.find( { ...query, + $or: [sourceFilter, emptySourceFilter], disabled: { $ne: true }, }, options, @@ -88,6 +120,19 @@ export class LivechatVisitorsRaw extends BaseRaw implements IL return this.findOne(query, options); } + findOneEnabledByIdAndSource( + { _id, sourceFilter }: { _id: string; sourceFilter: Filter }, + options?: FindOptions, + ): Promise { + const query = { + _id, + disabled: { $ne: true }, + ...(sourceFilter ? { $or: [sourceFilter, emptySourceFilter] } : emptySourceFilter), + }; + + return this.findOne(query, options); + } + findVisitorByToken(token: string): FindCursor { const query = { token, @@ -105,6 +150,18 @@ export class LivechatVisitorsRaw extends BaseRaw implements IL return this.findOne(query, options); } + getVisitorByTokenAndSource( + { token, sourceFilter }: { token: string; sourceFilter?: Filter }, + options: FindOptions, + ): Promise { + const query = { + token, + ...(sourceFilter ? { $or: [sourceFilter, emptySourceFilter] } : emptySourceFilter), + }; + + return this.findOne(query, options); + } + getVisitorsBetweenDate({ start, end, department }: { start: Date; end: Date; department?: string }): FindCursor { const query = { disabled: { $ne: true }, @@ -470,6 +527,17 @@ export class LivechatVisitorsRaw extends BaseRaw implements IL }, ); } + + setSourceById(_id: string, source: Required): Promise { + return this.updateOne( + { _id }, + { + $set: { + source, + }, + }, + ); + } } type DeepWriteable = { -readonly [P in keyof T]: DeepWriteable }; diff --git a/apps/meteor/tests/end-to-end/api/livechat/00-rooms.ts b/apps/meteor/tests/end-to-end/api/livechat/00-rooms.ts index 43454c5115dc9..e67e30d46f09c 100644 --- a/apps/meteor/tests/end-to-end/api/livechat/00-rooms.ts +++ b/apps/meteor/tests/end-to-end/api/livechat/00-rooms.ts @@ -13,7 +13,7 @@ import type { } from '@rocket.chat/core-typings'; import { LivechatPriorityWeight } from '@rocket.chat/core-typings'; import { expect } from 'chai'; -import { after, before, describe, it } from 'mocha'; +import { after, afterEach, before, describe, it } from 'mocha'; import type { Response } from 'supertest'; import type { SuccessResult } from '../../../../app/api/server/definition'; @@ -1150,6 +1150,19 @@ describe('LIVECHAT - rooms', () => { }); describe('livechat/upload/:rid', () => { + let visitor: ILivechatVisitor | undefined; + + afterEach(() => { + if (visitor?.token) { + return deleteVisitor(visitor.token); + } + }); + + after(async () => { + await updateSetting('FileUpload_Enabled', true); + await updateSetting('Livechat_fileupload_enabled', true); + }); + it('should throw an error if x-visitor-token header is not present', async () => { await request .post(api('livechat/upload/test')) @@ -1170,7 +1183,7 @@ describe('LIVECHAT - rooms', () => { }); it('should throw unauthorized if visitor with token exists but room is invalid', async () => { - const visitor = await createVisitor(); + visitor = await createVisitor(); await request .post(api('livechat/upload/test')) .set(credentials) @@ -1178,11 +1191,10 @@ describe('LIVECHAT - rooms', () => { .attach('file', fs.createReadStream(path.join(__dirname, '../../../data/livechat/sample.png'))) .expect('Content-Type', 'application/json') .expect(403); - await deleteVisitor(visitor.token); }); it('should throw an error if the file is not attached', async () => { - const visitor = await createVisitor(); + visitor = await createVisitor(); const room = await createLivechatRoom(visitor.token); await request .post(api(`livechat/upload/${room._id}`)) @@ -1190,12 +1202,11 @@ describe('LIVECHAT - rooms', () => { .set('x-visitor-token', visitor.token) .expect('Content-Type', 'application/json') .expect(400); - await deleteVisitor(visitor.token); }); it('should throw and error if file uploads are enabled but livechat file uploads are disabled', async () => { await updateSetting('Livechat_fileupload_enabled', false); - const visitor = await createVisitor(); + visitor = await createVisitor(); const room = await createLivechatRoom(visitor.token); await request .post(api(`livechat/upload/${room._id}`)) @@ -1205,12 +1216,11 @@ describe('LIVECHAT - rooms', () => { .expect('Content-Type', 'application/json') .expect(400); await updateSetting('Livechat_fileupload_enabled', true); - await deleteVisitor(visitor.token); }); it('should throw and error if livechat file uploads are enabled but file uploads are disabled', async () => { await updateSetting('FileUpload_Enabled', false); - const visitor = await createVisitor(); + visitor = await createVisitor(); const room = await createLivechatRoom(visitor.token); await request .post(api(`livechat/upload/${room._id}`)) @@ -1220,13 +1230,12 @@ describe('LIVECHAT - rooms', () => { .expect('Content-Type', 'application/json') .expect(400); await updateSetting('FileUpload_Enabled', true); - await deleteVisitor(visitor.token); }); it('should throw and error if both file uploads are disabled', async () => { await updateSetting('Livechat_fileupload_enabled', false); await updateSetting('FileUpload_Enabled', false); - const visitor = await createVisitor(); + visitor = await createVisitor(); const room = await createLivechatRoom(visitor.token); await request .post(api(`livechat/upload/${room._id}`)) @@ -1237,14 +1246,12 @@ describe('LIVECHAT - rooms', () => { .expect(400); await updateSetting('FileUpload_Enabled', true); await updateSetting('Livechat_fileupload_enabled', true); - - await deleteVisitor(visitor.token); }); it('should upload an image on the room if all params are valid', async () => { await updateSetting('FileUpload_Enabled', true); await updateSetting('Livechat_fileupload_enabled', true); - const visitor = await createVisitor(); + visitor = await createVisitor(); const room = await createLivechatRoom(visitor.token); await request .post(api(`livechat/upload/${room._id}`)) @@ -1253,11 +1260,34 @@ describe('LIVECHAT - rooms', () => { .attach('file', fs.createReadStream(path.join(__dirname, '../../../data/livechat/sample.png'))) .expect('Content-Type', 'application/json') .expect(200); - await deleteVisitor(visitor.token); + }); + + it("should set visitor's source as API after uploading a file", async () => { + await updateSetting('FileUpload_Enabled', true); + await updateSetting('Livechat_fileupload_enabled', true); + visitor = await createVisitor(); + const room = await createLivechatRoom(visitor.token); + await request + .post(api(`livechat/upload/${room._id}`)) + .set(credentials) + .set('x-visitor-token', visitor.token) + .attach('file', fs.createReadStream(path.join(__dirname, '../../../data/livechat/sample.png'))) + .expect('Content-Type', 'application/json') + .expect(200); + + const { body } = await request + .get(api('livechat/visitors.info')) + .query({ visitorId: visitor._id }) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(200); + expect(body).to.have.property('visitor').and.to.be.an('object'); + expect(body.visitor).to.have.property('source').and.to.be.an('object'); + expect(body.visitor.source).to.have.property('type', 'api'); }); it('should allow visitor to download file', async () => { - const visitor = await createVisitor(); + visitor = await createVisitor(); const room = await createLivechatRoom(visitor.token); const { body } = await request @@ -1272,12 +1302,11 @@ describe('LIVECHAT - rooms', () => { } = body; const imageUrl = `/file-upload/${_id}/${name}`; await request.get(imageUrl).query({ rc_token: visitor.token, rc_room_type: 'l', rc_rid: room._id }).expect(200); - await deleteVisitor(visitor.token); await closeOmnichannelRoom(room._id); }); it('should allow visitor to download file even after room is closed', async () => { - const visitor = await createVisitor(); + visitor = await createVisitor(); const room = await createLivechatRoom(visitor.token); const { body } = await request .post(api(`livechat/upload/${room._id}`)) @@ -1292,11 +1321,10 @@ describe('LIVECHAT - rooms', () => { } = body; const imageUrl = `/file-upload/${_id}/${name}`; await request.get(imageUrl).query({ rc_token: visitor.token, rc_room_type: 'l', rc_rid: room._id }).expect(200); - await deleteVisitor(visitor.token); }); it('should not allow visitor to download a file from a room he didnt create', async () => { - const visitor = await createVisitor(); + visitor = await createVisitor(); const visitor2 = await createVisitor(); const room = await createLivechatRoom(visitor.token); const { body } = await request @@ -1313,7 +1341,6 @@ describe('LIVECHAT - rooms', () => { } = body; const imageUrl = `/file-upload/${_id}/${name}`; await request.get(imageUrl).query({ rc_token: visitor2.token, rc_room_type: 'l', rc_rid: room._id }).expect(403); - await deleteVisitor(visitor.token); await deleteVisitor(visitor2.token); }); }); @@ -1651,6 +1678,33 @@ describe('LIVECHAT - rooms', () => { expect(body.messages[1]).to.have.property('username', visitor.username); await deleteVisitor(visitor.token); }); + + it("should set visitor's source as API after creating messages in a room", async () => { + const visitor = await createVisitor(); + const room = await createLivechatRoom(visitor.token); + await sendMessage(room._id, 'Hello', visitor.token); + + const { body } = await request + .post(api('livechat/messages')) + .set(credentials) + .send({ visitor: { token: visitor.token }, messages: [{ msg: 'Hello' }, { msg: 'Hello 2' }] }) + .expect('Content-Type', 'application/json') + .expect(200); + expect(body).to.have.property('success', true); + expect(body).to.have.property('messages').of.length(2); + + const { body: getVisitorResponse } = await request + .get(api('livechat/visitors.info')) + .query({ visitorId: visitor._id }) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(getVisitorResponse).to.have.property('visitor').and.to.be.an('object'); + expect(getVisitorResponse.visitor).to.have.property('source').and.to.be.an('object'); + expect(getVisitorResponse.visitor.source).to.have.property('type', 'api'); + await deleteVisitor(visitor.token); + }); }); describe('livechat/transfer.history/:rid', () => { diff --git a/apps/meteor/tests/end-to-end/api/livechat/06-integrations.ts b/apps/meteor/tests/end-to-end/api/livechat/06-integrations.ts index bc4d6fa04dc41..54b888645a73c 100644 --- a/apps/meteor/tests/end-to-end/api/livechat/06-integrations.ts +++ b/apps/meteor/tests/end-to-end/api/livechat/06-integrations.ts @@ -1,9 +1,10 @@ import type { ISetting } from '@rocket.chat/core-typings'; import { expect } from 'chai'; -import { before, describe, it } from 'mocha'; +import { after, before, describe, it } from 'mocha'; import type { Response } from 'supertest'; import { getCredentials, api, request, credentials } from '../../../data/api-data'; +import { deleteVisitor } from '../../../data/livechat/rooms'; import { updatePermission, updateSetting } from '../../../data/permissions.helper'; describe('LIVECHAT - Integrations', () => { @@ -49,11 +50,19 @@ describe('LIVECHAT - Integrations', () => { }); describe('Incoming SMS', () => { + const visitorTokens: string[] = []; + before(async () => { await updateSetting('SMS_Enabled', true); await updateSetting('SMS_Service', ''); }); + after(async () => { + await updateSetting('SMS_Default_Omnichannel_Department', ''); + await updateSetting('SMS_Service', 'twilio'); + return Promise.all(visitorTokens.map((token) => deleteVisitor(token))); + }); + describe('POST livechat/sms-incoming/:service', () => { it('should throw an error if SMS is disabled', async () => { await updateSetting('SMS_Enabled', false); @@ -115,6 +124,47 @@ describe('LIVECHAT - Integrations', () => { expect(res).to.have.property('text', ''); }); }); + + it("should set visitor's source as SMS after sending a message", async () => { + await updateSetting('SMS_Default_Omnichannel_Department', ''); + await updateSetting('SMS_Service', 'twilio'); + + const token = `${new Date().getTime()}-test2`; + const phone = new Date().getTime().toString(); + const { body: createVisitorResponse } = await request.post(api('livechat/visitor')).send({ visitor: { token, phone } }); + expect(createVisitorResponse).to.have.property('success', true); + expect(createVisitorResponse).to.have.property('visitor').and.to.be.an('object'); + expect(createVisitorResponse.visitor).to.have.property('_id'); + const visitorId = createVisitorResponse.visitor._id; + visitorTokens.push(createVisitorResponse.visitor.token); + + await request + .post(api('livechat/sms-incoming/twilio')) + .set(credentials) + .send({ + From: phone, + To: '+123456789', + Body: 'Hello', + }) + .expect('Content-Type', 'text/xml') + .expect(200) + .expect((res: Response) => { + expect(res).to.have.property('text', ''); + }); + + const { body } = await request + .get(api('livechat/visitors.info')) + .query({ visitorId }) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(body).to.have.property('visitor').and.to.be.an('object'); + expect(body.visitor).to.have.property('source').and.to.be.an('object'); + expect(body.visitor.source).to.have.property('type', 'sms'); + expect(body.visitor.source).to.have.property('alias', 'twilio'); + expect(body.visitor.source).to.have.property('destination', '+123456789'); + }); }); }); diff --git a/apps/meteor/tests/end-to-end/api/livechat/11-livechat.ts b/apps/meteor/tests/end-to-end/api/livechat/11-livechat.ts index 7ce582025538a..b5f237832f598 100644 --- a/apps/meteor/tests/end-to-end/api/livechat/11-livechat.ts +++ b/apps/meteor/tests/end-to-end/api/livechat/11-livechat.ts @@ -516,6 +516,10 @@ describe('LIVECHAT - Utils', () => { }); describe('livechat/message', () => { + const visitorTokens: string[] = []; + + after(() => Promise.all(visitorTokens.map((token) => deleteVisitor(token)))); + it('should fail if no token', async () => { await request.post(api('livechat/message')).set(credentials).send({}).expect(400); }); @@ -530,22 +534,29 @@ describe('LIVECHAT - Utils', () => { }); it('should fail if rid is invalid', async () => { const visitor = await createVisitor(); + visitorTokens.push(visitor.token); await request.post(api('livechat/message')).set(credentials).send({ token: visitor.token, rid: 'test', msg: 'test' }).expect(400); }); it('should fail if rid belongs to another visitor', async () => { const visitor = await createVisitor(); const visitor2 = await createVisitor(); + visitorTokens.push(visitor.token, visitor2.token); + const room = await createLivechatRoom(visitor2.token); await request.post(api('livechat/message')).set(credentials).send({ token: visitor.token, rid: room._id, msg: 'test' }).expect(400); }); it('should fail if room is closed', async () => { const visitor = await createVisitor(); + visitorTokens.push(visitor.token); + const room = await createLivechatRoom(visitor.token); await closeOmnichannelRoom(room._id); await request.post(api('livechat/message')).set(credentials).send({ token: visitor.token, rid: room._id, msg: 'test' }).expect(400); }); it('should fail if message is greater than Livechat_enable_message_character_limit setting', async () => { const visitor = await createVisitor(); + visitorTokens.push(visitor.token); + const room = await createLivechatRoom(visitor.token); await updateSetting('Livechat_enable_message_character_limit', true); await updateSetting('Livechat_message_character_limit', 1); @@ -555,9 +566,29 @@ describe('LIVECHAT - Utils', () => { }); it('should send a message', async () => { const visitor = await createVisitor(); + visitorTokens.push(visitor.token); + const room = await createLivechatRoom(visitor.token); await request.post(api('livechat/message')).set(credentials).send({ token: visitor.token, rid: room._id, msg: 'test' }).expect(200); }); + it("should set visitor's source as API after sending a message", async () => { + const visitor = await createVisitor(); + visitorTokens.push(visitor.token); + + const room = await createLivechatRoom(visitor.token); + await request.post(api('livechat/message')).set(credentials).send({ token: visitor.token, rid: room._id, msg: 'test' }).expect(200); + + const { body } = await request + .get(api('livechat/visitors.info')) + .query({ visitorId: visitor._id }) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(body).to.have.property('visitor').and.to.be.an('object'); + expect(body.visitor).to.have.property('source').and.to.be.an('object'); + expect(body.visitor.source).to.have.property('type', 'api'); + }); }); describe('[GET] livechat/message/:_id', () => { diff --git a/packages/apps/src/converters/IAppVisitorsConverter.ts b/packages/apps/src/converters/IAppVisitorsConverter.ts index 575845b57c105..5d359888155f0 100644 --- a/packages/apps/src/converters/IAppVisitorsConverter.ts +++ b/packages/apps/src/converters/IAppVisitorsConverter.ts @@ -4,7 +4,9 @@ import type { IAppsVisitor } from '../AppsEngine'; export interface IAppVisitorsConverter { convertById(visitorId: ILivechatVisitor['_id']): Promise; + convertByIdAndSource(visitorId: ILivechatVisitor['_id'], appId: string): Promise; convertByToken(token: string): Promise; + convertByTokenAndSource(token: string, appId: string): Promise; convertVisitor(visitor: undefined | null): Promise; convertVisitor(visitor: ILivechatVisitor): Promise; convertVisitor(visitor: ILivechatVisitor | undefined | null): Promise; diff --git a/packages/core-typings/src/ILivechatVisitor.ts b/packages/core-typings/src/ILivechatVisitor.ts index eefb4ebd720c8..3dc91911260df 100644 --- a/packages/core-typings/src/ILivechatVisitor.ts +++ b/packages/core-typings/src/ILivechatVisitor.ts @@ -1,4 +1,5 @@ import type { IRocketChatRecord } from './IRocketChatRecord'; +import type { IOmnichannelSource } from './IRoom'; import type { UserStatus } from './UserStatus'; export interface IVisitorPhone { @@ -50,6 +51,7 @@ export interface ILivechatVisitor extends IRocketChatRecord { activity?: string[]; disabled?: boolean; contactId?: string; + source?: IOmnichannelSource; } export interface ILivechatVisitorDTO { diff --git a/packages/model-typings/src/models/ILivechatVisitorsModel.ts b/packages/model-typings/src/models/ILivechatVisitorsModel.ts index 3e17fc2a59624..7b47a1b3f0cc2 100644 --- a/packages/model-typings/src/models/ILivechatVisitorsModel.ts +++ b/packages/model-typings/src/models/ILivechatVisitorsModel.ts @@ -16,6 +16,10 @@ import type { FindPaginated, IBaseModel } from './IBaseModel'; export interface ILivechatVisitorsModel extends IBaseModel { findById(_id: string, options?: FindOptions): FindCursor; getVisitorByToken(token: string, options?: FindOptions): Promise; + getVisitorByTokenAndSource( + { token, sourceFilter }: { token: string; sourceFilter?: Filter }, + options?: FindOptions, + ): Promise; getVisitorsBetweenDate({ start, end, department }: { start: Date; end: Date; department?: string }): FindCursor; findByNameRegexWithExceptionsAndConditions

( searchTerm: string, @@ -47,8 +51,20 @@ export interface ILivechatVisitorsModel extends IBaseModel { findOneGuestByEmailAddress(emailAddress: string): Promise; + findOneGuestByEmailAddressAndSource( + emailAddress: string, + sourceFilter: Filter, + options?: FindOptions, + ): Promise; + findOneVisitorByPhone(phone: string): Promise; + findOneVisitorByPhoneAndSource( + phone: string, + sourceFilter: Filter, + options?: FindOptions, + ): Promise; + removeDepartmentById(_id: string): Promise; getNextVisitorUsername(): Promise; @@ -67,9 +83,18 @@ export interface ILivechatVisitorsModel extends IBaseModel { findOneEnabledById(_id: string, options?: FindOptions): Promise; + findOneEnabledByIdAndSource( + { _id, sourceFilter }: { _id: string; sourceFilter: Filter }, + options?: FindOptions, + ): Promise; + disableById(_id: string): Promise; - findEnabled(query: Filter, options?: FindOptions): FindCursor; + findEnabledBySource( + sourceFilter: Filter, + query: Filter, + options?: FindOptions, + ): FindCursor; countVisitorsOnPeriod(period: string): Promise; saveGuestById( @@ -77,4 +102,5 @@ export interface ILivechatVisitorsModel extends IBaseModel { data: { name?: string; username?: string; email?: string; phone?: string; livechatData: { [k: string]: any } }, ): Promise; setLastChatById(_id: string, lastChat: Required): Promise; + setSourceById(_id: string, source: ILivechatVisitor['source']): Promise; }