iachat/app/services/wuzapi/client.rb
Rodribm10 5e7447f1d8 fix(wuzapi): troca /chat/send/file por /chat/send/document pra PDF
Wuzapi (asternic) atual usa /chat/send/document pra arquivos. O endpoint
/chat/send/file não existe (404), então PDF nunca chegava — propagado
como ECONNRESET pelo proxy. Mantém os antigos como fallback.

Também trata Errno::ECONNRESET como ConnectionError no http handler pra
ativar a cadeia de fallback se voltar a acontecer.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 06:27:28 -03:00

456 lines
14 KiB
Ruby

require 'net/http'
require 'json'
class Wuzapi::Client # rubocop:disable Metrics/ClassLength
class Error < StandardError; end
class AuthenticationError < Error; end
class ConnectionError < Error; end
attr_reader :base_url
def initialize(base_url)
@base_url = normalize_url(base_url)
end
# Admin Endpoints (Use Authorization header)
def create_user(admin_token, name, user_token)
payload = { name: name, token: user_token }
request(:post, '/admin/users', payload, admin_auth_headers(admin_token))
end
def delete_user(admin_token, user_id)
request(:delete, "/admin/users/#{user_id}", nil, admin_auth_headers(admin_token))
end
# User Endpoints (Use token header)
def send_text(user_token, phone_number, body, **options)
# Payload MUST be Case-Sensitive: Key 'Phone' and 'Body'
payload = { 'Phone' => phone_number, 'Body' => body }.merge(options)
request(
:post,
'/chat/send/text',
payload,
user_auth_headers(user_token),
fallback_paths: ['/send/text'],
allow_base_fallback: true
)
end
def send_image(user_token, phone_number, base64_data, caption = nil)
# Some Wuzapi builds expect `Image` while older forks accepted `Body`.
# Send both for compatibility; `Image` is the official key in current docs.
payload = {
'Phone' => phone_number,
'Image' => base64_data,
'Body' => base64_data,
'Caption' => caption
}
request(
:post,
'/chat/send/image',
payload,
user_auth_headers(user_token),
fallback_paths: ['/send/image'],
allow_base_fallback: true
)
end
def send_file(user_token, phone_number, base64_data, filename)
payload = { 'Phone' => phone_number, 'Body' => base64_data, 'Filename' => filename }
# Wuzapi usa `/chat/send/document` pra PDFs/arquivos. As versões antigas
# tinham `/chat/send/file` — mantém como fallback pra compat.
request(
:post,
'/chat/send/document',
payload,
user_auth_headers(user_token),
fallback_paths: ['/send/document', '/chat/send/file', '/send/file'],
allow_base_fallback: true
)
end
def send_reaction(user_token, phone_number, message_id, emoji)
payload = { 'Phone' => phone_number, 'Body' => emoji, 'Id' => message_id }
request(
:post,
'/chat/react',
payload,
user_auth_headers(user_token),
fallback_paths: ['/send/react'],
allow_base_fallback: true
)
end
# Sends quick-reply buttons (up to 3 by WhatsApp limit).
def send_buttons(user_token, phone_number, text, buttons)
payload = {
Phone: phone_number,
Text: text,
Buttons: buttons.map { |b| { DisplayText: b[:text] || b['text'] } }
}
request(:post, '/chat/sendbuttons', payload, user_auth_headers(user_token))
end
# Sends a list menu (sections with rows).
def send_list(user_token, phone_number, text:, button_text:, sections:, footer: nil)
payload = {
Phone: phone_number,
Text: text,
ButtonText: button_text,
FooterText: footer,
Sections: build_list_sections(sections)
}.compact
request(:post, '/chat/sendlist', payload, user_auth_headers(user_token))
end
# Sends a single button that opens a URL when clicked.
def send_url_button(user_token, phone_number, text:, button_text:, url:)
payload = {
Phone: phone_number,
Text: text,
Buttons: [{ Type: 'url', DisplayText: button_text, Url: url }]
}
request(:post, '/chat/sendbuttons', payload, user_auth_headers(user_token))
end
def send_chat_presence(user_token, phone_number, state, media = nil)
# State: "composing" or "paused"
# Media: "audio" (optional)
payload = { 'Phone' => phone_number, 'State' => state }
payload['Media'] = media if media
request(
:post,
'/chat/presence',
payload,
user_auth_headers(user_token),
fallback_paths: ['/send/presence'],
allow_base_fallback: true
)
end
def download_media(user_token, media_url)
# Some WuzAPI versions use a dedicated download endpoint to proxy Meta CDN
payload = { 'URL' => media_url }
request(
:post,
'/chat/downloadimage',
payload,
user_auth_headers(user_token),
fallback_paths: ['/downloadimage'],
allow_base_fallback: true
)
end
def session_status(user_token)
request(:get, '/session/status', nil, user_auth_headers(user_token))
end
def get_qr_code(user_token)
request(:get, '/session/qr', nil, user_auth_headers(user_token))
end
def session_connect(user_token)
request(:post, '/session/connect', {}, user_auth_headers(user_token))
end
def session_disconnect(user_token)
request(:post, '/session/disconnect', nil, user_auth_headers(user_token))
end
def session_logout(user_token)
request(:get, '/session/logout', nil, user_auth_headers(user_token))
end
def set_webhook(user_token, webhook_url)
upsert_webhook(user_token, webhook_url, :post)
end
def update_webhook(user_token, webhook_url)
upsert_webhook(user_token, webhook_url, :put)
end
def get_webhook(user_token)
request(
:get,
'/webhook',
nil,
user_auth_headers(user_token),
fallback_paths: ['/webhook/get'],
allow_base_fallback: true
)
end
private
def normalize_url(url)
url.to_s.gsub(%r{/$}, '')
end
def admin_auth_headers(token)
{ 'Authorization' => token }
end
def user_auth_headers(token)
{ 'token' => token }
end
def request(method, path, payload, headers, fallback_paths: [], allow_base_fallback: false)
candidates = build_request_candidates(path, fallback_paths, allow_base_fallback)
errors = []
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)
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'))
end
def execute_http_request(method, target_base_url, path, payload, headers)
uri = URI.parse("#{target_base_url}#{path}")
http = Net::HTTP.new(uri.host, uri.port)
if uri.scheme == 'https'
http.use_ssl = true
http.verify_mode = OpenSSL::SSL::VERIFY_NONE
end
request_obj = build_http_request_object(method, uri.request_uri, payload, headers)
begin
http.request(request_obj)
rescue SocketError, Errno::ECONNREFUSED, Errno::ECONNRESET, Errno::EHOSTUNREACH, Errno::ENETUNREACH, Net::OpenTimeout, Net::ReadTimeout => e
# ECONNRESET surge quando proxy intermediário corta antes do servidor
# responder — tratar como ConnectionError pra ativar fallback de path/base.
raise ConnectionError, "Could not connect to Wuzapi: #{e.message}"
end
end
def normalize_path(path)
return '/' if path.blank?
path.start_with?('/') ? path : "/#{path}"
end
def alternate_base_url(url)
normalized = normalize_url(url)
if normalized.match?(%r{/api/?$})
normalized.gsub(%r{/api/?$}, '')
else
"#{normalized}/api"
end
end
def retryable_not_found?(error)
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)}"
return parse_success_response(response) if success_status?(response)
return raise_authentication_error(response) if auth_error_status?(response)
raise Error, "API Error: #{response.code} #{response.body}"
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
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
def build_list_sections(sections)
sections.map do |s|
{
Title: s[:title] || s['title'],
Rows: Array(s[:rows] || s['rows']).map do |r|
{
Title: r[:title] || r['title'],
Description: r[:description] || r['description'],
RowId: r[:row_id] || r['row_id']
}
end
}
end
end
end