fix(ChatWoot): duplicate contact created for same LID without phone number - fix #2246
Release / WEBJS - chromium - amd64 - latest (push) Waiting to run
Release / WEBJS - chromium - linux/arm64 - arm (push) Waiting to run
Release / WEBJS - chrome - amd64 - chrome (push) Waiting to run
Release / GOWS - none - amd64 - gows (push) Waiting to run
Release / GOWS - none - linux/arm64 - gows-arm (push) Waiting to run
Release / NOWEB - none - amd64 - noweb (push) Waiting to run
Release / NOWEB - none - linux/arm64 - noweb-arm (push) Waiting to run

This commit is contained in:
devlikepro committed 2026-09-01 15:36:04 +07:00
1 parent 93fa55b7b7
commit 8ab642b20d
4 files changed
+162 -57

No files matched your search

@@ -24,6 +24,21 @@ import { AttributeKey } from '@waha/apps/chatwoot/const';
export interface ContactInfo {
ChatId(): string;
/**
* Linked id - @lid, null when unknown
*/
LidId(): Promise<string | null>;
/**
* Phone number jid - @c.us, null when unknown
*/
JidId(): Promise<string | null>;
/**
* Resolved E.164 phone number, null when not applicable
*/
PhoneNumberE164(): Promise<string | null>;
AvatarUrl(): Promise<string | null>;
Attributes(): Promise<any>;
+93 -57
View File
@@ -40,6 +40,18 @@ export function sanitizeName(name: string) {
return clean.slice(0, 255).trim();
}
function SearchClauseEqualTo(key: string, values: string[]) {
// equal_to with multiple values acts as IN
return {
attribute_key: key,
filter_operator: 'equal_to',
values: values,
attribute_model: 'standard',
custom_attribute_type: '',
query_operator: 'OR',
};
}
export class ContactService {
constructor(
private config: ChatWootAPIConfig,
@@ -52,8 +64,11 @@ export class ContactService {
contactInfo: ContactInfo,
): Promise<[ContactResponse, boolean]> {
const chatId = contactInfo.ChatId();
let contact = await this.searchByAnyID(chatId);
const lid = await contactInfo.LidId();
const jid = await contactInfo.JidId();
let contact = await this.search(chatId, lid, jid);
if (contact) {
await this.upsertPhoneNumber(contact, contactInfo);
return [contact, false];
}
@@ -62,73 +77,94 @@ export class ContactService {
return [contact, true];
}
async searchByAnyID(chatId: string): Promise<ContactResponse | null> {
const payload: any[] = [
{
attribute_key: AttributeKey.WA_CHAT_ID,
filter_operator: 'equal_to',
values: [chatId],
attribute_model: 'standard',
custom_attribute_type: '',
query_operator: 'OR',
},
{
attribute_key: AttributeKey.WA_JID,
filter_operator: 'equal_to',
values: [chatId],
attribute_model: 'standard',
custom_attribute_type: '',
query_operator: 'OR',
},
{
attribute_key: AttributeKey.WA_LID,
filter_operator: 'equal_to',
values: [chatId],
attribute_model: 'standard',
custom_attribute_type: '',
query_operator: 'OR',
},
{
attribute_key: 'identifier',
filter_operator: 'equal_to',
values: [chatId],
attribute_model: 'standard',
custom_attribute_type: '',
},
];
if (isJidCusFormat(chatId)) {
// Search by phone
const phoneNumberE164 = E164Parser.fromJid(chatId);
const phone_number = phoneNumberE164.replace('+', '');
payload[payload.length - 1].query_operator = 'OR';
payload.push({
attribute_key: 'phone_number',
filter_operator: 'equal_to',
values: [phone_number],
});
async search(
chatId: string,
lid: string | null,
jid: string | null,
): Promise<ContactResponse | null> {
// The chat id attribute holds the latest used address, so match any known form there
const chatIds = lodash.uniq(lodash.compact([chatId, jid, lid]));
if (chatIds.length == 0) {
return null;
}
const payload: any[] = [
SearchClauseEqualTo(AttributeKey.WA_CHAT_ID, chatIds),
SearchClauseEqualTo('identifier', chatIds),
];
if (jid) {
payload.push(SearchClauseEqualTo(AttributeKey.WA_JID, [jid]));
}
if (lid) {
payload.push(SearchClauseEqualTo(AttributeKey.WA_LID, [lid]));
}
if (jid && isJidCusFormat(jid)) {
// Search by phone - both with and without the leading '+'
const phoneNumberE164 = E164Parser.fromJid(jid);
let phones = [phoneNumberE164, phoneNumberE164.replace('+', '')];
payload.push(SearchClauseEqualTo('phone_number', phones));
}
// The terminal clause must have no query_operator
delete payload[payload.length - 1].query_operator;
const response: any = await this.accountAPI.contacts.filter({
accountId: this.config.accountId,
payload: payload as any,
});
const contacts = response.payload;
if (contacts.length == 0) {
const candidates: ContactResponse[] = [];
for (const contact of contacts) {
const inboxes = lodash.filter(contact.contact_inboxes, {
inbox: { id: this.config.inboxId },
});
if (inboxes.length == 0) {
continue;
}
candidates.push({
data: contact,
sourceId: inboxes[0].source_id,
});
}
if (candidates.length == 0) {
return null;
}
const contact = contacts[0];
const inboxes = lodash.filter(contact.contact_inboxes, {
inbox: { id: this.config.inboxId },
});
if (inboxes.length == 0) {
return null;
// Prefer a contact with a phone number over a phone-less duplicate
const withPhone = candidates.find((candidate) =>
Boolean(candidate.data.phone_number),
);
return withPhone ?? candidates[0];
}
private async upsertPhoneNumber(
contact: ContactResponse,
contactInfo: ContactInfo,
): Promise<void> {
if (contact.data.phone_number) {
return;
}
const phoneNumberE164 = await contactInfo.PhoneNumberE164();
if (!phoneNumberE164) {
return;
}
try {
await this.accountAPI.contacts.update({
id: contact.data.id,
accountId: this.config.accountId,
data: { phone_number: phoneNumberE164 },
});
contact.data.phone_number = phoneNumberE164;
this.logger.info(
`Set phone_number for contact.id: ${
contact.data.id
}, chat.id: ${contactInfo.ChatId()}`,
);
} catch (err) {
// Chatwoot returns 422 when another contact already owns the phone number
this.logger.warn(
`Error updating phone_number for contact.id: ${contact.data.id} - ${err}`,
);
}
return {
data: contact,
sourceId: inboxes[0].source_id,
};
}
public async upsertCustomAttributes(
@@ -14,6 +14,18 @@ export class InboxContactInfo implements ContactInfo {
return INBOX_CONTACT_CHAT_ID;
}
async LidId(): Promise<string | null> {
return null;
}
async JidId(): Promise<string | null> {
return null;
}
async PhoneNumberE164(): Promise<string | null> {
return null;
}
async AvatarUrl() {
return this.l.key(TKey.APP_INBOX_CONTACT_AVATAR_URL).render();
}
@@ -32,6 +32,18 @@ abstract class ChatContactInfo implements ContactInfo {
return this.chatId;
}
async LidId(): Promise<string | null> {
return null;
}
async JidId(): Promise<string | null> {
return null;
}
async PhoneNumberE164(): Promise<string | null> {
return null;
}
abstract AvatarUrl(): Promise<string | null>;
abstract Attributes(): Promise<any>;
@@ -53,6 +65,19 @@ class JidContactInfo extends ChatContactInfo {
return await this.session.findLIDByPN(this.chatId);
}
async LidId(): Promise<string | null> {
const lid = await this.fetchLid().catch(() => null);
return lid || null;
}
async JidId(): Promise<string | null> {
return this.chatId;
}
async PhoneNumberE164(): Promise<string | null> {
return E164Parser.fromJid(this.chatId);
}
@CacheAsync()
async Attributes() {
const attributes = {
@@ -112,6 +137,23 @@ class LidContactInfo extends ChatContactInfo {
return new JidContactInfo(this.session, pn, this.locale);
}
async LidId(): Promise<string | null> {
return this.chatId;
}
async JidId(): Promise<string | null> {
const jid = await this.jid();
return jid?.ChatId() ?? null;
}
async PhoneNumberE164(): Promise<string | null> {
const jid = await this.jid();
if (!jid) {
return null;
}
return await jid.PhoneNumberE164();
}
async AvatarUrl(): Promise<string | null> {
const jid = await this.jid();
if (jid) {