Fix Wuzapi webhook handling

This commit is contained in:
Rodrigo Borba 2026-02-26 10:49:00 -03:00
parent 58f5ae6157
commit ccc1bdf35f
9 changed files with 480 additions and 253 deletions

View File

@ -41,7 +41,7 @@ class Api::V1::Accounts::Inboxes::WuzapiController < Api::V1::Accounts::BaseCont
def connect def connect
# Trigger connection (if needed by Wuzapi flow) # Trigger connection (if needed by Wuzapi flow)
ensure_webhook_is_configured
result = client.session_connect(user_token) result = client.session_connect(user_token)
render json: result render json: result
rescue Wuzapi::Client::Error => e rescue Wuzapi::Client::Error => e
@ -79,7 +79,7 @@ class Api::V1::Accounts::Inboxes::WuzapiController < Api::V1::Accounts::BaseCont
def update_webhook def update_webhook
# Re-calculate correct webhook URL from model # Re-calculate correct webhook URL from model
url = @inbox.channel.webhook_url url = expected_webhook_url
client.update_webhook(user_token, url) client.update_webhook(user_token, url)
render json: { success: true, message: 'Webhook updated successfully', webhook_url: url } render json: { success: true, message: 'Webhook updated successfully', webhook_url: url }
rescue Wuzapi::Client::Error => e rescue Wuzapi::Client::Error => e
@ -115,6 +115,16 @@ class Api::V1::Accounts::Inboxes::WuzapiController < Api::V1::Accounts::BaseCont
token token
end 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) def already_connected?(status)
if %w[CONNECTED inChat success].include?(status) if %w[CONNECTED inChat success].include?(status)
Rails.logger.info 'Wuzapi is already connected. Skipping QR.' Rails.logger.info 'Wuzapi is already connected. Skipping QR.'

View File

@ -14,6 +14,12 @@ class Webhooks::WhatsappController < ActionController::API
private private
def perform_whatsapp_events_job 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? perform_sync if params[:awaitResponse].present?
return if performed? return if performed?
@ -59,4 +65,10 @@ class Webhooks::WhatsappController < ActionController::API
def normalize_phone(phone_number) def normalize_phone(phone_number)
phone_number.to_s.gsub(/\D/, '') phone_number.to_s.gsub(/\D/, '')
end 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 end

View File

