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:
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
@@ -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({});
|
||||
@@ -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",
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user