# app/services/notificacao/cliente_whatsapp.rb # # Cliente da ponte Baileys (container `whatsapp`). Substitui o Twilio como canal # padrão de WhatsApp. # # ⚠️ Canal NÃO OFICIAL: a ponte fala o protocolo do WhatsApp Web por engenharia # reversa. É gratuito e ilimitado, mas está fora dos Termos do WhatsApp e a # Meta pode banir o número. Por isso: chip dedicado, e o intervalo entre # mensagens é respeitado por quem chama (Notificacao::Despachante), não aqui. # # TIMEOUT em tudo: sem ele, uma ponte pendurada trava o worker Puma até o proxy # cortar — o mesmo motivo que levou o ClienteTwilio a configurar timeout. module Notificacao class ClienteWhatsapp TIMEOUT_ABERTURA = 5 TIMEOUT_LEITURA = 20 Resposta = Struct.new(:ok, :dados, :erro, keyword_init: true) do def ok? = ok end def self.padrao config = ConfiguracaoNotificacao.instancia new(url: config.baileys_url_efetiva, token: config.baileys_token_efetivo) end def initialize(url:, token:) @url = url.to_s.strip.chomp('/') @token = token.to_s end def configurado? @url.present? && @token.present? end # { conectado:, numero:, qr:, desde:, ultimo_erro: } def status get('/status') end def enviar(para:, texto:) post('/enviar', para: para, texto: texto) end # Grupos de que o número conectado participa. O id do grupo é opaco, então a # tela precisa desta lista para o ADM escolher pelo nome. def grupos get('/grupos') end def desconectar post('/logout') end private def get(caminho) requisitar(Net::HTTP::Get, caminho) end def post(caminho, **corpo) requisitar(Net::HTTP::Post, caminho, corpo) end def requisitar(classe, caminho, corpo = nil) return Resposta.new(ok: false, erro: 'Ponte do WhatsApp não configurada.') unless configurado? uri = URI.join("#{@url}/", caminho.delete_prefix('/')) req = classe.new(uri) req['Authorization'] = "Bearer #{@token}" req['Content-Type'] = 'application/json' req.body = corpo.to_json if corpo resposta = Net::HTTP.start(uri.hostname, uri.port, use_ssl: uri.scheme == 'https', open_timeout: TIMEOUT_ABERTURA, read_timeout: TIMEOUT_LEITURA) { |http| http.request(req) } dados = parse(resposta.body) if resposta.is_a?(Net::HTTPSuccess) Resposta.new(ok: true, dados: dados) else Resposta.new(ok: false, dados: dados, erro: mensagem_de_erro(resposta, dados)) end rescue StandardError => e # A ponte fora do ar não pode virar 500 na tela nem quebrar um fechamento. Rails.logger.error("[ClienteWhatsapp] #{e.class}: #{e.message}") Resposta.new(ok: false, erro: "#{e.class}: #{e.message}") end def parse(corpo) JSON.parse(corpo.to_s) rescue JSON::ParserError {} end def mensagem_de_erro(resposta, dados) return 'Token da ponte do WhatsApp inválido.' if resposta.code == '401' return 'WhatsApp desconectado — leia o QR code na tela.' if resposta.code == '503' dados['erro'].presence || "HTTP #{resposta.code}" end end end