Atualização das configuração de mesagens do Zap

This commit is contained in:
2026-08-24 19:05:48 -03:00
parent 568f185d8c
commit 7e093f3af8
12 changed files with 376 additions and 18 deletions

View File

@@ -16,8 +16,20 @@ class Admin::ContatosController < ApplicationController
@contatos = escopo.ordenados
end
# GET /admin/contatos/grupos_whatsapp (JSON)
def grupos_whatsapp
authorize Contato, :create?
resposta = Notificacao::ClienteWhatsapp.padrao.grupos
if resposta.ok?
render json: { grupos: resposta.dados['grupos'] || [] }
else
render json: { erro: resposta.erro, grupos: [] }, status: :ok
end
end
def new
@contato = Contato.new(grupo_contato_id: params[:grupo_id])
@contato = Contato.new(grupo_contato_id: params[:grupo_id], tipo: params[:tipo].presence || 'pessoa')
authorize @contato
end
@@ -67,7 +79,8 @@ class Admin::ContatosController < ApplicationController
end
def contato_params
params.require(:contato).permit(:nome, :telefone, :email, :grupo_contato_id, :ativo, :observacao)
params.require(:contato).permit(:nome, :telefone, :email, :grupo_contato_id, :ativo,
:observacao, :tipo, :whatsapp_grupo_jid, :whatsapp_grupo_nome)
end
def registrar(detalhes)

View File

@@ -14,16 +14,31 @@ class Contato < ApplicationRecord
EMAIL_RE = /\A[^@\s]+@[^@\s]+\z/
# Um destinatário é uma PESSOA (telefone e/ou e-mail) ou um GRUPO do WhatsApp.
# O grupo entra aqui, e não em tabela própria, para herdar grupo interno,
# assinatura de eventos e log de envios sem duplicar essa máquina.
TIPOS = %w[pessoa grupo_whatsapp].freeze
# Identificador de grupo do WhatsApp: "120363012345678901@g.us".
JID_GRUPO_RE = /\A\d{5,}@g\.us\z/
validates :nome, presence: true
validates :tipo, inclusion: { in: TIPOS }
validates :email, format: { with: EMAIL_RE }, allow_blank: true
validate :telefone_ou_email
validate :telefone_valido
validate :telefone_ou_email, if: :pessoa?
validate :telefone_valido, if: :pessoa?
validate :grupo_identificado, if: :grupo_whatsapp?
before_validation :normalizar
scope :ativos, -> { where(ativo: true) }
scope :ordenados, -> { order(Arel.sql('LOWER(nome)')) }
scope :com_whatsapp, -> { where.not(telefone: [nil, '']) }
scope :pessoas, -> { where(tipo: 'pessoa') }
scope :grupos_whatsapp, -> { where(tipo: 'grupo_whatsapp') }
# "Tem WhatsApp" = número de pessoa OU identificador de grupo.
scope :com_whatsapp, -> {
where.not(telefone: [nil, '']).or(where.not(whatsapp_grupo_jid: [nil, '']))
}
scope :com_email, -> { where.not(email: [nil, '']) }
# Filtra pelo canal do disparo. 'ambos' = quem tiver qualquer um dos dois.
@@ -35,22 +50,53 @@ class Contato < ApplicationRecord
end
}
def whatsapp? = telefone.present?
def email? = email.present?
def pessoa? = tipo.to_s != 'grupo_whatsapp'
def grupo_whatsapp? = tipo.to_s == 'grupo_whatsapp'
def whatsapp? = destino_whatsapp.present?
def email? = email.present? && pessoa?
# Para onde o WhatsApp vai: o JID do grupo ou o número da pessoa. É o que a
# ponte recebe — ela aceita as duas formas.
def destino_whatsapp
grupo_whatsapp? ? whatsapp_grupo_jid.presence : telefone.presence
end
# {{contato}} na mensagem. Em grupo não existe "primeiro nome" — cortar
# "Motoristas SP" em "Motoristas" só empobreceria o texto.
def primeiro_nome
nome.to_s.split.first.to_s
grupo_whatsapp? ? nome.to_s : nome.to_s.split.first.to_s
end
private
def normalizar
self.nome = nome.to_s.strip.squeeze(' ').presence
self.email = email.to_s.strip.downcase.presence
# Mantém o que foi digitado se não der para normalizar — a validação acusa e
# o ADM vê o próprio valor de volta no campo para corrigir.
bruto = telefone.to_s.strip.presence
self.telefone = bruto && (ConfiguracaoNotificacao.normalizar_telefone(bruto) || bruto)
self.tipo = tipo.presence || 'pessoa'
if grupo_whatsapp?
# Grupo não tem telefone nem e-mail: limpar evita um cadastro meio pessoa,
# meio grupo, que mandaria a mesma mensagem duas vezes.
self.telefone = nil
self.email = nil
self.whatsapp_grupo_jid = whatsapp_grupo_jid.to_s.strip.presence
self.whatsapp_grupo_nome = whatsapp_grupo_nome.to_s.strip.presence
else
self.whatsapp_grupo_jid = nil
self.whatsapp_grupo_nome = nil
self.email = email.to_s.strip.downcase.presence
# Mantém o que foi digitado se não der para normalizar — a validação acusa
# e o ADM vê o próprio valor de volta no campo para corrigir.
bruto = telefone.to_s.strip.presence
self.telefone = bruto && (ConfiguracaoNotificacao.normalizar_telefone(bruto) || bruto)
end
end
def grupo_identificado
return if whatsapp_grupo_jid.to_s.match?(JID_GRUPO_RE)
errors.add(:whatsapp_grupo_jid,
'inválido: escolha um grupo da lista (o identificador termina em @g.us)')
end
def telefone_ou_email

