Fix Wuzapi webhook handling
This commit is contained in:
parent
58f5ae6157
commit
ccc1bdf35f
@ -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.'
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 });
|
||||
};
|
||||
</script>
|
||||
|
||||
@ -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: <InboxName>_<Phone>
|
||||
# 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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -13,7 +13,7 @@ module Concerns::CaptainToolsHelpers
|
||||
#
|
||||
# @return [Array<Hash>] 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<String>] 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
|
||||
|
||||
@ -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
|
||||
@ -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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user