diff --git a/src/apps/chatwoot/client/ContactConversationService.ts b/src/apps/chatwoot/client/ContactConversationService.ts index accfe960..c3978294 100644 --- a/src/apps/chatwoot/client/ContactConversationService.ts +++ b/src/apps/chatwoot/client/ContactConversationService.ts @@ -24,6 +24,21 @@ import { AttributeKey } from '@waha/apps/chatwoot/const'; export interface ContactInfo { ChatId(): string; + /** + * Linked id - @lid, null when unknown + */ + LidId(): Promise; + + /** + * Phone number jid - @c.us, null when unknown + */ + JidId(): Promise; + + /** + * Resolved E.164 phone number, null when not applicable + */ + PhoneNumberE164(): Promise; + AvatarUrl(): Promise; Attributes(): Promise; diff --git a/src/apps/chatwoot/client/ContactService.ts b/src/apps/chatwoot/client/ContactService.ts index cf90d546..7d0a43f1 100644 --- a/src/apps/chatwoot/client/ContactService.ts +++ b/src/apps/chatwoot/client/ContactService.ts @@ -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 { - 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 { + // 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 { + 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( diff --git a/src/apps/chatwoot/contacts/InboxContactInfo.ts b/src/apps/chatwoot/contacts/InboxContactInfo.ts index 4f6a41f7..0d214ee8 100644 --- a/src/apps/chatwoot/contacts/InboxContactInfo.ts +++ b/src/apps/chatwoot/contacts/InboxContactInfo.ts @@ -14,6 +14,18 @@ export class InboxContactInfo implements ContactInfo { return INBOX_CONTACT_CHAT_ID; } + async LidId(): Promise { + return null; + } + + async JidId(): Promise { + return null; + } + + async PhoneNumberE164(): Promise { + return null; + } + async AvatarUrl() { return this.l.key(TKey.APP_INBOX_CONTACT_AVATAR_URL).render(); } diff --git a/src/apps/chatwoot/contacts/WhatsAppContactInfo.ts b/src/apps/chatwoot/contacts/WhatsAppContactInfo.ts index cb40cdeb..be7a46d6 100644 --- a/src/apps/chatwoot/contacts/WhatsAppContactInfo.ts +++ b/src/apps/chatwoot/contacts/WhatsAppContactInfo.ts @@ -32,6 +32,18 @@ abstract class ChatContactInfo implements ContactInfo { return this.chatId; } + async LidId(): Promise { + return null; + } + + async JidId(): Promise { + return null; + } + + async PhoneNumberE164(): Promise { + return null; + } + abstract AvatarUrl(): Promise; abstract Attributes(): Promise; @@ -53,6 +65,19 @@ class JidContactInfo extends ChatContactInfo { return await this.session.findLIDByPN(this.chatId); } + async LidId(): Promise { + const lid = await this.fetchLid().catch(() => null); + return lid || null; + } + + async JidId(): Promise { + return this.chatId; + } + + async PhoneNumberE164(): Promise { + 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 { + return this.chatId; + } + + async JidId(): Promise { + const jid = await this.jid(); + return jid?.ChatId() ?? null; + } + + async PhoneNumberE164(): Promise { + const jid = await this.jid(); + if (!jid) { + return null; + } + return await jid.PhoneNumberE164(); + } + async AvatarUrl(): Promise { const jid = await this.jid(); if (jid) {