Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
41 commits
Select commit Hold shift + click to select a range
986e803
feat: add IVisitorExternalIdentifier type for external visitor IDs
ricardogarim Mar 10, 2026
303ece0
feat: add findOneByExternalId and addExternalId methods to LivechatVi…
ricardogarim Mar 10, 2026
4203453
feat: add resolveVisitor for external ID lookup with progressive enri…
ricardogarim Mar 10, 2026
f386bbf
feat: add resolveVisitor method to ILivechatCreator
ricardogarim Mar 10, 2026
ca61b37
feat: display WhatsApp username alongside phone in contact info
ricardogarim Mar 10, 2026
bb87ace
eslint fix
ricardogarim Mar 11, 2026
0c75e6b
chore: document createVisitor deprecation
ricardogarim Mar 11, 2026
61b45f5
fix: change doResolveVisitor permission from read to write
ricardogarim Mar 11, 2026
6b24b1f
fix: correct externalIds query and deduplication logic
ricardogarim Mar 11, 2026
b1faad2
refactor: change visitor externalIds from array to record structure
ricardogarim Mar 12, 2026
7d79856
refactor: use findOneAndUpdate for phone lookup to save db roundtrip
ricardogarim Mar 12, 2026
99da74e
add changeset
ricardogarim Mar 12, 2026
3196fea
fix: prevent externalIds overwrite in registerGuest
ricardogarim Mar 13, 2026
57ed43a
fix: bump core-typings and apps-engine to minor for new externalIds API
ricardogarim Mar 13, 2026
ed5c091
test: add E2E tests for resolveVisitor Apps-Engine API
ricardogarim Mar 16, 2026
ffe7c1c
feat: add ResolveVisitorContactData for flexible visitor lookup fallback
ricardogarim Mar 18, 2026
b51e47f
fix: narrow visitorDataToUpdate type for externalIds
ricardogarim Mar 18, 2026
b0c123c
changeset all to minor
d-gubert Mar 18, 2026
d65e2b5
clarifying comment on why we're using dot notation
d-gubert Mar 18, 2026
b2633e3
docs: add external-id-test app to test packages README
ricardogarim Mar 18, 2026
5e2acdd
fix: correct external-id-test app package
ricardogarim Mar 18, 2026
25163ab
chore: mongodb wildcard indexes are sparse by default
ricardogarim Mar 18, 2026
9168409
feat: migrate externalIds to array format with compound index
ricardogarim Mar 18, 2026
292fe8d
test: update zip test app
ricardogarim Mar 18, 2026
cde168b
chore: add sparse prop to index creation
ricardogarim Mar 19, 2026
a955367
refactor: simplify externalIds handling in registerGuest
ricardogarim Mar 19, 2026
28ba4ed
chore: revert out-of-scope eslint auto-fix
ricardogarim Mar 19, 2026
34d647c
refactor: add entityId field to IVisitorExternalIdentifier
ricardogarim Apr 7, 2026
29b7cf9
refactor: add metadata field to IVisitorExternalIdentifier
ricardogarim Apr 8, 2026
072831b
refactor: expose only updateExternalIds and findByEntityId methods
ricardogarim Apr 8, 2026
bb8aaba
refactor: rename externalId source field to appId
ricardogarim Apr 8, 2026
7dbe67b
fix: make appId required on IVisitorExternalIdentifier
ricardogarim Apr 8, 2026
74734cc
fix: exclude externalIds from ChatTranscript PDF
ricardogarim Apr 8, 2026
54f3d2a
test: remove obsolete resolveVisitor tests
ricardogarim Apr 8, 2026
8d29889
fix: ensure externalIds queries are DocumentDB compatible
ricardogarim Apr 8, 2026
a9f9ddd
chore: revert unrelated eslint auto-fix in livechat bridge
ricardogarim Apr 8, 2026
a684a25
fix: add missing core-typings dependency
ricardogarim Apr 8, 2026
71acfcc
fix: skip type check for unserializable externalIds.metadata
ricardogarim Apr 9, 2026
1783c26
fix: resolveVisitor test app and MongoDB collection name
ricardogarim Apr 9, 2026
3b82c91
refactor: rename and simplify contact identifier in ContactField
ricardogarim Apr 13, 2026
78df16b
fix: transform externalIds.metadata to match Serialized type in PDF w…
ricardogarim Apr 13, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .changeset/forty-dolphins-check.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
'@rocket.chat/model-typings': minor
'@rocket.chat/core-typings': minor
'@rocket.chat/apps-engine': minor
'@rocket.chat/omni-core': minor
'@rocket.chat/models': minor
'@rocket.chat/meteor': minor
---

