From 73d14f204e236c433654d34ed87e2dedf9c7857f Mon Sep 17 00:00:00 2001 From: Jacson Santos <87338089+jacsonsantospht@users.noreply.github.com> Date: Wed, 8 Feb 2023 00:36:38 -0300 Subject: [PATCH] feat: Add the ability to receive contact(vCard) on a WhatsApp inbox (#6330) Co-authored-by: Pranav Raj S --- .../widgets/conversation/Message.vue | 7 ++ .../widgets/conversation/bubble/Contact.vue | 115 ++++++++++++++++++ .../stories/ContactBubble.stories.js | 28 +++++ .../i18n/locale/en/conversation.json | 1 + .../store/modules/contacts/actions.js | 34 ++++-- app/models/attachment.rb | 9 +- .../whatsapp/incoming_message_base_service.rb | 83 +++++++------ .../incoming_message_service_helpers.rb | 37 +++++- .../jobs/webhooks/whatsapp_events_job_spec.rb | 27 ---- .../whatsapp/incoming_message_service_spec.rb | 35 +++++- 10 files changed, 296 insertions(+), 80 deletions(-) create mode 100644 app/javascript/dashboard/components/widgets/conversation/bubble/Contact.vue create mode 100644 app/javascript/dashboard/components/widgets/conversation/stories/ContactBubble.stories.js diff --git a/app/javascript/dashboard/components/widgets/conversation/Message.vue b/app/javascript/dashboard/components/widgets/conversation/Message.vue index 2a6938234..29e1eade5 100644 --- a/app/javascript/dashboard/components/widgets/conversation/Message.vue +++ b/app/javascript/dashboard/components/widgets/conversation/Message.vue @@ -45,6 +45,11 @@ :longitude="attachment.coordinates_long" :name="attachment.fallback_title" /> + @@ -125,6 +130,7 @@ import BubbleLocation from './bubble/Location'; import BubbleMailHead from './bubble/MailHead'; import BubbleText from './bubble/Text'; import BubbleVideo from './bubble/Video.vue'; +import BubbleContact from './bubble/Contact'; import Spinner from 'shared/components/Spinner'; import ContextMenu from 'dashboard/modules/conversations/components/MessageContextMenu'; import instagramImageErrorPlaceholder from './instagramImageErrorPlaceholder.vue'; @@ -143,6 +149,7 @@ export default { BubbleMailHead, BubbleText, BubbleVideo, + BubbleContact, ContextMenu, Spinner, instagramImageErrorPlaceholder, diff --git a/app/javascript/dashboard/components/widgets/conversation/bubble/Contact.vue b/app/javascript/dashboard/components/widgets/conversation/bubble/Contact.vue new file mode 100644 index 000000000..5064b81d4 --- /dev/null +++ b/app/javascript/dashboard/components/widgets/conversation/bubble/Contact.vue @@ -0,0 +1,115 @@ + + + + + diff --git a/app/javascript/dashboard/components/widgets/conversation/stories/ContactBubble.stories.js b/app/javascript/dashboard/components/widgets/conversation/stories/ContactBubble.stories.js new file mode 100644 index 000000000..ec000ab45 --- /dev/null +++ b/app/javascript/dashboard/components/widgets/conversation/stories/ContactBubble.stories.js @@ -0,0 +1,28 @@ +import ContactBubble from '../bubble/Contact.vue'; + +export default { + title: 'Components/Messaging/ContactBubble', + component: ContactBubble, + argTypes: { + name: { + defaultValue: 'Eden Hazard', + control: { + type: 'string', + }, + }, + phoneNumber: { + defaultValue: '+517554433220', + control: { + type: 'string', + }, + }, + }, +}; + +const Template = (args, { argTypes }) => ({ + props: Object.keys(argTypes), + components: { ContactBubble }, + template: '', +}); + +export const ContactBubbleView = Template.bind({}); diff --git a/app/javascript/dashboard/i18n/locale/en/conversation.json b/app/javascript/dashboard/i18n/locale/en/conversation.json index 5d664e621..b483d239f 100644 --- a/app/javascript/dashboard/i18n/locale/en/conversation.json +++ b/app/javascript/dashboard/i18n/locale/en/conversation.json @@ -35,6 +35,7 @@ "REMOVE_SELECTION": "Remove Selection", "DOWNLOAD": "Download", "UNKNOWN_FILE_TYPE": "Unknown File", + "SAVE_CONTACT": "Save", "UPLOADING_ATTACHMENTS": "Uploading attachments...", "SUCCESS_DELETE_MESSAGE": "Message deleted successfully", "FAIL_DELETE_MESSSAGE": "Couldn't delete message! Try again", diff --git a/app/javascript/dashboard/store/modules/contacts/actions.js b/app/javascript/dashboard/store/modules/contacts/actions.js index fc5fa8591..7b898cbea 100644 --- a/app/javascript/dashboard/store/modules/contacts/actions.js +++ b/app/javascript/dashboard/store/modules/contacts/actions.js @@ -35,6 +35,16 @@ const buildContactFormData = contactParams => { return formData; }; +export const raiseContactCreateErrors = error => { + if (error.response?.status === 422) { + throw new DuplicateContactException(error.response.data.attributes); + } else if (error.response?.data?.message) { + throw new ExceptionWithMessage(error.response.data.message); + } else { + throw new Error(error); + } +}; + export const actions = { search: async ({ commit }, { search, page, sortAttr, label }) => { commit(types.SET_CONTACT_UI_FLAG, { isFetching: true }); @@ -110,13 +120,10 @@ export const actions = { AnalyticsHelper.track(CONTACTS_EVENTS.CREATE_CONTACT); commit(types.SET_CONTACT_ITEM, response.data.payload.contact); commit(types.SET_CONTACT_UI_FLAG, { isCreating: false }); + return response.data.payload.contact; } catch (error) { commit(types.SET_CONTACT_UI_FLAG, { isCreating: false }); - if (error.response?.data?.message) { - throw new ExceptionWithMessage(error.response.data.message); - } else { - throw new Error(error); - } + return raiseContactCreateErrors(error); } }, @@ -227,19 +234,26 @@ export const actions = { } }, - filter: async ({ commit }, { page = 1, sortAttr, queryPayload } = {}) => { + filter: async ( + { commit }, + { page = 1, sortAttr, queryPayload, resetState = true } = {} + ) => { commit(types.SET_CONTACT_UI_FLAG, { isFetching: true }); try { const { data: { payload, meta }, } = await ContactAPI.filter(page, sortAttr, queryPayload); - commit(types.CLEAR_CONTACTS); - commit(types.SET_CONTACTS, payload); - commit(types.SET_CONTACT_META, meta); - commit(types.SET_CONTACT_UI_FLAG, { isFetching: false }); + if (resetState) { + commit(types.CLEAR_CONTACTS); + commit(types.SET_CONTACTS, payload); + commit(types.SET_CONTACT_META, meta); + commit(types.SET_CONTACT_UI_FLAG, { isFetching: false }); + } + return payload; } catch (error) { commit(types.SET_CONTACT_UI_FLAG, { isFetching: false }); } + return []; }, setContactFilters({ commit }, data) { diff --git a/app/models/attachment.rb b/app/models/attachment.rb index 8f4d47c8e..a9732662b 100644 --- a/app/models/attachment.rb +++ b/app/models/attachment.rb @@ -38,12 +38,13 @@ class Attachment < ApplicationRecord has_one_attached :file validate :acceptable_file - enum file_type: [:image, :audio, :video, :file, :location, :fallback, :share, :story_mention] + enum file_type: [:image, :audio, :video, :file, :location, :fallback, :share, :story_mention, :contact] def push_event_data return unless file_type return base_data.merge(location_metadata) if file_type.to_sym == :location return base_data.merge(fallback_data) if file_type.to_sym == :fallback + return base_data.merge(contact_metadata) if file_type.to_sym == :contact base_data.merge(file_metadata) end @@ -106,6 +107,12 @@ class Attachment < ApplicationRecord } end + def contact_metadata + { + fallback_title: fallback_title + } + end + def should_validate_file? return unless file.attached? # we are only limiting attachment types in case of website widget diff --git a/app/services/whatsapp/incoming_message_base_service.rb b/app/services/whatsapp/incoming_message_base_service.rb index 5dc495412..124fad79d 100644 --- a/app/services/whatsapp/incoming_message_base_service.rb +++ b/app/services/whatsapp/incoming_message_base_service.rb @@ -55,33 +55,29 @@ class Whatsapp::IncomingMessageBaseService def create_messages return if unprocessable_message_type?(message_type) - @message = @conversation.messages.build( - content: message_content(@processed_params[:messages].first), - account_id: @inbox.account_id, - inbox_id: @inbox.id, - message_type: :incoming, - sender: @contact, - source_id: @processed_params[:messages].first[:id].to_s - ) + message = @processed_params[:messages].first + if message_type == 'contacts' + create_contact_messages(message) + else + create_regular_message(message) + end + end + + def create_contact_messages(message) + message['contacts'].each do |contact| + create_message(contact) + attach_contact(contact) + @message.save! + end + end + + def create_regular_message(message) + create_message(message) attach_files attach_location if message_type == 'location' @message.save! end - def processed_params - @processed_params ||= params - end - - def message_content(message) - # TODO: map interactive messages back to button messages in chatwoot - message.dig(:text, :body) || message.dig(:button, :text) || message.dig(:interactive, :button_reply, :title) || - message.dig(:interactive, :list_reply, :title) - end - - def account - @account ||= inbox.account - end - def set_contact contact_params = @processed_params[:contacts]&.first return if contact_params.blank? @@ -96,15 +92,6 @@ class Whatsapp::IncomingMessageBaseService @contact = contact_inbox.contact end - def conversation_params - { - account_id: @inbox.account_id, - inbox_id: @inbox.id, - contact_id: @contact.id, - contact_inbox_id: @contact_inbox.id - } - end - def set_conversation @conversation = @contact_inbox.conversations.last return if @conversation @@ -112,12 +99,8 @@ class Whatsapp::IncomingMessageBaseService @conversation = ::Conversation.create!(conversation_params) end - def message_type - @processed_params[:messages].first[:type] - end - def attach_files - return if %w[text button interactive location].include?(message_type) + return if %w[text button interactive location contacts].include?(message_type) attachment_payload = @processed_params[:messages].first[message_type.to_sym] @message.content ||= attachment_payload[:caption] @@ -136,10 +119,6 @@ class Whatsapp::IncomingMessageBaseService ) end - def download_attachment_file(attachment_payload) - Down.download(inbox.channel.media_url(attachment_payload[:id]), headers: inbox.channel.api_headers) - end - def attach_location location = @processed_params[:messages].first['location'] location_name = location['name'] ? "#{location['name']}, #{location['address']}" : '' @@ -152,4 +131,28 @@ class Whatsapp::IncomingMessageBaseService external_url: location['url'] ) end + + def create_message(message) + @message = @conversation.messages.build( + content: message_content(message), + account_id: @inbox.account_id, + inbox_id: @inbox.id, + message_type: :incoming, + sender: @contact, + source_id: message[:id].to_s + ) + end + + def attach_contact(contact) + phones = contact[:phones] + phones = [{ phone: 'Phone number is not available' }] if phones.blank? + + phones.each do |phone| + @message.attachments.new( + account_id: @message.account_id, + file_type: file_content_type(message_type), + fallback_title: phone[:phone].to_s + ) + end + end end diff --git a/app/services/whatsapp/incoming_message_service_helpers.rb b/app/services/whatsapp/incoming_message_service_helpers.rb index 263e21c11..6716fc1b2 100644 --- a/app/services/whatsapp/incoming_message_service_helpers.rb +++ b/app/services/whatsapp/incoming_message_service_helpers.rb @@ -1,14 +1,49 @@ module Whatsapp::IncomingMessageServiceHelpers + def download_attachment_file(attachment_payload) + Down.download(inbox.channel.media_url(attachment_payload[:id]), headers: inbox.channel.api_headers) + end + + def conversation_params + { + account_id: @inbox.account_id, + inbox_id: @inbox.id, + contact_id: @contact.id, + contact_inbox_id: @contact_inbox.id + } + end + + def processed_params + @processed_params ||= params + end + + def account + @account ||= inbox.account + end + + def message_type + @processed_params[:messages].first[:type] + end + + def message_content(message) + # TODO: map interactive messages back to button messages in chatwoot + message.dig(:text, :body) || + message.dig(:button, :text) || + message.dig(:interactive, :button_reply, :title) || + message.dig(:interactive, :list_reply, :title) || + message.dig(:name, :formatted_name) + end + def file_content_type(file_type) return :image if %w[image sticker].include?(file_type) return :audio if %w[audio voice].include?(file_type) return :video if ['video'].include?(file_type) return :location if ['location'].include?(file_type) + return :contact if ['contacts'].include?(file_type) :file end def unprocessable_message_type?(message_type) - %w[reaction contacts ephemeral unsupported].include?(message_type) + %w[reaction ephemeral unsupported].include?(message_type) end end diff --git a/spec/jobs/webhooks/whatsapp_events_job_spec.rb b/spec/jobs/webhooks/whatsapp_events_job_spec.rb index e4e06b5da..8dddd9fb4 100644 --- a/spec/jobs/webhooks/whatsapp_events_job_spec.rb +++ b/spec/jobs/webhooks/whatsapp_events_job_spec.rb @@ -95,33 +95,6 @@ RSpec.describe Webhooks::WhatsappEventsJob, type: :job do end.not_to change(Message, :count) end - it 'Ignore contacts type message and stop raising error' do - other_channel = create(:channel_whatsapp, phone_number: '+1987654', provider: 'whatsapp_cloud', sync_templates: false, - validate_provider_config: false) - wb_params = { - phone_number: channel.phone_number, - object: 'whatsapp_business_account', - entry: [{ - changes: [{ - value: { - contacts: [{ profile: { name: 'Test Test' }, wa_id: '1111981136571' }], - messages: [{ from: '1111981136571', - contacts: [{ phones: [{ phone: '+1987654' }], name: { first_name: 'contact name' } }], - timestamp: '1664799904', - type: 'contacts' }], - metadata: { - phone_number_id: other_channel.provider_config['phone_number_id'], - display_phone_number: other_channel.phone_number.delete('+') - } - } - }] - }] - }.with_indifferent_access - expect do - Whatsapp::IncomingMessageWhatsappCloudService.new(inbox: other_channel.inbox, params: wb_params).perform - end.not_to change(Message, :count) - end - it 'will not enque Whatsapp::IncomingMessageWhatsappCloudService when invalid phone number id' do other_channel = create(:channel_whatsapp, phone_number: '+1987654', provider: 'whatsapp_cloud', sync_templates: false, validate_provider_config: false) diff --git a/spec/services/whatsapp/incoming_message_service_spec.rb b/spec/services/whatsapp/incoming_message_service_spec.rb index c2f90dd36..9c2ee9127 100644 --- a/spec/services/whatsapp/incoming_message_service_spec.rb +++ b/spec/services/whatsapp/incoming_message_service_spec.rb @@ -23,7 +23,7 @@ describe Whatsapp::IncomingMessageService do expect(whatsapp_channel.inbox.messages.first.content).to eq('Test') end - it 'appends to last conversation when if conversation already exisits' do + it 'appends to last conversation when if conversation already exists' do contact_inbox = create(:contact_inbox, inbox: whatsapp_channel.inbox, source_id: params[:messages].first[:from]) 2.times.each { create(:conversation, inbox: whatsapp_channel.inbox, contact_inbox: contact_inbox) } last_conversation = create(:conversation, inbox: whatsapp_channel.inbox, contact_inbox: contact_inbox) @@ -213,5 +213,38 @@ describe Whatsapp::IncomingMessageService do expect(location_attachment.external_url).to eq('http://location_url.test') end end + + context 'when valid contact message params' do + it 'creates appropriate message and attachments' do + params = { 'contacts' => [{ 'profile' => { 'name' => 'Kedar' }, 'wa_id' => '919746334593' }], + 'messages' => [{ 'from' => '919446284490', + 'id' => 'wamid.SDFADSf23sfasdafasdfa', + 'timestamp' => '1675823265', + 'type' => 'contacts', + 'contacts' => [ + { + 'name' => { 'formatted_name' => 'Apple Inc.' }, + 'phones' => [{ 'phone' => '+911800', 'type' => 'MAIN' }] + }, + { 'name' => { 'first_name' => 'Chatwoot', 'formatted_name' => 'Chatwoot' }, + 'phones' => [{ 'phone' => '+1 (415) 341-8386' }] } + ] }] }.with_indifferent_access + described_class.new(inbox: whatsapp_channel.inbox, params: params).perform + expect(Contact.all.first.name).to eq('Kedar') + + expect(whatsapp_channel.inbox.conversations.count).not_to eq(0) + + # Two messages are tested deliberately to ensure multiple contact attachments work. + m1 = whatsapp_channel.inbox.messages.first + contact_attachments = m1.attachments.first + expect(m1.content).to eq('Apple Inc.') + expect(contact_attachments.fallback_title).to eq('+911800') + + m2 = whatsapp_channel.inbox.messages.last + contact_attachments = m2.attachments.first + expect(m2.content).to eq('Chatwoot') + expect(contact_attachments.fallback_title).to eq('+1 (415) 341-8386') + end + end end end