From d454fb27323eaa0942a835a9d742cd0f1e13088b Mon Sep 17 00:00:00 2001 From: Milton Sosa Date: Tue, 19 May 2026 10:54:23 -0300 Subject: [PATCH 1/2] feat(agent-bot): render Typebot choice blocks as interactive buttons - bot_runtime_controller: read content_type + items from postback payload; build content_attributes when content_type is input_select - response_processor: extract_text_from_artifacts returns {text:, select:} hash, scanning all artifact parts for both text and select types - message_creator: create messages with content_type: input_select and content_attributes: {items:[...]} when structured select data is present - base_service: add interactive_body_text helper that strips trailing numbered list lines from message body before sending WhatsApp interactive payloads - evolution_go_service: use interactive_body_text for button and list payloads - evolution_service: use interactive_body_text for button and list payloads Backward-compatible: falls back to plain text when no structured data present --- .../webhooks/bot_runtime_controller.rb | 15 ++++++- app/services/agent_bots/message_creator.rb | 32 +++++++++------ app/services/agent_bots/response_processor.rb | 39 ++++++++++++++----- .../whatsapp/providers/base_service.rb | 21 +++++++++- .../providers/evolution_go_service.rb | 21 +++++++++- .../whatsapp/providers/evolution_service.rb | 21 +++++++++- 6 files changed, 122 insertions(+), 27 deletions(-) diff --git a/app/controllers/webhooks/bot_runtime_controller.rb b/app/controllers/webhooks/bot_runtime_controller.rb index 3987eb370..7e9c9b094 100644 --- a/app/controllers/webhooks/bot_runtime_controller.rb +++ b/app/controllers/webhooks/bot_runtime_controller.rb @@ -22,7 +22,20 @@ def postback return end - message = AgentBots::MessageCreator.new(agent_bot).create_bot_reply(content, conversation) + content_type = params[:content_type].presence || 'text' + raw_items = params[:items] + content_attributes = nil + + if content_type == 'input_select' && raw_items.present? + items = raw_items.map { |item| { title: item[:title].to_s, value: item[:value].to_s } } + content_attributes = { items: items } + end + + message = AgentBots::MessageCreator.new(agent_bot).create_bot_reply( + content, conversation, + content_type: content_type, + content_attributes: content_attributes + ) if message Rails.logger.info "[BotRuntime::Postback] Message created: #{message.id} conversation=#{conversation.display_id}" diff --git a/app/services/agent_bots/message_creator.rb b/app/services/agent_bots/message_creator.rb index 6f9303cf5..4576438f3 100644 --- a/app/services/agent_bots/message_creator.rb +++ b/app/services/agent_bots/message_creator.rb @@ -3,7 +3,7 @@ def initialize(agent_bot) @agent_bot = agent_bot end - def create_bot_reply(content, conversation, force: false) + def create_bot_reply(content, conversation, force: false, content_type: 'text', content_attributes: nil) return if content.blank? # If force is true, skip eligibility check (e.g., for final response after transfer) @@ -18,7 +18,7 @@ def create_bot_reply(content, conversation, force: false) end Rails.logger.info "[AgentBot HTTP] Creating bot reply in conversation #{conversation.id}" - create_message_with_fallback(content, conversation) + create_message_with_fallback(content, conversation, content_type: content_type, content_attributes: content_attributes) end private @@ -53,23 +53,26 @@ def conversation_eligible_for_bot_reply?(conversation) eligible end - def create_message_with_fallback(content, conversation) - create_direct_message(content, conversation) + def create_message_with_fallback(content, conversation, content_type:, content_attributes:) + create_direct_message(content, conversation, content_type: content_type, content_attributes: content_attributes) rescue StandardError => e log_creation_error(e) - create_message_with_builder(content, conversation) + create_message_with_builder(content, conversation, content_type: content_type, content_attributes: content_attributes) end - def create_direct_message(content, conversation) + def create_direct_message(content, conversation, content_type:, content_attributes:) message_attributes = { inbox: conversation.inbox, conversation: conversation, content: content, message_type: 'outgoing', sender: @agent_bot, - content_type: 'text' + content_type: content_type } + merged_content_attributes = {} + merged_content_attributes.merge!(content_attributes) if content_attributes.present? + # Check if send_as_reply is enabled in bot_config send_as_reply = @agent_bot.bot_config&.dig('send_as_reply') == true @@ -77,9 +80,11 @@ def create_direct_message(content, conversation) # OR if send_as_reply is enabled in bot_config if conversation.post_conversation? || send_as_reply reply_attributes = build_reply_attributes(conversation) - message_attributes[:content_attributes] = reply_attributes if reply_attributes.present? + merged_content_attributes.merge!(reply_attributes) if reply_attributes.present? end + message_attributes[:content_attributes] = merged_content_attributes if merged_content_attributes.present? + message = Message.create!(message_attributes) Rails.logger.info "[AgentBot HTTP] Successfully created message #{message.id}" @@ -94,8 +99,11 @@ def log_creation_error(error) Rails.logger.info '[AgentBot HTTP] Trying with MessageBuilder as fallback' end - def create_message_with_builder(content, conversation) - message_params = { content: content, message_type: 'outgoing' } + def create_message_with_builder(content, conversation, content_type:, content_attributes:) + message_params = { content: content, message_type: 'outgoing', content_type: content_type } + + merged_content_attributes = {} + merged_content_attributes.merge!(content_attributes) if content_attributes.present? # Check if send_as_reply is enabled in bot_config send_as_reply = @agent_bot.bot_config&.dig('send_as_reply') == true @@ -104,9 +112,11 @@ def create_message_with_builder(content, conversation) # OR if send_as_reply is enabled in bot_config if conversation.post_conversation? || send_as_reply reply_attributes = build_reply_attributes(conversation) - message_params[:content_attributes] = reply_attributes if reply_attributes.present? + merged_content_attributes.merge!(reply_attributes) if reply_attributes.present? end + message_params[:content_attributes] = merged_content_attributes if merged_content_attributes.present? + message = Messages::MessageBuilder.new(@agent_bot, conversation, message_params).perform Rails.logger.info "[AgentBot HTTP] MessageBuilder fallback successful: #{message.id}" Rails.logger.info "[AgentBot HTTP] Reply attributes: #{message.content_attributes.slice(:in_reply_to, :in_reply_to_external_id).inspect}" if conversation.post_conversation? || send_as_reply diff --git a/app/services/agent_bots/response_processor.rb b/app/services/agent_bots/response_processor.rb index 9be30de75..e2cb36294 100644 --- a/app/services/agent_bots/response_processor.rb +++ b/app/services/agent_bots/response_processor.rb @@ -43,14 +43,18 @@ def process_bot_response(parsed_response) artifacts = extract_artifacts(parsed_response) return unless artifacts - text_content = extract_text_from_artifacts(artifacts) + extracted = extract_content_from_artifacts(artifacts) + text_content = extracted[:text] return unless text_content conversation = AgentBots::ConversationFinder.new(@agent_bot, @payload).find_conversation return unless conversation + select_part = extracted[:select] + select_items = select_part&.dig('items') + # Check if text segmentation is enabled for this agent bot - if @agent_bot.text_segmentation_enabled && ['evo_ai_provider', 'n8n_provider'].include?(@agent_bot.bot_provider) + if select_items.blank? && @agent_bot.text_segmentation_enabled && ['evo_ai_provider', 'n8n_provider'].include?(@agent_bot.bot_provider) process_segmented_response(text_content, conversation) else # Process as a single message with signature @@ -59,13 +63,15 @@ def process_bot_response(parsed_response) # Try to create message normally first message_creator = AgentBots::MessageCreator.new(@agent_bot) - message = message_creator.create_bot_reply(final_content, conversation) + content_type = select_items.present? ? 'input_select' : 'text' + content_attributes = select_items.present? ? { items: select_items } : nil + message = message_creator.create_bot_reply(final_content, conversation, content_type: content_type, content_attributes: content_attributes) # If message creation failed (conversation not eligible, e.g., after transfer), # try to force create it anyway (for final responses after transfer) unless message Rails.logger.info "[AgentBot HTTP] Message creation failed (conversation not eligible), attempting force create..." - message = message_creator.create_bot_reply(final_content, conversation, force: true) + message = message_creator.create_bot_reply(final_content, conversation, force: true, content_type: content_type, content_attributes: content_attributes) end message @@ -79,12 +85,27 @@ def extract_artifacts(parsed_response) artifacts end - def extract_text_from_artifacts(artifacts) - artifact = artifacts.first - return unless artifact['parts']&.any? + def extract_content_from_artifacts(artifacts) + text = nil + select = nil + + artifacts.each do |artifact| + next unless artifact.is_a?(Hash) && artifact['parts'].is_a?(Array) + + artifact['parts'].each do |part| + next unless part.is_a?(Hash) + + if text.nil? && part['type'] == 'text' && part['text'].present? + text = part['text'] + end + + if select.nil? && part['type'] == 'select' + select = part + end + end + end - text_part = artifact['parts'].find { |p| p['type'] == 'text' } - text_part&.dig('text') + { text: text, select: select } end def process_segmented_response(text_content, conversation) diff --git a/app/services/whatsapp/providers/base_service.rb b/app/services/whatsapp/providers/base_service.rb index 56f734608..c521499e4 100644 --- a/app/services/whatsapp/providers/base_service.rb +++ b/app/services/whatsapp/providers/base_service.rb @@ -114,10 +114,27 @@ def create_payload_based_on_items(message) end end + def interactive_body_text(message) + content = html_to_whatsapp(message.content.to_s) + return content if content.blank? + + lines = content.split("\n") + removed_any = false + + while lines.any? && lines.last.strip.match?(/^\d+\.\s+\S/) + lines.pop + removed_any = true + end + + pruned = lines.join("\n").strip + return content unless removed_any + pruned.presence || content + end + def create_button_payload(message) buttons = create_buttons(message.content_attributes['items']) json_hash = { 'buttons' => buttons } - create_payload('button', message.content, JSON.generate(json_hash)) + create_payload('button', interactive_body_text(message), JSON.generate(json_hash)) end def create_list_payload(message) @@ -125,6 +142,6 @@ def create_list_payload(message) section1 = { 'rows' => rows } sections = [section1] json_hash = { :button => 'Choose an item', 'sections' => sections } - create_payload('list', message.content, JSON.generate(json_hash)) + create_payload('list', interactive_body_text(message), JSON.generate(json_hash)) end end diff --git a/app/services/whatsapp/providers/evolution_go_service.rb b/app/services/whatsapp/providers/evolution_go_service.rb index 2ffb8de2e..bbd55fd4e 100644 --- a/app/services/whatsapp/providers/evolution_go_service.rb +++ b/app/services/whatsapp/providers/evolution_go_service.rb @@ -208,6 +208,23 @@ def instance_name whatsapp_channel.provider_config['instance_name'] end + def interactive_body_text(message) + content = html_to_whatsapp(message.content.to_s) + return content if content.blank? + + lines = content.split("\n") + removed_any = false + + while lines.any? && lines.last.strip.match?(/^\d+\.\s+\S/) + lines.pop + removed_any = true + end + + pruned = lines.join("\n").strip + return content unless removed_any + pruned.presence || content + end + def send_interactive_message(phone_number, message) clean_number = phone_number.delete('+') items = message.content_attributes&.dig('items') || [] @@ -233,7 +250,7 @@ def send_button_message(clean_number, message, items) { type: 'reply', displayText: item['title'].to_s.truncate(20), id: item['value'].to_s } end - content = html_to_whatsapp(message.content.to_s) + content = interactive_body_text(message) body = { number: clean_number, @@ -265,7 +282,7 @@ def send_list_message(clean_number, message, items) { rowId: item['value'].to_s, title: item['title'].to_s.truncate(24), description: '' } end - content = html_to_whatsapp(message.content.to_s) + content = interactive_body_text(message) body = { number: clean_number, diff --git a/app/services/whatsapp/providers/evolution_service.rb b/app/services/whatsapp/providers/evolution_service.rb index 606615022..7c003d60b 100644 --- a/app/services/whatsapp/providers/evolution_service.rb +++ b/app/services/whatsapp/providers/evolution_service.rb @@ -297,6 +297,23 @@ def instance_name whatsapp_channel.provider_config['instance_name'] end + def interactive_body_text(message) + content = html_to_whatsapp(message.content.to_s) + return content if content.blank? + + lines = content.split("\n") + removed_any = false + + while lines.any? && lines.last.strip.match?(/^\d+\.\s+\S/) + lines.pop + removed_any = true + end + + pruned = lines.join("\n").strip + return content unless removed_any + pruned.presence || content + end + def send_interactive_message(phone_number, message) clean_number = phone_number.delete('+') items = message.content_attributes&.dig('items') || [] @@ -320,7 +337,7 @@ def send_button_message(clean_number, message, items) { type: 'reply', displayText: item['title'].to_s.truncate(20), id: item['value'].to_s } end - content = html_to_whatsapp(message.content.to_s) + content = interactive_body_text(message) body = { number: clean_number, @@ -348,7 +365,7 @@ def send_list_message(clean_number, message, items) { rowId: item['value'].to_s, title: item['title'].to_s.truncate(24), description: '' } end - content = html_to_whatsapp(message.content.to_s) + content = interactive_body_text(message) body = { number: clean_number, From 88edab14e9d7119f85ba17c94da7bc0ebed4c271 Mon Sep 17 00:00:00 2001 From: Milton Sosa Date: Tue, 19 May 2026 11:38:57 -0300 Subject: [PATCH 2/2] refactor(whatsapp): remove duplicate interactive_body_text from subclasses Both EvolutionGoService and EvolutionService overrode interactive_body_text with an implementation identical to the one in BaseService. Since both classes already inherit from BaseService, the overrides are redundant. Remove them and rely on the single definition in the superclass. --- .../whatsapp/providers/evolution_go_service.rb | 17 ----------------- .../whatsapp/providers/evolution_service.rb | 17 ----------------- 2 files changed, 34 deletions(-) diff --git a/app/services/whatsapp/providers/evolution_go_service.rb b/app/services/whatsapp/providers/evolution_go_service.rb index bbd55fd4e..43b4e0074 100644 --- a/app/services/whatsapp/providers/evolution_go_service.rb +++ b/app/services/whatsapp/providers/evolution_go_service.rb @@ -208,23 +208,6 @@ def instance_name whatsapp_channel.provider_config['instance_name'] end - def interactive_body_text(message) - content = html_to_whatsapp(message.content.to_s) - return content if content.blank? - - lines = content.split("\n") - removed_any = false - - while lines.any? && lines.last.strip.match?(/^\d+\.\s+\S/) - lines.pop - removed_any = true - end - - pruned = lines.join("\n").strip - return content unless removed_any - pruned.presence || content - end - def send_interactive_message(phone_number, message) clean_number = phone_number.delete('+') items = message.content_attributes&.dig('items') || [] diff --git a/app/services/whatsapp/providers/evolution_service.rb b/app/services/whatsapp/providers/evolution_service.rb index 7c003d60b..91a05e5f2 100644 --- a/app/services/whatsapp/providers/evolution_service.rb +++ b/app/services/whatsapp/providers/evolution_service.rb @@ -297,23 +297,6 @@ def instance_name whatsapp_channel.provider_config['instance_name'] end - def interactive_body_text(message) - content = html_to_whatsapp(message.content.to_s) - return content if content.blank? - - lines = content.split("\n") - removed_any = false - - while lines.any? && lines.last.strip.match?(/^\d+\.\s+\S/) - lines.pop - removed_any = true - end - - pruned = lines.join("\n").strip - return content unless removed_any - pruned.presence || content - end - def send_interactive_message(phone_number, message) clean_number = phone_number.delete('+') items = message.content_attributes&.dig('items') || []