View File

@@ -42,6 +42,12 @@ module Notificacao
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

View File

@@ -132,7 +132,8 @@ module Notificacao
# [[destino, contato, user], ...] sem repetir o mesmo número/e-mail — a mesma
# pessoa pode ser contato de um grupo E o envolvido no fato.
def destinos_whatsapp
lista = @evento.destinatarios('whatsapp').map { |c| [c.telefone, c, c.user] }
# `destino_whatsapp` resolve pessoa (número) ou grupo (JID) — ver Contato.
lista = @evento.destinatarios('whatsapp').map { |c| [c.destino_whatsapp, c, c.user] }
lista += envolvido_whatsapp
dedup(lista)
end

View File

@@ -11,6 +11,16 @@ module Notificacao
Resposta = ClienteWhatsapp::Resposta
def self.enviar(para:, texto:, config: ConfiguracaoNotificacao.instancia)
# Grupo do WhatsApp vem como JID pronto ("120363...@g.us") — não é
# telefone, não normaliza. Passa direto para a ponte, que aceita as duas
# formas.
if Contato::JID_GRUPO_RE.match?(para.to_s.strip)
return Resposta.new(ok: false, erro: 'O Twilio não envia para grupo do WhatsApp. ' \
'Troque o provedor para QR code.') unless config.baileys?
return via_baileys(para.to_s.strip, texto)
end
destino = ConfiguracaoNotificacao.normalizar_telefone(para)
return Resposta.new(ok: false, erro: 'Número inválido.') if destino.blank?

View File

