From d2372e9e2ebb267c8e34fe3a4dcd65213576652f Mon Sep 17 00:00:00 2001 From: Kevin Aleman Date: Fri, 29 Aug 2025 10:23:20 -0600 Subject: [PATCH 1/6] fix leadcapture --- .../app/livechat/server/hooks/leadCapture.ts | 26 ++- apps/meteor/tests/data/livechat/rooms.ts | 49 +++++ .../end-to-end/api/livechat/09-visitors.ts | 173 ++++++++++++++++++ .../models/src/models/LivechatVisitors.ts | 33 +++- 4 files changed, 266 insertions(+), 15 deletions(-) diff --git a/apps/meteor/app/livechat/server/hooks/leadCapture.ts b/apps/meteor/app/livechat/server/hooks/leadCapture.ts index 6a3826b8ba116..8d40b23b74492 100644 --- a/apps/meteor/app/livechat/server/hooks/leadCapture.ts +++ b/apps/meteor/app/livechat/server/hooks/leadCapture.ts @@ -37,14 +37,28 @@ callbacks.add( return message; } - const phoneRegexp = new RegExp(settings.get('Livechat_lead_phone_regex'), 'g'); - const msgPhones = message.msg.match(phoneRegexp)?.filter(isTruthy) || []; + const phoneRegexSetting = settings.get('Livechat_lead_phone_regex'); + const emailRegexSetting = settings.get('Livechat_lead_email_regex'); - const emailRegexp = new RegExp(settings.get('Livechat_lead_email_regex'), 'gi'); - const msgEmails = message.msg.match(emailRegexp)?.filter(isTruthy) || []; - if (msgEmails || msgPhones) { - await LivechatVisitors.saveGuestEmailPhoneById(room.v._id, msgEmails, msgPhones); + const safeMatch = (pattern: string, flags: string, text: string): string[] => { + if (!pattern) { + return []; + } + try { + const re = new RegExp(pattern, flags); + return text.match(re)?.filter(isTruthy) ?? []; + } catch { + return []; + } + }; + const uniq = (arr: string[]) => [...new Set(arr.filter(isTruthy))]; + + const matchedPhones = uniq(safeMatch(phoneRegexSetting, 'g', message.msg)); + const matchedEmails = uniq(safeMatch(emailRegexSetting, 'gi', message.msg)); + + if (matchedEmails.length || matchedPhones.length) { + await LivechatVisitors.saveGuestEmailPhoneById(room.v._id, matchedEmails, matchedPhones); await callbacks.run('livechat.leadCapture', room); } diff --git a/apps/meteor/tests/data/livechat/rooms.ts b/apps/meteor/tests/data/livechat/rooms.ts index 9262db61b67d6..9c535662b5731 100644 --- a/apps/meteor/tests/data/livechat/rooms.ts +++ b/apps/meteor/tests/data/livechat/rooms.ts @@ -55,6 +55,55 @@ export const createLivechatRoomWidget = async ( return response.body.room; }; +export const createVisitorWithCustomData = async ({ + department, + visitorName, + customPhone, + customFields, + customToken, + customEmail, + ignoreEmail = false, + ignorePhone = false, +}: { + department?: string; + customPhone?: string; + visitorName?: string; + customEmail?: string; + customFields?: { key: string; value: string; overwrite: boolean }[]; + customToken?: string; + ignoreEmail?: boolean; + ignorePhone?: boolean; +}): Promise => { + const token = customToken || getRandomVisitorToken(); + const email = customEmail || `${token}@${token}.com`; + const phone = customPhone || `${Math.floor(Math.random() * 10000000000)}`; + + try { + const res = await request.get(api(`livechat/visitor/${token}`)); + if (res?.body?.visitor) { + return res.body.visitor as ILivechatVisitor; + } + } catch { + // Ignore errors from GET; we will create the visitor below. + } + + const res = await request + .post(api('livechat/visitor')) + .set(credentials) + .send({ + visitor: { + name: visitorName || `Visitor ${Date.now()}`, + token, + customFields: customFields || [{ key: 'address', value: 'Rocket.Chat street', overwrite: true }], + ...(department ? { department } : {}), + ...(!ignoreEmail ? { email } : {}), + ...(!ignorePhone ? { phone } : {}), + }, + }); + + return res.body.visitor as ILivechatVisitor; +}; + export const createVisitor = ( department?: string, visitorName?: string, diff --git a/apps/meteor/tests/end-to-end/api/livechat/09-visitors.ts b/apps/meteor/tests/end-to-end/api/livechat/09-visitors.ts index bc4f52c34e18b..7ded2620d1561 100644 --- a/apps/meteor/tests/end-to-end/api/livechat/09-visitors.ts +++ b/apps/meteor/tests/end-to-end/api/livechat/09-visitors.ts @@ -13,6 +13,9 @@ import { createVisitor, startANewLivechatRoomAndTakeIt, closeOmnichannelRoom, + createVisitorWithCustomData, + sendAgentMessage, + sendMessage, } from '../../../data/livechat/rooms'; import { getRandomVisitorToken } from '../../../data/livechat/users'; import { getLivechatVisitorByToken } from '../../../data/livechat/visitor'; @@ -1312,6 +1315,7 @@ describe('LIVECHAT - visitors', () => { expect(res.body.visitors[0]).to.have.property('visitorEmails'); }); }); + describe('omnichannel/contact', () => { let contact: ILivechatVisitor; it('should fail if user doesnt have view-l-room permission', async () => { @@ -1433,4 +1437,173 @@ describe('LIVECHAT - visitors', () => { expect(contact.livechatData).to.have.property(cfName, 'test'); }); }); + + describe('lead capture', () => { + let visitor: ILivechatVisitor; + let room: IOmnichannelRoom; + + before(async () => { + visitor = await createVisitorWithCustomData({ + ignoreEmail: true, + ignorePhone: true, + }); + room = await createLivechatRoom(visitor.token); + await createAgent(); + }); + after(async () => { + await closeOmnichannelRoom(room._id); + }); + + it('should capture the data matching the email regex and add it to the visitor', async () => { + await sendMessage(room._id, 'My email is random@email.com', visitor.token); + + visitor = await getLivechatVisitorByToken(visitor.token); + expect(visitor.visitorEmails).to.be.an('array'); + expect(visitor.visitorEmails).to.have.lengthOf(1); + expect(visitor.visitorEmails?.[0].address).to.be.equal('random@email.com'); + }); + + it('should capture more emails when the visitor sends the message', async () => { + await sendMessage(room._id, 'Another email is test@teste.com', visitor.token); + + visitor = await getLivechatVisitorByToken(visitor.token); + expect(visitor.visitorEmails).to.be.an('array'); + expect(visitor.visitorEmails).to.have.lengthOf(2); + const emails = visitor.visitorEmails?.map((e) => e.address); + expect(emails).to.include('test@teste.com'); + }); + + it('should capture multiple emails', async () => { + await sendMessage(room._id, 'My emails are test@123.com notest@1234.com', visitor.token); + + visitor = await getLivechatVisitorByToken(visitor.token); + expect(visitor.visitorEmails).to.be.an('array'); + expect(visitor.visitorEmails).to.have.lengthOf(4); + const emails = visitor.visitorEmails?.map((e) => e.address); + expect(emails).to.include('test@123.com'); + expect(emails).to.include('notest@1234.com'); + }); + + it('should not add an email thats already registered', async () => { + await sendMessage(room._id, 'My email is test@123.com', visitor.token); + + visitor = await getLivechatVisitorByToken(visitor.token); + expect(visitor.visitorEmails).to.be.an('array'); + expect(visitor.visitorEmails).to.have.lengthOf(4); + }); + + it('should not save emails the agent sends', async () => { + await sendAgentMessage(room._id, 'Confirming your email is test@12345.com?', credentials); + + visitor = await getLivechatVisitorByToken(visitor.token); + expect(visitor.visitorEmails).to.be.an('array'); + expect(visitor.visitorEmails).to.have.lengthOf(4); + }); + + it('should save phone numbers matching the regex', async () => { + await sendMessage(room._id, 'My phone number is 12345678', visitor.token); + + visitor = await getLivechatVisitorByToken(visitor.token); + expect(visitor.phone).to.be.an('array'); + expect(visitor.phone).to.have.lengthOf(1); + expect(visitor.phone?.[0].phoneNumber).to.be.equal('12345678'); + }); + + it('should capture more phone numbers when the visitor sends the message', async () => { + await sendMessage(room._id, 'Another phone number is 87654321 87654323', visitor.token); + + visitor = await getLivechatVisitorByToken(visitor.token); + expect(visitor.phone).to.be.an('array'); + expect(visitor.phone).to.have.lengthOf(3); + const phones = visitor.phone?.map((p) => p.phoneNumber); + expect(phones).to.include('87654321'); + expect(phones).to.include('87654323'); + }); + + it('should not add a phone number thats already registered', async () => { + await sendMessage(room._id, 'My phone number is 12345678', visitor.token); + + visitor = await getLivechatVisitorByToken(visitor.token); + expect(visitor.phone).to.be.an('array'); + expect(visitor.phone).to.have.lengthOf(3); + }); + + it('should not save phone numbers the agent sends', async () => { + await sendAgentMessage(room._id, 'Confirming your phone number is 99999999?', credentials); + visitor = await getLivechatVisitorByToken(visitor.token); + expect(visitor.phone).to.be.an('array'); + expect(visitor.phone).to.have.lengthOf(3); + }); + + it('should capture both phones & emails when sent on the same message', async () => { + await sendMessage(room._id, 'My email is zardw@asdf.com and my phone is 11223344', visitor.token); + + visitor = await getLivechatVisitorByToken(visitor.token); + expect(visitor.visitorEmails).to.be.an('array'); + expect(visitor.visitorEmails).to.have.lengthOf(5); + const emails = visitor.visitorEmails?.map((e) => e.address); + expect(emails).to.include('zardw@asdf.com'); + + expect(visitor.phone).to.be.an('array'); + expect(visitor.phone).to.have.lengthOf(4); + const phones = visitor.phone?.map((p) => p.phoneNumber); + expect(phones).to.include('11223344'); + }); + + describe('when settings are empty', () => { + before(async () => { + await updateSetting('Livechat_lead_phone_regex', ''); + await updateSetting('Livechat_lead_email_regex', ''); + }); + + after(async () => { + // reset settings + await updateSetting('Livechat_lead_email_regex', '\\b[A-Z0-9._%+-]+@(?:[A-Z0-9-]+\\.)+[A-Z]{2,4}\\b'); + await updateSetting( + 'Livechat_lead_phone_regex', + '((?:\\([0-9]{1,3}\\)|[0-9]{2})[ \\-]*?[0-9]{4,5}(?:[\\-\\s\\_]{1,2})?[0-9]{4}(?:(?=[^0-9])|$)|[0-9]{4,5}(?:[\\-\\s\\_]{1,2})?[0-9]{4}(?:(?=[^0-9])|$))', + ); + }); + + it('should not capture any email or phone no matter what the visitor sends', async () => { + await sendMessage(room._id, 'Now my email is this@shalnotpass.com', visitor.token); + + await sendMessage(room._id, 'And my phone number is 55667788', visitor.token); + + visitor = await getLivechatVisitorByToken(visitor.token); + const emails = visitor.visitorEmails?.map((e) => e.address); + expect(emails || []).to.not.include('this@shalnotpass.com'); + + const phones = visitor.phone?.map((p) => p.phoneNumber); + expect(phones || []).to.not.include('55667788'); + }); + }); + + describe('when the visitor has emails & phones already', () => { + let newVisitor: ILivechatVisitor; + let newRoom: IOmnichannelRoom; + before(async () => { + newVisitor = await createVisitor(); + newRoom = await createLivechatRoom(newVisitor.token); + }); + after(async () => { + await closeOmnichannelRoom(newRoom._id); + }); + + it('should capture new emails & phones and add them to the existing ones', async () => { + await sendMessage(newRoom._id, 'My email is 1234@12344.com and my phone is 11223344', newVisitor.token); + + newVisitor = await getLivechatVisitorByToken(newVisitor.token); + expect(newVisitor.visitorEmails).to.be.an('array'); + expect(newVisitor.visitorEmails).to.have.lengthOf(2); + const emails = newVisitor.visitorEmails?.map((e) => e.address); + expect(emails).to.include('1234@12344.com'); + + expect(newVisitor.phone).to.be.an('array'); + expect(newVisitor.phone).to.have.lengthOf(2); + const phones = newVisitor.phone?.map((p) => p.phoneNumber); + expect(phones).to.include('11223344'); + }); + }); + }); }); diff --git a/packages/models/src/models/LivechatVisitors.ts b/packages/models/src/models/LivechatVisitors.ts index 58b379bb23be5..2bd118833e2a5 100644 --- a/packages/models/src/models/LivechatVisitors.ts +++ b/packages/models/src/models/LivechatVisitors.ts @@ -380,18 +380,33 @@ export class LivechatVisitorsRaw extends BaseRaw implements IL .filter((phone) => phone?.trim().replace(/[^\d]/g, '')) .map((phone) => ({ phoneNumber: phone })); - const update: UpdateFilter = { - $addToSet: { - ...(saveEmail.length && { visitorEmails: { $each: saveEmail } }), - ...(savePhone.length && { phone: { $each: savePhone } }), - }, - }; - - if (!Object.keys(update.$addToSet as Record).length) { + if (!saveEmail.length && !savePhone.length) { return Promise.resolve(); } - return this.updateOne({ _id }, update); + const updatePipeline: Document[] = []; + + if (saveEmail.length) { + updatePipeline.push({ + $set: { + visitorEmails: { + $setUnion: [{ $ifNull: ['$visitorEmails', []] }, saveEmail], + }, + }, + }); + } + + if (savePhone.length) { + updatePipeline.push({ + $set: { + phone: { + $setUnion: [{ $ifNull: ['$phone', []] }, savePhone], + }, + }, + }); + } + + return this.updateOne({ _id }, updatePipeline); } removeContactManagerByUsername(manager: string): Promise { From c31f49e01cee7fe2e928b3abbe64794461ce4ae7 Mon Sep 17 00:00:00 2001 From: Kevin Aleman Date: Fri, 29 Aug 2025 10:24:57 -0600 Subject: [PATCH 2/6] cs --- .changeset/fair-dolls-trade.md | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 .changeset/fair-dolls-trade.md diff --git a/.changeset/fair-dolls-trade.md b/.changeset/fair-dolls-trade.md new file mode 100644 index 0000000000000..18f35371a543c --- /dev/null +++ b/.changeset/fair-dolls-trade.md @@ -0,0 +1,6 @@ +--- +'@rocket.chat/models': patch +'@rocket.chat/meteor': patch +--- + +Fixes the capture of lead's email or phone number when the visitor didn't have data already From 08d8000aa56e83a0d270730ba16b9eaff4103cae Mon Sep 17 00:00:00 2001 From: Diego Sampaio Date: Wed, 5 Nov 2025 13:58:22 -0300 Subject: [PATCH 3/6] revert model change --- .../models/src/models/LivechatVisitors.ts | 51 +++++++++++-------- 1 file changed, 30 insertions(+), 21 deletions(-) diff --git a/packages/models/src/models/LivechatVisitors.ts b/packages/models/src/models/LivechatVisitors.ts index 2bd118833e2a5..f495f1e23e45b 100644 --- a/packages/models/src/models/LivechatVisitors.ts +++ b/packages/models/src/models/LivechatVisitors.ts @@ -384,29 +384,38 @@ export class LivechatVisitorsRaw extends BaseRaw implements IL return Promise.resolve(); } - const updatePipeline: Document[] = []; + // const updatePipeline: Document[] = []; + + // if (saveEmail.length) { + // updatePipeline.push({ + // $set: { + // visitorEmails: { + // $setUnion: [{ $ifNull: ['$visitorEmails', []] }, saveEmail], + // }, + // }, + // }); + // } + + // if (savePhone.length) { + // updatePipeline.push({ + // $set: { + // phone: { + // $setUnion: [{ $ifNull: ['$phone', []] }, savePhone], + // }, + // }, + // }); + // } + + // return this.updateOne({ _id }, updatePipeline); - if (saveEmail.length) { - updatePipeline.push({ - $set: { - visitorEmails: { - $setUnion: [{ $ifNull: ['$visitorEmails', []] }, saveEmail], - }, - }, - }); - } - - if (savePhone.length) { - updatePipeline.push({ - $set: { - phone: { - $setUnion: [{ $ifNull: ['$phone', []] }, savePhone], - }, - }, - }); - } + const update: UpdateFilter = { + $addToSet: { + ...(saveEmail.length && { visitorEmails: { $each: saveEmail } }), + ...(savePhone.length && { phone: { $each: savePhone } }), + }, + }; - return this.updateOne({ _id }, updatePipeline); + return this.updateOne({ _id }, update); } removeContactManagerByUsername(manager: string): Promise { From d9bbaec2e5c37db318ee7cce7cfa6894162cd2e0 Mon Sep 17 00:00:00 2001 From: Diego Sampaio Date: Wed, 5 Nov 2025 14:04:34 -0300 Subject: [PATCH 4/6] remove comments --- .../models/src/models/LivechatVisitors.ts | 24 ------------------- 1 file changed, 24 deletions(-) diff --git a/packages/models/src/models/LivechatVisitors.ts b/packages/models/src/models/LivechatVisitors.ts index f495f1e23e45b..accb3c3625e0d 100644 --- a/packages/models/src/models/LivechatVisitors.ts +++ b/packages/models/src/models/LivechatVisitors.ts @@ -384,30 +384,6 @@ export class LivechatVisitorsRaw extends BaseRaw implements IL return Promise.resolve(); } - // const updatePipeline: Document[] = []; - - // if (saveEmail.length) { - // updatePipeline.push({ - // $set: { - // visitorEmails: { - // $setUnion: [{ $ifNull: ['$visitorEmails', []] }, saveEmail], - // }, - // }, - // }); - // } - - // if (savePhone.length) { - // updatePipeline.push({ - // $set: { - // phone: { - // $setUnion: [{ $ifNull: ['$phone', []] }, savePhone], - // }, - // }, - // }); - // } - - // return this.updateOne({ _id }, updatePipeline); - const update: UpdateFilter = { $addToSet: { ...(saveEmail.length && { visitorEmails: { $each: saveEmail } }), From 7f64a4c4d33e189b809df0dba10ce72960f2ee31 Mon Sep 17 00:00:00 2001 From: Diego Sampaio Date: Wed, 5 Nov 2025 16:36:27 -0300 Subject: [PATCH 5/6] use setUnion --- packages/models/src/models/LivechatVisitors.ts | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/packages/models/src/models/LivechatVisitors.ts b/packages/models/src/models/LivechatVisitors.ts index accb3c3625e0d..d7e1feed247f9 100644 --- a/packages/models/src/models/LivechatVisitors.ts +++ b/packages/models/src/models/LivechatVisitors.ts @@ -384,14 +384,16 @@ export class LivechatVisitorsRaw extends BaseRaw implements IL return Promise.resolve(); } - const update: UpdateFilter = { - $addToSet: { - ...(saveEmail.length && { visitorEmails: { $each: saveEmail } }), - ...(savePhone.length && { phone: { $each: savePhone } }), + // the only reason we're using $setUnion here instead of $addToSet is because + // old visitors might have `visitorEmails` or `phone` as `null` which would cause $addToSet to fail + return this.updateOne({ _id }, [ + { + $set: { + ...(saveEmail.length && { visitorEmails: { $setUnion: [{ $ifNull: ['$visitorEmails', []] }, saveEmail] } }), + ...(savePhone.length && { phone: { $setUnion: [{ $ifNull: ['$phone', []] }, savePhone] } }), + }, }, - }; - - return this.updateOne({ _id }, update); + ]); } removeContactManagerByUsername(manager: string): Promise { From 3abcb06901439855c5a5733ab0e2ca5a700cce23 Mon Sep 17 00:00:00 2001 From: Diego Sampaio Date: Wed, 5 Nov 2025 16:49:51 -0300 Subject: [PATCH 6/6] test: with broken regex --- .../end-to-end/api/livechat/09-visitors.ts | 59 +++++++++++++++++++ 1 file changed, 59 insertions(+) diff --git a/apps/meteor/tests/end-to-end/api/livechat/09-visitors.ts b/apps/meteor/tests/end-to-end/api/livechat/09-visitors.ts index 7ded2620d1561..f884caf21c405 100644 --- a/apps/meteor/tests/end-to-end/api/livechat/09-visitors.ts +++ b/apps/meteor/tests/end-to-end/api/livechat/09-visitors.ts @@ -1579,6 +1579,65 @@ describe('LIVECHAT - visitors', () => { }); }); + describe('when phone regex is broken', () => { + before(async () => { + await updateSetting('Livechat_lead_phone_regex', '('); + }); + + after(async () => { + // reset settings + await updateSetting( + 'Livechat_lead_phone_regex', + '((?:\\([0-9]{1,3}\\)|[0-9]{2})[ \\-]*?[0-9]{4,5}(?:[\\-\\s\\_]{1,2})?[0-9]{4}(?:(?=[^0-9])|$)|[0-9]{4,5}(?:[\\-\\s\\_]{1,2})?[0-9]{4}(?:(?=[^0-9])|$))', + ); + }); + + it('should capture email', async () => { + await sendMessage(room._id, 'Now my email is this@isok.com', visitor.token); + + await sendMessage(room._id, 'And my phone number is 55667799', visitor.token); + + visitor = await getLivechatVisitorByToken(visitor.token); + + expect(visitor.visitorEmails).to.be.an('array'); + expect(visitor.visitorEmails).to.have.lengthOf(6); + const emails = visitor.visitorEmails?.map((e) => e.address); + expect(emails).to.include('this@isok.com'); + + expect(visitor.phone).to.have.lengthOf(4); + const phones = visitor.phone?.map((p) => p.phoneNumber); + expect(phones || []).to.not.include('55667788'); + }); + }); + + describe('when email regex is broken', () => { + before(async () => { + await updateSetting('Livechat_lead_email_regex', '('); + }); + + after(async () => { + // reset settings + await updateSetting('Livechat_lead_email_regex', '\\b[A-Z0-9._%+-]+@(?:[A-Z0-9-]+\\.)+[A-Z]{2,4}\\b'); + }); + + it('should capture email', async () => { + await sendMessage(room._id, 'Now my email is this@shalnotpass.com', visitor.token); + + await sendMessage(room._id, 'And my phone number is 98765432', visitor.token); + + visitor = await getLivechatVisitorByToken(visitor.token); + + expect(visitor.visitorEmails).to.have.lengthOf(6); + const emails = visitor.visitorEmails?.map((e) => e.address); + expect(emails || []).to.not.include('this@shalnotpass.com'); + + expect(visitor.phone).to.be.an('array'); + expect(visitor.phone).to.have.lengthOf(5); + const phones = visitor.phone?.map((p) => p.phoneNumber); + expect(phones).to.include('98765432'); + }); + }); + describe('when the visitor has emails & phones already', () => { let newVisitor: ILivechatVisitor; let newRoom: IOmnichannelRoom;