# app/models/configuracao_notificacao.rb # # Configuração de envio (SMTP + WhatsApp/Twilio) editável pelo ADM na tela # /admin/configuracao_notificacao. Tabela SINGLETON: sempre uma linha só. # # HIERARQUIA: banco > .env. Enquanto a tela não estiver preenchida e marcada # como ativa, `smtp_settings` devolve nil e `credenciais_whatsapp` cai no ENV — # ou seja, o comportamento é idêntico ao que existia antes desta tela. class ConfiguracaoNotificacao < ApplicationRecord include AtributoCifrado self.table_name = 'configuracao_notificacoes' atributo_cifrado :smtp_password, :twilio_auth_token, :baileys_token AUTENTICACOES = %w[plain login cram_md5].freeze # Quem entrega o WhatsApp. `baileys` é a ponte por QR code (container # `whatsapp`), gratuita e ilimitada, porém NÃO OFICIAL — a Meta pode banir o # número. `twilio` é o canal oficial pago que existia antes. PROVEDORES_WHATSAPP = %w[baileys twilio].freeze BAILEYS_URL_PADRAO = 'http://whatsapp:3001'.freeze EMAIL_RE = /\A[^@\s]+@[^@\s]+\z/ REMETENTE_PADRAO = 'noreply@gade.com.br' before_validation :normalizar validates :smtp_address, presence: true, if: :smtp_ativo? validates :smtp_username, presence: true, if: :smtp_ativo? validates :smtp_port, numericality: { only_integer: true, greater_than: 0, less_than_or_equal_to: 65_535 } validates :smtp_autenticacao, inclusion: { in: AUTENTICACOES } validates :remetente_email, format: { with: EMAIL_RE }, allow_blank: true validates :email_admin, format: { with: EMAIL_RE }, allow_blank: true validate :senha_smtp_presente, if: :smtp_ativo? validates :whatsapp_provedor, inclusion: { in: PROVEDORES_WHATSAPP } validates :whatsapp_intervalo_segundos, numericality: { only_integer: true, greater_than_or_equal_to: 0, less_than_or_equal_to: 300 } # As credenciais do Twilio só são exigidas quando ele É o provedor escolhido. validates :twilio_account_sid, presence: true, if: :twilio_exigido? validates :twilio_from, presence: true, if: :twilio_exigido? validate :token_twilio_presente, if: :twilio_exigido? validate :baileys_configuravel, if: :baileys_exigido? # ── Singleton ─────────────────────────────────────────────── def self.instancia first || create! rescue ActiveRecord::RecordNotUnique # Dois workers Puma criando a primeira linha ao mesmo tempo. first rescue ActiveRecord::StatementInvalid, ActiveRecord::NoDatabaseError, ActiveRecord::ConnectionNotEstablished => e # Boot do container antes do db:migrate, assets:precompile, console sem banco… Rails.logger.warn("[ConfiguracaoNotificacao] tabela indisponível (#{e.class}) — usando .env") new end # ── Usados pelo ApplicationMailer: NUNCA podem estourar ───── # (um erro aqui derrubaria todo envio de e-mail, inclusive o reset de senha) def self.remetente instancia.remetente_formatado rescue StandardError => e Rails.logger.error("[ConfiguracaoNotificacao.remetente] #{e.class}: #{e.message}") ENV.fetch('SMTP_USERNAME', REMETENTE_PADRAO) end # nil => o ActionMailer faz `.merge({})` e mantém o que o # config/initializers/smtp.rb montou a partir do .env no boot. def self.opcoes_de_entrega instancia.smtp_settings rescue StandardError => e Rails.logger.error("[ConfiguracaoNotificacao.opcoes_de_entrega] #{e.class}: #{e.message}") nil end # ── SMTP ──────────────────────────────────────────────────── def smtp_pronto? smtp_ativo? && smtp_address.present? && smtp_port.to_i.positive? && smtp_username.present? && smtp_password.present? end def smtp_settings return nil unless smtp_pronto? porta = smtp_port.to_i base = { address: smtp_address, port: porta, user_name: smtp_username, password: smtp_password, domain: smtp_domain.presence || dominio_padrao, authentication: (smtp_autenticacao.presence || 'plain').to_sym, # Sem timeout, uma porta filtrada pelo firewall do VPS pendura o worker # Puma até o Nginx cortar — o botão "enviar teste" ficaria girando. open_timeout: 10, read_timeout: 10 } # 465 = SSL implícito; 587/25 = STARTTLS. base.merge(porta == 465 ? { ssl: true, tls: true } : { enable_starttls_auto: true }) end def remetente_formatado endereco = remetente_email.presence || smtp_username.presence || ENV.fetch('SMTP_USERNAME', REMETENTE_PADRAO) remetente_nome.present? ? %("#{remetente_nome}" <#{endereco}>) : endereco end def email_habilitado? email_notificacoes_ativo? end # ── WhatsApp: escolha de provedor ─────────────────────────── def baileys? = whatsapp_provedor.to_s == 'baileys' def twilio? = whatsapp_provedor.to_s == 'twilio' def twilio_exigido? = whatsapp_ativo? && twilio? def baileys_exigido? = whatsapp_ativo? && baileys? # URL e token da ponte: banco > .env, mesma hierarquia do resto da tela. # # `WHATSAPP_URL` é o nome atual — a tela e o .env.example falam em "ponte do # WhatsApp", não no nome da biblioteca. `BAILEYS_URL` continua sendo lido para # não quebrar quem já tem a variável antiga no .env em produção. def baileys_url_efetiva baileys_url.presence || ENV['WHATSAPP_URL'].presence || ENV['BAILEYS_URL'].presence || BAILEYS_URL_PADRAO end def baileys_token_efetivo baileys_token.presence || ENV['WHATSAPP_TOKEN'].presence end def baileys_pronto? whatsapp_ativo? && baileys? && baileys_token_efetivo.present? end # Segundos entre uma mensagem e a seguinte. Disparo em rajada para dezenas de # contatos é a forma mais rápida de o número ser banido no canal não oficial. def intervalo_envio whatsapp_intervalo_segundos.to_i.clamp(0, 300) end # ── WhatsApp / Twilio ─────────────────────────────────────── def whatsapp_pronto? whatsapp_ativo? && twilio_account_sid.present? && twilio_auth_token.present? && twilio_from.present? end def whatsapp_habilitado? return false unless whatsapp_ativo? baileys? ? baileys_pronto? : credenciais_whatsapp.present? end # Tudo-ou-nada: nunca mistura o SID do banco com o token do .env (a conta # seria outra e o Twilio devolveria 20003 sem explicação óbvia). def credenciais_whatsapp if whatsapp_pronto? { sid: twilio_account_sid, token: twilio_auth_token, from: self.class.canal(twilio_from) } elsif ENV['TWILIO_ACCOUNT_SID'].present? && ENV['TWILIO_AUTH_TOKEN'].present? { sid: ENV['TWILIO_ACCOUNT_SID'], token: ENV['TWILIO_AUTH_TOKEN'], from: self.class.canal(ENV['TWILIO_WHATSAPP_FROM']) } end end # ── Telefone ──────────────────────────────────────────────── # "11 920051157", "(11) 92005-1157", "011 92005-1157", "+55 11 92005-1157" # e "whatsapp:+5511920051157" → "+5511920051157". def self.normalizar_telefone(valor) return nil if valor.blank? d = valor.to_s.gsub(/\D/, '').sub(/\A0+/, '') # Só tira o 55 quando sobra número demais — senão quebraria o DDD 55 # (Santa Maria/RS), onde "55991234567" já é o número completo. d = d.sub(/\A55/, '') if d.length > 11 && d.start_with?('55') return nil unless d.length.between?(10, 11) "+55#{d}" end def self.canal(numero) e164 = normalizar_telefone(numero) return nil if e164.blank? "whatsapp:#{e164}" end private def dominio_padrao (remetente_email.presence || smtp_username).to_s.split('@').last.presence || 'localhost' end def normalizar self.smtp_address = smtp_address.to_s.strip.presence self.smtp_username = smtp_username.to_s.strip.presence self.smtp_domain = smtp_domain.to_s.strip.presence self.remetente_email = remetente_email.to_s.strip.downcase.presence self.remetente_nome = remetente_nome.to_s.strip.presence self.email_admin = email_admin.to_s.strip.downcase.presence self.twilio_account_sid = twilio_account_sid.to_s.strip.presence self.baileys_url = baileys_url.to_s.strip.chomp('/').presence self.whatsapp_provedor = whatsapp_provedor.presence || 'baileys' # Mantém o que o admin digitou se não der para normalizar — a validação de # presença acusa, e ele vê o próprio valor de volta no campo para corrigir. self.twilio_from = self.class.normalizar_telefone(twilio_from) || twilio_from.presence self.whatsapp_admin = self.class.normalizar_telefone(whatsapp_admin) || whatsapp_admin.presence end def baileys_configuravel return if baileys_token_efetivo.present? errors.add(:baileys_token, 'é obrigatório: defina WHATSAPP_TOKEN no .env (o mesmo do container `whatsapp`) ' \ 'ou preencha aqui.') end def senha_smtp_presente return if smtp_password.present? errors.add(:smtp_password, if smtp_password_cifrado.present? 'gravada está ilegível (o SECRET_KEY_BASE do servidor mudou?). Digite a senha novamente.' else 'é obrigatória quando o envio de e-mail está ativo' end) end def token_twilio_presente return if twilio_auth_token.present? errors.add(:twilio_auth_token, if twilio_auth_token_cifrado.present? 'gravado está ilegível (o SECRET_KEY_BASE do servidor mudou?). Cole o token novamente.' else 'é obrigatório quando o WhatsApp está ativo' end) end end