feat: Add the ability to receive contact(vCard) on a WhatsApp inbox (#6330)

Co-authored-by: Pranav Raj S <pranav@chatwoot.com>
This commit is contained in:
Jacson Santos
2023-02-08 00:36:38 -03:00
committed by GitHub
parent bc96e5ed22
commit 73d14f204e
10 changed files with 296 additions and 80 deletions

View File

@@ -45,6 +45,11 @@
:longitude="attachment.coordinates_long"
:name="attachment.fallback_title"
/>
<bubble-contact
v-else-if="attachment.file_type === 'contact'"
:name="data.content"
:phone-number="attachment.fallback_title"
/>
<instagram-image-error-placeholder
v-else-if="hasImageError && hasInstagramStory"
/>
@@ -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,

View File

@@ -0,0 +1,115 @@
<template>
<div class="contact--group">
<fluent-icon icon="call" class="file--icon" size="18" />
<div class="meta">
<p class="text-truncate margin-bottom-0">
{{ phoneNumber }}
</p>
</div>
<div v-if="formattedPhoneNumber" class="link-wrap">
<woot-button variant="clear" size="small" @click.prevent="addContact">
{{ $t('CONVERSATION.SAVE_CONTACT') }}
</woot-button>
</div>
</div>
</template>
<script>
import {
DuplicateContactException,
ExceptionWithMessage,
} from 'shared/helpers/CustomErrors';
import alertMixin from 'shared/mixins/alertMixin';
export default {
mixins: [alertMixin],
props: {
name: {
type: String,
default: '',
},
phoneNumber: {
type: String,
default: '',
},
},
computed: {
formattedPhoneNumber() {
return this.phoneNumber.replace(/\s|-|[A-Za-z]/g, '');
},
rawPhoneNumber() {
return this.phoneNumber.replace(/\D/g, '');
},
},
methods: {
async addContact() {
try {
let contact = await this.filterContactByNumber(this.rawPhoneNumber);
if (!contact) {
contact = await this.$store.dispatch(
'contacts/create',
this.getContactObject()
);
this.showAlert(this.$t('CONTACT_FORM.SUCCESS_MESSAGE'));
}
this.openContactNewTab(contact.id);
} catch (error) {
if (error instanceof DuplicateContactException) {
if (error.data.includes('phone_number')) {
this.showAlert(this.$t('CONTACT_FORM.FORM.PHONE_NUMBER.DUPLICATE'));
}
} else if (error instanceof ExceptionWithMessage) {
this.showAlert(error.data);
} else {
this.showAlert(this.$t('CONTACT_FORM.ERROR_MESSAGE'));
}
}
},
getContactObject() {
const contactItem = {
name: this.name,
phone_number: `+${this.rawPhoneNumber}`,
};
return contactItem;
},
async filterContactByNumber(phoneNumber) {
const query = {
attribute_key: 'phone_number',
filter_operator: 'equal_to',
values: [phoneNumber],
attribute_model: 'standard',
custom_attribute_type: '',
};
const queryPayload = { payload: [query] };
const contacts = await this.$store.dispatch('contacts/filter', {
queryPayload,
resetState: false,
});
return contacts.shift();
},
openContactNewTab(contactId) {
const accountId = window.location.pathname.split('/')[3];
const url = `/app/accounts/${accountId}/contacts/${contactId}`;
window.open(url, '_blank');
},
},
};
</script>
<style lang="scss" scoped>
.contact--group {
align-items: center;
display: flex;
margin-top: var(--space-smaller);
.meta {
flex: 1;
margin-left: var(--space-small);
}
.link-wrap {
margin-left: var(--space-small);
}
}
</style>

View File

@@ -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: '<contact-bubble v-bind="$props" />',
});
export const ContactBubbleView = Template.bind({});

View File

@@ -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",

View File

@@ -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) {

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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)

View File

@@ -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