Atualização das configuração de mesagens do Zap
This commit is contained in:
74
README.md
74
README.md
@@ -2696,3 +2696,77 @@ Hoje: 7 blocos, cores fixas no renderizador, só texto.
|
|||||||
vazia). Juntos são baixo esforço e resolvem o que mais incomoda no uso diário.
|
vazia). Juntos são baixo esforço e resolvem o que mais incomoda no uso diário.
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary><strong>👥 Envio para GRUPO do WhatsApp (24/08/2026)</strong></summary>
|
||||||
|
|
||||||
|
> ⚠️ **STATUS: implementado, não executado.** 1 migration nova. Sintaxe conferida em `.rb`, `.erb`,
|
||||||
|
> no JavaScript do formulário e no `server.js`.
|
||||||
|
|
||||||
|
### 🎯 O caso de uso
|
||||||
|
Informe pontual para o **grupo de motoristas** da própria empresa, com o número já dentro do grupo.
|
||||||
|
É o oposto do padrão de spam — não é mensagem para desconhecido, é aviso interno.
|
||||||
|
|
||||||
|
### ⚙️ Por que não funcionava
|
||||||
|
Grupo no WhatsApp **não é telefone**: é um identificador próprio (`120363012345678901@g.us`). Três
|
||||||
|
pontos assumiam pessoa:
|
||||||
|
|
||||||
|
1. `jidDe()` no `server.js` colava `@s.whatsapp.net` nos dígitos — id de grupo nem passava.
|
||||||
|
2. A checagem `onWhatsApp()` (que evita mandar para número inexistente, um dos sinais que a Meta usa
|
||||||
|
para marcar conta de spam) **responde sobre números** e devolveria vazio para um grupo,
|
||||||
|
derrubando um envio válido.
|
||||||
|
3. `Contato` exigia telefone brasileiro válido.
|
||||||
|
|
||||||
|
### 🆕 Como ficou
|
||||||
|
**Contato ganhou tipo**: `pessoa` ou `grupo_whatsapp`. O grupo entra como Contato de propósito —
|
||||||
|
herda grupo interno, assinatura de eventos e log de envios. Tabela separada duplicaria essa máquina
|
||||||
|
inteira.
|
||||||
|
|
||||||
|
**A lista de grupos vem da ponte** (`GET /grupos` → `groupFetchAllParticipating`). O id é opaco;
|
||||||
|
digitar na mão é pedir erro. No formulário, "Buscar grupos" traz os nomes com a contagem de
|
||||||
|
participantes.
|
||||||
|
|
||||||
|
**Grupo restrito é avisado ANTES.** Se o grupo tem "somente administradores" e o número conectado
|
||||||
|
não é admin, a opção aparece com ⚠️ na lista — senão o envio falharia e o erro só apareceria depois,
|
||||||
|
no log.
|
||||||
|
|
||||||
|
**O nome do grupo é gravado junto com o id** (`whatsapp_grupo_nome`): a tela precisa mostrar algo
|
||||||
|
legível mesmo com a ponte fora do ar.
|
||||||
|
|
||||||
|
**Twilio recusa com mensagem clara.** O Twilio não envia para grupo; em vez de erro genérico, a
|
||||||
|
resposta diz para trocar o provedor para QR.
|
||||||
|
|
||||||
|
### ⚠️ Limites, ditos na tela
|
||||||
|
- O número **precisa já ser membro** do grupo. A ponte não entra em grupo.
|
||||||
|
- Grupo com "somente admins" recusa se o número não for admin.
|
||||||
|
- `{{contato}}` num grupo é o **nome inteiro** do grupo — cortar "Motoristas SP" em "Motoristas"
|
||||||
|
só empobreceria o texto.
|
||||||
|
- Cadastro não fica meio pessoa, meio grupo: ao escolher grupo, telefone e e-mail são limpos.
|
||||||
|
Senão a mesma mensagem sairia duas vezes.
|
||||||
|
|
||||||
|
### 📂 Arquivos
|
||||||
|
```
|
||||||
|
db/migrate/20260824000008_add_grupo_whatsapp_to_contatos.rb (NOVO)
|
||||||
|
whatsapp/server.js (jidDe aceita @g.us; GET /grupos; pula onWhatsApp em grupo)
|
||||||
|
app/models/contato.rb (tipo, destino_whatsapp, validações por tipo)
|
||||||
|
app/services/notificacao/whatsapp.rb (JID passa direto; Twilio recusa)
|
||||||
|
app/services/notificacao/cliente_whatsapp.rb (#grupos)
|
||||||
|
app/services/notificacao/despachante.rb (usa destino_whatsapp)
|
||||||
|
app/controllers/admin/contatos_controller.rb + rota (JSON de grupos, sob demanda)
|
||||||
|
app/views/admin/contatos/_form.html.erb (seletor de tipo + busca de grupos)
|
||||||
|
app/views/admin/contatos/index.html.erb (marca "grupo")
|
||||||
|
spec/models/contato_spec.rb, spec/services/notificacao/whatsapp_spec.rb
|
||||||
|
```
|
||||||
|
|
||||||
|
### ⏳ Pendente
|
||||||
|
```bash
|
||||||
|
docker compose up -d --build whatsapp # o server.js mudou
|
||||||
|
docker compose exec app bin/rails db:migrate
|
||||||
|
|
||||||
|
# Depois: Notificações → Contatos → Novo contato → Tipo "Grupo do WhatsApp"
|
||||||
|
# → "Buscar grupos" → escolher → pôr num grupo interno que assine o evento.
|
||||||
|
```
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|||||||
@@ -16,8 +16,20 @@ class Admin::ContatosController < ApplicationController
|
|||||||
@contatos = escopo.ordenados
|
@contatos = escopo.ordenados
|
||||||
end
|
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
|
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
|
authorize @contato
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -67,7 +79,8 @@ class Admin::ContatosController < ApplicationController
|
|||||||
end
|
end
|
||||||
|
|
||||||
def contato_params
|
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
|
end
|
||||||
|
|
||||||
def registrar(detalhes)
|
def registrar(detalhes)
|
||||||
|
|||||||
@@ -14,16 +14,31 @@ class Contato < ApplicationRecord
|
|||||||
|
|
||||||
EMAIL_RE = /\A[^@\s]+@[^@\s]+\z/
|
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 :nome, presence: true
|
||||||
|
validates :tipo, inclusion: { in: TIPOS }
|
||||||
validates :email, format: { with: EMAIL_RE }, allow_blank: true
|
validates :email, format: { with: EMAIL_RE }, allow_blank: true
|
||||||
validate :telefone_ou_email
|
validate :telefone_ou_email, if: :pessoa?
|
||||||
validate :telefone_valido
|
validate :telefone_valido, if: :pessoa?
|
||||||
|
validate :grupo_identificado, if: :grupo_whatsapp?
|
||||||
|
|
||||||
before_validation :normalizar
|
before_validation :normalizar
|
||||||
|
|
||||||
scope :ativos, -> { where(ativo: true) }
|
scope :ativos, -> { where(ativo: true) }
|
||||||
scope :ordenados, -> { order(Arel.sql('LOWER(nome)')) }
|
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, '']) }
|
scope :com_email, -> { where.not(email: [nil, '']) }
|
||||||
|
|
||||||
# Filtra pelo canal do disparo. 'ambos' = quem tiver qualquer um dos dois.
|
# Filtra pelo canal do disparo. 'ambos' = quem tiver qualquer um dos dois.
|
||||||
@@ -35,23 +50,54 @@ class Contato < ApplicationRecord
|
|||||||
end
|
end
|
||||||
}
|
}
|
||||||
|
|
||||||
def whatsapp? = telefone.present?
|
def pessoa? = tipo.to_s != 'grupo_whatsapp'
|
||||||
def email? = email.present?
|
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
|
def primeiro_nome
|
||||||
nome.to_s.split.first.to_s
|
grupo_whatsapp? ? nome.to_s : nome.to_s.split.first.to_s
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def normalizar
|
def normalizar
|
||||||
self.nome = nome.to_s.strip.squeeze(' ').presence
|
self.nome = nome.to_s.strip.squeeze(' ').presence
|
||||||
|
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
|
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
|
# Mantém o que foi digitado se não der para normalizar — a validação acusa
|
||||||
# o ADM vê o próprio valor de volta no campo para corrigir.
|
# e o ADM vê o próprio valor de volta no campo para corrigir.
|
||||||
bruto = telefone.to_s.strip.presence
|
bruto = telefone.to_s.strip.presence
|
||||||
self.telefone = bruto && (ConfiguracaoNotificacao.normalizar_telefone(bruto) || bruto)
|
self.telefone = bruto && (ConfiguracaoNotificacao.normalizar_telefone(bruto) || bruto)
|
||||||
end
|
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
|
def telefone_ou_email
|
||||||
return if telefone.present? || email.present?
|
return if telefone.present? || email.present?
|
||||||
|
|||||||
@@ -42,6 +42,12 @@ module Notificacao
|
|||||||
post('/enviar', para: para, texto: texto)
|
post('/enviar', para: para, texto: texto)
|
||||||
end
|
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
|
def desconectar
|
||||||
post('/logout')
|
post('/logout')
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -132,7 +132,8 @@ module Notificacao
|
|||||||
# [[destino, contato, user], ...] sem repetir o mesmo número/e-mail — a mesma
|
# [[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.
|
# pessoa pode ser contato de um grupo E o envolvido no fato.
|
||||||
def destinos_whatsapp
|
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
|
lista += envolvido_whatsapp
|
||||||
dedup(lista)
|
dedup(lista)
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -11,6 +11,16 @@ module Notificacao
|
|||||||
Resposta = ClienteWhatsapp::Resposta
|
Resposta = ClienteWhatsapp::Resposta
|
||||||
|
|
||||||
def self.enviar(para:, texto:, config: ConfiguracaoNotificacao.instancia)
|
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)
|
destino = ConfiguracaoNotificacao.normalizar_telefone(para)
|
||||||
return Resposta.new(ok: false, erro: 'Número inválido.') if destino.blank?
|
return Resposta.new(ok: false, erro: 'Número inválido.') if destino.blank?
|
||||||
|
|
||||||
|
|||||||
@@ -7,13 +7,25 @@
|
|||||||
</div>
|
</div>
|
||||||
<% end %>
|
<% 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>
|
<div>
|
||||||
<%= f.label :nome, class: 'block text-gray-400 text-sm mb-1' %>
|
<%= f.label :nome, class: 'block text-gray-400 text-sm mb-1' %>
|
||||||
<%= f.text_field :nome, required: true,
|
<%= 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' %>
|
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>
|
||||||
|
|
||||||
<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>
|
<div>
|
||||||
<%= f.label :telefone, 'WhatsApp', class: 'block text-gray-400 text-sm mb-1' %>
|
<%= f.label :telefone, 'WhatsApp', class: 'block text-gray-400 text-sm mb-1' %>
|
||||||
<%= f.text_field :telefone, placeholder: '(11) 92005-1157',
|
<%= f.text_field :telefone, placeholder: '(11) 92005-1157',
|
||||||
@@ -27,6 +39,33 @@
|
|||||||
</div>
|
</div>
|
||||||
</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>
|
<div>
|
||||||
<%= f.label :grupo_contato_id, 'Grupo', class: 'block text-gray-400 text-sm mb-1' %>
|
<%= 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)' },
|
<%= 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' %>
|
<%= link_to 'Cancelar', admin_contatos_path, data: { turbo: false }, class: 'text-gray-400 hover:text-white text-sm' %>
|
||||||
</div>
|
</div>
|
||||||
<% end %>
|
<% 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>
|
||||||
|
|||||||
@@ -42,8 +42,19 @@
|
|||||||
<tbody class="divide-y divide-white/5">
|
<tbody class="divide-y divide-white/5">
|
||||||
<% @contatos.each do |contato| %>
|
<% @contatos.each do |contato| %>
|
||||||
<tr class="hover:bg-white/5">
|
<tr class="hover:bg-white/5">
|
||||||
<td class="px-6 py-2.5 text-white"><%= contato.nome %></td>
|
<td class="px-6 py-2.5 text-white">
|
||||||
<td class="px-4 py-2.5 text-gray-300 font-mono"><%= contato.telefone.presence || '—' %></td>
|
<%= 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 text-gray-300"><%= contato.email.presence || '—' %></td>
|
||||||
<td class="px-4 py-2.5">
|
<td class="px-4 py-2.5">
|
||||||
<% if contato.grupo_contato %>
|
<% if contato.grupo_contato %>
|
||||||
|
|||||||
@@ -121,7 +121,12 @@ Rails.application.routes.draw do
|
|||||||
# singular igual ao plural para o Inflector (que não fala português), e aí
|
# singular igual ao plural para o Inflector (que não fala português), e aí
|
||||||
# o Rails sufixa o helper de index com `_index` — pegadinha silenciosa.
|
# o Rails sufixa o helper de index com `_index` — pegadinha silenciosa.
|
||||||
resources :grupos, except: [:show], controller: 'grupos_contato'
|
resources :grupos, except: [:show], controller: 'grupos_contato'
|
||||||
resources :contatos, except: [:show]
|
resources :contatos, except: [:show] do
|
||||||
|
# Grupos do WhatsApp de que o número conectado participa (JSON). Carregado
|
||||||
|
# sob demanda pelo form: se fosse no render da tela, uma ponte fora do ar
|
||||||
|
# faria o cadastro de contato inteiro esperar o timeout.
|
||||||
|
collection { get :grupos_whatsapp }
|
||||||
|
end
|
||||||
resources :eventos, except: [:show], controller: 'eventos_notificacao' do
|
resources :eventos, except: [:show], controller: 'eventos_notificacao' do
|
||||||
member do
|
member do
|
||||||
post :disparar # gatilho `manual` — manda mensagem de verdade
|
post :disparar # gatilho `manual` — manda mensagem de verdade
|
||||||
|
|||||||
27
db/migrate/20260824000008_add_grupo_whatsapp_to_contatos.rb
Normal file
27
db/migrate/20260824000008_add_grupo_whatsapp_to_contatos.rb
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
# Permite que um destinatário seja um GRUPO do WhatsApp, e não só uma pessoa.
|
||||||
|
#
|
||||||
|
# O grupo entra como Contato de propósito: assim ele herda tudo que já existe —
|
||||||
|
# pertence a um grupo de contatos, recebe os eventos que o grupo assina e cada
|
||||||
|
# envio fica registrado em notificacao_envios. Uma tabela separada duplicaria
|
||||||
|
# essa máquina inteira.
|
||||||
|
#
|
||||||
|
# `whatsapp_grupo_jid` guarda o identificador do WhatsApp (ex.:
|
||||||
|
# "120363012345678901@g.us"). NÃO é telefone: não tem DDD, não normaliza, e a
|
||||||
|
# validação de número não se aplica — daí a coluna própria em vez de reaproveitar
|
||||||
|
# `telefone`.
|
||||||
|
#
|
||||||
|
# ⚠️ Nome: `grupo_contato_id` é o GRUPO INTERNO (Diretoria, Motoristas...);
|
||||||
|
# `whatsapp_grupo_*` é o grupo lá no WhatsApp. Coisas diferentes.
|
||||||
|
class AddGrupoWhatsappToContatos < ActiveRecord::Migration[7.1]
|
||||||
|
def change
|
||||||
|
change_table :contatos, bulk: true do |t|
|
||||||
|
t.string :tipo, null: false, default: 'pessoa' # pessoa | grupo_whatsapp
|
||||||
|
t.string :whatsapp_grupo_jid
|
||||||
|
# Nome do grupo no momento do cadastro: o JID é opaco e a tela precisa
|
||||||
|
# mostrar algo legível mesmo com a ponte fora do ar.
|
||||||
|
t.string :whatsapp_grupo_nome
|
||||||
|
end
|
||||||
|
|
||||||
|
add_index :contatos, :whatsapp_grupo_jid
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -41,3 +41,50 @@ RSpec.describe Contato do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
RSpec.describe Contato, 'grupo do WhatsApp' do
|
||||||
|
let(:jid) { '120363012345678901@g.us' }
|
||||||
|
|
||||||
|
def grupo(**extra)
|
||||||
|
build(:contato, { tipo: 'grupo_whatsapp', nome: 'Motoristas SP',
|
||||||
|
whatsapp_grupo_jid: jid, telefone: nil }.merge(extra))
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'aceita grupo sem telefone nem e-mail' do
|
||||||
|
expect(grupo).to be_valid
|
||||||
|
expect(grupo.destino_whatsapp).to eq(jid)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'recusa identificador que não é de grupo' do
|
||||||
|
contato = grupo(whatsapp_grupo_jid: '5511920051157')
|
||||||
|
expect(contato).not_to be_valid
|
||||||
|
expect(contato.errors[:whatsapp_grupo_jid].join).to include('@g.us')
|
||||||
|
end
|
||||||
|
|
||||||
|
# Cadastro meio pessoa, meio grupo mandaria a mesma mensagem duas vezes.
|
||||||
|
it 'limpa telefone e e-mail ao virar grupo' do
|
||||||
|
contato = grupo(telefone: '11 92005-1157', email: 'x@y.com')
|
||||||
|
contato.validate
|
||||||
|
expect(contato.telefone).to be_nil
|
||||||
|
expect(contato.email).to be_nil
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'limpa o grupo ao voltar a ser pessoa' do
|
||||||
|
contato = build(:contato, tipo: 'pessoa', telefone: '11 92005-1157',
|
||||||
|
whatsapp_grupo_jid: jid, whatsapp_grupo_nome: 'Motoristas')
|
||||||
|
contato.validate
|
||||||
|
expect(contato.whatsapp_grupo_jid).to be_nil
|
||||||
|
end
|
||||||
|
|
||||||
|
# Cortar "Motoristas SP" em "Motoristas" no {{contato}} só empobreceria o texto.
|
||||||
|
it '{{contato}} usa o nome inteiro do grupo' do
|
||||||
|
expect(grupo.primeiro_nome).to eq('Motoristas SP')
|
||||||
|
expect(build(:contato, nome: 'Carlos Matheus').primeiro_nome).to eq('Carlos')
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'entra no canal whatsapp e fica fora do canal e-mail' do
|
||||||
|
salvo = grupo.tap(&:save!)
|
||||||
|
expect(Contato.com_canal('whatsapp')).to include(salvo)
|
||||||
|
expect(Contato.com_canal('email')).not_to include(salvo)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|||||||
@@ -33,3 +33,26 @@ RSpec.describe Notificacao::Whatsapp do
|
|||||||
expect(resposta.erro).to include('rede fora')
|
expect(resposta.erro).to include('rede fora')
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
RSpec.describe Notificacao::Whatsapp, 'grupo do WhatsApp' do
|
||||||
|
let(:jid) { '120363012345678901@g.us' }
|
||||||
|
|
||||||
|
it 'manda o JID cru para a ponte, sem tentar normalizar como telefone' do
|
||||||
|
config = ConfiguracaoNotificacao.new(whatsapp_ativo: true, whatsapp_provedor: 'baileys')
|
||||||
|
ponte = instance_double(Notificacao::ClienteWhatsapp,
|
||||||
|
enviar: Notificacao::ClienteWhatsapp::Resposta.new(ok: true))
|
||||||
|
allow(Notificacao::ClienteWhatsapp).to receive(:padrao).and_return(ponte)
|
||||||
|
|
||||||
|
expect(described_class.enviar(para: jid, texto: 'oi', config: config)).to be_ok
|
||||||
|
expect(ponte).to have_received(:enviar).with(para: jid, texto: 'oi')
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'recusa com mensagem clara quando o provedor é Twilio' do
|
||||||
|
config = ConfiguracaoNotificacao.new(whatsapp_ativo: true, whatsapp_provedor: 'twilio')
|
||||||
|
|
||||||
|
resposta = described_class.enviar(para: jid, texto: 'oi', config: config)
|
||||||
|
|
||||||
|
expect(resposta).not_to be_ok
|
||||||
|
expect(resposta.erro).to include('grupo')
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|||||||
Reference in New Issue
Block a user