Adds externalIds field to livechat visitors for external platform identification.
46 changes: 45 additions & 1 deletion apps/meteor/app/apps/server/bridges/livechat.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,13 @@
import type { IAppServerOrchestrator, IAppsLivechatMessage, IAppsMessage } from '@rocket.chat/apps';
import type { IExtraRoomParams } from '@rocket.chat/apps-engine/definition/accessors/ILivechatCreator';
import type { IVisitor, ILivechatRoom, ILivechatTransferData, IDepartment } from '@rocket.chat/apps-engine/definition/livechat';
import type {
IVisitorExternalIdentifier,
IVisitor,
ILivechatRoom,
ILivechatTransferData,
IDepartment,
ResolveVisitorContactData,
} from '@rocket.chat/apps-engine/definition/livechat';
import type { IMessage as IAppsEngineMessage } from '@rocket.chat/apps-engine/definition/messages';
import type { IUser } from '@rocket.chat/apps-engine/definition/users';
import { LivechatBridge } from '@rocket.chat/apps-engine/server/bridges/LivechatBridge';
Expand All @@ -16,6 +23,7 @@ import { setCustomFields } from '../../../livechat/server/lib/custom-fields';
import { getRoomMessages } from '../../../livechat/server/lib/getRoomMessages';
import type { ILivechatMessage } from '../../../livechat/server/lib/localTypes';
import { updateMessage, sendMessage } from '../../../livechat/server/lib/messages';
import { resolveVisitor } from '../../../livechat/server/lib/resolveVisitor';
import { createRoom } from '../../../livechat/server/lib/rooms';
import { online } from '../../../livechat/server/lib/service-status';
import { transfer } from '../../../livechat/server/lib/transfer';
Expand Down Expand Up @@ -198,6 +206,10 @@ export class AppLivechatBridge extends LivechatBridge {
return Promise.all(result.map((room) => this.orch.getConverters()?.get('rooms').convertRoom(room) as Promise<ILivechatRoom>));
}

