From fdcfed2cd7330b72fa9df375993b47abaac09e04 Mon Sep 17 00:00:00 2001 From: Muhsin Keloth Date: Tue, 12 Aug 2025 14:20:52 +0530 Subject: [PATCH 01/26] feat: Add WhatsApp profile for contact name resolution (#12123) Fixes https://linear.app/chatwoot/issue/CW-4397/whatsapp-contacts-name-update-after-responsd-to-template --- .../whatsapp/incoming_message_base_service.rb | 20 ++++ .../whatsapp/incoming_message_service_spec.rb | 95 +++++++++++++++++++ 2 files changed, 115 insertions(+) diff --git a/app/services/whatsapp/incoming_message_base_service.rb b/app/services/whatsapp/incoming_message_base_service.rb index 94ad5c7d1..0aed8dba0 100644 --- a/app/services/whatsapp/incoming_message_base_service.rb +++ b/app/services/whatsapp/incoming_message_base_service.rb @@ -92,6 +92,9 @@ class Whatsapp::IncomingMessageBaseService @contact_inbox = contact_inbox @contact = contact_inbox.contact + + # Update existing contact name if ProfileName is available and current name is just phone number + update_contact_with_profile_name(contact_params) end def set_conversation @@ -171,4 +174,21 @@ class Whatsapp::IncomingMessageBaseService ) end end + + def update_contact_with_profile_name(contact_params) + profile_name = contact_params.dig(:profile, :name) + return if profile_name.blank? + return if @contact.name == profile_name + + # Only update if current name exactly matches the phone number or formatted phone number + return unless contact_name_matches_phone_number? + + @contact.update!(name: profile_name) + end + + def contact_name_matches_phone_number? + phone_number = "+#{@processed_params[:messages].first[:from]}" + formatted_phone_number = TelephoneNumber.parse(phone_number).international_number + @contact.name == phone_number || @contact.name == formatted_phone_number + end end diff --git a/spec/services/whatsapp/incoming_message_service_spec.rb b/spec/services/whatsapp/incoming_message_service_spec.rb index 4035a47df..ede1ba824 100644 --- a/spec/services/whatsapp/incoming_message_service_spec.rb +++ b/spec/services/whatsapp/incoming_message_service_spec.rb @@ -371,5 +371,100 @@ describe Whatsapp::IncomingMessageService do Redis::Alfred.delete(key) end end + + context 'when profile name is available for contact updates' do + let(:wa_id) { '1234567890' } + let(:phone_number) { "+#{wa_id}" } + + it 'updates existing contact name when current name matches phone number' do + # Create contact with phone number as name + existing_contact = create(:contact, + account: whatsapp_channel.inbox.account, + name: phone_number, + phone_number: phone_number) + create(:contact_inbox, + contact: existing_contact, + inbox: whatsapp_channel.inbox, + source_id: wa_id) + + params = { + 'contacts' => [{ 'profile' => { 'name' => 'Jane Smith' }, 'wa_id' => wa_id }], + 'messages' => [{ 'from' => wa_id, 'id' => 'message123', 'text' => { 'body' => 'Hello' }, + 'timestamp' => '1633034394', 'type' => 'text' }] + }.with_indifferent_access + + described_class.new(inbox: whatsapp_channel.inbox, params: params).perform + existing_contact.reload + expect(existing_contact.name).to eq('Jane Smith') + end + + it 'does not update contact name when current name is different from phone number' do + # Create contact with human name + existing_contact = create(:contact, + account: whatsapp_channel.inbox.account, + name: 'John Doe', + phone_number: phone_number) + create(:contact_inbox, + contact: existing_contact, + inbox: whatsapp_channel.inbox, + source_id: wa_id) + + params = { + 'contacts' => [{ 'profile' => { 'name' => 'Jane Smith' }, 'wa_id' => wa_id }], + 'messages' => [{ 'from' => wa_id, 'id' => 'message123', 'text' => { 'body' => 'Hello' }, + 'timestamp' => '1633034394', 'type' => 'text' }] + }.with_indifferent_access + + described_class.new(inbox: whatsapp_channel.inbox, params: params).perform + existing_contact.reload + expect(existing_contact.name).to eq('John Doe') # Should not change + end + + it 'updates contact name when current name matches formatted phone number' do + formatted_number = TelephoneNumber.parse(phone_number).international_number + + # Create contact with formatted phone number as name + existing_contact = create(:contact, + account: whatsapp_channel.inbox.account, + name: formatted_number, + phone_number: phone_number) + create(:contact_inbox, + contact: existing_contact, + inbox: whatsapp_channel.inbox, + source_id: wa_id) + + params = { + 'contacts' => [{ 'profile' => { 'name' => 'Alice Johnson' }, 'wa_id' => wa_id }], + 'messages' => [{ 'from' => wa_id, 'id' => 'message123', 'text' => { 'body' => 'Hello' }, + 'timestamp' => '1633034394', 'type' => 'text' }] + }.with_indifferent_access + + described_class.new(inbox: whatsapp_channel.inbox, params: params).perform + existing_contact.reload + expect(existing_contact.name).to eq('Alice Johnson') + end + + it 'does not update when profile name is blank' do + # Create contact with phone number as name + existing_contact = create(:contact, + account: whatsapp_channel.inbox.account, + name: phone_number, + phone_number: phone_number) + create(:contact_inbox, + contact: existing_contact, + inbox: whatsapp_channel.inbox, + source_id: wa_id) + + params = { + 'contacts' => [{ 'profile' => { 'name' => '' }, 'wa_id' => wa_id }], + 'messages' => [{ 'from' => wa_id, 'id' => 'message123', 'text' => { 'body' => 'Hello' }, + 'timestamp' => '1633034394', 'type' => 'text' }] + }.with_indifferent_access + + described_class.new(inbox: whatsapp_channel.inbox, params: params).perform + existing_contact.reload + expect(existing_contact.name).to eq(phone_number) # Should not change + end + end end end From dbb164a37de765e509388a033dc415a8eddcec82 Mon Sep 17 00:00:00 2001 From: Muhsin Keloth Date: Tue, 12 Aug 2025 16:31:56 +0530 Subject: [PATCH 02/26] fix: Improve WhatsApp template message error handling (#12168) WhatsApp template message errors were not being properly handled because the `@message instance` variable was only set in the `send_message` method but not in `send_template`. When template sending failed, the `handle_error` method couldn't update the message status due to the missing @message reference, resulting in silent failures with no user feedback. --- app/services/whatsapp/oneoff_campaign_service.rb | 2 +- app/services/whatsapp/providers/base_service.rb | 16 ++++++++-------- .../providers/whatsapp_360_dialog_service.rb | 10 +++++----- .../whatsapp/providers/whatsapp_cloud_service.rb | 10 +++++----- .../whatsapp/send_on_whatsapp_service.rb | 2 +- .../whatsapp/oneoff_campaign_service_spec.rb | 7 ++++--- .../providers/whatsapp_cloud_service_spec.rb | 6 +++--- 7 files changed, 27 insertions(+), 26 deletions(-) diff --git a/app/services/whatsapp/oneoff_campaign_service.rb b/app/services/whatsapp/oneoff_campaign_service.rb index 47a971f41..de2713ac0 100644 --- a/app/services/whatsapp/oneoff_campaign_service.rb +++ b/app/services/whatsapp/oneoff_campaign_service.rb @@ -84,7 +84,7 @@ class Whatsapp::OneoffCampaignService namespace: namespace, lang_code: lang_code, parameters: processed_parameters - }) + }, nil) rescue StandardError => e Rails.logger.error "Failed to send WhatsApp template message to #{to}: #{e.message}" diff --git a/app/services/whatsapp/providers/base_service.rb b/app/services/whatsapp/providers/base_service.rb index 97665f7ef..9fd1f6267 100644 --- a/app/services/whatsapp/providers/base_service.rb +++ b/app/services/whatsapp/providers/base_service.rb @@ -15,7 +15,7 @@ class Whatsapp::Providers::BaseService raise 'Overwrite this method in child class' end - def send_template(_phone_number, _template_info) + def send_template(_phone_number, _template_info, _message) raise 'Overwrite this method in child class' end @@ -31,27 +31,27 @@ class Whatsapp::Providers::BaseService raise 'Overwrite this method in child class' end - def process_response(response) + def process_response(response, message) parsed_response = response.parsed_response if response.success? && parsed_response['error'].blank? parsed_response['messages'].first['id'] else - handle_error(response) + handle_error(response, message) nil end end - def handle_error(response) + def handle_error(response, message) Rails.logger.error response.body - return if @message.blank? + return if message.blank? # https://developers.facebook.com/docs/whatsapp/cloud-api/support/error-codes/#sample-response error_message = error_message(response) return if error_message.blank? - @message.external_error = error_message - @message.status = :failed - @message.save! + message.external_error = error_message + message.status = :failed + message.save! end def create_buttons(items) diff --git a/app/services/whatsapp/providers/whatsapp_360_dialog_service.rb b/app/services/whatsapp/providers/whatsapp_360_dialog_service.rb index beb11d556..352f2d246 100644 --- a/app/services/whatsapp/providers/whatsapp_360_dialog_service.rb +++ b/app/services/whatsapp/providers/whatsapp_360_dialog_service.rb @@ -10,7 +10,7 @@ class Whatsapp::Providers::Whatsapp360DialogService < Whatsapp::Providers::BaseS end end - def send_template(phone_number, template_info) + def send_template(phone_number, template_info, message) response = HTTParty.post( "#{api_base_path}/messages", headers: api_headers, @@ -21,7 +21,7 @@ class Whatsapp::Providers::Whatsapp360DialogService < Whatsapp::Providers::BaseS }.to_json ) - process_response(response) + process_response(response, message) end def sync_templates @@ -68,7 +68,7 @@ class Whatsapp::Providers::Whatsapp360DialogService < Whatsapp::Providers::BaseS }.to_json ) - process_response(response) + process_response(response, message) end def send_attachment_message(phone_number, message) @@ -90,7 +90,7 @@ class Whatsapp::Providers::Whatsapp360DialogService < Whatsapp::Providers::BaseS }.to_json ) - process_response(response) + process_response(response, message) end def error_message(response) @@ -123,6 +123,6 @@ class Whatsapp::Providers::Whatsapp360DialogService < Whatsapp::Providers::BaseS }.to_json ) - process_response(response) + process_response(response, message) end end diff --git a/app/services/whatsapp/providers/whatsapp_cloud_service.rb b/app/services/whatsapp/providers/whatsapp_cloud_service.rb index 34939048a..68e965595 100644 --- a/app/services/whatsapp/providers/whatsapp_cloud_service.rb +++ b/app/services/whatsapp/providers/whatsapp_cloud_service.rb @@ -11,7 +11,7 @@ class Whatsapp::Providers::WhatsappCloudService < Whatsapp::Providers::BaseServi end end - def send_template(phone_number, template_info) + def send_template(phone_number, template_info, message) template_body = template_body_parameters(template_info) request_body = { @@ -28,7 +28,7 @@ class Whatsapp::Providers::WhatsappCloudService < Whatsapp::Providers::BaseServi body: request_body.to_json ) - process_response(response) + process_response(response, message) end def sync_templates @@ -92,7 +92,7 @@ class Whatsapp::Providers::WhatsappCloudService < Whatsapp::Providers::BaseServi }.to_json ) - process_response(response) + process_response(response, message) end def send_attachment_message(phone_number, message) @@ -115,7 +115,7 @@ class Whatsapp::Providers::WhatsappCloudService < Whatsapp::Providers::BaseServi }.to_json ) - process_response(response) + process_response(response, message) end def error_message(response) @@ -179,6 +179,6 @@ class Whatsapp::Providers::WhatsappCloudService < Whatsapp::Providers::BaseServi }.to_json ) - process_response(response) + process_response(response, message) end end diff --git a/app/services/whatsapp/send_on_whatsapp_service.rb b/app/services/whatsapp/send_on_whatsapp_service.rb index 5f91bce16..20419c0cd 100644 --- a/app/services/whatsapp/send_on_whatsapp_service.rb +++ b/app/services/whatsapp/send_on_whatsapp_service.rb @@ -33,7 +33,7 @@ class Whatsapp::SendOnWhatsappService < Base::SendOnChannelService namespace: namespace, lang_code: lang_code, parameters: processed_parameters - }) + }, message) message.update!(source_id: message_id) if message_id.present? end diff --git a/spec/services/whatsapp/oneoff_campaign_service_spec.rb b/spec/services/whatsapp/oneoff_campaign_service_spec.rb index 599081e23..dd8d51c54 100644 --- a/spec/services/whatsapp/oneoff_campaign_service_spec.rb +++ b/spec/services/whatsapp/oneoff_campaign_service_spec.rb @@ -133,7 +133,8 @@ describe Whatsapp::OneoffCampaignService do ) ) ) - ) + ), + nil ) described_class.new(campaign: campaign).perform @@ -164,8 +165,8 @@ describe Whatsapp::OneoffCampaignService do allow(whatsapp_channel).to receive(:send_template).and_return(nil) - expect(whatsapp_channel).to receive(:send_template).with(contact_error.phone_number, anything).and_raise(StandardError, error_message) - expect(whatsapp_channel).to receive(:send_template).with(contact_success.phone_number, anything).once + expect(whatsapp_channel).to receive(:send_template).with(contact_error.phone_number, anything, nil).and_raise(StandardError, error_message) + expect(whatsapp_channel).to receive(:send_template).with(contact_success.phone_number, anything, nil).once expect(Rails.logger).to receive(:error) .with("Failed to send WhatsApp template message to #{contact_error.phone_number}: #{error_message}") diff --git a/spec/services/whatsapp/providers/whatsapp_cloud_service_spec.rb b/spec/services/whatsapp/providers/whatsapp_cloud_service_spec.rb index 8735ccfbb..69ba69379 100644 --- a/spec/services/whatsapp/providers/whatsapp_cloud_service_spec.rb +++ b/spec/services/whatsapp/providers/whatsapp_cloud_service_spec.rb @@ -187,7 +187,7 @@ describe Whatsapp::Providers::WhatsappCloudService do ) .to_return(status: 200, body: whatsapp_response.to_json, headers: response_headers) - expect(service.send_template('+123456789', template_info)).to eq('message_id') + expect(service.send_template('+123456789', template_info, message)).to eq('message_id') end end end @@ -287,7 +287,7 @@ describe Whatsapp::Providers::WhatsappCloudService do context 'when there is a message' do it 'logs error and updates message status' do service.instance_variable_set(:@message, message) - service.send(:handle_error, error_response_object) + service.send(:handle_error, error_response_object, message) expect(message.reload.status).to eq('failed') expect(message.reload.external_error).to eq(error_message) @@ -305,7 +305,7 @@ describe Whatsapp::Providers::WhatsappCloudService do it 'logs error but does not update message' do service.instance_variable_set(:@message, message) - service.send(:handle_error, error_response_object) + service.send(:handle_error, error_response_object, message) expect(message.reload.status).not_to eq('failed') expect(message.reload.external_error).to be_nil From 5c560c762858bf3c77ee5b76307dad8a526fef77 Mon Sep 17 00:00:00 2001 From: Muhsin Keloth Date: Tue, 12 Aug 2025 18:53:19 +0530 Subject: [PATCH 03/26] feat: WhatsApp enhanced templates front end changes (#12117) Part of the https://github.com/chatwoot/chatwoot/pull/11997 Co-authored-by: Sojan Jose Co-authored-by: iamsivin --- .../WhatsAppCampaign/WhatsAppCampaignForm.vue | 121 +----- .../components/ActionButtons.vue | 6 +- .../components/ComposeNewConversationForm.vue | 1 + .../components/WhatsAppOptions.vue | 35 +- .../components/WhatsappTemplate.vue | 64 +++ .../whatsapp/WhatsAppTemplateParser.vue | 279 +++++++++++++ .../WhatsappTemplates/TemplateParser.vue | 179 ++------- .../WhatsappTemplates/TemplatesPicker.vue | 197 ++++++---- .../helper/specs/templateHelper.spec.js | 368 ++++++++++++++++++ .../dashboard/helper/templateHelper.js | 91 +++++ .../i18n/locale/en/whatsappTemplates.json | 69 ++-- .../dashboard/store/modules/inboxes.js | 53 ++- .../modules/specs/inboxes/getters.spec.js | 266 +++++++++++++ .../specs/inboxes/templateFixtures.js} | 281 +++++++++++++ .../whatsappTemplates.spec.js | 61 --- 15 files changed, 1635 insertions(+), 436 deletions(-) create mode 100644 app/javascript/dashboard/components-next/NewConversation/components/WhatsappTemplate.vue create mode 100644 app/javascript/dashboard/components-next/whatsapp/WhatsAppTemplateParser.vue create mode 100644 app/javascript/dashboard/helper/specs/templateHelper.spec.js create mode 100644 app/javascript/dashboard/helper/templateHelper.js rename app/javascript/{shared/mixins/specs/whatsappTemplates/fixtures.js => dashboard/store/modules/specs/inboxes/templateFixtures.js} (50%) delete mode 100644 app/javascript/shared/mixins/specs/whatsappTemplates/whatsappTemplates.spec.js diff --git a/app/javascript/dashboard/components-next/Campaigns/Pages/CampaignPage/WhatsAppCampaign/WhatsAppCampaignForm.vue b/app/javascript/dashboard/components-next/Campaigns/Pages/CampaignPage/WhatsAppCampaign/WhatsAppCampaignForm.vue index df76ae901..0babd11ec 100644 --- a/app/javascript/dashboard/components-next/Campaigns/Pages/CampaignPage/WhatsAppCampaign/WhatsAppCampaignForm.vue +++ b/app/javascript/dashboard/components-next/Campaigns/Pages/CampaignPage/WhatsAppCampaign/WhatsAppCampaignForm.vue @@ -9,6 +9,7 @@ import Input from 'dashboard/components-next/input/Input.vue'; import Button from 'dashboard/components-next/button/Button.vue'; import ComboBox from 'dashboard/components-next/combobox/ComboBox.vue'; import TagMultiSelectComboBox from 'dashboard/components-next/combobox/TagMultiSelectComboBox.vue'; +import WhatsAppTemplateParser from 'dashboard/components-next/whatsapp/WhatsAppTemplateParser.vue'; const emit = defineEmits(['submit', 'cancel']); @@ -18,7 +19,9 @@ const formState = { uiFlags: useMapGetter('campaigns/getUIFlags'), labels: useMapGetter('labels/getLabels'), inboxes: useMapGetter('inboxes/getWhatsAppInboxes'), - getWhatsAppTemplates: useMapGetter('inboxes/getWhatsAppTemplates'), + getFilteredWhatsAppTemplates: useMapGetter( + 'inboxes/getFilteredWhatsAppTemplates' + ), }; const initialState = { @@ -30,7 +33,7 @@ const initialState = { }; const state = reactive({ ...initialState }); -const processedParams = ref({}); +const templateParserRef = ref(null); const rules = { title: { required, minLength: minLength(1) }, @@ -67,7 +70,7 @@ const inboxOptions = computed(() => const templateOptions = computed(() => { if (!state.inboxId) return []; - const templates = formState.getWhatsAppTemplates.value(state.inboxId); + const templates = formState.getFilteredWhatsAppTemplates.value(state.inboxId); return templates.map(template => { // Create a more user-friendly label from template name const friendlyName = template.name @@ -88,26 +91,6 @@ const selectedTemplate = computed(() => { ?.template; }); -const templateString = computed(() => { - if (!selectedTemplate.value) return ''; - try { - return ( - selectedTemplate.value.components?.find( - component => component.type === 'BODY' - )?.text || '' - ); - } catch (error) { - return ''; - } -}); - -const processedString = computed(() => { - if (!templateString.value) return ''; - return templateString.value.replace(/{{([^}]+)}}/g, (match, variable) => { - return processedParams.value[variable] || `{{${variable}}}`; - }); -}); - const getErrorMessage = (field, errorKey) => { const baseKey = 'CAMPAIGN.WHATSAPP.CREATE.FORM'; return v$.value[field].$error ? t(`${baseKey}.${errorKey}.ERROR`) : ''; @@ -122,8 +105,7 @@ const formErrors = computed(() => ({ })); const hasRequiredTemplateParams = computed(() => { - const params = Object.values(processedParams.value); - return params.length === 0 || params.every(param => param.trim() !== ''); + return templateParserRef.value?.v$?.$invalid === false || true; }); const isSubmitDisabled = computed( @@ -135,32 +117,18 @@ const formatToUTCString = localDateTime => const resetState = () => { Object.assign(state, initialState); - processedParams.value = {}; v$.value.$reset(); }; const handleCancel = () => emit('cancel'); -const generateVariables = () => { - const matchedVariables = templateString.value.match(/{{([^}]+)}}/g); - if (!matchedVariables) { - processedParams.value = {}; - return; - } - - const finalVars = matchedVariables.map(match => match.replace(/{{|}}/g, '')); - processedParams.value = finalVars.reduce((acc, variable) => { - acc[variable] = processedParams.value[variable] || ''; - return acc; - }, {}); -}; - const prepareCampaignDetails = () => { // Find the selected template to get its content const currentTemplate = selectedTemplate.value; + const parserData = templateParserRef.value; // Extract template content - this should be the template message body - const templateContent = templateString.value; + const templateContent = parserData?.renderedTemplate || ''; // Prepare template_params object with the same structure as used in contacts const templateParams = { @@ -168,7 +136,7 @@ const prepareCampaignDetails = () => { namespace: currentTemplate?.namespace || '', category: currentTemplate?.category || 'UTILITY', language: currentTemplate?.language || 'en_US', - processed_params: processedParams.value, + processed_params: parserData?.processedParams || {}, }; return { @@ -198,15 +166,6 @@ watch( () => state.inboxId, () => { state.templateId = null; - processedParams.value = {}; - } -); - -// Generate variables when template changes -watch( - () => state.templateId, - () => { - generateVariables(); } ); @@ -254,62 +213,12 @@ watch(

- -
+ -
-

- {{ selectedTemplate.name }} -

- - {{ t('CAMPAIGN.WHATSAPP.CREATE.FORM.TEMPLATE.LANGUAGE') }}: - {{ selectedTemplate.language || 'en' }} - -
- -
-
-
- {{ processedString }} -
-
-
- -
- {{ t('CAMPAIGN.WHATSAPP.CREATE.FORM.TEMPLATE.CATEGORY') }}: - {{ selectedTemplate.category || 'UTILITY' }} -
-
- - -
- -
-
- -
-
-
+ ref="templateParserRef" + :template="selectedTemplate" + />