From 79e28ae92245a8312ba596c83c37c18c4f27bf19 Mon Sep 17 00:00:00 2001 From: Muhsin Keloth Date: Thu, 31 Jul 2025 18:47:51 +0400 Subject: [PATCH] chore: add template parameter service --- .../whatsapp/template_parameter_service.rb | 148 ++++++++++ .../whatsapp/template_processor_service.rb | 160 ++--------- .../whatsapp/send_on_whatsapp_service_spec.rb | 257 +++++++----------- 3 files changed, 258 insertions(+), 307 deletions(-) create mode 100644 app/services/whatsapp/template_parameter_service.rb diff --git a/app/services/whatsapp/template_parameter_service.rb b/app/services/whatsapp/template_parameter_service.rb new file mode 100644 index 000000000..fb321900e --- /dev/null +++ b/app/services/whatsapp/template_parameter_service.rb @@ -0,0 +1,148 @@ +class Whatsapp::TemplateParameterService + def build_parameter(value) + case value + when String + build_string_parameter(value) + when Hash + build_hash_parameter(value) + else + { type: 'text', text: value.to_s } + end + end + + def build_button_parameter(button) + return { type: 'text', text: '' } if button.blank? + + case button['type'] + when 'copy_code' + coupon_code = button['parameter'].to_s.strip + raise ArgumentError, 'Coupon code cannot be empty' if coupon_code.blank? + raise ArgumentError, 'Coupon code cannot exceed 15 characters' if coupon_code.length > 15 + + { + type: 'coupon_code', + coupon_code: coupon_code + } + else + # For URL buttons and other button types, treat parameter as text + # If parameter is blank, use empty string (required for URL buttons) + { type: 'text', text: button['parameter'].to_s.strip } + end + end + + def build_media_parameter(url, media_type) + return nil if url.blank? + + sanitized_url = sanitize_parameter(url) + validate_url(sanitized_url) + build_media_type_parameter(sanitized_url, media_type.downcase) + end + + def build_named_parameter(parameter_name, value) + sanitized_value = sanitize_parameter(value.to_s) + { type: 'text', parameter_name: parameter_name, text: sanitized_value } + end + + private + + def build_string_parameter(value) + sanitized_value = sanitize_parameter(value) + if rich_formatting?(sanitized_value) + build_rich_text_parameter(sanitized_value) + else + { type: 'text', text: sanitized_value } + end + end + + def build_hash_parameter(value) + case value['type'] + when 'currency' + build_currency_parameter(value) + when 'date_time' + build_date_time_parameter(value) + else + { type: 'text', text: value.to_s } + end + end + + def build_currency_parameter(value) + { + type: 'currency', + currency: { + fallback_value: value['fallback_value'], + code: value['code'], + amount_1000: value['amount_1000'] + } + } + end + + def build_date_time_parameter(value) + { + type: 'date_time', + date_time: { + fallback_value: value['fallback_value'], + day_of_week: value['day_of_week'], + day_of_month: value['day_of_month'], + month: value['month'], + year: value['year'] + } + } + end + + def build_media_type_parameter(sanitized_url, media_type) + case media_type + when 'image' + build_image_parameter(sanitized_url) + when 'video' + build_video_parameter(sanitized_url) + when 'document' + build_document_parameter(sanitized_url) + else + raise ArgumentError, "Unsupported media type: #{media_type}" + end + end + + def build_image_parameter(url) + { type: 'image', image: { link: url } } + end + + def build_video_parameter(url) + { type: 'video', video: { link: url } } + end + + def build_document_parameter(url) + { type: 'document', document: { link: url } } + end + + def rich_formatting?(text) + # Check if text contains WhatsApp rich formatting markers + text.match?(/\*[^*]+\*/) || # Bold: *text* + text.match?(/_[^_]+_/) || # Italic: _text_ + text.match?(/~[^~]+~/) || # Strikethrough: ~text~ + text.match?(/```[^`]+```/) # Monospace: ```text``` + end + + def build_rich_text_parameter(text) + # WhatsApp supports rich text formatting in templates + # This preserves the formatting markers for the API + { type: 'text', text: text } + end + + def sanitize_parameter(value) + # Basic sanitization - remove dangerous characters and limit length + sanitized = value.to_s.strip + sanitized = sanitized.gsub(/[<>\"']/, '') # Remove potential HTML/JS chars + sanitized[0...1000] # Limit length to prevent DoS + end + + def validate_url(url) + return if url.blank? + + uri = URI.parse(url) + raise ArgumentError, "Invalid URL scheme: #{uri.scheme}. Only http and https are allowed" unless %w[http https].include?(uri.scheme) + raise ArgumentError, 'URL too long (max 2000 characters)' if url.length > 2000 + + rescue URI::InvalidURIError => e + raise ArgumentError, "Invalid URL format: #{e.message}. Please enter a valid URL like https://example.com/document.pdf" + end +end diff --git a/app/services/whatsapp/template_processor_service.rb b/app/services/whatsapp/template_processor_service.rb index 1eda04320..1b0b30c08 100644 --- a/app/services/whatsapp/template_processor_service.rb +++ b/app/services/whatsapp/template_processor_service.rb @@ -46,20 +46,27 @@ class Whatsapp::TemplateProcessorService def process_header_components(processed_params) return [] if processed_params['header'].blank? + header_params = build_header_params(processed_params['header']) + header_params.present? ? [{ type: 'header', parameters: header_params }] : [] + end + + def build_header_params(header_data) header_params = [] - processed_params['header'].each do |key, value| + header_data.each do |key, value| next if value.blank? - if key == 'media_url' && processed_params['header']['media_type'].present? - media_type = processed_params['header']['media_type'] - media_param = build_media_parameter(value, media_type) + if media_url_with_type?(key, header_data) + media_param = parameter_builder.build_media_parameter(value, header_data['media_type']) header_params << media_param if media_param elsif key != 'media_type' - header_params << build_parameter(value) + header_params << parameter_builder.build_parameter(value) end end + header_params + end - header_params.present? ? [{ type: 'header', parameters: header_params }] : [] + def media_url_with_type?(key, header_data) + key == 'media_url' && header_data['media_type'].present? end def process_body_components(processed_params, template) @@ -70,9 +77,9 @@ class Whatsapp::TemplateProcessorService parameter_format = template['parameter_format'] if parameter_format == 'NAMED' - build_named_parameter(key, value) + parameter_builder.build_named_parameter(key, value) else - build_parameter(value) + parameter_builder.build_parameter(value) end end @@ -85,7 +92,7 @@ class Whatsapp::TemplateProcessorService footer_params = processed_params['footer'].filter_map do |_, value| next if value.blank? - build_parameter(value) + parameter_builder.build_parameter(value) end footer_params.present? ? [{ type: 'footer', parameters: footer_params }] : [] @@ -102,7 +109,7 @@ class Whatsapp::TemplateProcessorService type: 'button', sub_type: button['type'] || 'url', index: index, - parameters: [build_button_parameter(button)] + parameters: [parameter_builder.build_button_parameter(button)] } end end @@ -110,136 +117,7 @@ class Whatsapp::TemplateProcessorService button_params.compact end - def build_parameter(value) - case value - when String - sanitized_value = sanitize_parameter(value) - # Check if this is rich text formatting - if rich_formatting?(sanitized_value) - build_rich_text_parameter(sanitized_value) - else - # For regular template parameters, always treat as text - # Media parameters are handled separately via build_media_parameter - { type: 'text', text: sanitized_value } - end - when Hash - # Advanced parameter types - case value['type'] - when 'currency' - { - type: 'currency', - currency: { - fallback_value: value['fallback_value'], - code: value['code'], - amount_1000: value['amount_1000'] - } - } - when 'date_time' - { - type: 'date_time', - date_time: { - fallback_value: value['fallback_value'], - day_of_week: value['day_of_week'], - day_of_month: value['day_of_month'], - month: value['month'], - year: value['year'] - } - } - else - { type: 'text', text: value.to_s } - end - else - { type: 'text', text: value.to_s } - end - end - - def build_button_parameter(button) - return { type: 'text', text: '' } if button.blank? - - case button['type'] - when 'copy_code' - coupon_code = button['parameter'].to_s.strip - raise ArgumentError, 'Coupon code cannot be empty' if coupon_code.blank? - raise ArgumentError, 'Coupon code cannot exceed 15 characters' if coupon_code.length > 15 - - { - type: 'coupon_code', - coupon_code: coupon_code - } - else - # For URL buttons and other button types, treat parameter as text - # If parameter is blank, use empty string (required for URL buttons) - { type: 'text', text: button['parameter'].to_s.strip } - end - end - - def sanitize_parameter(value) - # Basic sanitization - remove dangerous characters and limit length - sanitized = value.to_s.strip - sanitized = sanitized.gsub(/[<>\"']/, '') # Remove potential HTML/JS chars - sanitized[0...1000] # Limit length to prevent DoS - end - - def validate_url(url) - return if url.blank? - - uri = URI.parse(url) - raise ArgumentError, "Invalid URL scheme: #{uri.scheme}. Only http and https are allowed" unless %w[http https].include?(uri.scheme) - raise ArgumentError, 'URL too long (max 2000 characters)' if url.length > 2000 - - rescue URI::InvalidURIError => e - raise ArgumentError, "Invalid URL format: #{e.message}. Please enter a valid URL like https://example.com/document.pdf" - end - - def build_media_parameter(url, media_type) - return nil if url.blank? - - sanitized_url = sanitize_parameter(url) - validate_url(sanitized_url) - - case media_type.downcase - when 'image' - { - type: 'image', - image: { - link: sanitized_url - } - } - when 'video' - { - type: 'video', - video: { - link: sanitized_url - } - } - when 'document' - { - type: 'document', - document: { - link: sanitized_url - } - } - else - raise ArgumentError, "Unsupported media type: #{media_type}" - end - end - - def rich_formatting?(text) - # Check if text contains WhatsApp rich formatting markers - text.match?(/\*[^*]+\*/) || # Bold: *text* - text.match?(/_[^_]+_/) || # Italic: _text_ - text.match?(/~[^~]+~/) || # Strikethrough: ~text~ - text.match?(/```[^`]+```/) # Monospace: ```text``` - end - - def build_rich_text_parameter(text) - # WhatsApp supports rich text formatting in templates - # This preserves the formatting markers for the API - { type: 'text', text: text } - end - - def build_named_parameter(parameter_name, value) - sanitized_value = sanitize_parameter(value.to_s) - { type: 'text', parameter_name: parameter_name, text: sanitized_value } + def parameter_builder + @parameter_builder ||= Whatsapp::TemplateParameterService.new end end diff --git a/spec/services/whatsapp/send_on_whatsapp_service_spec.rb b/spec/services/whatsapp/send_on_whatsapp_service_spec.rb index 922e4de76..e6b699a52 100644 --- a/spec/services/whatsapp/send_on_whatsapp_service_spec.rb +++ b/spec/services/whatsapp/send_on_whatsapp_service_spec.rb @@ -131,74 +131,28 @@ describe Whatsapp::SendOnWhatsappService do end it 'calls channel.send_template when template has regexp characters' do - regexp_template_params = { - name: 'customer_yes_no', - namespace: '2342384942_32423423_23423fdsdaf23', - language: 'ar', - category: 'SHIPPING_UPDATE', - processed_params: {} - } - - message = create( - :message, - message_type: :outgoing, - content: 'عميلنا العزيز الرجاء الرد على هذه الرسالة بكلمة *نعم* للرد على إستفساركم من قبل خدمة العملاء.', - conversation: conversation, - additional_attributes: { template_params: regexp_template_params } - ) - - stub_request(:post, 'https://waba.360dialog.io/v1/messages') - .with( - headers: headers, - body: { - to: '123456789', - template: { - name: 'customer_yes_no', - namespace: '2342384942_32423423_23423fdsdaf23', - language: { 'policy': 'deterministic', 'code': 'ar' }, - components: [] - }, - type: 'template' - }.to_json - ).to_return(status: 200, body: success_response, headers: { 'content-type' => 'application/json' }) + regexp_template_params = build_template_params('customer_yes_no', '2342384942_32423423_23423fdsdaf23', 'ar', {}) + arabic_content = 'عميلنا العزيز الرجاء الرد على هذه الرسالة بكلمة *نعم* للرد على إستفساركم من قبل خدمة العملاء.' + message = create_message_with_template(arabic_content, regexp_template_params) + stub_template_request(regexp_template_params, []) described_class.new(message: message).perform expect(message.reload.source_id).to eq('123456789') end it 'handles template with header parameters' do - # Test with body parameters only since no media templates in factory - header_template_params = { - name: 'sample_shipping_confirmation', - namespace: '23423423_2342423_324234234_2343224', - language: 'en_US', - category: 'SHIPPING_UPDATE', - processed_params: { - 'body' => { '1' => '3' }, - 'header' => { 'media_url' => 'https://example.com/image.jpg', 'media_type' => 'image' } - } + processed_params = { + 'body' => { '1' => '3' }, + 'header' => { 'media_url' => 'https://example.com/image.jpg', 'media_type' => 'image' } } + header_template_params = build_sample_template_params(processed_params) + message = create_message_with_template('', header_template_params) - message = create(:message, additional_attributes: { template_params: header_template_params }, - conversation: conversation, message_type: :outgoing) - - stub_request(:post, 'https://waba.360dialog.io/v1/messages') - .with( - headers: headers, - body: { - to: '123456789', - template: { - name: 'sample_shipping_confirmation', - namespace: '23423423_2342423_324234234_2343224', - language: { 'policy': 'deterministic', 'code': 'en_US' }, - components: [ - { type: 'header', parameters: [{ type: 'image', image: { link: 'https://example.com/image.jpg' } }] }, - { type: 'body', parameters: [{ type: 'text', text: '3' }] } - ] - }, - type: 'template' - }.to_json - ).to_return(status: 200, body: success_response, headers: { 'content-type' => 'application/json' }) + components = [ + { type: 'header', parameters: [{ type: 'image', image: { link: 'https://example.com/image.jpg' } }] }, + { type: 'body', parameters: [{ type: 'text', text: '3' }] } + ] + stub_sample_template_request(components) described_class.new(message: message).perform expect(message.reload.source_id).to eq('123456789') @@ -236,78 +190,37 @@ describe Whatsapp::SendOnWhatsappService do end it 'handles template with button parameters' do - # Test button processing with existing template - simulate buttons being added - button_template_params = { - name: 'sample_shipping_confirmation', - namespace: '23423423_2342423_324234234_2343224', - language: 'en_US', - category: 'SHIPPING_UPDATE', - processed_params: { - 'body' => { '1' => '3' }, - 'buttons' => [{ 'type' => 'url', 'parameter' => 'https://track.example.com/123' }] - } + processed_params = { + 'body' => { '1' => '3' }, + 'buttons' => [{ 'type' => 'url', 'parameter' => 'https://track.example.com/123' }] } + button_template_params = build_sample_template_params(processed_params) + message = create_message_with_template('', button_template_params) - message = create(:message, additional_attributes: { template_params: button_template_params }, - conversation: conversation, message_type: :outgoing) - - stub_request(:post, 'https://waba.360dialog.io/v1/messages') - .with( - headers: headers, - body: { - to: '123456789', - template: { - name: 'sample_shipping_confirmation', - namespace: '23423423_2342423_324234234_2343224', - language: { 'policy': 'deterministic', 'code': 'en_US' }, - components: [ - { type: 'body', parameters: [{ type: 'text', text: '3' }] }, - { type: 'button', sub_type: 'url', index: 0, parameters: [{ type: 'text', text: 'https://track.example.com/123' }] } - ] - }, - type: 'template' - }.to_json - ).to_return(status: 200, body: success_response, headers: { 'content-type' => 'application/json' }) + components = [ + { type: 'body', parameters: [{ type: 'text', text: '3' }] }, + { type: 'button', sub_type: 'url', index: 0, parameters: [{ type: 'text', text: 'https://track.example.com/123' }] } + ] + stub_sample_template_request(components) described_class.new(message: message).perform expect(message.reload.source_id).to eq('123456789') end it 'processes template parameters correctly via integration' do - # Test enhanced format processing through the integration - complex_template_params = { - name: 'sample_shipping_confirmation', - namespace: '23423423_2342423_324234234_2343224', - language: 'en_US', - category: 'SHIPPING_UPDATE', - processed_params: { - 'body' => { '1' => '5' }, - 'footer' => { 'text' => 'Thank you' } - } + processed_params = { + 'body' => { '1' => '5' }, + 'footer' => { 'text' => 'Thank you' } } + complex_template_params = build_sample_template_params(processed_params) + message = create_message_with_template('', complex_template_params) - message = create(:message, additional_attributes: { template_params: complex_template_params }, - conversation: conversation, message_type: :outgoing) + components = [ + { type: 'body', parameters: [{ type: 'text', text: '5' }] }, + { type: 'footer', parameters: [{ type: 'text', text: 'Thank you' }] } + ] + stub_sample_template_request(components) - stub_request(:post, 'https://waba.360dialog.io/v1/messages') - .with( - headers: headers, - body: { - to: '123456789', - template: { - name: 'sample_shipping_confirmation', - namespace: '23423423_2342423_324234234_2343224', - language: { 'policy': 'deterministic', 'code': 'en_US' }, - components: [ - { type: 'body', parameters: [{ type: 'text', text: '5' }] }, - { type: 'footer', parameters: [{ type: 'text', text: 'Thank you' }] } - ] - }, - type: 'template' - }.to_json - ).to_return(status: 200, body: success_response, headers: { 'content-type' => 'application/json' }) - - # This tests the full integration including TemplateProcessorService expect { described_class.new(message: message).perform }.not_to raise_error expect(message.reload.source_id).to eq('123456789') end @@ -336,37 +249,15 @@ describe Whatsapp::SendOnWhatsappService do end it 'handles template with blank parameter values correctly' do - # Test that blank/empty values are skipped in parameter processing - blank_values_template_params = { - name: 'sample_shipping_confirmation', - namespace: '23423423_2342423_324234234_2343224', - language: 'en_US', - category: 'SHIPPING_UPDATE', - processed_params: { - 'body' => { '1' => '', '2' => 'valid_value', '3' => nil }, - 'header' => { 'media_url' => '', 'media_type' => 'image' } - } + processed_params = { + 'body' => { '1' => '', '2' => 'valid_value', '3' => nil }, + 'header' => { 'media_url' => '', 'media_type' => 'image' } } + blank_values_template_params = build_sample_template_params(processed_params) + message = create_message_with_template('', blank_values_template_params) - message = create(:message, additional_attributes: { template_params: blank_values_template_params }, - conversation: conversation, message_type: :outgoing) - - stub_request(:post, 'https://waba.360dialog.io/v1/messages') - .with( - headers: headers, - body: { - to: '123456789', - template: { - name: 'sample_shipping_confirmation', - namespace: '23423423_2342423_324234234_2343224', - language: { 'policy': 'deterministic', 'code': 'en_US' }, - components: [ - { type: 'body', parameters: [{ type: 'text', text: 'valid_value' }] } - ] - }, - type: 'template' - }.to_json - ).to_return(status: 200, body: success_response, headers: { 'content-type' => 'application/json' }) + components = [{ type: 'body', parameters: [{ type: 'text', text: 'valid_value' }] }] + stub_sample_template_request(components) described_class.new(message: message).perform expect(message.reload.source_id).to eq('123456789') @@ -392,20 +283,59 @@ describe Whatsapp::SendOnWhatsappService do end it 'processes template with rich text formatting' do - # Test that rich text formatting is preserved - rich_text_template_params = { - name: 'sample_shipping_confirmation', - namespace: '23423423_2342423_324234234_2343224', - language: 'en_US', + processed_params = { 'body' => { '1' => '*Bold text* and _italic text_' } } + rich_text_template_params = build_sample_template_params(processed_params) + message = create_message_with_template('', rich_text_template_params) + + components = [{ type: 'body', parameters: [{ type: 'text', text: '*Bold text* and _italic text_' }] }] + stub_sample_template_request(components) + + described_class.new(message: message).perform + expect(message.reload.source_id).to eq('123456789') + end + + private + + def build_template_params(name, namespace, language, processed_params) + { + name: name, + namespace: namespace, + language: language, category: 'SHIPPING_UPDATE', - processed_params: { - 'body' => { '1' => '*Bold text* and _italic text_' } - } + processed_params: processed_params } + end - message = create(:message, additional_attributes: { template_params: rich_text_template_params }, - conversation: conversation, message_type: :outgoing) + def create_message_with_template(content, template_params) + create(:message, + message_type: :outgoing, + content: content, + conversation: conversation, + additional_attributes: { template_params: template_params }) + end + def stub_template_request(template_params, components) + stub_request(:post, 'https://waba.360dialog.io/v1/messages') + .with( + headers: headers, + body: { + to: '123456789', + template: { + name: template_params[:name], + namespace: template_params[:namespace], + language: { 'policy': 'deterministic', 'code': template_params[:language] }, + components: components + }, + type: 'template' + }.to_json + ).to_return(status: 200, body: success_response, headers: { 'content-type' => 'application/json' }) + end + + def build_sample_template_params(processed_params) + build_template_params('sample_shipping_confirmation', '23423423_2342423_324234234_2343224', 'en_US', processed_params) + end + + def stub_sample_template_request(components) stub_request(:post, 'https://waba.360dialog.io/v1/messages') .with( headers: headers, @@ -415,16 +345,11 @@ describe Whatsapp::SendOnWhatsappService do name: 'sample_shipping_confirmation', namespace: '23423423_2342423_324234234_2343224', language: { 'policy': 'deterministic', 'code': 'en_US' }, - components: [ - { type: 'body', parameters: [{ type: 'text', text: '*Bold text* and _italic text_' }] } - ] + components: components }, type: 'template' }.to_json ).to_return(status: 200, body: success_response, headers: { 'content-type' => 'application/json' }) - - described_class.new(message: message).perform - expect(message.reload.source_id).to eq('123456789') end end end