diff --git a/app/services/whatsapp/populate_template_parameters_service.rb b/app/services/whatsapp/populate_template_parameters_service.rb new file mode 100644 index 000000000..278e52f64 --- /dev/null +++ b/app/services/whatsapp/populate_template_parameters_service.rb @@ -0,0 +1,148 @@ +class Whatsapp::PopulateTemplateParametersService + 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/providers/whatsapp_360_dialog_service.rb b/app/services/whatsapp/providers/whatsapp_360_dialog_service.rb index 6e393f9f5..beb11d556 100644 --- a/app/services/whatsapp/providers/whatsapp_360_dialog_service.rb +++ b/app/services/whatsapp/providers/whatsapp_360_dialog_service.rb @@ -106,10 +106,7 @@ class Whatsapp::Providers::Whatsapp360DialogService < Whatsapp::Providers::BaseS policy: 'deterministic', code: template_info[:lang_code] }, - components: [{ - type: 'body', - parameters: template_info[:parameters] - }] + components: template_info[:parameters] } end diff --git a/app/services/whatsapp/providers/whatsapp_cloud_service.rb b/app/services/whatsapp/providers/whatsapp_cloud_service.rb index 1620e4e42..34939048a 100644 --- a/app/services/whatsapp/providers/whatsapp_cloud_service.rb +++ b/app/services/whatsapp/providers/whatsapp_cloud_service.rb @@ -12,15 +12,20 @@ class Whatsapp::Providers::WhatsappCloudService < Whatsapp::Providers::BaseServi end def send_template(phone_number, template_info) + template_body = template_body_parameters(template_info) + + request_body = { + messaging_product: 'whatsapp', + recipient_type: 'individual', # Only individual messages supported (not group messages) + to: phone_number, + type: 'template', + template: template_body + } + response = HTTParty.post( "#{phone_id_path}/messages", headers: api_headers, - body: { - messaging_product: 'whatsapp', - to: phone_number, - template: template_body_parameters(template_info), - type: 'template' - }.to_json + body: request_body.to_json ) process_response(response) @@ -119,17 +124,36 @@ class Whatsapp::Providers::WhatsappCloudService < Whatsapp::Providers::BaseServi end def template_body_parameters(template_info) - { + template_body = { name: template_info[:name], language: { policy: 'deterministic', code: template_info[:lang_code] - }, - components: [{ - type: 'body', - parameters: template_info[:parameters] - }] + } } + + # Enhanced template parameters structure + # Note: Legacy format support (simple parameter arrays) has been removed + # in favor of the enhanced component-based structure that supports + # headers, buttons, and authentication templates. + # + # Expected payload format from frontend: + # { + # processed_params: { + # body: { '1': 'John', '2': '123 Main St' }, + # header: { media_url: 'https://...', media_type: 'image' }, + # buttons: [{ type: 'url', parameter: 'otp123456' }] + # } + # } + # This gets transformed into WhatsApp API component format: + # [ + # { type: 'body', parameters: [...] }, + # { type: 'header', parameters: [...] }, + # { type: 'button', sub_type: 'url', parameters: [...] } + # ] + template_body[:components] = template_info[:parameters] || [] + + template_body end def whatsapp_reply_context(message) diff --git a/app/services/whatsapp/template_parameter_converter_service.rb b/app/services/whatsapp/template_parameter_converter_service.rb new file mode 100644 index 000000000..b9a9d55d9 --- /dev/null +++ b/app/services/whatsapp/template_parameter_converter_service.rb @@ -0,0 +1,117 @@ +# Service to convert legacy WhatsApp template parameter formats to enhanced format +# +# Legacy formats (deprecated): +# - Array: ["John", "Order123"] - positional parameters +# - Flat Hash: {"1": "John", "2": "Order123"} - direct key-value mapping +# +# Enhanced format: +# - Component-based: {"body": {"1": "John", "2": "Order123"}} - structured by template components +# - Supports header, body, footer, and button parameters separately +# +class Whatsapp::TemplateParameterConverterService + def initialize(template_params, template) + @template_params = template_params + @template = template + end + + def normalize_to_enhanced + processed_params = @template_params['processed_params'] + + # Early return if already enhanced format + return @template_params if enhanced_format?(processed_params) + + # Mark as legacy format before conversion for tracking + @template_params['format_version'] = 'legacy' + + # Convert legacy formats to enhanced structure + # TODO: Legacy format support will be deprecated and removed after 2-3 releases + enhanced_params = convert_legacy_to_enhanced(processed_params, @template) + + # Replace original params with enhanced structure + @template_params['processed_params'] = enhanced_params + + @template_params + end + + private + + def enhanced_format?(processed_params) + return false unless processed_params.is_a?(Hash) + + # Enhanced format has component-based structure + component_keys = %w[body header footer buttons] + has_component_structure = processed_params.keys.any? { |k| component_keys.include?(k) } + + # Additional validation for enhanced format + if has_component_structure + validate_enhanced_structure(processed_params) + else + false + end + end + + def validate_enhanced_structure(params) + valid_body?(params['body']) && + valid_header?(params['header']) && + valid_buttons?(params['buttons']) + end + + def valid_body?(body) + body.nil? || body.is_a?(Hash) + end + + def valid_header?(header) + header.nil? || header.is_a?(Hash) + end + + def valid_buttons?(buttons) + return true if buttons.nil? + return false unless buttons.is_a?(Array) + + buttons.all? { |b| b.is_a?(Hash) && b['type'] } + end + + def convert_legacy_to_enhanced(legacy_params, _template) + # Legacy system only supported text-based templates with body parameters + # We only convert the parameter format, not add new features + + enhanced = {} + + case legacy_params + when Array + # Array format: ["John", "Order123"] → {body: {"1": "John", "2": "Order123"}} + body_params = convert_array_to_body_params(legacy_params) + enhanced['body'] = body_params unless body_params.empty? + when Hash + # Hash format: {"1": "John", "name": "Jane"} → {body: {"1": "John", "name": "Jane"}} + body_params = convert_hash_to_body_params(legacy_params) + enhanced['body'] = body_params unless body_params.empty? + else + raise ArgumentError, "Unknown legacy format: #{legacy_params.class}" + end + + enhanced + end + + def convert_array_to_body_params(params_array) + return {} if params_array.empty? + + body_params = {} + params_array.each_with_index do |value, index| + body_params[(index + 1).to_s] = value.to_s + end + + body_params + end + + def convert_hash_to_body_params(params_hash) + return {} if params_hash.empty? + + body_params = {} + params_hash.each do |key, value| + body_params[key.to_s] = value.to_s + end + + body_params + end +end diff --git a/app/services/whatsapp/template_processor_service.rb b/app/services/whatsapp/template_processor_service.rb index 2ce9fcf8f..3b12bf58b 100644 --- a/app/services/whatsapp/template_processor_service.rb +++ b/app/services/whatsapp/template_processor_service.rb @@ -2,11 +2,9 @@ class Whatsapp::TemplateProcessorService pattr_initialize [:channel!, :template_params, :message] def call - if template_params.present? - process_template_with_params - else - process_template_from_message - end + return [nil, nil, nil, nil] if template_params.blank? + + process_template_with_params end private @@ -20,51 +18,6 @@ class Whatsapp::TemplateProcessorService ] end - def process_template_from_message - return [nil, nil, nil, nil] if message.blank? - - # Delete the following logic once the update for template_params is stable - # see if we can match the message content to a template - # An example template may look like "Your package has been shipped. It will be delivered in {{1}} business days. - # We want to iterate over these templates with our message body and see if we can fit it to any of the templates - # Then we use regex to parse the template varibles and convert them into the proper payload - channel.message_templates&.each do |template| - match_obj = template_match_object(template) - next if match_obj.blank? - - # we have a match, now we need to parse the template variables and convert them into the wa recommended format - processed_parameters = match_obj.captures.map { |x| { type: 'text', text: x } } - - # no need to look up further end the search - return [template['name'], template['namespace'], template['language'], processed_parameters] - end - [nil, nil, nil, nil] - end - - def template_match_object(template) - body_object = validated_body_object(template) - return if body_object.blank? - - template_match_regex = build_template_match_regex(body_object['text']) - message.outgoing_content.match(template_match_regex) - end - - def build_template_match_regex(template_text) - # Converts the whatsapp template to a comparable regex string to check against the message content - # the variables are of the format {{num}} ex:{{1}} - - # transform the template text into a regex string - # we need to replace the {{num}} with matchers that can be used to capture the variables - template_text = template_text.gsub(/{{\d}}/, '(.*)') - # escape if there are regex characters in the template text - template_text = Regexp.escape(template_text) - # ensuring only the variables remain as capture groups - template_text = template_text.gsub(Regexp.escape('(.*)'), '(.*)') - - template_match_string = "^#{template_text}$" - Regexp.new template_match_string - end - def find_template channel.message_templates.find do |t| t['name'] == template_params['name'] && t['language'] == template_params['language'] && t['status']&.downcase == 'approved' @@ -75,21 +28,100 @@ class Whatsapp::TemplateProcessorService template = find_template return if template.blank? - parameter_format = template['parameter_format'] + # Convert legacy format to enhanced format before processing + converter = Whatsapp::TemplateParameterConverterService.new(template_params, template) + normalized_params = converter.normalize_to_enhanced - if parameter_format == 'NAMED' - template_params['processed_params']&.map { |key, value| { type: 'text', parameter_name: key, text: value } } - else - template_params['processed_params']&.map { |_, value| { type: 'text', text: value } } - end + process_enhanced_template_params(template, normalized_params['processed_params']) end - def validated_body_object(template) - # we don't care if its not approved template - return if template['status'] != 'approved' + def process_enhanced_template_params(template, processed_params = nil) + processed_params ||= template_params['processed_params'] + components = [] - # we only care about text body object in template. if not present we discard the template - # we don't support other forms of templates - template['components'].find { |obj| obj['type'] == 'BODY' && obj.key?('text') } + components.concat(process_header_components(processed_params)) + components.concat(process_body_components(processed_params, template)) + components.concat(process_footer_components(processed_params)) + components.concat(process_button_components(processed_params)) + + @template_params = components + end + + 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 = [] + header_data.each do |key, value| + next if value.blank? + + 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 << parameter_builder.build_parameter(value) + end + end + header_params + end + + def media_url_with_type?(key, header_data) + key == 'media_url' && header_data['media_type'].present? + end + + def process_body_components(processed_params, template) + return [] if processed_params['body'].blank? + + body_params = processed_params['body'].filter_map do |key, value| + next if value.blank? + + parameter_format = template['parameter_format'] + if parameter_format == 'NAMED' + parameter_builder.build_named_parameter(key, value) + else + parameter_builder.build_parameter(value) + end + end + + body_params.present? ? [{ type: 'body', parameters: body_params }] : [] + end + + def process_footer_components(processed_params) + return [] if processed_params['footer'].blank? + + footer_params = processed_params['footer'].filter_map do |_, value| + next if value.blank? + + parameter_builder.build_parameter(value) + end + + footer_params.present? ? [{ type: 'footer', parameters: footer_params }] : [] + end + + def process_button_components(processed_params) + return [] if processed_params['buttons'].blank? + + button_params = processed_params['buttons'].filter_map.with_index do |button, index| + next if button.blank? + + if button['type'] == 'url' || button['parameter'].present? + { + type: 'button', + sub_type: button['type'] || 'url', + index: index, + parameters: [parameter_builder.build_button_parameter(button)] + } + end + end + + button_params.compact + end + + def parameter_builder + @parameter_builder ||= Whatsapp::PopulateTemplateParametersService.new end end diff --git a/spec/services/whatsapp/oneoff_campaign_service_spec.rb b/spec/services/whatsapp/oneoff_campaign_service_spec.rb index 5cf56bf25..599081e23 100644 --- a/spec/services/whatsapp/oneoff_campaign_service_spec.rb +++ b/spec/services/whatsapp/oneoff_campaign_service_spec.rb @@ -19,7 +19,7 @@ describe Whatsapp::OneoffCampaignService do 'namespace' => '23423423_2342423_324234234_2343224', 'category' => 'UTILITY', 'language' => 'en', - 'processed_params' => { 'name' => 'John', 'ticket_id' => '2332' } + 'processed_params' => { 'body' => { 'name' => 'John', 'ticket_id' => '2332' } } } end @@ -125,8 +125,13 @@ describe Whatsapp::OneoffCampaignService do namespace: '23423423_2342423_324234234_2343224', lang_code: 'en', parameters: array_including( - hash_including(type: 'text', parameter_name: 'name', text: 'John'), - hash_including(type: 'text', parameter_name: 'ticket_id', text: '2332') + hash_including( + type: 'body', + parameters: array_including( + hash_including(type: 'text', parameter_name: 'name', text: 'John'), + hash_including(type: 'text', parameter_name: 'ticket_id', text: '2332') + ) + ) ) ) ) diff --git a/spec/services/whatsapp/providers/whatsapp_cloud_service_spec.rb b/spec/services/whatsapp/providers/whatsapp_cloud_service_spec.rb index 1bef635b3..8735ccfbb 100644 --- a/spec/services/whatsapp/providers/whatsapp_cloud_service_spec.rb +++ b/spec/services/whatsapp/providers/whatsapp_cloud_service_spec.rb @@ -165,19 +165,17 @@ describe Whatsapp::Providers::WhatsappCloudService do let(:template_body) do { messaging_product: 'whatsapp', + recipient_type: 'individual', # Added recipient_type field to: '+123456789', + type: 'template', template: { name: template_info[:name], language: { policy: 'deterministic', code: template_info[:lang_code] }, - components: [ - { type: 'body', - parameters: template_info[:parameters] } - ] - }, - type: 'template' + components: template_info[:parameters] # Changed to use parameters directly (enhanced format) + } } end diff --git a/spec/services/whatsapp/send_on_whatsapp_service_spec.rb b/spec/services/whatsapp/send_on_whatsapp_service_spec.rb index a0e3e893a..e6b699a52 100644 --- a/spec/services/whatsapp/send_on_whatsapp_service_spec.rb +++ b/spec/services/whatsapp/send_on_whatsapp_service_spec.rb @@ -6,7 +6,7 @@ describe Whatsapp::SendOnWhatsappService do namespace: '23423423_2342423_324234234_2343224', language: 'en_US', category: 'Marketing', - processed_params: { '1' => '3' } + processed_params: { 'body' => { '1' => '3' } } } describe '#perform' do @@ -39,15 +39,16 @@ describe Whatsapp::SendOnWhatsappService do let(:named_template_body) do { messaging_product: 'whatsapp', + recipient_type: 'individual', to: '123456789', + type: 'template', template: { name: 'ticket_status_updated', language: { 'policy': 'deterministic', 'code': 'en_US' }, components: [{ 'type': 'body', 'parameters': [{ 'type': 'text', parameter_name: 'last_name', 'text': 'Dale' }, { 'type': 'text', parameter_name: 'ticket_id', 'text': '2332' }] }] - }, - type: 'template' + } } end @@ -73,7 +74,7 @@ describe Whatsapp::SendOnWhatsappService do it 'calls channel.send_template when after 24 hour limit' do message = create(:message, message_type: :outgoing, content: 'Your package has been shipped. It will be delivered in 3 business days.', - conversation: conversation) + conversation: conversation, additional_attributes: { template_params: template_params }) stub_request(:post, 'https://waba.360dialog.io/v1/messages') .with( @@ -107,12 +108,18 @@ describe Whatsapp::SendOnWhatsappService do name: 'ticket_status_updated', language: 'en_US', category: 'UTILITY', - processed_params: { 'last_name' => 'Dale', 'ticket_id' => '2332' } + processed_params: { 'body' => { 'last_name' => 'Dale', 'ticket_id' => '2332' } } } stub_request(:post, "https://graph.facebook.com/v13.0/#{whatsapp_cloud_channel.provider_config['phone_number_id']}/messages") .with( - :headers => { 'Content-Type' => 'application/json', 'Authorization' => "Bearer #{whatsapp_cloud_channel.provider_config['api_key']}" }, + :headers => { + 'Accept' => '*/*', + 'Accept-Encoding' => 'gzip;q=1.0,deflate;q=0.6,identity;q=0.3', + 'Content-Type' => 'application/json', + 'Authorization' => "Bearer #{whatsapp_cloud_channel.provider_config['api_key']}", + 'User-Agent' => 'Ruby' + }, :body => named_template_body.to_json ).to_return(status: 200, body: success_response, headers: { 'content-type' => 'application/json' }) message = create(:message, @@ -124,12 +131,44 @@ describe Whatsapp::SendOnWhatsappService do end it 'calls channel.send_template when template has regexp characters' do - message = create( - :message, - message_type: :outgoing, - content: 'عميلنا العزيز الرجاء الرد على هذه الرسالة بكلمة *نعم* للرد على إستفساركم من قبل خدمة العملاء.', - conversation: conversation - ) + 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 + 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) + + 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') + end + + it 'handles empty processed_params gracefully' do + empty_template_params = { + name: 'sample_shipping_confirmation', + namespace: '23423423_2342423_324234234_2343224', + language: 'en_US', + category: 'SHIPPING_UPDATE', + processed_params: {} + } + + message = create(:message, additional_attributes: { template_params: empty_template_params }, + conversation: conversation, message_type: :outgoing) stub_request(:post, 'https://waba.360dialog.io/v1/messages') .with( @@ -137,10 +176,10 @@ describe Whatsapp::SendOnWhatsappService do body: { to: '123456789', template: { - name: 'customer_yes_no', - namespace: '2342384942_32423423_23423fdsdaf23', - language: { 'policy': 'deterministic', 'code': 'ar' }, - components: [{ 'type': 'body', 'parameters': [] }] + name: 'sample_shipping_confirmation', + namespace: '23423423_2342423_324234234_2343224', + language: { 'policy': 'deterministic', 'code': 'en_US' }, + components: [] }, type: 'template' }.to_json @@ -149,6 +188,169 @@ describe Whatsapp::SendOnWhatsappService do described_class.new(message: message).perform expect(message.reload.source_id).to eq('123456789') end + + it 'handles template with button parameters' do + 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) + + 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 + 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) + + components = [ + { type: 'body', parameters: [{ type: 'text', text: '5' }] }, + { type: 'footer', parameters: [{ type: 'text', text: 'Thank you' }] } + ] + stub_sample_template_request(components) + + expect { described_class.new(message: message).perform }.not_to raise_error + expect(message.reload.source_id).to eq('123456789') + end + + it 'handles edge case with missing template gracefully' do + # Test the service behavior when template is not found + missing_template_params = { + 'name' => 'non_existent_template', + 'namespace' => 'missing_namespace', + 'language' => 'en_US', + 'category' => 'UTILITY', + 'processed_params' => { 'body' => { '1' => 'test' } } + } + + service = Whatsapp::TemplateProcessorService.new( + channel: whatsapp_channel, + template_params: missing_template_params + ) + + expect { service.call }.not_to raise_error + name, namespace, language, processed_params = service.call + expect(name).to eq('non_existent_template') + expect(namespace).to eq('missing_namespace') + expect(language).to eq('en_US') + expect(processed_params).to be_nil + end + + it 'handles template with blank parameter values correctly' do + 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) + + 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') + end + + it 'handles nil template_params gracefully' do + # Test service behavior when template_params is completely nil + message = create(:message, additional_attributes: {}, + conversation: conversation, message_type: :outgoing) + + # Should send regular message, not template + stub_request(:post, 'https://waba.360dialog.io/v1/messages') + .with( + headers: headers, + body: { + to: '123456789', + text: { body: message.content }, + type: 'text' + }.to_json + ).to_return(status: 200, body: success_response, headers: { 'content-type' => 'application/json' }) + + expect { described_class.new(message: message).perform }.not_to raise_error + end + + it 'processes template with rich text formatting' do + 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: processed_params + } + end + + 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, + body: { + to: '123456789', + template: { + name: 'sample_shipping_confirmation', + namespace: '23423423_2342423_324234234_2343224', + language: { 'policy': 'deterministic', 'code': 'en_US' }, + components: components + }, + type: 'template' + }.to_json + ).to_return(status: 200, body: success_response, headers: { 'content-type' => 'application/json' }) + end end end end diff --git a/spec/services/whatsapp/template_parameter_converter_service_spec.rb b/spec/services/whatsapp/template_parameter_converter_service_spec.rb new file mode 100644 index 000000000..2994bb472 --- /dev/null +++ b/spec/services/whatsapp/template_parameter_converter_service_spec.rb @@ -0,0 +1,199 @@ +require 'rails_helper' + +describe Whatsapp::TemplateParameterConverterService do + let(:template) do + { + 'name' => 'test_template', + 'language' => 'en', + 'components' => [ + { + 'type' => 'BODY', + 'text' => 'Hello {{1}}, your order {{2}} is ready!' + } + ] + } + end + + let(:media_template) do + { + 'name' => 'media_template', + 'language' => 'en', + 'components' => [ + { + 'type' => 'HEADER', + 'format' => 'IMAGE' + }, + { + 'type' => 'BODY', + 'text' => 'Check out {{1}}!' + } + ] + } + end + + let(:button_template) do + { + 'name' => 'button_template', + 'language' => 'en', + 'components' => [ + { + 'type' => 'BODY', + 'text' => 'Visit our website!' + }, + { + 'type' => 'BUTTONS', + 'buttons' => [ + { + 'type' => 'URL', + 'url' => 'https://example.com/{{1}}' + }, + { + 'type' => 'COPY_CODE' + } + ] + } + ] + } + end + + describe '#normalize_to_enhanced' do + context 'when already enhanced format' do + let(:enhanced_params) do + { + 'processed_params' => { + 'body' => { '1' => 'John', '2' => 'Order123' } + } + } + end + + it 'returns unchanged' do + converter = described_class.new(enhanced_params, template) + result = converter.normalize_to_enhanced + expect(result).to eq(enhanced_params) + end + end + + context 'when legacy array format' do + let(:legacy_array_params) do + { + 'processed_params' => %w[John Order123] + } + end + + it 'converts to enhanced format' do + converter = described_class.new(legacy_array_params, template) + result = converter.normalize_to_enhanced + + expect(result['processed_params']).to eq({ + 'body' => { '1' => 'John', '2' => 'Order123' } + }) + expect(result['format_version']).to eq('legacy') + end + end + + context 'when legacy flat hash format' do + let(:legacy_hash_params) do + { + 'processed_params' => { '1' => 'John', '2' => 'Order123' } + } + end + + it 'converts to enhanced format' do + converter = described_class.new(legacy_hash_params, template) + result = converter.normalize_to_enhanced + + expect(result['processed_params']).to eq({ + 'body' => { '1' => 'John', '2' => 'Order123' } + }) + expect(result['format_version']).to eq('legacy') + end + end + + context 'when legacy hash with all body parameters' do + let(:legacy_hash_params) do + { + 'processed_params' => { + '1' => 'Product', + 'customer_name' => 'John' + } + } + end + + it 'converts to enhanced format with body only' do + converter = described_class.new(legacy_hash_params, media_template) + result = converter.normalize_to_enhanced + + expect(result['processed_params']).to eq({ + 'body' => { + '1' => 'Product', + 'customer_name' => 'John' + } + }) + expect(result['format_version']).to eq('legacy') + end + end + + context 'when invalid format' do + let(:invalid_params) do + { + 'processed_params' => 'invalid_string' + } + end + + it 'raises ArgumentError' do + expect do + converter = described_class.new(invalid_params, template) + converter.normalize_to_enhanced + end.to raise_error(ArgumentError, /Unknown legacy format/) + end + end + end + + describe '#enhanced_format?' do + it 'returns true for valid enhanced format' do + enhanced = { 'body' => { '1' => 'test' } } + converter = described_class.new({}, template) + expect(converter.send(:enhanced_format?, enhanced)).to be true + end + + it 'returns false for array' do + converter = described_class.new({}, template) + expect(converter.send(:enhanced_format?, ['test'])).to be false + end + + it 'returns false for flat hash' do + converter = described_class.new({}, template) + expect(converter.send(:enhanced_format?, { '1' => 'test' })).to be false + end + + it 'returns false for invalid structure' do + invalid = { 'body' => 'not_a_hash' } + converter = described_class.new({}, template) + expect(converter.send(:enhanced_format?, invalid)).to be false + end + end + + describe 'simplified conversion methods' do + describe '#convert_array_to_body_params' do + it 'converts empty array' do + converter = described_class.new({}, template) + result = converter.send(:convert_array_to_body_params, []) + expect(result).to eq({}) + end + + it 'converts array to numbered body parameters' do + converter = described_class.new({}, template) + result = converter.send(:convert_array_to_body_params, %w[John Order123]) + expect(result).to eq({ '1' => 'John', '2' => 'Order123' }) + end + end + + describe '#convert_hash_to_body_params' do + it 'converts hash to body parameters' do + converter = described_class.new({}, template) + result = converter.send(:convert_hash_to_body_params, { 'name' => 'John', 'order' => '123' }) + expect(result).to eq({ 'name' => 'John', 'order' => '123' }) + end + end + end +end diff --git a/swagger/definitions/request/conversation/create_message_payload.yml b/swagger/definitions/request/conversation/create_message_payload.yml index ef1a051c2..4b1851293 100644 --- a/swagger/definitions/request/conversation/create_message_payload.yml +++ b/swagger/definitions/request/conversation/create_message_payload.yml @@ -48,4 +48,4 @@ properties: type: object description: The processed param values for template variables in template example: - 1: 'Chatwoot' + 1: 'Chatwoot' \ No newline at end of file