/**
* @deprecated Use `createAndReturnVisitor` instead.
* Note: This method does not support `externalIds`.
*/
protected async createVisitor(visitor: IVisitor, appId: string): Promise<string> {
this.orch.debugLog(`The App ${appId} is creating a livechat visitor.`);

Expand Down Expand Up @@ -226,6 +238,9 @@ export class AppLivechatBridge extends LivechatBridge {
protected async createAndReturnVisitor(visitor: IVisitor, appId: string): Promise<IVisitor | undefined> {
this.orch.debugLog(`The App ${appId} is creating a livechat visitor.`);

// Add appId to each externalId entry
const externalIds = visitor.externalIds?.map((entry) => ({ ...entry, appId }));

const registerData = {
department: visitor.department,
username: visitor.username,
Expand All @@ -235,6 +250,7 @@ export class AppLivechatBridge extends LivechatBridge {
id: visitor.id,
...(visitor.phone?.length && { phone: { number: visitor.phone[0].phoneNumber } }),
...(visitor.visitorEmails?.length && { email: visitor.visitorEmails[0].address }),
...(externalIds?.length && { externalIds }),
};

const livechatVisitor = await registerGuest(registerData, {
Expand Down Expand Up @@ -335,6 +351,34 @@ export class AppLivechatBridge extends LivechatBridge {
.convertVisitor(await LivechatVisitors.findOneVisitorByPhone(phoneNumber));
}

protected async resolveVisitor(
externalId: Omit<IVisitorExternalIdentifier, 'appId'>,
contactData: ResolveVisitorContactData | undefined,
appId: string,
): Promise<IVisitor | undefined> {
this.orch.debugLog(`The App ${appId} is resolving a livechat visitor by external ID.`);

const visitor = await resolveVisitor({
appId,
externalId,
contactData,
});

return this.orch.getConverters()?.get('visitors').convertVisitor(visitor);
}

protected async updateVisitorExternalId(
visitorId: string,
externalId: Omit<IVisitorExternalIdentifier, 'appId'>,
appId: string,
): Promise<IVisitor | undefined> {
this.orch.debugLog(`The App ${appId} is updating externalId for visitor ${visitorId}.`);

const visitor = await LivechatVisitors.updateExternalIdById(visitorId, appId, externalId);

return this.orch.getConverters()?.get('visitors').convertVisitor(visitor);
}

protected async findDepartmentByIdOrName(value: string, appId: string): Promise<IDepartment | undefined> {
this.orch.debugLog(`The App ${appId} is looking for livechat departments.`);

Expand Down
2 changes: 2 additions & 0 deletions apps/meteor/app/apps/server/converters/visitors.js
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ export class AppVisitorsConverter {
livechatData: 'livechatData',
status: 'status',
activity: 'activity',
externalIds: 'externalIds',
};

return transformMappedData(visitor, map);
Expand All @@ -57,6 +58,7 @@ export class AppVisitorsConverter {
status: visitor.status || 'online',
...(visitor.visitorEmails && { visitorEmails: visitor.visitorEmails }),
...(visitor.department && { department: visitor.department }),
...(visitor.externalIds && { externalIds: visitor.externalIds }),
};

return Object.assign(newVisitor, visitor._unmappedProperties_);
Expand Down
23 changes: 23 additions & 0 deletions apps/meteor/app/livechat/server/lib/resolveVisitor.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import type { IVisitorExternalIdentifier, ILivechatVisitor } from '@rocket.chat/core-typings';
import { LivechatVisitors } from '@rocket.chat/models';

type ResolveVisitorContactData = { phone: string } | { email: string };

type ResolveVisitorParams = {
appId: string;
externalId: Omit<IVisitorExternalIdentifier, 'appId'>;
contactData?: ResolveVisitorContactData;
};

export async function resolveVisitor({ appId, externalId, contactData }: ResolveVisitorParams): Promise<ILivechatVisitor | null> {
const visitorByExternalId = await LivechatVisitors.findOneByExternalId(externalId.entityId);
if (visitorByExternalId) {
return visitorByExternalId;
}

if (contactData && (('phone' in contactData && contactData.phone) || ('email' in contactData && contactData.email))) {
return LivechatVisitors.findOneVisitorByPhoneOrEmailAndAddExternalId(contactData, appId, externalId);
}

return null;
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { useBlockChannel } from './useBlockChannel';
import { OmnichannelRoomIcon } from '../../../../../components/RoomIcon/OmnichannelRoomIcon';
import { useTimeFromNow } from '../../../../../hooks/useTimeFromNow';
import { useOutboundMessageModal } from '../../../components/outboundMessage/modals/OutboundMessageModal';
import { useVisitorInfo } from '../../../directory/hooks/useVisitorInfo';
import { useOmnichannelSource } from '../../../hooks/useOmnichannelSource';

type ContactInfoChannelsItemProps = Serialized<ILivechatContactChannel> & {
Expand All @@ -29,6 +30,19 @@ const ContactInfoChannelsItem = ({
const { getSourceLabel, getSourceName } = useOmnichannelSource();
const getTimeFromNow = useTimeFromNow(true);

const { data: visitorData } = useVisitorInfo(visitor.visitorId);

const channelLabel = useMemo(() => {
const phone = getSourceLabel(details);
const externalId = details?.id ? visitorData?.externalIds?.find((e) => e.appId === details.id) : undefined;
const username = externalId?.metadata?.username;

if (typeof username === 'string' && phone) {
return `${username} - ${phone}`;
}
return typeof username === 'string' ? username : phone;
}, [visitorData?.externalIds, details, getSourceLabel]);
Comment thread
gabriellsh marked this conversation as resolved.

const [showButton, setShowButton] = useState(false);
const handleBlockContact = useBlockChannel({ association: visitor, blocked });
const outboundMessageModal = useOutboundMessageModal();
Expand Down Expand Up @@ -94,7 +108,7 @@ const ContactInfoChannelsItem = ({
)}
</Box>
<Box minHeight='x24' alignItems='center' mbs={4} display='flex' justifyContent='space-between'>
<Box>{getSourceLabel(details)}</Box>
<Box>{channelLabel}</Box>
{showButton && <GenericMenu detached title={t('Options')} sections={[{ items: menuItems }]} tiny />}
</Box>
</Box>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,6 @@ const ContactField = ({ contact, room }: ContactFieldProps) => {
const getVisitorInfo = useEndpoint('GET', '/v1/livechat/visitors.info');
const { data, isPending, isError } = useQuery({
queryKey: ['/v1/livechat/visitors.info', contact._id],

queryFn: () => getVisitorInfo({ visitorId: contact._id }),
});

Expand All @@ -39,17 +38,19 @@ const ContactField = ({ contact, room }: ContactFieldProps) => {
}

const {
visitor: { username, name },
visitor: { username, name, phone },
} = data;

const displayName = name || username;
const phoneNumber = phone?.[0]?.phoneNumber;
const contactIdentifier = [...new Set([username, phoneNumber])].filter(Boolean).join(' · ');

return (
<Field>
<Label>{t('Contact')}</Label>
<Info style={{ display: 'flex' }}>
<Avatar size='x40' title={fname} url={avatarUrl} />
<AgentInfoDetails mis={10} name={displayName} shortName={username} status={<UserStatus status={status} />} />
<AgentInfoDetails mis={10} name={displayName} shortName={contactIdentifier} status={<UserStatus status={status} />} />
</Info>
</Field>
);
Expand Down
147 changes: 147 additions & 0 deletions apps/meteor/tests/data/apps/app-packages/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,153 @@ export class TestEndpoint extends ApiEndpoint {

</details>

#### External ID Test (resolveVisitor API)

File name: `external-id-test_0.0.1.zip`

An app that tests the `ILivechatCreator.resolveVisitor()` and `ILivechatUpdater.updateVisitorExternalId()` APIs for resolving and updating livechat visitors by external identifiers. This is used to test the WhatsApp BSUID (Business Scoped User ID) support and progressive visitor enrichment.

**Endpoints:**

1. `POST /api/apps/public/:appId/resolve-visitor` - Resolve visitor by externalId with phone/email fallback
2. `POST /api/apps/public/:appId/update-external-id` - Update visitor's externalId for this app

**Request body (resolve-visitor):**
```json
{
"externalId": { "entityId": "bsuid-123", "metadata": { "username": "@johndoe" } },
"phone": "+1234567890"
}
```

**Request body (update-external-id):**
```json
{
"visitorId": "visitor-123",
"externalId": { "entityId": "bsuid-123", "metadata": { "username": "@johndoe" } }
}
```

**Response:**
- Returns the visitor if found/updated
- Returns `{ visitor: null }` if not found

<details>
<summary>App source code</summary>

**ExternalIdTestApp.ts**
```typescript
import {
IAppAccessors,
IConfigurationExtend,
ILogger,
} from '@rocket.chat/apps-engine/definition/accessors';
import { App } from '@rocket.chat/apps-engine/definition/App';
import { IAppInfo } from '@rocket.chat/apps-engine/definition/metadata';
import { ApiSecurity, ApiVisibility } from '@rocket.chat/apps-engine/definition/api';
import { ResolveVisitorEndpoint } from './ResolveVisitorEndpoint';
import { UpdateExternalIdEndpoint } from './UpdateExternalIdEndpoint';

export class ExternalIdTestApp extends App {
constructor(info: IAppInfo, logger: ILogger, accessors: IAppAccessors) {
super(info, logger, accessors);
}

public override async extendConfiguration(configuration: IConfigurationExtend): Promise<void> {
await configuration.api.provideApi({
visibility: ApiVisibility.PUBLIC,
security: ApiSecurity.UNSECURE,
endpoints: [new ResolveVisitorEndpoint(this), new UpdateExternalIdEndpoint(this)],
});
}
}
```

**ResolveVisitorEndpoint.ts**
```typescript
import {
HttpStatusCode,
IHttp,
IModify,
IPersistence,
IRead,
} from '@rocket.chat/apps-engine/definition/accessors';
import { ApiEndpoint, IApiEndpointInfo, IApiRequest, IApiResponse } from '@rocket.chat/apps-engine/definition/api';

export class ResolveVisitorEndpoint extends ApiEndpoint {
public override path = 'resolve-visitor';

public async post(
request: IApiRequest,
_endpoint: IApiEndpointInfo,
_read: IRead,
modify: IModify,
_http: IHttp,
_persistence: IPersistence,
): Promise<IApiResponse> {
const { externalId, phone, email } = request.content;

let contactData: { phone: string } | { email: string } | undefined;

if (phone) {
contactData = { phone };
} else if (email) {
contactData = { email };
}

const visitor = await modify.getCreator().getLivechatCreator().resolveVisitor(externalId, contactData);

return {
status: HttpStatusCode.OK,
content: { visitor: visitor || null },
};
}
}
```

**UpdateExternalIdEndpoint.ts**
```typescript
import {
HttpStatusCode,
IHttp,
IModify,
IPersistence,
IRead,
} from '@rocket.chat/apps-engine/definition/accessors';
import { ApiEndpoint, IApiEndpointInfo, IApiRequest, IApiResponse } from '@rocket.chat/apps-engine/definition/api';

export class UpdateExternalIdEndpoint extends ApiEndpoint {
public override path = 'update-external-id';

public async post(
request: IApiRequest,
_endpoint: IApiEndpointInfo,
_read: IRead,
modify: IModify,
_http: IHttp,
_persistence: IPersistence,
): Promise<IApiResponse> {
const { visitorId, externalId } = request.content;

if (!visitorId || !externalId) {
return {
status: HttpStatusCode.BAD_REQUEST,
content: { error: 'visitorId and externalId are required' },
};
}

const visitor = await modify.getUpdater().getLivechatUpdater().updateVisitorExternalId(visitorId, externalId);

return {
status: HttpStatusCode.OK,
content: { visitor: visitor || null },
};
}
}
```

</details>

#### Nested Requests simulation

File name: `nested-requests_0.0.1.zip`
Expand Down
Binary file not shown.
2 changes: 2 additions & 0 deletions apps/meteor/tests/data/apps/app-packages/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,5 @@ export const appAPIParameterTest = path.resolve(__dirname, './api-parameter-test
export const appCausingNestedRequests = path.resolve(__dirname, './nested-requests_0.0.1.zip');

export const appUpdateStatusTest = path.resolve(__dirname, './update-status-test_0.0.1.zip');

export const appExternalIdTest = path.resolve(__dirname, './external-id-test_0.0.1.zip');
Loading
Loading