From ccc1bdf35f19aa12ff868edf2721b676ee8a6fb7 Mon Sep 17 00:00:00 2001 From: Rodrigo Borba Date: Thu, 26 Feb 2026 10:49:00 -0300 Subject: [PATCH] Fix Wuzapi webhook handling --- .../v1/accounts/inboxes/wuzapi_controller.rb | 14 +- .../webhooks/whatsapp_controller.rb | 12 + .../dashboard/components-next/icon/Icon.vue | 6 +- app/models/channel/whatsapp.rb | 320 +++++++++--------- app/services/wuzapi/client.rb | 280 ++++++++++----- .../conversation/response_builder_job.rb | 18 +- .../models/concerns/captain_tools_helpers.rb | 8 +- .../inboxes/wuzapi_controller_spec.rb | 56 +++ .../webhooks/whatsapp_controller_spec.rb | 19 ++ 9 files changed, 480 insertions(+), 253 deletions(-) create mode 100644 spec/controllers/api/v1/accounts/inboxes/wuzapi_controller_spec.rb diff --git a/app/controllers/api/v1/accounts/inboxes/wuzapi_controller.rb b/app/controllers/api/v1/accounts/inboxes/wuzapi_controller.rb index 0007f1276..193b29686 100644 --- a/app/controllers/api/v1/accounts/inboxes/wuzapi_controller.rb +++ b/app/controllers/api/v1/accounts/inboxes/wuzapi_controller.rb @@ -41,7 +41,7 @@ class Api::V1::Accounts::Inboxes::WuzapiController < Api::V1::Accounts::BaseCont def connect # Trigger connection (if needed by Wuzapi flow) - + ensure_webhook_is_configured result = client.session_connect(user_token) render json: result rescue Wuzapi::Client::Error => e @@ -79,7 +79,7 @@ class Api::V1::Accounts::Inboxes::WuzapiController < Api::V1::Accounts::BaseCont def update_webhook # Re-calculate correct webhook URL from model - url = @inbox.channel.webhook_url + url = expected_webhook_url client.update_webhook(user_token, url) render json: { success: true, message: 'Webhook updated successfully', webhook_url: url } rescue Wuzapi::Client::Error => e @@ -115,6 +115,16 @@ class Api::V1::Accounts::Inboxes::WuzapiController < Api::V1::Accounts::BaseCont token end + def ensure_webhook_is_configured + client.set_webhook(user_token, expected_webhook_url) + rescue Wuzapi::Client::Error => e + Rails.logger.warn "Wuzapi webhook pre-connect setup failed for inbox #{@inbox.id}: #{e.message}" + end + + def expected_webhook_url + @expected_webhook_url ||= @inbox.callback_webhook_url.to_s.sub('/webhooks/whatsapp/+', '/webhooks/whatsapp/') + end + def already_connected?(status) if %w[CONNECTED inChat success].include?(status) Rails.logger.info 'Wuzapi is already connected. Skipping QR.' diff --git a/app/controllers/webhooks/whatsapp_controller.rb b/app/controllers/webhooks/whatsapp_controller.rb index 59459fe8b..37118b3fd 100644 --- a/app/controllers/webhooks/whatsapp_controller.rb +++ b/app/controllers/webhooks/whatsapp_controller.rb @@ -14,6 +14,12 @@ class Webhooks::WhatsappController < ActionController::API private def perform_whatsapp_events_job + if ignorable_wuzapi_status_event? + Rails.logger.info("Ignoring WuzAPI status broadcast event for #{params[:phone_number]}") + head :ok + return + end + perform_sync if params[:awaitResponse].present? return if performed? @@ -59,4 +65,10 @@ class Webhooks::WhatsappController < ActionController::API def normalize_phone(phone_number) phone_number.to_s.gsub(/\D/, '') end + + def ignorable_wuzapi_status_event? + params[:type].to_s == 'Message' && + (params.dig(:event, :Info, :Chat).to_s == 'status@broadcast' || + params.dig(:event, :Chat).to_s == 'status@broadcast') + end end diff --git a/app/javascript/dashboard/components-next/icon/Icon.vue b/app/javascript/dashboard/components-next/icon/Icon.vue index 2c0156b09..2a3fedb43 100644 --- a/app/javascript/dashboard/components-next/icon/Icon.vue +++ b/app/javascript/dashboard/components-next/icon/Icon.vue @@ -7,9 +7,13 @@ const props = defineProps({ const renderIcon = () => { if (!props.icon) return null; - if (typeof props.icon === 'function' || isVNode(props.icon)) { + if (isVNode(props.icon)) { return props.icon; } + if (typeof props.icon === 'function') { + const resolved = props.icon(); + return isVNode(resolved) ? resolved : h(props.icon); + } return h('span', { class: props.icon }); }; diff --git a/app/models/channel/whatsapp.rb b/app/models/channel/whatsapp.rb index e23507cac..e3676eee6 100644 --- a/app/models/channel/whatsapp.rb +++ b/app/models/channel/whatsapp.rb @@ -25,7 +25,7 @@ # index_channel_whatsapp_provider_connection (provider_connection) WHERE ((provider)::text = ANY ((ARRAY['baileys'::character varying, 'zapi'::character varying])::text[])) USING gin # -class Channel::Whatsapp < ApplicationRecord +class Channel::Whatsapp < ApplicationRecord # rubocop:disable Metrics/ClassLength include Channelable include Reauthorizable @@ -203,22 +203,11 @@ class Channel::Whatsapp < ApplicationRecord end def move_tokens_to_encrypted_attributes - if (provider == 'evolution') && provider_config['evolution_api_token'].present? - self.evolution_api_token = provider_config['evolution_api_token'] - provider_config.delete('evolution_api_token') - end - + move_evolution_token_to_encrypted_attribute return unless provider == 'wuzapi' - if provider_config['wuzapi_user_token'].present? - self.wuzapi_user_token = provider_config['wuzapi_user_token'] - provider_config.delete('wuzapi_user_token') - end - - return if provider_config['wuzapi_admin_token'].blank? - - self.wuzapi_admin_token = provider_config['wuzapi_admin_token'] - provider_config.delete('wuzapi_admin_token') + move_wuzapi_user_token_to_encrypted_attribute + move_wuzapi_admin_token_to_encrypted_attribute end def validate_provider_config @@ -226,53 +215,11 @@ class Channel::Whatsapp < ApplicationRecord end def perform_webhook_setup - if provider == 'wuzapi' - return if inbox.blank? + return setup_wuzapi_webhook if provider == 'wuzapi' + return setup_evolution_webhook if provider == 'evolution' + return provider_service.setup_channel_provider if provider_service.respond_to?(:setup_channel_provider) - base_url = provider_config['wuzapi_base_url'] - # Use encrypted token - user_token = wuzapi_user_token - - return if user_token.blank? - - # Construct Chatwoot Webhook URL - # Using standard route: /webhooks/whatsapp/:phone_number for WuzAPI as per fix - app_url = ENV['FRONTEND_URL'].presence || 'http://localhost:3000' - webhook_url = "#{app_url}/webhooks/whatsapp/#{phone_number}" - - begin - client = Wuzapi::Client.new(base_url) - client.set_webhook(user_token, webhook_url) - rescue StandardError => e - Rails.logger.error "Wuzapi Webhook Setup Failed: #{e.message}" - end - elsif provider == 'evolution' - return if inbox.blank? - - base_url = provider_config['evolution_base_url'] - api_token = evolution_api_token - - return if api_token.blank? - - app_url = ENV['FRONTEND_URL'].presence || 'http://localhost:3000' - webhook_url = "#{app_url}/webhooks/evolution/#{phone_number}" - - begin - client = EvolutionApi::Client.new(base_url, api_token) - instance_name = "Chatwoot_#{phone_number}" - client.set_webhook(instance_name, webhook_url) - rescue StandardError => e - Rails.logger.error "Evolution Webhook Setup Failed: #{e.message}" - end - elsif provider_service.respond_to?(:setup_channel_provider) - provider_service.setup_channel_provider - else - # 360Dialog / Cloud logic - business_account_id = provider_config['business_account_id'] - api_key = provider_config['api_key'] - - Whatsapp::WebhookSetupService.new(self, business_account_id, api_key).perform - end + setup_default_webhook end def teardown_webhooks @@ -291,79 +238,18 @@ class Channel::Whatsapp < ApplicationRecord return if provider_config['wuzapi_base_url'].blank? client = Wuzapi::Client.new(provider_config['wuzapi_base_url']) - - # 1. Try Logout (User Token) - if wuzapi_user_token.present? - begin - client.session_logout(wuzapi_user_token) - rescue StandardError => e - Rails.logger.warn "Wuzapi Logout Failed: #{e.message}" - end - - # 2. Try Disconnect (User Token) - begin - client.session_disconnect(wuzapi_user_token) - rescue StandardError => e - Rails.logger.warn "Wuzapi Disconnect Failed: #{e.message}" - end - end - - # 3. Last Resort: Delete User via Admin API (Global Token) - return unless wuzapi_admin_token.present? && provider_config['wuzapi_user_id'].present? - - begin - client.delete_user(wuzapi_admin_token, provider_config['wuzapi_user_id']) - rescue StandardError => e - Rails.logger.warn "Wuzapi Delete User Failed: #{e.message}" - end + disconnect_wuzapi_user_session(client) + delete_wuzapi_user_with_admin_token(client) end def provision_wuzapi_user return unless provider == 'wuzapi' && provider_config['auto_create_user'] return if wuzapi_user_token.present? - base_url = provider_config['wuzapi_base_url'] - # Use encrypted admin token admin_token = wuzapi_admin_token - - # Custom Name: _ - # Sanitize to allow only alphanumeric (Wuzapi limitations) - raw_name = inbox&.name || inbox_name_for_provisioning - sanitized_inbox_name = raw_name.to_s.gsub(/[^a-zA-Z0-9]/, '_') - prefix = (sanitized_inbox_name.presence || 'Chatwoot') - user_name = "#{prefix}_#{phone_number}" - - # Helper to attempt provision - attempt_provision = lambda do |url| - service = Wuzapi::ProvisioningService.new(url, admin_token) - service.provision(user_name) - end - - begin - result = attempt_provision.call(base_url) - rescue StandardError => e - Rails.logger.warn "Wuzapi Provisioning failed with URL #{base_url}: #{e.message}" - # Fallback: if url ends in /api, strip it and try again - if base_url.match?(%r{/api/?$}) - fallback_url = base_url.gsub(%r{/api/?$}, '') - Rails.logger.info "Retrying Wuzapi Provisioning with fallback URL: #{fallback_url}" - begin - result = attempt_provision.call(fallback_url) - # If success, update the config to use the working URL - provider_config['wuzapi_base_url'] = fallback_url - Rails.logger.info "Wuzapi Provisioning fallback successful. Updated base_url to #{fallback_url}" - rescue StandardError => retry_e - Rails.logger.error "Wuzapi Provisioning fallback also failed: #{retry_e.message}" - errors.add(:base, "Wuzapi Provisioning Failed: #{retry_e.message}") - throw(:abort) - end - else - errors.add(:base, "Wuzapi Provisioning Failed: #{e.message}") - throw(:abort) - end - end - - # Success handling + base_url = provider_config['wuzapi_base_url'] + user_name = build_wuzapi_user_name + result = provision_wuzapi_user_with_fallback(base_url, admin_token, user_name) provider_config['wuzapi_user_id'] = result[:wuzapi_user_id] self.wuzapi_user_token = result[:wuzapi_user_token] @@ -394,40 +280,11 @@ class Channel::Whatsapp < ApplicationRecord return unless provider == 'evolution' return if evolution_api_token.blank? - base_url = provider_config['evolution_base_url'] - token = evolution_api_token - instance_name = "Chatwoot_#{phone_number}" - begin - client = EvolutionApi::Client.new(base_url, token) - # Tenta criar a instância; se já existe, não tem problema fahar, usamos a mesma ou damos fetch no token - begin - client.create_instance(instance_name) - rescue StandardError => e - Rails.logger.warn "Evolution Create Instance failed (might already exist): #{e.message}" - end - - # Apply instances settings if present in provider_config - evolution_settings = provider_config['settings'] - if evolution_settings.is_a?(Hash) - begin - # Set settings (Always Online) - client.set_settings(instance_name, { - 'alwaysOnline' => evolution_settings['always_online'] == 'true' || evolution_settings['always_online'] == true - }) - - # Set instance settings (Reject Call, Read, groups, status) - client.set_instance_settings(instance_name, { - 'rejectCall' => evolution_settings['reject_call'] == 'true' || evolution_settings['reject_call'] == true, - 'readMessages' => evolution_settings['read_messages'] == 'true' || evolution_settings['read_messages'] == true, - 'ignoreGroups' => evolution_settings['ignore_groups'] == 'true' || evolution_settings['ignore_groups'] == true, - 'ignoreStatus' => evolution_settings['ignore_status'] == 'true' || evolution_settings['ignore_status'] == true - }) - rescue StandardError => e - Rails.logger.warn "Evolution Apply Settings failed: #{e.message}" - end - end - # Success: Store the instance ID + instance_name = "Chatwoot_#{phone_number}" + client = EvolutionApi::Client.new(provider_config['evolution_base_url'], evolution_api_token) + create_evolution_instance(client, instance_name) + apply_evolution_instance_settings(client, instance_name) provider_config['evolution_instance_id'] = instance_name rescue StandardError => e Rails.logger.error "Evolution Provisioning failed: #{e.message}" @@ -435,4 +292,147 @@ class Channel::Whatsapp < ApplicationRecord throw(:abort) end end + + def move_evolution_token_to_encrypted_attribute + return unless provider == 'evolution' + return if provider_config['evolution_api_token'].blank? + + self.evolution_api_token = provider_config['evolution_api_token'] + provider_config.delete('evolution_api_token') + end + + def move_wuzapi_user_token_to_encrypted_attribute + return if provider_config['wuzapi_user_token'].blank? + + self.wuzapi_user_token = provider_config['wuzapi_user_token'] + provider_config.delete('wuzapi_user_token') + end + + def move_wuzapi_admin_token_to_encrypted_attribute + return if provider_config['wuzapi_admin_token'].blank? + + self.wuzapi_admin_token = provider_config['wuzapi_admin_token'] + provider_config.delete('wuzapi_admin_token') + end + + def setup_wuzapi_webhook + return if inbox.blank? + return if wuzapi_user_token.blank? + + client = Wuzapi::Client.new(provider_config['wuzapi_base_url']) + client.set_webhook(wuzapi_user_token, wuzapi_webhook_url) + rescue StandardError => e + Rails.logger.error "Wuzapi Webhook Setup Failed: #{e.message}" + end + + def setup_evolution_webhook + return if inbox.blank? + return if evolution_api_token.blank? + + client = EvolutionApi::Client.new(provider_config['evolution_base_url'], evolution_api_token) + client.set_webhook("Chatwoot_#{phone_number}", evolution_webhook_url) + rescue StandardError => e + Rails.logger.error "Evolution Webhook Setup Failed: #{e.message}" + end + + def setup_default_webhook + business_account_id = provider_config['business_account_id'] + api_key = provider_config['api_key'] + Whatsapp::WebhookSetupService.new(self, business_account_id, api_key).perform + end + + def wuzapi_webhook_url + app_url = ENV['FRONTEND_URL'].presence || 'http://localhost:3000' + webhook_phone = phone_number.to_s.gsub(/\D/, '') + "#{app_url}/webhooks/whatsapp/#{webhook_phone}" + end + + def evolution_webhook_url + app_url = ENV['FRONTEND_URL'].presence || 'http://localhost:3000' + "#{app_url}/webhooks/evolution/#{phone_number}" + end + + def disconnect_wuzapi_user_session(client) + return if wuzapi_user_token.blank? + + safely_with_wuzapi_log('Logout') { client.session_logout(wuzapi_user_token) } + safely_with_wuzapi_log('Disconnect') { client.session_disconnect(wuzapi_user_token) } + end + + def delete_wuzapi_user_with_admin_token(client) + return unless wuzapi_admin_token.present? && provider_config['wuzapi_user_id'].present? + + safely_with_wuzapi_log('Delete User') do + client.delete_user(wuzapi_admin_token, provider_config['wuzapi_user_id']) + end + end + + def safely_with_wuzapi_log(action) + yield + rescue StandardError => e + Rails.logger.warn "Wuzapi #{action} Failed: #{e.message}" + end + + def build_wuzapi_user_name + raw_name = inbox&.name || inbox_name_for_provisioning + sanitized_inbox_name = raw_name.to_s.gsub(/[^a-zA-Z0-9]/, '_') + prefix = sanitized_inbox_name.presence || 'Chatwoot' + "#{prefix}_#{phone_number}" + end + + def provision_wuzapi_user_with_fallback(base_url, admin_token, user_name) + provision_wuzapi_user_for_url(base_url, admin_token, user_name) + rescue StandardError => e + handle_wuzapi_provision_failure(base_url, admin_token, user_name, e) + end + + def provision_wuzapi_user_for_url(url, admin_token, user_name) + service = Wuzapi::ProvisioningService.new(url, admin_token) + service.provision(user_name) + end + + def handle_wuzapi_provision_failure(base_url, admin_token, user_name, error) + Rails.logger.warn "Wuzapi Provisioning failed with URL #{base_url}: #{error.message}" + raise error unless base_url.match?(%r{/api/?$}) + + fallback_url = base_url.gsub(%r{/api/?$}, '') + Rails.logger.info "Retrying Wuzapi Provisioning with fallback URL: #{fallback_url}" + result = provision_wuzapi_user_for_url(fallback_url, admin_token, user_name) + provider_config['wuzapi_base_url'] = fallback_url + Rails.logger.info "Wuzapi Provisioning fallback successful. Updated base_url to #{fallback_url}" + result + rescue StandardError => e + Rails.logger.error "Wuzapi Provisioning fallback also failed: #{e.message}" + errors.add(:base, "Wuzapi Provisioning Failed: #{e.message}") + throw(:abort) + end + + def create_evolution_instance(client, instance_name) + client.create_instance(instance_name) + rescue StandardError => e + Rails.logger.warn "Evolution Create Instance failed (might already exist): #{e.message}" + end + + def apply_evolution_instance_settings(client, instance_name) + evolution_settings = provider_config['settings'] + return unless evolution_settings.is_a?(Hash) + + client.set_settings(instance_name, { 'alwaysOnline' => to_boolean(evolution_settings['always_online']) }) + client.set_instance_settings(instance_name, evolution_instance_settings_payload(evolution_settings)) + rescue StandardError => e + Rails.logger.warn "Evolution Apply Settings failed: #{e.message}" + end + + def evolution_instance_settings_payload(evolution_settings) + { + 'rejectCall' => to_boolean(evolution_settings['reject_call']), + 'readMessages' => to_boolean(evolution_settings['read_messages']), + 'ignoreGroups' => to_boolean(evolution_settings['ignore_groups']), + 'ignoreStatus' => to_boolean(evolution_settings['ignore_status']) + } + end + + def to_boolean(value) + value == true || value == 'true' + end end diff --git a/app/services/wuzapi/client.rb b/app/services/wuzapi/client.rb index 5a55d8418..3e49d5f38 100644 --- a/app/services/wuzapi/client.rb +++ b/app/services/wuzapi/client.rb @@ -1,7 +1,7 @@ require 'net/http' require 'json' -class Wuzapi::Client +class Wuzapi::Client # rubocop:disable Metrics/ClassLength class Error < StandardError; end class AuthenticationError < Error; end class ConnectionError < Error; end @@ -128,18 +128,22 @@ class Wuzapi::Client end def set_webhook(user_token, webhook_url) - # Wuzapi expects PascalCase keys 'WebhookURL' and 'Events' with 'All' per user verification. - payload = { 'WebhookURL' => webhook_url, 'Events' => ['All'] } - request(:post, '/webhook', payload, user_auth_headers(user_token)) + upsert_webhook(user_token, webhook_url, :post) end def update_webhook(user_token, webhook_url) - payload = { 'WebhookURL' => webhook_url, 'Events' => ['All'] } - request(:put, '/webhook', payload, user_auth_headers(user_token)) + upsert_webhook(user_token, webhook_url, :put) end def get_webhook(user_token) - request(:get, '/webhook', nil, user_auth_headers(user_token)) + request( + :get, + '/webhook', + nil, + user_auth_headers(user_token), + fallback_paths: ['/webhook/get'], + allow_base_fallback: true + ) end private @@ -157,38 +161,19 @@ class Wuzapi::Client end def request(method, path, payload, headers, fallback_paths: [], allow_base_fallback: false) - candidate_paths = [path, *Array(fallback_paths)].map { |p| normalize_path(p) }.uniq - candidate_bases = [base_url] - primary_path = candidate_paths.first - primary_base = candidate_bases.first - - if allow_base_fallback - alternative = alternate_base_url(base_url) - candidate_bases << alternative if alternative.present? && alternative != base_url - end - + candidates = build_request_candidates(path, fallback_paths, allow_base_fallback) errors = [] - candidate_bases.each do |candidate_base| - candidate_paths.each do |candidate_path| - response = execute_http_request(method, candidate_base, candidate_path, payload, headers) - if candidate_base != primary_base || candidate_path != primary_path - Rails.logger.info("Wuzapi fallback route worked base=#{candidate_base} path=#{candidate_path}") - end - return handle_response(response) - rescue Error => e - if retryable_not_found?(e) - errors << e - Rails.logger.warn("Wuzapi endpoint not found, trying fallback route base=#{candidate_base} path=#{candidate_path}") - next - end + candidates.each_with_index do |candidate, index| + return perform_candidate_request(method, payload, headers, candidate, index.zero?) + rescue Error => e + raise unless retryable_not_found?(e) - raise - rescue ConnectionError => e - errors << e - Rails.logger.warn("Wuzapi connection error on fallback route base=#{candidate_base} path=#{candidate_path}: #{e.message}") - next - end + errors << e + log_fallback_warning('endpoint not found', candidate, e.message) + rescue ConnectionError => e + errors << e + log_fallback_warning('connection error', candidate, e.message) end raise(errors.last || Error.new('Wuzapi request failed with unknown error')) @@ -203,21 +188,7 @@ class Wuzapi::Client http.verify_mode = OpenSSL::SSL::VERIFY_NONE end - request_obj = case method - when :get - Net::HTTP::Get.new(uri.request_uri) - when :post - Net::HTTP::Post.new(uri.request_uri) - when :put - Net::HTTP::Put.new(uri.request_uri) - when :delete - Net::HTTP::Delete.new(uri.request_uri) - end - - request_obj['Content-Type'] = 'application/json' - request_obj['Accept'] = 'application/json' - headers.each { |k, v| request_obj[k] = v } - request_obj.body = payload.to_json if payload + request_obj = build_http_request_object(method, uri.request_uri, payload, headers) begin http.request(request_obj) @@ -245,42 +216,189 @@ class Wuzapi::Client error.message.include?('API Error: 404') end + def upsert_webhook(user_token, webhook_url, preferred_method) + successful_request_without_verification = false + last_error = nil + + webhook_attempts(preferred_method, webhook_url).each do |attempt| + verification = request_and_verify_webhook(user_token, webhook_url, attempt) + return verification if webhook_matches?(verification, webhook_url) + + successful_request_without_verification = true if verification.blank? + rescue Error, ConnectionError => e + last_error = e + end + + return { 'success' => true, 'webhook' => webhook_url } if successful_request_without_verification + + raise(last_error || Error.new('Unable to configure webhook on Wuzapi')) + end + + def webhook_payload_candidates(webhook_url) + [ + { 'webhook' => webhook_url, 'events' => ['All'] }, + { 'webhook' => webhook_url, 'events' => %w[Message ReadReceipt Presence ChatPresence HistorySync] }, + { 'WebhookURL' => webhook_url, 'Events' => ['All'] }, + { 'WebhookURL' => webhook_url, 'Events' => %w[Message ReadReceipt Presence ChatPresence HistorySync] } + ] + end + + def safe_get_webhook(user_token) + get_webhook(user_token) + rescue Error, ConnectionError + nil + end + + def webhook_matches?(payload, expected_url) + return false if payload.blank? + + actual = extract_webhook_url(payload) + return false if actual.blank? + + normalize_webhook_url(actual) == normalize_webhook_url(expected_url) + end + + def extract_webhook_url(payload) + payload['webhook'] || + payload['WebhookURL'] || + payload['url'] || + payload.dig('data', 'webhook') || + payload.dig('data', 'WebhookURL') || + payload.dig('data', 'url') + end + + def normalize_webhook_url(url) + url.to_s.strip.delete_suffix('/') + end + def handle_response(response) Rails.logger.info "WUZAPI RAW RESPONSE: status=#{response.code} ct=#{response['content-type']} body=#{response.body.to_s.truncate(1000)}" - if response.code.to_i >= 200 && response.code.to_i < 300 - content_type = response['content-type'] || '' + return parse_success_response(response) if success_status?(response) + return raise_authentication_error(response) if auth_error_status?(response) - if content_type.include?('image/') - require 'base64' - base64_image = Base64.strict_encode64(response.body) - return { 'qrcode' => "data:#{content_type};base64,#{base64_image}" } - end + raise Error, "API Error: #{response.code} #{response.body}" + end - begin - body = JSON.parse(response.body) - # Normalize keys to 'qrcode' - # Check nested data object - if body['data'].is_a?(Hash) - found = body['data']['qrcode'] || body['data']['qr'] || body['data']['QRCode'] || body['data']['QR'] || body['data']['base64'] || body['data']['image'] - body['qrcode'] = found if found - # Check if data is the string itself - elsif body['data'].is_a?(String) && (body['data'].start_with?('data:') || body['data'].length > 50) - body['qrcode'] = body['data'] - end + def build_request_candidates(path, fallback_paths, allow_base_fallback) + candidate_paths = [path, *Array(fallback_paths)].map { |p| normalize_path(p) }.uniq + candidate_bases = [base_url] + if allow_base_fallback + alternative = alternate_base_url(base_url) + candidate_bases << alternative if alternative.present? && alternative != base_url + end - # Check root keys if still not found - body['qrcode'] = body['qr'] || body['QRCode'] || body['QR'] || body['base64'] || body['image'] unless body['qrcode'] - - return body - rescue JSON::ParserError - Rails.logger.warn "Wuzapi response parse error or non-JSON: #{response.body}" - return { 'raw_body' => response.body } - end - elsif response.code.to_i == 401 || response.code.to_i == 403 - raise AuthenticationError, "Authentication failed: #{response.code} #{response.body}" - else - raise Error, "API Error: #{response.code} #{response.body}" + candidate_bases.product(candidate_paths).map do |candidate_base, candidate_path| + { base: candidate_base, path: candidate_path } end end + + def perform_candidate_request(method, payload, headers, candidate, is_primary) + response = execute_http_request(method, candidate[:base], candidate[:path], payload, headers) + log_successful_fallback(candidate) unless is_primary + handle_response(response) + end + + def log_successful_fallback(candidate) + Rails.logger.info("Wuzapi fallback route worked base=#{candidate[:base]} path=#{candidate[:path]}") + end + + def log_fallback_warning(reason, candidate, details) + Rails.logger.warn("Wuzapi #{reason}, trying fallback route base=#{candidate[:base]} path=#{candidate[:path]} #{details}") + end + + def build_http_request_object(method, request_uri, payload, headers) + request_obj = http_request_class_for(method).new(request_uri) + request_obj['Content-Type'] = 'application/json' + request_obj['Accept'] = 'application/json' + headers.each { |k, v| request_obj[k] = v } + request_obj.body = payload.to_json if payload + request_obj + end + + def http_request_class_for(method) + { + get: Net::HTTP::Get, + post: Net::HTTP::Post, + put: Net::HTTP::Put, + delete: Net::HTTP::Delete + }.fetch(method) + end + + def parse_response_json(raw_body) + JSON.parse(raw_body) + rescue JSON::ParserError + Rails.logger.warn "Wuzapi response parse error or non-JSON: #{raw_body}" + { 'raw_body' => raw_body } + end + + def extract_qr_code(body) + nested = extract_nested_qr_code(body) + return nested if nested.present? + + body['qr'] || body['QRCode'] || body['QR'] || body['base64'] || body['image'] + end + + def extract_nested_qr_code(body) + return nil if body['data'].blank? + + return extract_qr_code_from_hash(body['data']) if body['data'].is_a?(Hash) + return body['data'] if body['data'].is_a?(String) && possible_qr_blob?(body['data']) + + nil + end + + def extract_qr_code_from_hash(data) + data['qrcode'] || data['qr'] || data['QRCode'] || data['QR'] || data['base64'] || data['image'] + end + + def possible_qr_blob?(value) + value.start_with?('data:') || value.length > 50 + end + + def webhook_attempts(preferred_method, webhook_url) + methods = [preferred_method, :put, :post].uniq + payloads = webhook_payload_candidates(webhook_url) + methods.product(payloads).map { |method, payload| { method: method, payload: payload } } + end + + def request_and_verify_webhook(user_token, _webhook_url, attempt) + request( + attempt[:method], + '/webhook', + attempt[:payload], + user_auth_headers(user_token), + fallback_paths: ['/webhook/set'], + allow_base_fallback: true + ) + safe_get_webhook(user_token) + end + + def success_status?(response) + code = response.code.to_i + code >= 200 && code < 300 + end + + def auth_error_status?(response) + [401, 403].include?(response.code.to_i) + end + + def parse_success_response(response) + content_type = response['content-type'] || '' + return image_success_response(response.body, content_type) if content_type.include?('image/') + + body = parse_response_json(response.body) + body['qrcode'] ||= extract_qr_code(body) + body + end + + def image_success_response(raw_body, content_type) + require 'base64' + base64_image = Base64.strict_encode64(raw_body) + { 'qrcode' => "data:#{content_type};base64,#{base64_image}" } + end + + def raise_authentication_error(response) + raise AuthenticationError, "Authentication failed: #{response.code} #{response.body}" + end end diff --git a/enterprise/app/jobs/captain/conversation/response_builder_job.rb b/enterprise/app/jobs/captain/conversation/response_builder_job.rb index ff94c10dd..e1f7096f7 100644 --- a/enterprise/app/jobs/captain/conversation/response_builder_job.rb +++ b/enterprise/app/jobs/captain/conversation/response_builder_job.rb @@ -100,13 +100,19 @@ class Captain::Conversation::ResponseBuilderJob < ApplicationJob def humanized_delay(response_text) return if response_text.blank? - # Simulação: ~45ms por caracter (digitadores rápidos / humanos focados) - typing_speed = 45 - target_delay = (response_text.length * typing_speed) / 1000.0 + text = response_text.to_s + words_count = text.scan(/\b[\p{L}\p{N}]+\b/u).size + chars_count = text.length + punctuation_pauses = text.count(',.!?;:') - # Limite Mínimo (2s) para não parecer robótico demais em palavras como "Sim". - # Limite Máximo (7s) para não prender a UI do chat por longo tempo dando ansiedade ao usuário - target_delay = target_delay.clamp(2.0, 7.0) + # Modela tempo de digitação de forma mais humana: + # - base por palavra (mais estável para textos longos), + # - ajuste por tamanho, + # - pequenas pausas por pontuação, + # - jitter para não repetir sempre o mesmo tempo. + base_time = (words_count * 0.32) + (chars_count * 0.01) + (punctuation_pauses * 0.18) + jitter = 0.85 + (rand * 0.35) + target_delay = (base_time * jitter).clamp(1.8, 18.0) elapsed_time = Time.zone.now - @start_time remaining_delay = target_delay - elapsed_time diff --git a/enterprise/app/models/concerns/captain_tools_helpers.rb b/enterprise/app/models/concerns/captain_tools_helpers.rb index 34133aac2..f152aea6f 100644 --- a/enterprise/app/models/concerns/captain_tools_helpers.rb +++ b/enterprise/app/models/concerns/captain_tools_helpers.rb @@ -13,7 +13,7 @@ module Concerns::CaptainToolsHelpers # # @return [Array] Array of tool hashes with :id, :title, :description, :icon def built_in_agent_tools - @built_in_agent_tools ||= load_agent_tools + load_agent_tools end # Resolves a tool class from a tool ID. @@ -22,7 +22,9 @@ module Concerns::CaptainToolsHelpers # @param tool_id [String] The snake_case tool identifier # @return [Class, nil] The tool class if found, nil if not resolvable def resolve_tool_class(tool_id) - class_name = "Captain::Tools::#{tool_id.classify}Tool" + # Use camelize instead of classify to avoid singularization issues + # (e.g. send_suite_images -> SendSuiteImagesTool). + class_name = "Captain::Tools::#{tool_id.to_s.camelize}Tool" class_name.safe_constantize end @@ -31,7 +33,7 @@ module Concerns::CaptainToolsHelpers # # @return [Array] Array of built-in tool IDs def built_in_tool_ids - @built_in_tool_ids ||= built_in_agent_tools.map { |tool| tool[:id] } + built_in_agent_tools.map { |tool| tool[:id] } end private diff --git a/spec/controllers/api/v1/accounts/inboxes/wuzapi_controller_spec.rb b/spec/controllers/api/v1/accounts/inboxes/wuzapi_controller_spec.rb new file mode 100644 index 000000000..74ad22a8b --- /dev/null +++ b/spec/controllers/api/v1/accounts/inboxes/wuzapi_controller_spec.rb @@ -0,0 +1,56 @@ +require 'rails_helper' + +RSpec.describe 'Wuzapi Inbox API', type: :request do + let(:account) { create(:account) } + let(:admin) { create(:user, account: account, role: :administrator) } + let(:wuzapi_client) { instance_double(Wuzapi::Client) } + + let!(:channel) do + create( + :channel_whatsapp, + account: account, + provider: 'wuzapi', + phone_number: '+5511999999999', + provider_config: { + 'wuzapi_base_url' => 'https://wuzapi.example.com', + 'auto_create_user' => false + }, + wuzapi_user_token: 'user-token', + wuzapi_admin_token: 'admin-token', + validate_provider_config: false, + sync_templates: false + ) + end + let(:inbox) { channel.inbox } + let(:headers) { admin.create_new_auth_token } + let(:expected_webhook_url) { inbox.callback_webhook_url.to_s.sub('/webhooks/whatsapp/+', '/webhooks/whatsapp/') } + + before do + allow(channel).to receive(:setup_webhooks) + allow(Wuzapi::Client).to receive(:new).and_return(wuzapi_client) + end + + describe 'POST /api/v1/accounts/:account_id/inboxes/:inbox_id/wuzapi/connect' do + it 'configures webhook before connecting the session' do + allow(wuzapi_client).to receive(:set_webhook).and_return({ 'success' => true }) + allow(wuzapi_client).to receive(:session_connect).and_return({ 'success' => true }) + + post "/api/v1/accounts/#{account.id}/inboxes/#{inbox.id}/wuzapi/connect", headers: headers, as: :json + + expect(response).to have_http_status(:ok) + expect(wuzapi_client).to have_received(:set_webhook).with('user-token', expected_webhook_url) + expect(wuzapi_client).to have_received(:session_connect).with('user-token') + end + end + + describe 'PUT /api/v1/accounts/:account_id/inboxes/:inbox_id/wuzapi/update_webhook' do + it 'updates webhook using inbox callback url' do + allow(wuzapi_client).to receive(:update_webhook).and_return({ 'success' => true }) + + put "/api/v1/accounts/#{account.id}/inboxes/#{inbox.id}/wuzapi/update_webhook", headers: headers, as: :json + + expect(response).to have_http_status(:ok) + expect(wuzapi_client).to have_received(:update_webhook).with('user-token', expected_webhook_url) + end + end +end diff --git a/spec/controllers/webhooks/whatsapp_controller_spec.rb b/spec/controllers/webhooks/whatsapp_controller_spec.rb index ad2e73c95..e26d80eb4 100644 --- a/spec/controllers/webhooks/whatsapp_controller_spec.rb +++ b/spec/controllers/webhooks/whatsapp_controller_spec.rb @@ -99,5 +99,24 @@ RSpec.describe 'Webhooks::WhatsappController', type: :request do expect(response).to have_http_status(:not_found) end end + + context 'when payload is WuzAPI status broadcast event' do + it 'returns ok without enqueuing job' do + allow(Webhooks::WhatsappEventsJob).to receive(:perform_later) + + post '/webhooks/whatsapp/556133712229', params: { + type: 'Message', + event: { + Info: { + Chat: 'status@broadcast', + Type: 'media' + } + } + } + + expect(Webhooks::WhatsappEventsJob).not_to have_received(:perform_later) + expect(response).to have_http_status(:ok) + end + end end end