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
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:
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>;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in new issue
Block a user