@@ -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+
4355export 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 (
0 commit comments