@@ -7,13 +7,25 @@
</div>
<% end %>
<%# Pessoa x grupo do WhatsApp. O JavaScript no fim troca os blocos; sem JS a
página continua utilizável — os dois blocos ficam visíveis e o model limpa
o que não pertence ao tipo escolhido. %>
<div data-tipo-contato>
<%= f.label :tipo, 'Tipo de destinatário', class: 'block text-gray-400 text-sm mb-1' %>
<%= f.select :tipo,
[['Pessoa (número ou e-mail)', 'pessoa'], ['Grupo do WhatsApp', 'grupo_whatsapp']], {},
class: 'w-full bg-[#1a1a1a] border border-white/10 rounded-xl text-white text-sm px-3 py-2.5 focus:outline-none focus:border-orange-500',
id: 'contato-tipo' %>
</div>
<div>
<%= f.label :nome, class: 'block text-gray-400 text-sm mb-1' %>
<%= f.text_field :nome, required: true,
class: 'w-full bg-[#1a1a1a] border border-white/10 rounded-xl text-white text-sm px-3 py-2.5 focus:outline-none focus:border-orange-500' %>
<p class="text-xs text-gray-500 mt-1">Como aparece nas listas e em <code>{{contato}}</code>.</p>
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4" id="bloco-pessoa">
<div>
<%= f.label :telefone, 'WhatsApp', class: 'block text-gray-400 text-sm mb-1' %>
<%= f.text_field :telefone, placeholder: '(11) 92005-1157',
@@ -27,6 +39,33 @@
</div>
</div>
<%# O identificador do grupo é opaco ("120363…@g.us") — digitar na mão é pedir
erro. A lista vem da ponte, com o número já conectado. %>
<div id="bloco-grupo" data-url="<%= grupos_whatsapp_admin_contatos_path %>">
<%= f.label :whatsapp_grupo_jid, 'Grupo do WhatsApp', class: 'block text-gray-400 text-sm mb-1' %>
<div class="flex gap-2">
<select id="grupo-jid" name="contato[whatsapp_grupo_jid]"
class="flex-1 bg-[#1a1a1a] border border-white/10 rounded-xl text-white text-sm px-3 py-2.5 focus:outline-none focus:border-orange-500">
<% if contato.whatsapp_grupo_jid.present? %>
<option value="<%= contato.whatsapp_grupo_jid %>" selected>
<%= contato.whatsapp_grupo_nome.presence || contato.whatsapp_grupo_jid %>
</option>
<% else %>
<option value="">— carregue a lista —</option>
<% end %>
</select>
<button type="button" id="btn-carregar-grupos"
class="px-4 py-2.5 rounded-xl text-sm whitespace-nowrap bg-[#1a1a1a] text-gray-300 border border-white/10 hover:border-orange-500">
Buscar grupos
</button>
</div>
<%= f.hidden_field :whatsapp_grupo_nome, id: 'grupo-nome' %>
<p class="text-xs text-gray-500 mt-1" id="grupo-aviso">
Só aparecem grupos em que o número conectado já é membro. Grupo com "somente
administradores" recusa o envio se o número não for admin.
</p>
</div>
<div>
<%= f.label :grupo_contato_id, 'Grupo', class: 'block text-gray-400 text-sm mb-1' %>
<%= f.collection_select :grupo_contato_id, @grupos, :id, :nome, { include_blank: 'Sem grupo (não recebe nada)' },
@@ -50,3 +89,59 @@
<%= link_to 'Cancelar', admin_contatos_path, data: { turbo: false }, class: 'text-gray-400 hover:text-white text-sm' %>
</div>
<% end %>
<script>
(function () {
var tipo = document.getElementById('contato-tipo');
var pessoa = document.getElementById('bloco-pessoa');
var grupo = document.getElementById('bloco-grupo');
var select = document.getElementById('grupo-jid');
var oculto = document.getElementById('grupo-nome');
var aviso = document.getElementById('grupo-aviso');
var botao = document.getElementById('btn-carregar-grupos');
if (!tipo || !pessoa || !grupo) return;
function alternar() {
var ehGrupo = tipo.value === 'grupo_whatsapp';
pessoa.classList.toggle('hidden', ehGrupo);
grupo.classList.toggle('hidden', !ehGrupo);
}
tipo.addEventListener('change', alternar);
alternar();
// O nome do grupo é gravado junto com o id: a tela precisa mostrar algo
// legível mesmo com a ponte fora do ar (o id sozinho não diz nada).
select.addEventListener('change', function () {
var opcao = select.options[select.selectedIndex];
oculto.value = opcao ? opcao.dataset.nome || opcao.textContent.trim() : '';
});
botao.addEventListener('click', function () {
botao.disabled = true;
botao.textContent = 'Buscando…';
fetch(grupo.dataset.url, { headers: { Accept: 'application/json' } })
.then(function (r) { return r.json(); })
.then(function (dados) {
if (dados.erro) { aviso.textContent = dados.erro; return; }
if (!dados.grupos.length) {
aviso.textContent = 'Nenhum grupo encontrado — o número conectado não participa de nenhum.';
return;
}
select.innerHTML = '<option value="">— escolha —</option>';
dados.grupos.forEach(function (g) {
var o = document.createElement('option');
o.value = g.id;
o.dataset.nome = g.nome;
// Avisar ANTES sobre grupo restrito: sem isso o envio falharia e o
// erro só apareceria depois, no log.
var alerta = (g.somente_admin && !g.sou_admin) ? ' ⚠️ só admin escreve' : '';
o.textContent = g.nome + ' (' + g.participantes + ')' + alerta;
select.appendChild(o);
});
aviso.textContent = dados.grupos.length + ' grupo(s) encontrado(s).';
})
.catch(function () { aviso.textContent = 'Não foi possível falar com a ponte do WhatsApp.'; })
.finally(function () { botao.disabled = false; botao.textContent = 'Buscar grupos'; });
});
})();
</script>

View File

@@ -42,8 +42,19 @@
<tbody class="divide-y divide-white/5">
<% @contatos.each do |contato| %>
<tr class="hover:bg-white/5">
<td class="px-6 py-2.5 text-white"><%= contato.nome %></td>
<td class="px-4 py-2.5 text-gray-300 font-mono"><%= contato.telefone.presence || '—' %></td>
<td class="px-6 py-2.5 text-white">
<%= contato.nome %>
<% if contato.grupo_whatsapp? %>
<span class="ml-1 px-2 py-0.5 rounded-full bg-green-500/15 border border-green-500/30 text-xs text-green-300">grupo</span>
<% end %>
</td>
<td class="px-4 py-2.5 text-gray-300 font-mono text-xs">
<% if contato.grupo_whatsapp? %>
<span title="<%= contato.whatsapp_grupo_jid %>"><%= contato.whatsapp_grupo_nome.presence || contato.whatsapp_grupo_jid %></span>
<% else %>
<%= contato.telefone.presence || '—' %>
<% end %>
</td>
<td class="px-4 py-2.5 text-gray-300"><%= contato.email.presence || '—' %></td>
<td class="px-4 py-2.5">
<% if contato.grupo_contato %>