249 lines
9.9 KiB
Ruby
249 lines
9.9 KiB
Ruby
# 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
|