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