From 7e093f3af807c7eda7c9e6accfa05a77cb4998feecd72f9e35c7cca5efa6e9a5 Mon Sep 17 00:00:00 2001 From: victor Date: Mon, 24 Aug 2026 19:05:48 -0300 Subject: [PATCH] =?UTF-8?q?Atualiza=C3=A7=C3=A3o=20das=20configura=C3=A7?= =?UTF-8?q?=C3=A3o=20de=20mesagens=20do=20Zap?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 74 ++++++++++++++ app/controllers/admin/contatos_controller.rb | 17 +++- app/models/contato.rb | 68 ++++++++++--- app/services/notificacao/cliente_whatsapp.rb | 6 ++ app/services/notificacao/despachante.rb | 3 +- app/services/notificacao/whatsapp.rb | 10 ++ app/views/admin/contatos/_form.html.erb | 97 ++++++++++++++++++- app/views/admin/contatos/index.html.erb | 15 ++- config/routes.rb | 7 +- ...24000008_add_grupo_whatsapp_to_contatos.rb | 27 ++++++ spec/models/contato_spec.rb | 47 +++++++++ spec/services/notificacao/whatsapp_spec.rb | 23 +++++ 12 files changed, 376 insertions(+), 18 deletions(-) create mode 100644 db/migrate/20260824000008_add_grupo_whatsapp_to_contatos.rb diff --git a/README.md b/README.md index 60dd942..42f9714 100644 --- a/README.md +++ b/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. + +--- + +
+👥 Envio para GRUPO do WhatsApp (24/08/2026) + +> ⚠️ **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. +``` + +
diff --git a/app/controllers/admin/contatos_controller.rb b/app/controllers/admin/contatos_controller.rb index 82d5d39..d24d0b6 100644 --- a/app/controllers/admin/contatos_controller.rb +++ b/app/controllers/admin/contatos_controller.rb @@ -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) diff --git a/app/models/contato.rb b/app/models/contato.rb index a385c77..480fcce 100644 --- a/app/models/contato.rb +++ b/app/models/contato.rb @@ -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 diff --git a/app/services/notificacao/cliente_whatsapp.rb b/app/services/notificacao/cliente_whatsapp.rb index aad0ebd..5c0581c 100644 --- a/app/services/notificacao/cliente_whatsapp.rb +++ b/app/services/notificacao/cliente_whatsapp.rb @@ -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 diff --git a/app/services/notificacao/despachante.rb b/app/services/notificacao/despachante.rb index 8ffb911..841cb27 100644 --- a/app/services/notificacao/despachante.rb +++ b/app/services/notificacao/despachante.rb @@ -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 diff --git a/app/services/notificacao/whatsapp.rb b/app/services/notificacao/whatsapp.rb index d46c7a6..1dffbd5 100644 --- a/app/services/notificacao/whatsapp.rb +++ b/app/services/notificacao/whatsapp.rb @@ -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? diff --git a/app/views/admin/contatos/_form.html.erb b/app/views/admin/contatos/_form.html.erb index 885a9c5..0f0a5ad 100644 --- a/app/views/admin/contatos/_form.html.erb +++ b/app/views/admin/contatos/_form.html.erb @@ -7,13 +7,25 @@ <% 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. %> +
+ <%= 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' %> +
+
<%= 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' %> +

Como aparece nas listas e em {{contato}}.

-
+
<%= 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 @@
+ <%# 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. %> +
+ <%= f.label :whatsapp_grupo_jid, 'Grupo do WhatsApp', class: 'block text-gray-400 text-sm mb-1' %> +
+ + +
+ <%= f.hidden_field :whatsapp_grupo_nome, id: 'grupo-nome' %> +

+ 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. +

+
+
<%= 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' %>
<% end %> + + diff --git a/app/views/admin/contatos/index.html.erb b/app/views/admin/contatos/index.html.erb index 624898a..12fa0bb 100644 --- a/app/views/admin/contatos/index.html.erb +++ b/app/views/admin/contatos/index.html.erb @@ -42,8 +42,19 @@ <% @contatos.each do |contato| %> - <%= contato.nome %> - <%= contato.telefone.presence || '—' %> + + <%= contato.nome %> + <% if contato.grupo_whatsapp? %> + grupo + <% end %> + + + <% if contato.grupo_whatsapp? %> + <%= contato.whatsapp_grupo_nome.presence || contato.whatsapp_grupo_jid %> + <% else %> + <%= contato.telefone.presence || '—' %> + <% end %> + <%= contato.email.presence || '—' %> <% if contato.grupo_contato %> diff --git a/config/routes.rb b/config/routes.rb index 0d925e6..ec50104 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -121,7 +121,12 @@ Rails.application.routes.draw do # 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. 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 member do post :disparar # gatilho `manual` — manda mensagem de verdade diff --git a/db/migrate/20260824000008_add_grupo_whatsapp_to_contatos.rb b/db/migrate/20260824000008_add_grupo_whatsapp_to_contatos.rb new file mode 100644 index 0000000..8ca374b --- /dev/null +++ b/db/migrate/20260824000008_add_grupo_whatsapp_to_contatos.rb @@ -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 diff --git a/spec/models/contato_spec.rb b/spec/models/contato_spec.rb index 7139c18..61fdce6 100644 --- a/spec/models/contato_spec.rb +++ b/spec/models/contato_spec.rb @@ -41,3 +41,50 @@ RSpec.describe Contato do 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 diff --git a/spec/services/notificacao/whatsapp_spec.rb b/spec/services/notificacao/whatsapp_spec.rb index a5174c4..6fb535a 100644 --- a/spec/services/notificacao/whatsapp_spec.rb +++ b/spec/services/notificacao/whatsapp_spec.rb @@ -33,3 +33,26 @@ RSpec.describe Notificacao::Whatsapp do expect(resposta.erro).to include('rede fora') 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