diff --git a/app/javascript/dashboard/components-next/avatar/Avatar.vue b/app/javascript/dashboard/components-next/avatar/Avatar.vue
index 1b2ecbc05..a47566566 100644
--- a/app/javascript/dashboard/components-next/avatar/Avatar.vue
+++ b/app/javascript/dashboard/components-next/avatar/Avatar.vue
@@ -251,7 +251,7 @@ watch(
diff --git a/app/javascript/dashboard/components-next/captain/pageComponents/assistant/EditAssistantForm.vue b/app/javascript/dashboard/components-next/captain/pageComponents/assistant/EditAssistantForm.vue
index 3134d7062..ad65f7511 100644
--- a/app/javascript/dashboard/components-next/captain/pageComponents/assistant/EditAssistantForm.vue
+++ b/app/javascript/dashboard/components-next/captain/pageComponents/assistant/EditAssistantForm.vue
@@ -41,6 +41,7 @@ const initialState = {
features: {
conversationFaqs: false,
memories: false,
+ citations: false,
},
temperature: 1,
};
@@ -87,6 +88,7 @@ const updateStateFromAssistant = assistant => {
state.features = {
conversationFaqs: config.feature_faq || false,
memories: config.feature_memory || false,
+ citations: config.feature_citation || false,
};
state.temperature = config.temperature || 1;
};
@@ -152,6 +154,7 @@ const handleFeaturesUpdate = () => {
...props.assistant.config,
feature_faq: state.features.conversationFaqs,
feature_memory: state.features.memories,
+ feature_citation: state.features.citations,
},
};
@@ -314,6 +317,14 @@ watch(
/>
{{ t('CAPTAIN.ASSISTANTS.FORM.FEATURES.ALLOW_MEMORIES') }}
+
diff --git a/app/javascript/widget/i18n/locale/en.json b/app/javascript/widget/i18n/locale/en.json
index 279ead47e..4f244c566 100644
--- a/app/javascript/widget/i18n/locale/en.json
+++ b/app/javascript/widget/i18n/locale/en.json
@@ -12,6 +12,11 @@
"ERROR_MESSAGE": "Couldn't send, try again"
}
},
+ "THUMBNAIL": {
+ "AUTHOR": {
+ "NOT_AVAILABLE": "Not available"
+ }
+ },
"TEAM_AVAILABILITY": {
"ONLINE": "We are online",
"OFFLINE": "We are away at the moment"
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/send_on_whatsapp_service.rb b/app/services/whatsapp/send_on_whatsapp_service.rb
index 8cecfd41f..5f91bce16 100644
--- a/app/services/whatsapp/send_on_whatsapp_service.rb
+++ b/app/services/whatsapp/send_on_whatsapp_service.rb
@@ -23,7 +23,10 @@ class Whatsapp::SendOnWhatsappService < Base::SendOnChannelService
name, namespace, lang_code, processed_parameters = processor.call
- return if name.blank?
+ if name.blank?
+ message.update!(status: :failed, external_error: 'Template not found or invalid template name')
+ return
+ end
message_id = channel.send_template(message.conversation.contact_inbox.source_id, {
name: name,
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/builders/messages/instagram/message_builder_spec.rb b/spec/builders/messages/instagram/message_builder_spec.rb
index 8e863823a..386087fbe 100644
--- a/spec/builders/messages/instagram/message_builder_spec.rb
+++ b/spec/builders/messages/instagram/message_builder_spec.rb
@@ -17,40 +17,34 @@ describe Messages::Instagram::MessageBuilder do
let!(:shared_reel_params) { build(:instagram_shared_reel_event).with_indifferent_access }
let!(:instagram_story_reply_event) { build(:instagram_story_reply_event).with_indifferent_access }
let!(:instagram_message_reply_event) { build(:instagram_message_reply_event).with_indifferent_access }
- let!(:contact) { create(:contact, id: 'Sender-id-1', name: 'Jane Dae') }
- let!(:contact_inbox) { create(:contact_inbox, contact_id: contact.id, inbox_id: instagram_inbox.id, source_id: 'Sender-id-1') }
- let(:conversation) do
- create(:conversation, account_id: account.id, inbox_id: instagram_inbox.id, contact_id: contact.id)
- end
- let(:message) do
- create(:message, account_id: account.id, inbox_id: instagram_inbox.id, conversation_id: conversation.id, message_type: 'outgoing',
- source_id: 'message-id-1')
- end
describe '#perform' do
before do
instagram_channel.update(access_token: 'valid_instagram_token')
- stub_request(:get, %r{https://graph\.instagram\.com/.*?/Sender-id-1\?.*})
+ stub_request(:get, %r{https://graph\.instagram\.com/.*?/Sender-id-.*?\?.*})
.to_return(
status: 200,
- body: {
- name: 'Jane',
- username: 'some_user_name',
- profile_pic: 'https://chatwoot-assets.local/sample.png',
- id: 'Sender-id-1',
- follower_count: 100,
- is_user_follow_business: true,
- is_business_follow_user: true,
- is_verified_user: false
- }.to_json,
+ body: proc { |request|
+ sender_id = request.uri.path.split('/').last.split('?').first
+ {
+ name: 'Jane',
+ username: 'some_user_name',
+ profile_pic: 'https://chatwoot-assets.local/sample.png',
+ id: sender_id,
+ follower_count: 100,
+ is_user_follow_business: true,
+ is_business_follow_user: true,
+ is_verified_user: false
+ }.to_json
+ },
headers: { 'Content-Type' => 'application/json' }
)
end
it 'creates contact and message for the instagram direct inbox' do
messaging = dm_params[:entry][0]['messaging'][0]
- contact_inbox
+ create_instagram_contact_for_sender(messaging['sender']['id'], instagram_inbox)
described_class.new(messaging, instagram_inbox).perform
instagram_inbox.reload
@@ -63,13 +57,15 @@ describe Messages::Instagram::MessageBuilder do
end
it 'discard echo message already sent by chatwoot' do
- conversation
- message
+ messaging = dm_params[:entry][0]['messaging'][0]
+ contact = create_instagram_contact_for_sender(messaging['sender']['id'], instagram_inbox)
+ conversation = create(:conversation, account_id: account.id, inbox_id: instagram_inbox.id, contact_id: contact.id)
+ create(:message, account_id: account.id, inbox_id: instagram_inbox.id, conversation_id: conversation.id, message_type: 'outgoing',
+ source_id: 'message-id-1')
expect(instagram_inbox.conversations.count).to be 1
expect(instagram_inbox.messages.count).to be 1
- messaging = dm_params[:entry][0]['messaging'][0]
messaging[:message][:mid] = 'message-id-1' # Set same source_id as the existing message
described_class.new(messaging, instagram_inbox, outgoing_echo: true).perform
@@ -81,6 +77,7 @@ describe Messages::Instagram::MessageBuilder do
it 'discards duplicate messages from webhook events with the same message_id' do
messaging = dm_params[:entry][0]['messaging'][0]
+ create_instagram_contact_for_sender(messaging['sender']['id'], instagram_inbox)
described_class.new(messaging, instagram_inbox).perform
initial_message_count = instagram_inbox.messages.count
@@ -93,6 +90,7 @@ describe Messages::Instagram::MessageBuilder do
it 'creates message for shared reel' do
messaging = shared_reel_params[:entry][0]['messaging'][0]
+ create_instagram_contact_for_sender(messaging['sender']['id'], instagram_inbox)
described_class.new(messaging, instagram_inbox).perform
message = instagram_inbox.messages.first
@@ -103,7 +101,9 @@ describe Messages::Instagram::MessageBuilder do
end
it 'creates message with story id' do
- story_source_id = instagram_story_reply_event[:entry][0]['messaging'][0]['message']['mid']
+ messaging = instagram_story_reply_event[:entry][0]['messaging'][0]
+ create_instagram_contact_for_sender(messaging['sender']['id'], instagram_inbox)
+ story_source_id = messaging['message']['mid']
stub_request(:get, %r{https://graph\.instagram\.com/.*?/#{story_source_id}\?.*})
.to_return(
@@ -121,7 +121,6 @@ describe Messages::Instagram::MessageBuilder do
headers: { 'Content-Type' => 'application/json' }
)
- messaging = instagram_story_reply_event[:entry][0]['messaging'][0]
described_class.new(messaging, instagram_inbox).perform
message = instagram_inbox.messages.first
@@ -134,10 +133,13 @@ describe Messages::Instagram::MessageBuilder do
it 'creates message with reply to mid' do
# Create first message to ensure reply to is valid
first_messaging = dm_params[:entry][0]['messaging'][0]
+ sender_id = first_messaging['sender']['id']
+ create_instagram_contact_for_sender(sender_id, instagram_inbox)
described_class.new(first_messaging, instagram_inbox).perform
- # Create second message with reply to mid
+ # Create second message with reply to mid, using same sender_id
messaging = instagram_message_reply_event[:entry][0]['messaging'][0]
+ messaging['sender']['id'] = sender_id
described_class.new(messaging, instagram_inbox).perform
first_message = instagram_inbox.messages.first
@@ -148,12 +150,13 @@ describe Messages::Instagram::MessageBuilder do
end
it 'handles deleted story' do
- story_source_id = story_mention_params[:entry][0][:messaging][0]['message']['mid']
+ messaging = story_mention_params[:entry][0][:messaging][0]
+ create_instagram_contact_for_sender(messaging['sender']['id'], instagram_inbox)
+ story_source_id = messaging['message']['mid']
stub_request(:get, %r{https://graph\.instagram\.com/.*?/#{story_source_id}\?.*})
.to_return(status: 404, body: { error: { message: 'Story not found', code: 1_609_005 } }.to_json)
- messaging = story_mention_params[:entry][0][:messaging][0]
described_class.new(messaging, instagram_inbox).perform
message = instagram_inbox.messages.first
@@ -163,11 +166,12 @@ describe Messages::Instagram::MessageBuilder do
end
it 'does not create message for unsupported file type' do
- conversation
+ messaging = story_mention_params[:entry][0][:messaging][0]
+ contact = create_instagram_contact_for_sender(messaging['sender']['id'], instagram_inbox)
+ create(:conversation, account_id: account.id, inbox_id: instagram_inbox.id, contact_id: contact.id)
# try to create a message with unsupported file type
- story_mention_params[:entry][0][:messaging][0]['message']['attachments'][0]['type'] = 'unsupported_type'
- messaging = story_mention_params[:entry][0][:messaging][0]
+ messaging['message']['attachments'][0]['type'] = 'unsupported_type'
described_class.new(messaging, instagram_inbox, outgoing_echo: false).perform
@@ -177,7 +181,11 @@ describe Messages::Instagram::MessageBuilder do
end
it 'does not create message if the message is already exists' do
- message
+ messaging = dm_params[:entry][0]['messaging'][0]
+ contact = create_instagram_contact_for_sender(messaging['sender']['id'], instagram_inbox)
+ conversation = create(:conversation, account_id: account.id, inbox_id: instagram_inbox.id, contact_id: contact.id)
+ create(:message, account_id: account.id, inbox_id: instagram_inbox.id, conversation_id: conversation.id, message_type: 'outgoing',
+ source_id: 'message-id-1')
expect(instagram_inbox.conversations.count).to be 1
expect(instagram_inbox.messages.count).to be 1
@@ -194,7 +202,7 @@ describe Messages::Instagram::MessageBuilder do
instagram_channel.update(access_token: 'invalid_token')
# Stub the request to return authorization error status
- stub_request(:get, %r{https://graph\.instagram\.com/.*?/Sender-id-1\?.*})
+ stub_request(:get, %r{https://graph\.instagram\.com/.*?/Sender-id-.*?\?.*})
.to_return(
status: 401,
body: { error: { message: 'unauthorized access token', code: 190 } }.to_json,
@@ -218,6 +226,7 @@ describe Messages::Instagram::MessageBuilder do
it 'creates a new conversation if existing conversation is not present' do
initial_count = Conversation.count
messaging = dm_params[:entry][0]['messaging'][0]
+ create_instagram_contact_for_sender(messaging['sender']['id'], instagram_inbox)
described_class.new(messaging, instagram_inbox).perform
@@ -226,21 +235,23 @@ describe Messages::Instagram::MessageBuilder do
end
it 'will not create a new conversation if last conversation is not resolved' do
+ messaging = dm_params[:entry][0]['messaging'][0]
+ contact = create_instagram_contact_for_sender(messaging['sender']['id'], instagram_inbox)
existing_conversation = create(:conversation, account_id: account.id, inbox_id: instagram_inbox.id,
contact_id: contact.id, status: :open)
- messaging = dm_params[:entry][0]['messaging'][0]
described_class.new(messaging, instagram_inbox).perform
expect(instagram_inbox.conversations.last.id).to eq(existing_conversation.id)
end
it 'creates a new conversation if last conversation is resolved' do
+ messaging = dm_params[:entry][0]['messaging'][0]
+ contact = create_instagram_contact_for_sender(messaging['sender']['id'], instagram_inbox)
existing_conversation = create(:conversation, account_id: account.id, inbox_id: instagram_inbox.id,
contact_id: contact.id, status: :resolved)
initial_count = Conversation.count
- messaging = dm_params[:entry][0]['messaging'][0]
described_class.new(messaging, instagram_inbox).perform
@@ -257,6 +268,7 @@ describe Messages::Instagram::MessageBuilder do
it 'creates a new conversation if existing conversation is not present' do
initial_count = Conversation.count
messaging = dm_params[:entry][0]['messaging'][0]
+ create_instagram_contact_for_sender(messaging['sender']['id'], instagram_inbox)
described_class.new(messaging, instagram_inbox).perform
@@ -265,6 +277,8 @@ describe Messages::Instagram::MessageBuilder do
end
it 'reopens last conversation if last conversation is resolved' do
+ messaging = dm_params[:entry][0]['messaging'][0]
+ contact = create_instagram_contact_for_sender(messaging['sender']['id'], instagram_inbox)
existing_conversation = create(:conversation, account_id: account.id, inbox_id: instagram_inbox.id,
contact_id: contact.id, status: :resolved)
@@ -307,6 +321,7 @@ describe Messages::Instagram::MessageBuilder do
it 'saves story information when story mention is processed' do
messaging = story_mention_params[:entry][0][:messaging][0]
+ create_instagram_contact_for_sender(messaging['sender']['id'], instagram_inbox)
described_class.new(messaging, instagram_inbox).perform
message = instagram_inbox.messages.first
@@ -328,6 +343,7 @@ describe Messages::Instagram::MessageBuilder do
)
messaging = story_mention_params[:entry][0][:messaging][0]
+ create_instagram_contact_for_sender(messaging['sender']['id'], instagram_inbox)
described_class.new(messaging, instagram_inbox).perform
message = instagram_inbox.messages.first
diff --git a/spec/builders/messages/instagram/messenger/message_builder_spec.rb b/spec/builders/messages/instagram/messenger/message_builder_spec.rb
index 03194673a..593dfb946 100644
--- a/spec/builders/messages/instagram/messenger/message_builder_spec.rb
+++ b/spec/builders/messages/instagram/messenger/message_builder_spec.rb
@@ -17,30 +17,23 @@ describe Messages::Instagram::Messenger::MessageBuilder do
let!(:instagram_story_reply_event) { build(:instagram_story_reply_event).with_indifferent_access }
let!(:instagram_message_reply_event) { build(:instagram_message_reply_event).with_indifferent_access }
let(:fb_object) { double }
- let(:contact) { create(:contact, id: 'Sender-id-1', name: 'Jane Dae') }
- let(:contact_inbox) { create(:contact_inbox, contact_id: contact.id, inbox_id: instagram_messenger_inbox.id, source_id: 'Sender-id-1') }
- let(:conversation) do
- create(:conversation, account_id: account.id, inbox_id: instagram_messenger_inbox.id, contact_id: contact.id,
- additional_attributes: { type: 'instagram_direct_message', conversation_language: 'en' })
- end
- let(:message) do
- create(:message, account_id: account.id, inbox_id: instagram_messenger_inbox.id, conversation_id: conversation.id, message_type: 'outgoing',
- source_id: 'message-id-1')
- end
describe '#perform' do
it 'creates contact and message for the facebook inbox' do
+ messaging = dm_params[:entry][0]['messaging'][0]
+ sender_id = messaging['sender']['id']
+
allow(Koala::Facebook::API).to receive(:new).and_return(fb_object)
allow(fb_object).to receive(:get_object).and_return(
{
name: 'Jane',
- id: 'Sender-id-1',
+ id: sender_id,
account_id: instagram_messenger_inbox.account_id,
profile_pic: 'https://chatwoot-assets.local/sample.png'
}.with_indifferent_access
)
- messaging = dm_params[:entry][0]['messaging'][0]
- contact_inbox
+
+ create_instagram_contact_for_sender(sender_id, instagram_messenger_inbox)
described_class.new(messaging, instagram_messenger_inbox).perform
instagram_messenger_inbox.reload
@@ -56,7 +49,13 @@ describe Messages::Instagram::Messenger::MessageBuilder do
end
it 'discard echo message already sent by chatwoot' do
- message
+ messaging = dm_params[:entry][0]['messaging'][0]
+ sender_id = messaging['sender']['id']
+ contact = create_instagram_contact_for_sender(sender_id, instagram_messenger_inbox)
+ conversation = create(:conversation, account_id: account.id, inbox_id: instagram_messenger_inbox.id, contact_id: contact.id,
+ additional_attributes: { type: 'instagram_direct_message', conversation_language: 'en' })
+ create(:message, account_id: account.id, inbox_id: instagram_messenger_inbox.id, conversation_id: conversation.id, message_type: 'outgoing',
+ source_id: 'message-id-1')
expect(instagram_messenger_inbox.conversations.count).to be 1
expect(instagram_messenger_inbox.messages.count).to be 1
@@ -65,13 +64,11 @@ describe Messages::Instagram::Messenger::MessageBuilder do
allow(fb_object).to receive(:get_object).and_return(
{
name: 'Jane',
- id: 'Sender-id-1',
+ id: sender_id,
account_id: instagram_messenger_inbox.account_id,
profile_pic: 'https://chatwoot-assets.local/sample.png'
}.with_indifferent_access
)
- messaging = dm_params[:entry][0]['messaging'][0]
- contact_inbox
described_class.new(messaging, instagram_messenger_inbox, outgoing_echo: true).perform
instagram_messenger_inbox.reload
@@ -81,17 +78,20 @@ describe Messages::Instagram::Messenger::MessageBuilder do
end
it 'creates message for shared reel' do
+ messaging = shared_reel_params[:entry][0]['messaging'][0]
+ sender_id = messaging['sender']['id']
+
allow(Koala::Facebook::API).to receive(:new).and_return(fb_object)
allow(fb_object).to receive(:get_object).and_return(
{
name: 'Jane',
- id: 'Sender-id-1',
+ id: sender_id,
account_id: instagram_messenger_inbox.account_id,
profile_pic: 'https://chatwoot-assets.local/sample.png'
}.with_indifferent_access
)
- messaging = shared_reel_params[:entry][0]['messaging'][0]
- contact_inbox
+
+ create_instagram_contact_for_sender(sender_id, instagram_messenger_inbox)
described_class.new(messaging, instagram_messenger_inbox).perform
message = instagram_messenger_channel.inbox.messages.first
@@ -102,18 +102,20 @@ describe Messages::Instagram::Messenger::MessageBuilder do
end
it 'creates message with for reply with story id' do
+ messaging = instagram_story_reply_event[:entry][0]['messaging'][0]
+ sender_id = messaging['sender']['id']
+
allow(Koala::Facebook::API).to receive(:new).and_return(fb_object)
allow(fb_object).to receive(:get_object).and_return(
{
name: 'Jane',
- id: 'Sender-id-1',
+ id: sender_id,
account_id: instagram_messenger_inbox.account_id,
profile_pic: 'https://chatwoot-assets.local/sample.png'
}.with_indifferent_access
)
- messaging = instagram_story_reply_event[:entry][0]['messaging'][0]
- contact_inbox
+ create_instagram_contact_for_sender(sender_id, instagram_messenger_inbox)
described_class.new(messaging, instagram_messenger_inbox).perform
message = instagram_messenger_channel.inbox.messages.first
@@ -125,24 +127,26 @@ describe Messages::Instagram::Messenger::MessageBuilder do
end
it 'creates message with for reply with mid' do
+ # create first message to ensure reply to is valid
+ first_message_data = dm_params[:entry][0]['messaging'][0]
+ sender_id = first_message_data['sender']['id']
+
allow(Koala::Facebook::API).to receive(:new).and_return(fb_object)
allow(fb_object).to receive(:get_object).and_return(
{
name: 'Jane',
- id: 'Sender-id-1',
+ id: sender_id,
account_id: instagram_messenger_inbox.account_id,
profile_pic: 'https://chatwoot-assets.local/sample.png'
}.with_indifferent_access
)
- # create first message to ensure reply to is valid
- first_message = dm_params[:entry][0]['messaging'][0]
- contact_inbox
- described_class.new(first_message, instagram_messenger_inbox).perform
- # create the second message with the reply to mid set
+ create_instagram_contact_for_sender(sender_id, instagram_messenger_inbox)
+ described_class.new(first_message_data, instagram_messenger_inbox).perform
+
+ # create the second message with the reply to mid set, ensure same sender_id
messaging = instagram_message_reply_event[:entry][0]['messaging'][0]
- contact_inbox
-
+ messaging['sender']['id'] = sender_id # Use the same sender_id
described_class.new(messaging, instagram_messenger_inbox).perform
first_message = instagram_messenger_channel.inbox.messages.first
message = instagram_messenger_channel.inbox.messages.last
@@ -153,14 +157,16 @@ describe Messages::Instagram::Messenger::MessageBuilder do
end
it 'raises exception on deleted story' do
+ messaging = story_mention_params[:entry][0][:messaging][0]
+ sender_id = messaging['sender']['id']
+
allow(Koala::Facebook::API).to receive(:new).and_return(fb_object)
allow(fb_object).to receive(:get_object).and_raise(Koala::Facebook::ClientError.new(
190,
'This Message has been deleted by the user or the business.'
))
- messaging = story_mention_params[:entry][0][:messaging][0]
- contact_inbox
+ create_instagram_contact_for_sender(sender_id, instagram_messenger_inbox)
described_class.new(messaging, instagram_messenger_inbox, outgoing_echo: false).perform
instagram_messenger_inbox.reload
@@ -180,22 +186,24 @@ describe Messages::Instagram::Messenger::MessageBuilder do
end
it 'does not create message for unsupported file type' do
+ # create a message with unsupported file type
+ story_mention_params[:entry][0][:messaging][0]['message']['attachments'][0]['type'] = 'unsupported_type'
+ messaging = story_mention_params[:entry][0][:messaging][0]
+ sender_id = messaging['sender']['id']
+
allow(Koala::Facebook::API).to receive(:new).and_return(fb_object)
allow(fb_object).to receive(:get_object).and_return(
{
name: 'Jane',
- id: 'Sender-id-1',
+ id: sender_id,
account_id: instagram_messenger_inbox.account_id,
profile_pic: 'https://chatwoot-assets.local/sample.png'
}.with_indifferent_access
)
- conversation
-
- # create a message with unsupported file type
- story_mention_params[:entry][0][:messaging][0]['message']['attachments'][0]['type'] = 'unsupported_type'
- messaging = story_mention_params[:entry][0][:messaging][0]
-
+ contact = create_instagram_contact_for_sender(sender_id, instagram_messenger_inbox)
+ create(:conversation, account_id: account.id, inbox_id: instagram_messenger_inbox.id, contact_id: contact.id,
+ additional_attributes: { type: 'instagram_direct_message', conversation_language: 'en' })
described_class.new(messaging, instagram_messenger_inbox, outgoing_echo: false).perform
instagram_messenger_inbox.reload
@@ -218,18 +226,22 @@ describe Messages::Instagram::Messenger::MessageBuilder do
it 'creates a new conversation if existing conversation is not present' do
inital_count = Conversation.count
message = dm_params[:entry][0]['messaging'][0]
- contact_inbox
+ sender_id = message['sender']['id']
+ create_instagram_contact_for_sender(sender_id, instagram_messenger_inbox)
described_class.new(message, instagram_messenger_inbox).perform
instagram_messenger_inbox.reload
- contact_inbox.reload
expect(instagram_messenger_inbox.conversations.count).to eq(1)
expect(Conversation.count).to eq(inital_count + 1)
end
it 'will not create a new conversation if last conversation is not resolved' do
+ message = dm_params[:entry][0]['messaging'][0]
+ sender_id = message['sender']['id']
+ contact = create_instagram_contact_for_sender(sender_id, instagram_messenger_inbox)
+
existing_conversation = create(
:conversation,
account_id: account.id,
@@ -239,18 +251,18 @@ describe Messages::Instagram::Messenger::MessageBuilder do
additional_attributes: { type: 'instagram_direct_message', conversation_language: 'en' }
)
- message = dm_params[:entry][0]['messaging'][0]
- contact_inbox
-
described_class.new(message, instagram_messenger_inbox).perform
instagram_messenger_inbox.reload
- contact_inbox.reload
expect(instagram_messenger_inbox.conversations.last.id).to eq(existing_conversation.id)
end
it 'creates a new conversation if last conversation is resolved' do
+ message = dm_params[:entry][0]['messaging'][0]
+ sender_id = message['sender']['id']
+ contact = create_instagram_contact_for_sender(sender_id, instagram_messenger_inbox)
+
existing_conversation = create(
:conversation,
account_id: account.id,
@@ -261,13 +273,9 @@ describe Messages::Instagram::Messenger::MessageBuilder do
)
inital_count = Conversation.count
- message = dm_params[:entry][0]['messaging'][0]
- contact_inbox
-
described_class.new(message, instagram_messenger_inbox).perform
instagram_messenger_inbox.reload
- contact_inbox.reload
expect(instagram_messenger_inbox.conversations.last.id).not_to eq(existing_conversation.id)
expect(Conversation.count).to eq(inital_count + 1)
@@ -283,18 +291,22 @@ describe Messages::Instagram::Messenger::MessageBuilder do
it 'creates a new conversation if existing conversation is not present' do
inital_count = Conversation.count
message = dm_params[:entry][0]['messaging'][0]
- contact_inbox
+ sender_id = message['sender']['id']
+ create_instagram_contact_for_sender(sender_id, instagram_messenger_inbox)
described_class.new(message, instagram_messenger_inbox).perform
instagram_messenger_inbox.reload
- contact_inbox.reload
expect(instagram_messenger_inbox.conversations.count).to eq(1)
expect(Conversation.count).to eq(inital_count + 1)
end
it 'reopens last conversation if last conversation is resolved' do
+ message = dm_params[:entry][0]['messaging'][0]
+ sender_id = message['sender']['id']
+ contact = create_instagram_contact_for_sender(sender_id, instagram_messenger_inbox)
+
existing_conversation = create(
:conversation,
account_id: account.id,
@@ -306,13 +318,9 @@ describe Messages::Instagram::Messenger::MessageBuilder do
inital_count = Conversation.count
- message = dm_params[:entry][0]['messaging'][0]
- contact_inbox
-
described_class.new(message, instagram_messenger_inbox).perform
instagram_messenger_inbox.reload
- contact_inbox.reload
expect(instagram_messenger_inbox.conversations.last.id).to eq(existing_conversation.id)
expect(Conversation.count).to eq(inital_count)
@@ -344,7 +352,9 @@ describe Messages::Instagram::Messenger::MessageBuilder do
allow(fb_object).to receive(:get_object).and_return(story_data)
messaging = story_mention_params[:entry][0][:messaging][0]
- contact_inbox
+ sender_id = messaging['sender']['id']
+
+ create_instagram_contact_for_sender(sender_id, instagram_messenger_inbox)
builder = described_class.new(messaging, instagram_messenger_inbox)
builder.perform
@@ -358,18 +368,20 @@ describe Messages::Instagram::Messenger::MessageBuilder do
end
it 'handles story mentions specifically in the Instagram builder' do
+ messaging = story_mention_params[:entry][0][:messaging][0]
+ sender_id = messaging['sender']['id']
+
# First allow contact info fetch
allow(fb_object).to receive(:get_object).and_return({
name: 'Jane',
- id: 'Sender-id-1'
+ id: sender_id
}.with_indifferent_access)
# Then allow story data fetch
allow(fb_object).to receive(:get_object).with(anything, fields: %w[story from])
.and_return(story_data)
- messaging = story_mention_params[:entry][0][:messaging][0]
- contact_inbox
+ create_instagram_contact_for_sender(sender_id, instagram_messenger_inbox)
described_class.new(messaging, instagram_messenger_inbox).perform
message = instagram_messenger_inbox.messages.first
diff --git a/spec/factories/instagram/instagram_message_create_event.rb b/spec/factories/instagram/instagram_message_create_event.rb
index 66d5e8a81..a5de8e7d3 100644
--- a/spec/factories/instagram/instagram_message_create_event.rb
+++ b/spec/factories/instagram/instagram_message_create_event.rb
@@ -1,14 +1,18 @@
FactoryBot.define do
factory :instagram_message_create_event, class: Hash do
+ transient do
+ ig_entry_id { SecureRandom.uuid }
+ sender_id { "Sender-id-#{SecureRandom.hex(4)}" }
+ end
entry do
[
{
- 'id': 'instagram-message-id-123',
+ 'id': ig_entry_id,
'time': '2021-09-08T06:34:04+0000',
'messaging': [
{
'sender': {
- 'id': 'Sender-id-1'
+ 'id': sender_id
},
'recipient': {
'id': 'chatwoot-app-user-id-1'
@@ -27,15 +31,19 @@ FactoryBot.define do
end
factory :instagram_message_standby_event, class: Hash do
+ transient do
+ ig_entry_id { SecureRandom.uuid }
+ sender_id { "Sender-id-#{SecureRandom.hex(4)}" }
+ end
entry do
[
{
'time': '2021-09-08T06:34:04+0000',
- 'id': 'instagram-message-id-123',
+ 'id': ig_entry_id,
'standby': [
{
'sender': {
- 'id': 'Sender-id-1'
+ 'id': sender_id
},
'recipient': {
'id': 'chatwoot-app-user-id-1'
@@ -54,15 +62,19 @@ FactoryBot.define do
end
factory :instagram_story_reply_event, class: Hash do
+ transient do
+ ig_entry_id { SecureRandom.uuid }
+ sender_id { "Sender-id-#{SecureRandom.hex(4)}" }
+ end
entry do
[
{
- 'id': 'instagram-message-id-123',
+ 'id': ig_entry_id,
'time': '2021-09-08T06:34:04+0000',
'messaging': [
{
'sender': {
- 'id': 'Sender-id-1'
+ 'id': sender_id
},
'recipient': {
'id': 'chatwoot-app-user-id-1'
@@ -87,15 +99,19 @@ FactoryBot.define do
end
factory :instagram_message_reply_event, class: Hash do
+ transient do
+ ig_entry_id { SecureRandom.uuid }
+ sender_id { "Sender-id-#{SecureRandom.hex(4)}" }
+ end
entry do
[
{
- 'id': 'instagram-message-id-123',
+ 'id': ig_entry_id,
'time': '2021-09-08T06:35:04+0000',
'messaging': [
{
'sender': {
- 'id': 'Sender-id-1'
+ 'id': sender_id
},
'recipient': {
'id': 'chatwoot-app-user-id-1'
@@ -117,11 +133,14 @@ FactoryBot.define do
end
factory :instagram_test_text_event, class: Hash do
+ transient do
+ ig_entry_id { SecureRandom.uuid }
+ end
entry do
[
{
'time' => 1_661_141_837_537,
- 'id' => '0',
+ 'id' => ig_entry_id,
'messaging' => [
{
'sender' => {
@@ -144,15 +163,19 @@ FactoryBot.define do
end
factory :instagram_message_unsend_event, class: Hash do
+ transient do
+ ig_entry_id { SecureRandom.uuid }
+ sender_id { "Sender-id-#{SecureRandom.hex(4)}" }
+ end
entry do
[
{
- 'id': 'instagram-message-id-123',
+ 'id': ig_entry_id,
'time': '2021-09-08T06:34:04+0000',
'messaging': [
{
'sender': {
- 'id': 'Sender-id-1'
+ 'id': sender_id
},
'recipient': {
'id': 'chatwoot-app-user-id-1'
@@ -171,15 +194,19 @@ FactoryBot.define do
end
factory :instagram_message_attachment_event, class: Hash do
+ transient do
+ ig_entry_id { SecureRandom.uuid }
+ sender_id { "Sender-id-#{SecureRandom.hex(4)}" }
+ end
entry do
[
{
- 'id': 'instagram-message-id-1234',
+ 'id': ig_entry_id,
'time': '2021-09-08T06:34:04+0000',
'messaging': [
{
'sender': {
- 'id': 'Sender-id-1'
+ 'id': sender_id
},
'recipient': {
'id': 'chatwoot-app-user-id-1'
@@ -205,15 +232,19 @@ FactoryBot.define do
end
factory :instagram_shared_reel_event, class: Hash do
+ transient do
+ ig_entry_id { SecureRandom.uuid }
+ sender_id { "Sender-id-#{SecureRandom.hex(4)}" }
+ end
entry do
[
{
- 'id': 'instagram-message-id-1234',
+ 'id': ig_entry_id,
'time': '2021-09-08T06:34:04+0000',
'messaging': [
{
'sender': {
- 'id': 'Sender-id-1'
+ 'id': sender_id
},
'recipient': {
'id': 'chatwoot-app-user-id-1'
@@ -241,15 +272,19 @@ FactoryBot.define do
end
factory :instagram_story_mention_event, class: Hash do
+ transient do
+ ig_entry_id { SecureRandom.uuid }
+ sender_id { "Sender-id-#{SecureRandom.hex(4)}" }
+ end
entry do
[
{
- 'id': 'instagram-message-id-1234',
+ 'id': ig_entry_id,
'time': '2021-09-08T06:34:04+0000',
'messaging': [
{
'sender': {
- 'id': 'Sender-id-1'
+ 'id': sender_id
},
'recipient': {
'id': 'chatwoot-app-user-id-1'
@@ -275,15 +310,19 @@ FactoryBot.define do
end
factory :instagram_story_mention_event_with_echo, class: Hash do
+ transient do
+ ig_entry_id { SecureRandom.uuid }
+ sender_id { "Sender-id-#{SecureRandom.hex(4)}" }
+ end
entry do
[
{
- 'id': 'instagram-message-id-1234',
+ 'id': ig_entry_id,
'time': '2021-09-08T06:34:04+0000',
'messaging': [
{
'sender': {
- 'id': 'Sender-id-1'
+ 'id': sender_id
},
'recipient': {
'id': 'chatwoot-app-user-id-1'
@@ -310,15 +349,19 @@ FactoryBot.define do
end
factory :instagram_message_unsupported_event, class: Hash do
+ transient do
+ ig_entry_id { SecureRandom.uuid }
+ sender_id { "Sender-id-#{SecureRandom.hex(4)}" }
+ end
entry do
[
{
- 'id': 'instagram-message-unsupported-id-123',
+ 'id': ig_entry_id,
'time': '2021-09-08T06:34:04+0000',
'messaging': [
{
'sender': {
- 'id': 'Sender-id-1'
+ 'id': sender_id
},
'recipient': {
'id': 'chatwoot-app-user-id-1'
@@ -337,15 +380,19 @@ FactoryBot.define do
end
factory :messaging_seen_event, class: Hash do
+ transient do
+ ig_entry_id { SecureRandom.uuid }
+ sender_id { "Sender-id-#{SecureRandom.hex(4)}" }
+ end
entry do
[
{
- 'id': 'instagram-message-id-123',
+ 'id': ig_entry_id,
'time': '2021-09-08T06:34:04+0000',
'messaging': [
{
'sender': {
- 'id': 'Sender-id-1'
+ 'id': sender_id
},
'recipient': {
'id': 'chatwoot-app-user-id-1'
diff --git a/spec/jobs/webhooks/instagram_events_job_spec.rb b/spec/jobs/webhooks/instagram_events_job_spec.rb
index 1962c78a5..9edd9a34d 100644
--- a/spec/jobs/webhooks/instagram_events_job_spec.rb
+++ b/spec/jobs/webhooks/instagram_events_job_spec.rb
@@ -10,19 +10,10 @@ describe Webhooks::InstagramEventsJob do
end
let!(:account) { create(:account) }
- let(:return_object) do
- { name: 'Jane',
- id: 'Sender-id-1',
- account_id: instagram_messenger_inbox.account_id,
- profile_pic: 'https://chatwoot-assets.local/sample.png',
- username: 'some_user_name' }
- end
let!(:instagram_messenger_channel) { create(:channel_instagram_fb_page, account: account, instagram_id: 'chatwoot-app-user-id-1') }
let!(:instagram_messenger_inbox) { create(:inbox, channel: instagram_messenger_channel, account: account, greeting_enabled: false) }
-
let!(:instagram_channel) { create(:channel_instagram, account: account, instagram_id: 'chatwoot-app-user-id-1') }
let!(:instagram_inbox) { create(:inbox, channel: instagram_channel, account: account, greeting_enabled: false) }
-
# Combined message events into one helper
let(:message_events) do
{
@@ -37,6 +28,14 @@ describe Webhooks::InstagramEventsJob do
}
end
+ def return_object_for(sender_id)
+ { name: 'Jane',
+ id: sender_id,
+ account_id: instagram_messenger_inbox.account_id,
+ profile_pic: 'https://chatwoot-assets.local/sample.png',
+ username: 'some_user_name' }
+ end
+
describe '#perform' do
context 'when handling messaging events for Instagram via Facebook page' do
let(:fb_object) { double }
@@ -47,8 +46,9 @@ describe Webhooks::InstagramEventsJob do
it 'creates incoming message in the instagram inbox' do
allow(Koala::Facebook::API).to receive(:new).and_return(fb_object)
+ sender_id = message_events[:dm][:entry][0][:messaging][0][:sender][:id]
allow(fb_object).to receive(:get_object).and_return(
- return_object.with_indifferent_access
+ return_object_for(sender_id).with_indifferent_access
)
instagram_webhook.perform_now(message_events[:dm][:entry])
@@ -63,8 +63,9 @@ describe Webhooks::InstagramEventsJob do
it 'creates standby message in the instagram inbox' do
allow(Koala::Facebook::API).to receive(:new).and_return(fb_object)
+ sender_id = message_events[:standby][:entry][0][:standby][0][:sender][:id]
allow(fb_object).to receive(:get_object).and_return(
- return_object.with_indifferent_access
+ return_object_for(sender_id).with_indifferent_access
)
instagram_webhook.perform_now(message_events[:standby][:entry])
@@ -82,10 +83,11 @@ describe Webhooks::InstagramEventsJob do
it 'handle instagram unsend message event' do
message = create(:message, inbox_id: instagram_messenger_inbox.id, source_id: 'message-id-to-delete')
allow(Koala::Facebook::API).to receive(:new).and_return(fb_object)
+ sender_id = message_events[:unsend][:entry][0][:messaging][0][:sender][:id]
allow(fb_object).to receive(:get_object).and_return(
{
name: 'Jane',
- id: 'Sender-id-1',
+ id: sender_id,
account_id: instagram_messenger_inbox.account_id,
profile_pic: 'https://chatwoot-assets.local/sample.png'
}.with_indifferent_access
@@ -104,8 +106,9 @@ describe Webhooks::InstagramEventsJob do
it 'creates incoming message with attachments in the instagram inbox' do
allow(Koala::Facebook::API).to receive(:new).and_return(fb_object)
+ sender_id = message_events[:attachment][:entry][0][:messaging][0][:sender][:id]
allow(fb_object).to receive(:get_object).and_return(
- return_object.with_indifferent_access
+ return_object_for(sender_id).with_indifferent_access
)
instagram_webhook.perform_now(message_events[:attachment][:entry])
@@ -118,8 +121,9 @@ describe Webhooks::InstagramEventsJob do
it 'creates incoming message with attachments in the instagram inbox for story mention' do
allow(Koala::Facebook::API).to receive(:new).and_return(fb_object)
+ sender_id = message_events[:story_mention][:entry][0][:messaging][0][:sender][:id]
allow(fb_object).to receive(:get_object).and_return(
- return_object.with_indifferent_access,
+ return_object_for(sender_id).with_indifferent_access,
{ story:
{
mention: {
@@ -165,8 +169,9 @@ describe Webhooks::InstagramEventsJob do
it 'handles unsupported message' do
allow(Koala::Facebook::API).to receive(:new).and_return(fb_object)
+ sender_id = message_events[:unsupported][:entry][0][:messaging][0][:sender][:id]
allow(fb_object).to receive(:get_object).and_return(
- return_object.with_indifferent_access
+ return_object_for(sender_id).with_indifferent_access
)
instagram_webhook.perform_now(message_events[:unsupported][:entry])
@@ -184,19 +189,22 @@ describe Webhooks::InstagramEventsJob do
before do
instagram_channel.update(access_token: 'valid_instagram_token')
- stub_request(:get, %r{https://graph\.instagram\.com/v22\.0/Sender-id-1\?.*})
+ stub_request(:get, %r{https://graph\.instagram\.com/v22\.0/Sender-id-.*\?.*})
.to_return(
status: 200,
- body: {
- name: 'Jane',
- username: 'some_user_name',
- profile_pic: 'https://chatwoot-assets.local/sample.png',
- id: 'Sender-id-1',
- follower_count: 100,
- is_user_follow_business: true,
- is_business_follow_user: true,
- is_verified_user: false
- }.to_json,
+ body: proc { |request|
+ sender_id = request.uri.path.split('/').last.split('?').first
+ {
+ name: 'Jane',
+ username: 'some_user_name',
+ profile_pic: 'https://chatwoot-assets.local/sample.png',
+ id: sender_id,
+ follower_count: 100,
+ is_user_follow_business: true,
+ is_business_follow_user: true,
+ is_verified_user: false
+ }.to_json
+ },
headers: { 'Content-Type' => 'application/json' }
)
end
@@ -289,14 +297,15 @@ describe Webhooks::InstagramEventsJob do
stub_request(:get, %r{https://graph\.instagram\.com/v22\.0/.*\?.*})
.to_return(status: 401, body: { error: { message: 'No matching Instagram user', code: 9010 } }.to_json)
+ sender_id = message_events[:dm][:entry][0][:messaging][0][:sender][:id]
instagram_webhook.perform_now(message_events[:dm][:entry])
instagram_inbox.reload
expect(instagram_inbox.contacts.count).to be 1
- expect(instagram_inbox.contacts.last.name).to eq 'Unknown (IG: Sender-id-1)'
+ expect(instagram_inbox.contacts.last.name).to eq "Unknown (IG: #{sender_id})"
expect(instagram_inbox.contacts.last.contact_inboxes.count).to be 1
- expect(instagram_inbox.contacts.last.contact_inboxes.first.source_id).to eq 'Sender-id-1'
+ expect(instagram_inbox.contacts.last.contact_inboxes.first.source_id).to eq sender_id
expect(instagram_inbox.conversations.count).to eq 1
expect(instagram_inbox.messages.count).to eq 1
diff --git a/spec/rails_helper.rb b/spec/rails_helper.rb
index 616d62d28..bb8ea89e1 100644
--- a/spec/rails_helper.rb
+++ b/spec/rails_helper.rb
@@ -70,6 +70,7 @@ RSpec.configure do |config|
config.include SlackStubs
config.include FileUploadHelpers
config.include CsvSpecHelpers
+ config.include InstagramSpecHelpers
config.include Devise::Test::IntegrationHelpers, type: :request
config.include ActiveSupport::Testing::TimeHelpers
config.include ActionCable::TestHelper
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..769b1f080 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
@@ -71,9 +72,33 @@ describe Whatsapp::SendOnWhatsappService do
expect(message.reload.source_id).to eq('123456789')
end
+ it 'marks message as failed when template name is blank' do
+ processor = instance_double(Whatsapp::TemplateProcessorService)
+ allow(Whatsapp::TemplateProcessorService).to receive(:new).and_return(processor)
+ allow(processor).to receive(:call).and_return([nil, nil, nil, nil])
+
+ invalid_template_params = {
+ name: '',
+ namespace: 'test_namespace',
+ language: 'en_US',
+ category: 'UTILITY',
+ processed_params: { '1' => 'test' }
+ }
+
+ message = create(:message,
+ additional_attributes: { template_params: invalid_template_params },
+ conversation: conversation,
+ message_type: :outgoing)
+
+ described_class.new(message: message).perform
+
+ expect(message.reload.status).to eq('failed')
+ expect(message.reload.external_error).to eq('Template not found or invalid template name')
+ end
+
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 +132,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 +155,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 +200,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 +212,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/spec/support/instagram_spec_helpers.rb b/spec/support/instagram_spec_helpers.rb
new file mode 100644
index 000000000..651bff585
--- /dev/null
+++ b/spec/support/instagram_spec_helpers.rb
@@ -0,0 +1,20 @@
+module InstagramSpecHelpers
+ def create_instagram_contact_for_sender(sender_id, inbox)
+ contact = Contact.find_by(identifier: sender_id)
+ if contact.nil?
+ contact = create(:contact, identifier: sender_id, name: 'Jane Dae')
+ create(:contact_inbox, contact_id: contact.id, inbox_id: inbox.id, source_id: sender_id)
+ end
+ contact
+ end
+
+ def instagram_user_response_object_for(sender_id, account_id)
+ {
+ name: 'Jane',
+ id: sender_id,
+ account_id: account_id,
+ profile_pic: 'https://chatwoot-assets.local/sample.png',
+ username: 'some_user_name'
+ }
+ 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