chore: add template parameter service

This commit is contained in:
Muhsin Keloth
2025-07-31 18:47:51 +04:00
parent 77aa9b8f74
commit 79e28ae922
3 changed files with 258 additions and 307 deletions
@@ -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
@@ -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
@@ -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