@ -7,9 +7,13 @@ const props = defineProps({
const renderIcon = () => { const renderIcon = () => {
if (!props.icon) return null; if (!props.icon) return null;
if (typeof props.icon === 'function' || isVNode(props.icon)) { if (isVNode(props.icon)) {
return 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 }); return h('span', { class: props.icon });
}; };
</script> </script>

View File

@ -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 # 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 Channelable
include Reauthorizable include Reauthorizable
@ -203,22 +203,11 @@ class Channel::Whatsapp < ApplicationRecord
end end
def move_tokens_to_encrypted_attributes def move_tokens_to_encrypted_attributes
if (provider == 'evolution') && provider_config['evolution_api_token'].present? move_evolution_token_to_encrypted_attribute
self.evolution_api_token = provider_config['evolution_api_token']
provider_config.delete('evolution_api_token')
end
return unless provider == 'wuzapi' return unless provider == 'wuzapi'
if provider_config['wuzapi_user_token'].present? move_wuzapi_user_token_to_encrypted_attribute
self.wuzapi_user_token = provider_config['wuzapi_user_token'] move_wuzapi_admin_token_to_encrypted_attribute
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')
end end
def validate_provider_config def validate_provider_config
@ -226,53 +215,11 @@ class Channel::Whatsapp < ApplicationRecord
end end
def perform_webhook_setup def perform_webhook_setup
if provider == 'wuzapi' return setup_wuzapi_webhook if provider == 'wuzapi'
return if inbox.blank? 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'] setup_default_webhook
# 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
end end
def teardown_webhooks def teardown_webhooks
@ -291,79 +238,18 @@ class Channel::Whatsapp < ApplicationRecord
return if provider_config['wuzapi_base_url'].blank? return if provider_config['wuzapi_base_url'].blank?
client = Wuzapi::Client.new(provider_config['wuzapi_base_url']) client = Wuzapi::Client.new(provider_config['wuzapi_base_url'])
disconnect_wuzapi_user_session(client)
# 1. Try Logout (User Token) delete_wuzapi_user_with_admin_token(client)
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
end end
def provision_wuzapi_user def provision_wuzapi_user
return unless provider == 'wuzapi' && provider_config['auto_create_user'] return unless provider == 'wuzapi' && provider_config['auto_create_user']
return if wuzapi_user_token.present? return if wuzapi_user_token.present?
base_url = provider_config['wuzapi_base_url']
# Use encrypted admin token
admin_token = wuzapi_admin_token admin_token = wuzapi_admin_token
base_url = provider_config['wuzapi_base_url']
# Custom Name: <InboxName>_<Phone> user_name = build_wuzapi_user_name
# Sanitize to allow only alphanumeric (Wuzapi limitations) result = provision_wuzapi_user_with_fallback(base_url, admin_token, 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')
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
provider_config['wuzapi_user_id'] = result[:wuzapi_user_id] provider_config['wuzapi_user_id'] = result[:wuzapi_user_id]
self.wuzapi_user_token = result[:wuzapi_user_token] self.wuzapi_user_token = result[:wuzapi_user_token]
@ -394,40 +280,11 @@ class Channel::Whatsapp < ApplicationRecord
return unless provider == 'evolution' return unless provider == 'evolution'
return if evolution_api_token.blank? return if evolution_api_token.blank?
base_url = provider_config['evolution_base_url']
token = evolution_api_token
instance_name = "Chatwoot_#{phone_number}"
begin begin
client = EvolutionApi::Client.new(base_url, token) instance_name = "Chatwoot_#{phone_number}"
# Tenta criar a instância; se já existe, não tem problema fahar, usamos a mesma ou damos fetch no token client = EvolutionApi::Client.new(provider_config['evolution_base_url'], evolution_api_token)
begin create_evolution_instance(client, instance_name)
client.create_instance(instance_name) apply_evolution_instance_settings(client, 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
provider_config['evolution_instance_id'] = instance_name provider_config['evolution_instance_id'] = instance_name
rescue StandardError => e rescue StandardError => e
Rails.logger.error "Evolution Provisioning failed: #{e.message}" Rails.logger.error "Evolution Provisioning failed: #{e.message}"
@ -435,4 +292,147 @@ class Channel::Whatsapp < ApplicationRecord
throw(:abort) throw(:abort)
end end
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 end

View File

@ -1,7 +1,7 @@
require 'net/http' require 'net/http'
require 'json' require 'json'
class Wuzapi::Client class Wuzapi::Client # rubocop:disable Metrics/ClassLength
class Error < StandardError; end class Error < StandardError; end
class AuthenticationError < Error; end class AuthenticationError < Error; end
class ConnectionError < Error; end class ConnectionError < Error; end
@ -128,18 +128,22 @@ class Wuzapi::Client
end end
def set_webhook(user_token, webhook_url) def set_webhook(user_token, webhook_url)
# Wuzapi expects PascalCase keys 'WebhookURL' and 'Events' with 'All' per user verification. upsert_webhook(user_token, webhook_url, :post)
payload = { 'WebhookURL' => webhook_url, 'Events' => ['All'] }
request(:post, '/webhook', payload, user_auth_headers(user_token))
end end
def update_webhook(user_token, webhook_url) def update_webhook(user_token, webhook_url)
payload = { 'WebhookURL' => webhook_url, 'Events' => ['All'] } upsert_webhook(user_token, webhook_url, :put)
request(:put, '/webhook', payload, user_auth_headers(user_token))
end end
def get_webhook(user_token) 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 end
private private
@ -157,38 +161,19 @@ class Wuzapi::Client
end end
def request(method, path, payload, headers, fallback_paths: [], allow_base_fallback: false) def request(method, path, payload, headers, fallback_paths: [], allow_base_fallback: false)
candidate_paths = [path, *Array(fallback_paths)].map { |p| normalize_path(p) }.uniq candidates = build_request_candidates(path, fallback_paths, allow_base_fallback)
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
errors = [] errors = []
candidate_bases.each do |candidate_base| candidates.each_with_index do |candidate, index|
candidate_paths.each do |candidate_path| return perform_candidate_request(method, payload, headers, candidate, index.zero?)
response = execute_http_request(method, candidate_base, candidate_path, payload, headers) rescue Error => e
if candidate_base != primary_base || candidate_path != primary_path raise unless retryable_not_found?(e)
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
raise errors << e
rescue ConnectionError => e log_fallback_warning('endpoint not found', candidate, e.message)
errors << e rescue ConnectionError => e
Rails.logger.warn("Wuzapi connection error on fallback route base=#{candidate_base} path=#{candidate_path}: #{e.message}") errors << e
next log_fallback_warning('connection error', candidate, e.message)
end
end end
raise(errors.last || Error.new('Wuzapi request failed with unknown error')) 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 http.verify_mode = OpenSSL::SSL::VERIFY_NONE
end end
request_obj = case method request_obj = build_http_request_object(method, uri.request_uri, payload, headers)
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
begin begin
http.request(request_obj) http.request(request_obj)
@ -245,42 +216,189 @@ class Wuzapi::Client
error.message.include?('API Error: 404') error.message.include?('API Error: 404')
end 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) def handle_response(response)
Rails.logger.info "WUZAPI RAW RESPONSE: status=#{response.code} ct=#{response['content-type']} body=#{response.body.to_s.truncate(1000)}" 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 return parse_success_response(response) if success_status?(response)
content_type = response['content-type'] || '' return raise_authentication_error(response) if auth_error_status?(response)
if content_type.include?('image/') raise Error, "API Error: #{response.code} #{response.body}"
require 'base64' end
base64_image = Base64.strict_encode64(response.body)
return { 'qrcode' => "data:#{content_type};base64,#{base64_image}" }
end
begin def build_request_candidates(path, fallback_paths, allow_base_fallback)
body = JSON.parse(response.body) candidate_paths = [path, *Array(fallback_paths)].map { |p| normalize_path(p) }.uniq
# Normalize keys to 'qrcode' candidate_bases = [base_url]
# Check nested data object if allow_base_fallback
if body['data'].is_a?(Hash) alternative = alternate_base_url(base_url)
found = body['data']['qrcode'] || body['data']['qr'] || body['data']['QRCode'] || body['data']['QR'] || body['data']['base64'] || body['data']['image'] candidate_bases << alternative if alternative.present? && alternative != base_url
body['qrcode'] = found if found end
# 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
# Check root keys if still not found candidate_bases.product(candidate_paths).map do |candidate_base, candidate_path|
body['qrcode'] = body['qr'] || body['QRCode'] || body['QR'] || body['base64'] || body['image'] unless body['qrcode'] { base: candidate_base, path: candidate_path }
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}"
end end
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 end

View File

@ -100,13 +100,19 @@ class Captain::Conversation::ResponseBuilderJob < ApplicationJob
def humanized_delay(response_text) def humanized_delay(response_text)
return if response_text.blank? return if response_text.blank?
# Simulação: ~45ms por caracter (digitadores rápidos / humanos focados) text = response_text.to_s
typing_speed = 45 words_count = text.scan(/\b[\p{L}\p{N}]+\b/u).size
target_delay = (response_text.length * typing_speed) / 1000.0 chars_count = text.length
punctuation_pauses = text.count(',.!?;:')
# Limite Mínimo (2s) para não parecer robótico demais em palavras como "Sim". # Modela tempo de digitação de forma mais humana:
# Limite Máximo (7s) para não prender a UI do chat por longo tempo dando ansiedade ao usuário # - base por palavra (mais estável para textos longos),
target_delay = target_delay.clamp(2.0, 7.0) # - 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 elapsed_time = Time.zone.now - @start_time
remaining_delay = target_delay - elapsed_time remaining_delay = target_delay - elapsed_time

View File

@ -13,7 +13,7 @@ module Concerns::CaptainToolsHelpers
# #
# @return [Array<Hash>] Array of tool hashes with :id, :title, :description, :icon # @return [Array<Hash>] Array of tool hashes with :id, :title, :description, :icon
def built_in_agent_tools def built_in_agent_tools
@built_in_agent_tools ||= load_agent_tools load_agent_tools
end end
# Resolves a tool class from a tool ID. # Resolves a tool class from a tool ID.
@ -22,7 +22,9 @@ module Concerns::CaptainToolsHelpers
# @param tool_id [String] The snake_case tool identifier # @param tool_id [String] The snake_case tool identifier
# @return [Class, nil] The tool class if found, nil if not resolvable # @return [Class, nil] The tool class if found, nil if not resolvable
def resolve_tool_class(tool_id) 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 class_name.safe_constantize
end end
@ -31,7 +33,7 @@ module Concerns::CaptainToolsHelpers
# #
# @return [Array<String>] Array of built-in tool IDs # @return [Array<String>] Array of built-in tool IDs
def 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 end
private private

View File

@ -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

View File

@ -99,5 +99,24 @@ RSpec.describe 'Webhooks::WhatsappController', type: :request do
expect(response).to have_http_status(:not_found) expect(response).to have_http_status(:not_found)
end end
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
end end