Skip to content

Commit 8ab642b

Browse files
committed
fix(ChatWoot): duplicate contact created for same LID without phone number - fix #2246
1 parent 93fa55b commit 8ab642b

4 files changed

Lines changed: 160 additions & 55 deletions

File tree

‎src/apps/chatwoot/client/ContactConversationService.ts‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,21 @@ import { AttributeKey } from '@waha/apps/chatwoot/const';
2424
export interface ContactInfo {
2525
ChatId(): string;
2626

27+
/**
28+
* Linked id - @lid, null when unknown
29+
*/
30+
LidId(): Promise<string | null>;
31+
32+
/**
33+
* Phone number jid - @c.us, null when unknown
34+
*/
35+
JidId(): Promise<string | null>;
36+
37+
/**
38+
* Resolved E.164 phone number, null when not applicable
39+
*/
40+
PhoneNumberE164(): Promise<string | null>;
41+
2742
AvatarUrl(): Promise<string | null>;
2843

2944
Attributes(): Promise<any>;

‎src/apps/chatwoot/client/ContactService.ts‎

Lines changed: 91 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,18 @@ export function sanitizeName(name: string) {
4040
return clean.slice(0, 255).trim();
4141
}
4242

43+
function SearchClauseEqualTo(key: string, values: string[]) {
44+
// equal_to with multiple values acts as IN
45+
return {
46+
attribute_key: key,
47+
filter_operator: 'equal_to',
48+
values: values,
49+
attribute_model: 'standard',
50+
custom_attribute_type: '',
51+
query_operator: 'OR',
52+
};
53+
}
54+
4355
export class ContactService {
4456
constructor(
4557
private config: ChatWootAPIConfig,
@@ -52,8 +64,11 @@ export class ContactService {
5264
contactInfo: ContactInfo,
5365
): Promise<[ContactResponse, boolean]> {
5466
const chatId = contactInfo.ChatId();
55-
let contact = await this.searchByAnyID(chatId);
67+
const lid = await contactInfo.LidId();
68+
const jid = await contactInfo.JidId();
69+
let contact = await this.search(chatId, lid, jid);
5670
if (contact) {
71+
await this.upsertPhoneNumber(contact, contactInfo);
5772
return [contact, false];
5873
}
5974

@@ -62,73 +77,94 @@ export class ContactService {
6277
return [contact, true];
6378
}
6479

65-
async searchByAnyID(chatId: string): Promise<ContactResponse | null> {
80+
async search(
81+
chatId: string,
82+
lid: string | null,
83+
jid: string | null,
84+
): Promise<ContactResponse | null> {
85+
// The chat id attribute holds the latest used address, so match any known form there
86+
const chatIds = lodash.uniq(lodash.compact([chatId, jid, lid]));
87+
if (chatIds.length == 0) {
88+
return null;
89+
}
90+
6691
const payload: any[] = [
67-
{
68-
attribute_key: AttributeKey.WA_CHAT_ID,
69-
filter_operator: 'equal_to',
70-
values: [chatId],
71-
attribute_model: 'standard',
72-
custom_attribute_type: '',
73-
query_operator: 'OR',
74-
},
75-
{
76-
attribute_key: AttributeKey.WA_JID,
77-
filter_operator: 'equal_to',
78-
values: [chatId],
79-
attribute_model: 'standard',
80-
custom_attribute_type: '',
81-
query_operator: 'OR',
82-
},
83-
{
84-
attribute_key: AttributeKey.WA_LID,
85-
filter_operator: 'equal_to',
86-
values: [chatId],
87-
attribute_model: 'standard',
88-
custom_attribute_type: '',
89-
query_operator: 'OR',
90-
},
91-
{
92-
attribute_key: 'identifier',
93-
filter_operator: 'equal_to',
94-
values: [chatId],
95-
attribute_model: 'standard',
96-
custom_attribute_type: '',
97-
},
92+
SearchClauseEqualTo(AttributeKey.WA_CHAT_ID, chatIds),
93+
SearchClauseEqualTo('identifier', chatIds),
9894
];
99-
100-
if (isJidCusFormat(chatId)) {
101-
// Search by phone
102-
const phoneNumberE164 = E164Parser.fromJid(chatId);
103-
const phone_number = phoneNumberE164.replace('+', '');
104-
payload[payload.length - 1].query_operator = 'OR';
105-
payload.push({
106-
attribute_key: 'phone_number',
107-
filter_operator: 'equal_to',
108-
values: [phone_number],
109-
});
95+
if (jid) {
96+
payload.push(SearchClauseEqualTo(AttributeKey.WA_JID, [jid]));
11097
}
98+
if (lid) {
99+
payload.push(SearchClauseEqualTo(AttributeKey.WA_LID, [lid]));
100+
}
101+
if (jid && isJidCusFormat(jid)) {
102+
// Search by phone - both with and without the leading '+'
103+
const phoneNumberE164 = E164Parser.fromJid(jid);
104+
let phones = [phoneNumberE164, phoneNumberE164.replace('+', '')];
105+
payload.push(SearchClauseEqualTo('phone_number', phones));
106+
}
107+
// The terminal clause must have no query_operator
108+
delete payload[payload.length - 1].query_operator;
111109

112110
const response: any = await this.accountAPI.contacts.filter({
113111
accountId: this.config.accountId,
114112
payload: payload as any,
115113
});
116114

117115
const contacts = response.payload;
118-
if (contacts.length == 0) {
119-
return null;
116+
const candidates: ContactResponse[] = [];
117+
for (const contact of contacts) {
118+
const inboxes = lodash.filter(contact.contact_inboxes, {
119+
inbox: { id: this.config.inboxId },
120+
});
121+
if (inboxes.length == 0) {
122+
continue;
123+
}
124+
candidates.push({
125+
data: contact,
126+
sourceId: inboxes[0].source_id,
127+
});
120128
}
121-
const contact = contacts[0];
122-
const inboxes = lodash.filter(contact.contact_inboxes, {
123-
inbox: { id: this.config.inboxId },
124-
});
125-
if (inboxes.length == 0) {
129+
if (candidates.length == 0) {
126130
return null;
127131
}
128-
return {
129-
data: contact,
130-
sourceId: inboxes[0].source_id,
131-
};
132+
// Prefer a contact with a phone number over a phone-less duplicate
133+
const withPhone = candidates.find((candidate) =>
134+
Boolean(candidate.data.phone_number),
135+
);
136+
return withPhone ?? candidates[0];
137+
}
138+
139+
private async upsertPhoneNumber(
140+
contact: ContactResponse,
141+
contactInfo: ContactInfo,
142+
): Promise<void> {
143+
if (contact.data.phone_number) {
144+
return;
145+
}
146+
const phoneNumberE164 = await contactInfo.PhoneNumberE164();
147+
if (!phoneNumberE164) {
148+
return;
149+
}
150+
try {
151+
await this.accountAPI.contacts.update({
152+
id: contact.data.id,
153+
accountId: this.config.accountId,
154+
data: { phone_number: phoneNumberE164 },
155+
});
156+
contact.data.phone_number = phoneNumberE164;
157+
this.logger.info(
158+
`Set phone_number for contact.id: ${
159+
contact.data.id
160+
}, chat.id: ${contactInfo.ChatId()}`,
161+
);
162+
} catch (err) {
163+
// Chatwoot returns 422 when another contact already owns the phone number
164+
this.logger.warn(
165+
`Error updating phone_number for contact.id: ${contact.data.id} - ${err}`,
166+
);
167+
}
132168
}
133169

134170
public async upsertCustomAttributes(

‎src/apps/chatwoot/contacts/InboxContactInfo.ts‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,18 @@ export class InboxContactInfo implements ContactInfo {
1414
return INBOX_CONTACT_CHAT_ID;
1515
}
1616

17+
async LidId(): Promise<string | null> {
18+
return null;
19+
}
20+
21+
async JidId(): Promise<string | null> {
22+
return null;
23+
}
24+
25+
async PhoneNumberE164(): Promise<string | null> {
26+
return null;
27+
}
28+
1729
async AvatarUrl() {
1830
return this.l.key(TKey.APP_INBOX_CONTACT_AVATAR_URL).render();
1931
}

‎src/apps/chatwoot/contacts/WhatsAppContactInfo.ts‎

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,18 @@ abstract class ChatContactInfo implements ContactInfo {
3232
return this.chatId;
3333
}
3434

35+
async LidId(): Promise<string | null> {
36+
return null;
37+
}
38+
39+
async JidId(): Promise<string | null> {
40+
return null;
41+
}
42+
43+
async PhoneNumberE164(): Promise<string | null> {
44+
return null;
45+
}
46+
3547
abstract AvatarUrl(): Promise<string | null>;
3648

3749
abstract Attributes(): Promise<any>;
@@ -53,6 +65,19 @@ class JidContactInfo extends ChatContactInfo {
5365
return await this.session.findLIDByPN(this.chatId);
5466
}
5567

68+
async LidId(): Promise<string | null> {
69+
const lid = await this.fetchLid().catch(() => null);
70+
return lid || null;
71+
}
72+
73+
async JidId(): Promise<string | null> {
74+
return this.chatId;
75+
}
76+
77+
async PhoneNumberE164(): Promise<string | null> {
78+
return E164Parser.fromJid(this.chatId);
79+
}
80+
5681
@CacheAsync()
5782
async Attributes() {
5883
const attributes = {
@@ -112,6 +137,23 @@ class LidContactInfo extends ChatContactInfo {
112137
return new JidContactInfo(this.session, pn, this.locale);
113138
}
114139

140+
async LidId(): Promise<string | null> {
141+
return this.chatId;
142+
}
143+
144+
async JidId(): Promise<string | null> {
145+
const jid = await this.jid();
146+
return jid?.ChatId() ?? null;
147+
}
148+
149+
async PhoneNumberE164(): Promise<string | null> {
150+
const jid = await this.jid();
151+
if (!jid) {
152+
return null;
153+
}
154+
return await jid.PhoneNumberE164();
155+
}
156+
115157
async AvatarUrl(): Promise<string | null> {
116158
const jid = await this.jid();
117159
if (jid) {

0 commit comments

Comments
 (0)