Docs›Comunicação›Chat em tempo real
Comunicação

Chat em tempo real

Chat 1:1/grupo estilo GTalk sobre Reverb: canais, eventos, flag em duas camadas.

Chat em tempo real

Chat interno estilo GTalk (janelas/dock flutuante) sobre Laravel Reverb, atrás de uma feature flag em duas camadas (deploy + preferência do dono). É um módulo separado do correio interno — mensageria 1:1/grupo de baixa latência, não substitui e-mail interno com pastas/rótulos.

Arquitetura (visão de 1 minuto)

Browser (tema notch)                          App Laravel
┌────────────────────────────┐                ┌───────────────────────────────────┐
│ ícone #chat-icon-btn        │  fetch api/chat │ App\Http\Controllers\Comm\         │
│ dock flutuante (engine.js + │ ──────────────▶ │   ChatApiController                │
│  variations.js + mad-chat.js│                 │     → App\Service\Comm\            │
│  → classe JS MadChat)       │                 │       ChatMessageService           │
│ window.Echo (reverb)        │ ◀── WebSocket ── │     → models App\Models\Comm\Chat* │
│   presence-chat              │  (proto Pusher) │     → broadcast(...)->toOthers()   │
│   private conversation.*     │                 └──────────────┬──────────────────┘
│   private user.*             │ ── /broadcasting/auth ────────▶ ChatBroadcastAuthController
└────────────────────────────┘                                  ▲ php artisan reverb:start
  • Dados: conexão comm, tabelas mad_comm_chat_*.
  • Transporte: Reverb (protocolo Pusher) + laravel-echo/pusher-js — builds UMD self-hosted em public/lib/echo/ (echo.iife.js, pusher.min.js). Sem npm/Vite no runtime do chat.
  • Identidade: session('userid') — o app não usa a facade Auth. Por isso /broadcasting/auth é uma rota custom (assina por sessão), não o padrão do Laravel.

Variáveis de ambiente (.env)

# Driver de broadcasting
BROADCAST_CONNECTION=reverb

# Flag de DEPLOY (gate de rotas/broadcasting). Fail-closed.
MAD_REVERB_CHAT_ENABLED=true

# Servidor Reverb (protocolo Pusher)
REVERB_APP_ID=<id>
REVERB_APP_KEY=<key>          # público (vai pro browser)
REVERB_APP_SECRET=<secret>    # SEGREDO — só server-side (assina os canais)
REVERB_HOST=127.0.0.1         # host que o BROWSER usa pra abrir o websocket
REVERB_PORT=8080
REVERB_SCHEME=http            # http | https (https em prod com TLS)
REVERB_SERVER_HOST=0.0.0.0    # bind do processo reverb:start
REVERB_SERVER_PORT=8080

REVERB_APP_SECRET nunca vai para o browser — o cliente recebe só key/host/port/scheme via ChatService::getReverbClientConfig().

Switch em duas camadas

