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, tabelasmad_comm_chat_*. - Transporte: Reverb (protocolo Pusher) +
laravel-echo/pusher-js— builds UMD self-hosted empublic/lib/echo/(echo.iife.js,pusher.min.js). Sem npm/Vite no runtime do chat. - Identidade:
session('userid')— o app não usa a facadeAuth. 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:
- Deploy:
config('mad.reverb_chat.enabled')(envMAD_REVERB_CHAT_ENABLED) — registra as rotasapi/chat/*e/broadcasting/auth. - 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 dequeue:workrodando 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 mandaX-Socket-Id(deecho.socketId()) em todo POST.MessageSentusa->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 porfrom === meno 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.phpnão passachannels:nowithRouting(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/authtambé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 emroutes/modules/communication.php(ordem intencional, comentada no topo do arquivo), então com ambas as flags on quem responde é oChatBroadcastAuthController. 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 deconfig('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}.jsapp/templates/theme-notch/css/mad-chat.css, registrados emresources/views/shell/partials/libraries-builder.blade.phpna ordem engine → variations → mad-chat (MadChat.init()roda internamente).
laravel-echo+pusher-js: UMD self-hosted empublic/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 quandoenabled !== true). O payload é{enabled, meId, users, conversations, reverb:{key,host,port,scheme,authEndpoint,csrf}}— só akeypública do Reverb, nunca oREVERB_APP_SECRET.
Desde a migração do casco para Blade não existem mais os tokens
{CHAT_*}/{REVERB_*}nem oBuilderTemplateParser: quem monta a config é oShellViewModel.
- 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 delibraries-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/authcompartilhada 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ãocomm) ·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.