App\Service\Comm\ChatService::isEnabled() exige as duas:

  1. Deploy: config('mad.reverb_chat.enabled') (env MAD_REVERB_CHAT_ENABLED) — registra as rotas api/chat/* e /broadcasting/auth.
  2. Dono: preferência internal_chat = 'T' — switch in-app em Admin → Preferências → Chat interno (App\Control\Sys\PreferenceForm, /app/admin/preferencias).
php artisan tinker --execute='\App\Models\Sys\Preference::updateOrCreate(["id"=>"internal_chat"],["preference"=>"T"]);'

Outras preferências de visibilidade (opcionais, lidas por ChatService::getUsersQuery()):

  • internal_chat_unauthorized_groups — grupos que não veem o chat.
  • internal_chat_unauthorized_users — usuários bloqueados individualmente.

Rollback instantâneo: MAD_REVERB_CHAT_ENABLED=false ou internal_chat != 'T'.

Subir o servidor Reverb

reverb:start é um processo long-running separado do php artisan serve/PHP-FPM — não nasce nem morre com a request, precisa de supervisor em produção.

# terminal 1 — app
php artisan serve --host=127.0.0.1 --port=8911
# terminal 2 — websocket
php artisan reverb:start --host=0.0.0.0 --port=8080

Em produção, systemd (/etc/systemd/system/mad-reverb.service) ou Supervisor com reverb:start --host=0.0.0.0 --port=8080, Restart=always.

Os eventos do chat são ShouldBroadcastNow (síncronos) — não precisa de queue:work rodando para o chat funcionar.

Contrato dos canais

Canal Tipo Quem pode assinar Pra quê
presence-chat presence qualquer logado roster online/offline
conversation.{id} private só participante ativo da conversa mensagens, recibo, reação, "digitando"
user.{id} private só o próprio usuário push de conversa nova (ConversationCreated)

No front (Echo): Echo.join('chat') → presence-chat; Echo.private('conversation.'+id); Echo.private('user.'+meId).

Eventos (broadcast) — app/Events/

Todos ShouldBroadcastNow. Nome do evento = FQCN (Echo namespace App.Events).

Evento Canal ->toOthers()
MessageSent conversation.{id} sim
MessageRead conversation.{id} sim
ReactionToggled conversation.{id} —
ConversationCreated user.{id} (cada participante) —
typing (whisper, client→client) conversation.{id} —

->toOthers() + X-Socket-Id: o front manda X-Socket-Id (de echo.socketId()) em todo POST. MessageSent usa ->toOthers() para que a aba que enviou não receba o próprio eco (não duplica com a renderização otimista); outras abas do mesmo usuário recebem via Reverb e renderizam 1 vez (dedup por id no front). Não filtre por from === me no cliente.

// App\Service\Comm\ChatMessageService::send()
broadcast(new MessageSent($msg))->toOthers();

Bridge /broadcasting/auth (por sessão, sem guard)

O /broadcasting/auth padrão do Laravel resolve $user por guard de Auth — este app não tem guard (identidade = session('userid')). Por isso:

  • bootstrap/app.php não passa channels: no withRouting (senão monta o auth padrão do Laravel).
  • Rota custom em routes/modules/communication.php (atrás da flag): POST /broadcasting/auth → App\Http\Controllers\Comm\ChatBroadcastAuthController::authenticate.
  • O controller re-deriva a autorização do banco a cada request e assina o token com Pusher\Pusher:
    • private-conversation.{id} → ChatParticipant::isParticipant($id, $userId).
    • private-user.{id} → $id === $userId.
    • presence-chat → logado ok; channel_data = {user_id, user_info:{id,name}}.

routes/channels.php fica só como documentação (superseded) — closures Broadcast::channel() ali não rodam.

A rota /broadcasting/auth também é usada pelo correio interno (sob a flag dele). Com as duas flags ligadas, a última registrada vence — o bloco do chat é registrado DEPOIS do bloco pessoal em routes/modules/communication.php (ordem intencional, comentada no topo do arquivo), então com ambas as flags on quem responde é o ChatBroadcastAuthController. Não há, hoje, namespacing de canal entre os dois módulos (dívida técnica conhecida, ver roadmap).

As notificações nativas não entram nessa disputa: elas têm endpoint próprio, POST /broadcasting/auth-notifications (NotificationBroadcastAuthController, atrás de config('mad.notifications.reverb')).

Degradação: ChatDegraded

Se a montagem da config falhar no render (banco fora, tabela ausente, preferência corrompida), o casco não devolve 500: ShellViewModel::chatConfig() captura o Throwable, loga [MadChat] chatConfig falhou — degradando chat p/ off e devolve o estado desligado (o MadChat.init vira no-op). Além do log, dispara o evento interno App\Events\ChatDegraded (reason, userId) — não é broadcast, é um gancho para monitoramento engatar um listener e alertar sem garimpar log.

Event::listen(\App\Events\ChatDegraded::class, function ($e) {
    // métrica / alerta
});

Coberto por tests/Feature/ChatRenderResilienceTest.php.

Frontend (sem build)

  • Assets do widget: app/templates/theme-notch/js/mad-chat/{engine,variations,mad-chat}.js
    • app/templates/theme-notch/css/mad-chat.css, registrados em resources/views/shell/partials/libraries-builder.blade.php na ordem engine → variations → mad-chat (MadChat.init() roda internamente).
  • laravel-echo + pusher-js: UMD self-hosted em public/lib/echo/.
  • Config montada no servidor por App\Lib\Builder\ShellViewModel::chatConfig() (app/lib/builder/ShellViewModel.php) e emitida pela blade do casco via @json → MadTemplate.init({ chat: {...} }) → MadChat.init(options.chat) (no-op quando enabled !== true). O payload é {enabled, meId, users, conversations, reverb:{key,host,port,scheme,authEndpoint,csrf}} — só a key pública do Reverb, nunca o REVERB_APP_SECRET.

Desde a migração do casco para Blade não existem mais os tokens {CHAT_*}/{REVERB_*} nem o BuilderTemplateParser: quem monta a config é o ShellViewModel.

  • Classe pública: window.MadChat → init/disable/openConversation/startDm/startGroup.
  • Editou um asset do chat? Bumpe o ?appver= na tag correspondente (cache-bust) — ver o comentário no topo de libraries-builder.blade.php.

Endpoints REST (api/chat, web + mad.auth + flag)

GET    api/chat/conversations                 # lista
GET    api/chat/conversations/{id}            # uma conversa
GET    api/chat/conversations/{id}/messages   # histórico keyset (?before_id=&limit=)
POST   api/chat/conversations                 # {type:dm,user_id} | {type:group,name,user_ids[]}
POST   api/chat/conversations/{id}/messages   # body + attachments[] (multipart)
POST   api/chat/conversations/{id}/read       # {message_id}
POST   api/chat/messages/{id}/reactions       # {emoji}
GET    api/chat/attachment/{id}               # stream do anexo (autorizado por membership)

Todos os endpoints validam membership server-side (ChatParticipant::isParticipant) — o client nunca é fonte de verdade sobre quem participa de uma conversa.

Modelo de dados (conexão comm)

Tabela Model Papel
mad_comm_chat_conversation ChatConversation type (dm|group), name, created_by, last_message_id/last_message_at.
mad_comm_chat_participant ChatParticipant Membership por conversa: role, last_read_message_id, joined_at/left_at, muted.
mad_comm_chat_message ChatMessage conversation_id, user_id, type (text|system), body.
mad_comm_chat_message_attachment ChatMessageAttachment Anexos de mensagem.
mad_comm_chat_message_reaction ChatMessageReaction Reações por usuário+emoji (unique message_id, user_id, emoji).

Checklist de fresh-clone

composer install
cp .env.example .env
php artisan key:generate
php artisan migrate    # cria as tabelas mad_comm_chat_* (mad.sqlite auto-provisionado)
php artisan db:seed    # obrigatório — usuário admin e dados de referência

Ligar o chat:

# .env: BROADCAST_CONNECTION=reverb, MAD_REVERB_CHAT_ENABLED=true, REVERB_* preenchidos
php artisan tinker --execute='\App\Models\Sys\Preference::updateOrCreate(["id"=>"internal_chat"],["preference"=>"T"]);'
php artisan config:clear
php artisan serve --port=8911 &
php artisan reverb:start --port=8080 &
# login → ícone de chat no notch → dock abre, Echo conecta

Troubleshooting — "Echo não conecta"

Diagnóstico rápido no console do browser:

window.MadChatEcho.connector.pusher.connection.state   // esperado: "connected"
Sintoma Causa provável Correção
state em connecting/unavailable reverb:start não está rodando, ou porta errada suba o reverb:start; confira REVERB_HOST/REVERB_PORT (o que o BROWSER alcança)
conecta mas canais dão 403 no /broadcasting/auth CSRF ausente ou cross-origin authEndpoint precisa ser relativo same-origin; Echo manda X-CSRF-TOKEN; não exclua /broadcasting/auth do CSRF
403 só em conversation.{id} de terceiro comportamento correto o usuário não é participante — o bridge nega de propósito
window.Echo/window.Pusher undefined UMD não carregou confira as tags de lib/echo/{pusher.min.js,echo.iife.js} (antes de mad-chat.js)
ícone do chat não aparece flag/preferência off MAD_REVERB_CHAT_ENABLED=true e internal_chat='T'; php artisan config:clear
mensagem duplica/some entre abas X-Socket-Id / toOthers quebrados garanta que o POST manda X-Socket-Id e que MessageSent usa ->toOthers(); dedup é por id, NÃO por from===me
broadcast não chega mas POST grava secret/app_id divergente entre app e reverb:start REVERB_APP_* iguais no .env lido pelos dois processos

Limitações conhecidas

  • Sem presence de "digitando" persistido além do whisper client→client (não há fallback se um dos dois lados perde a conexão no meio do evento).
  • /broadcasting/auth compartilhada com o correio interno (ver acima) — última flag registrada vence quando ambas estão ativas.
  • Sem push fora da aba (Web Push/service worker) — uma conversa nova só chega enquanto a aba está aberta e o Echo conectado.

Arquivos-chave

  • Flag/visibilidade: app/Service/Comm/ChatService.php
  • Escrita + broadcast: app/Service/Comm/ChatMessageService.php
  • API: app/Http/Controllers/Comm/ChatApiController.php
  • Bridge auth: app/Http/Controllers/Comm/ChatBroadcastAuthController.php
  • Eventos: app/Events/{MessageSent,MessageRead,ReactionToggled,ConversationCreated}.php · app/Events/ChatDegraded.php (interno, não-broadcast)
  • Config do cliente (casco): app/lib/builder/ShellViewModel.php (chatConfig())
  • Rotas: routes/modules/communication.php (bloco final, atrás da flag) · routes/channels.php (superseded)
  • Schema/models: database/migrations/0001_01_01_000100_create_mad_schema.php (conexão comm) · app/Models/Comm/Chat*.php
  • Config: config/mad.php (reverb_chat.enabled) · config/reverb.php · config/broadcasting.php
  • Front: app/templates/theme-notch/js/mad-chat/* · app/templates/theme-notch/css/mad-chat.css · public/lib/echo/*
  • Testes: tests/Feature/ChatBroadcastAuthTest.php, tests/Feature/ChatRenderResilienceTest.php

Veja também

  • Correio interno — mensageria com pastas/Cc/ rótulos no mesmo transporte Reverb.
  • Notificações — sino do header, também sobre Reverb.
  • Busca global (⌘K) — a ação rápida "Nova mensagem" do palette aponta para o correio, não para o chat.