Appearance
Canal WebChat
O canal WebChat permite incorporar um chat ao vivo em qualquer página web. Ele funciona sobre Socket.io e compartilha toda a infraestrutura de flows, helpdesk e sessões com o canal WhatsApp — a diferença está na camada de transporte e no mecanismo de identidade.
O serviço responsável é o flow-ai-webchat-gateway.
Topologia
Browser (Socket.io client)
│
│ WebSocket
▼
flow-ai-webchat-gateway ← ponto de entrada do canal
├─ auth.ts ← negociação de identidade
├─ gateway.ts ← publicação de mensagens recebidas
└─ consumer.ts ← entrega de mensagens ao socket
│ ↑
│ stream:incoming │ stream:outgoing-chat
▼ │
flow-ai-orchestrator flow-ai-engine
│
▼ stream:flow
flow-ai-engineO gateway é a única peça que conhece sockets. O orchestrator e o engine nunca sabem se estão processando WhatsApp ou WebChat — a distinção está no prefixo do sessionId.
Autenticação e WebChatIdentity
A conexão WebChat usa um protocolo de autenticação stateful com dois estados: nova visita e visita recorrente.
Nova visita
Cliente → { channelId }
↓
gateway valida channelId contra wc:channel:{channelId} (Redis)
↓
Gera: userId = cuid()
sessionKey = cuid() + cuid() ← dois CUIDs concatenados (maior entropia)
↓
Cria Contact { phoneNumber: "wc-{channelId}-{userId}", name: "Flow Chat", origin: "webchat" }
Cria WebChatIdentity { channelId, contactId, sessionKeyHash: bcrypt(sessionKey, 10) }
↓
gateway → auth_ok { userId, sessionKey, channelName, appearance }O browser persiste userId e sessionKey em localStorage. O auth_ok sempre devolve o par { userId, sessionKey }, mas um novo sessionKey só é gerado quando o browser conecta sem identidade — em visitas recorrentes o gateway apenas ecoa de volta o mesmo sessionKey que o cliente enviou (nunca gera outro).
Visita recorrente
Cliente → { channelId, userId, sessionKey }
↓
gateway busca WebChatIdentity por channelId + phoneNumber "wc-{channelId}-{userId}"
├─ Identidade não encontrada → throw
└─ Encontrada → bcrypt.compare(sessionKey, identity.sessionKeyHash)
├─ OK → reutiliza a identidade e ecoa { userId, sessionKey }
└─ Falha → throw
↓
gateway → auth_ok { userId, sessionKey, ... } ← só no caminho OKNota: um
sessionKeyinválido ou uma identidade inexistente não caem para o fluxo de nova visita.negotiateIdentitylança um erro (auth.ts), otry/catchdo gateway o captura e emiteauth_error { message: "erro interno" }— a conexão não é autenticada. Uma nova identidade só é gerada quando o cliente conecta semuserId/sessionKey.
Modelo de dados
prisma
model WebChatChannel {
id String @id @default(cuid())
name String
routerId String
router Router @relation(...)
isTestChannel Boolean @default(false)
appearance Json? // WebChatAppearance (cores, avatar, textos)
identities WebChatIdentity[]
@@map("webchat_channels")
}
model WebChatIdentity {
id String @id @default(cuid())
channelId String
channel WebChatChannel @relation(...)
contactId String
contact Contact @relation(...)
sessionKeyHash String // bcrypt hash (custo 10) — nunca plaintext
@@unique([channelId, contactId])
@@map("webchat_identities")
}Por que bcrypt para o sessionKey? O sessionKey é um identificador secreto que o browser usa para reivindicar uma sessão. Se o banco vazar, um atacante não poderia impersonar usuários — precisaria do valor original, não do hash.
Cache do canal
O flow-ai-core mantém a configuração de cada WebChatChannel em Redis:
Chave: wc:channel:{channelId}
TTL: nenhumtypescript
type WebChatChannelConfig = {
id: string
name: string
routerId: string
initialFlowId: string | null // efetivo: o do canal, senão o do router
isTestChannel: boolean
appearance?: WebChatAppearance
embedSecret?: string // presente = canal exige token de embed
}O cache é consumido por dois serviços:
flow-ai-webchat-gateway— valida ochannelIdno auth e devolvechannelName/appearanceao clienteflow-ai-orchestrator— resolverouterIdeinitialFlowIdpara criar a sessão
A chave não tem TTL, como os demais caches de configuração do sistema. Ela é escrita em toda mudança do canal, em toda mudança do router (o initialFlowId do router é o fallback de quem não define o próprio) e no warmup do boot do flow-ai-core.
Histórico
Até 08/2026 essa chave tinha TTL 3600. Como nenhum dos dois leitores re-hidrata em cache miss — os dois tratam ausência como "canal não existe" —, um canal de produção parava de responder uma hora depois do último save. Passava despercebido porque o canal de teste do builder era reescrito a cada publicação de teste.
SessionId e contact phone
O sessionId do WebChat segue o padrão:
wc-{channelId}-{userId}
wc-{channelId}-{userId}-ctx{hash} // quando a abertura traz contexto de embedE o Contact criado pelo gateway recebe:
phoneNumber: "wc-{channelId}-{userId}"Sem contexto de embed esses dois valores são idênticos — o phoneNumber do Contact é o sessionId. Isso permite que o orchestrator reencontre o Contact em mensagens subsequentes fazendo uma busca por phoneNumber sem precisar de um campo extra.
Com contexto de embed o sessionId ganha o sufixo -ctx{hash}, mas o phoneNumber do Contact não: o contato continua sendo um por pessoa, enquanto cada contexto abre a sua conversa. Ver Embutir em outro sistema.
Separação de streams por prefixo
O engine detecta o canal pelo prefixo do sessionId:
typescript
// services/flow-ai-engine/src/runtime/publish.ts
const targetStream = outgoingEvent.sessionId.startsWith("wc-")
? STREAMS.OUTGOING_CHAT // "stream:outgoing-chat"
: outgoingEvent.sessionId.startsWith("ig-")
? STREAMS.OUTGOING_IG // "stream:outgoing-ig"
: STREAMS.OUTGOING_META // "stream:outgoing-meta"A mesma convenção de prefixo é exposta pela função resolveContentChannel(sessionId) (ig- → ig, wc- → wc, demais → wa), usada para montar o conteúdo por canal. Nenhuma configuração adicional — o prefixo é o seletor de canal para toda a cadeia de outbound.
Fluxo de mensagem recebida (inbound)
O gateway aceita dois eventos de socket inbound:
messagecom{ text: string }→ mensagem de texto (type: "text")message_interactivecom{ type: "button_reply" | "list_reply", id, title }→ resposta a botão/lista (type: "interactive")
Browser → socket emit("message", { text: "Olá" })
↓
gateway.ts
├─ Valida texto não-vazio
├─ Monta IncomingStreamEvent:
│ channelType: "webchat"
│ sessionId: "wc-{channelId}-{userId}"
│ phoneNumberId: "wc-{channelId}"
│ from: userId
│ contactName: "WebChat User"
│ messages[0]: { id: "wc-{timestamp}-{random}", type: "text", text }
└─ xadd(STREAMS.INCOMING, payload)
↓
flow-ai-orchestrator (group=orchestrator)
├─ getSession(sessionId) ← Redis
├─ Se nova sessão:
│ Busca Contact por phoneNumber "wc-{channelId}-{userId}"
│ └─ ausente → loga erro e aborta (o Contact é criado pelo gateway no auth)
│ Cria Chat se necessário
│ Carrega WebChatChannelConfig de wc:channel:{channelId}
│ Cria SessionState { routerId, flowId: initialFlowId, mode: "flow" }
│
└─ Publica em stream:flow ou stream:helpdesk (conforme session.mode)Nota: diferente do WhatsApp e do Instagram — onde o orchestrator cria o Contact na primeira mensagem — no WebChat o Contact já existe (criado pelo gateway durante o auth). Se ele não for encontrado, o orchestrator loga erro e descarta a mensagem.
Fluxo de mensagem enviada (outbound)
flow-ai-engine
└─ publica OutgoingStreamEvent em stream:outgoing-chat
(detectado pelo prefixo "wc-" do sessionId)
consumer.ts do gateway (group=webchat, consumer=webchat-gateway-1)
├─ xreadgroup(stream:outgoing-chat)
├─ Para cada mensagem:
│ Lookup Socket.io room = sessionId ("wc-{channelId}-{userId}")
│ ├─ Se room vazia (usuário offline):
│ │ Loga "sem sockets na room"
│ │ delivered = false
│ └─ Se há sockets:
│ io.to(roomId).emit("message", msg) ← um emit por mensagem do evento
│ delivered = true
├─ Publica stream:status { status: "sent" } ← sempre
│ Publica stream:status { status: "delivered" } ← se delivered=true
└─ xack() ← marca como processadoO gateway não tenta re-entregar mensagens para usuários offline — o histórico fica no banco e pode ser carregado via HTTP quando o usuário reconectar.
Embutir em outro sistema
A página /chat/{channelId} do flow-ai-core-ui é pública (fora do matcher do middleware de auth) e não envia X-Frame-Options nem CSP frame-ancestors — ou seja, funciona dentro de um <iframe> de qualquer origem. O gateway Socket.io responde com Access-Control-Allow-Origin: *.
O canal aberto e o canal fechado
O comportamento depende de o canal ter ou não um segredo de embed:
| Sem segredo | Com segredo | |
|---|---|---|
| Acesso | anônimo, qualquer um com a URL | só com token assinado válido |
| Identidade | cuid no localStorage do navegador | claim sub do token |
| Contexto | não aceita | claim context do token |
| Uso típico | canal de teste do builder | embed em sistema interno |
Quando o segredo é obrigatório
/chat/{channelId} é um link portador: quem descobre a URL conversa com o bot. Se o flow do canal executa qualquer ação sensível — consultar dados de cliente, reiniciar equipamento, trocar senha —, o segredo não é opcional. Sem ele o controle de acesso do canal não existe, e nem depende de o atacante saber passar contexto: uma tool de busca já encontra qualquer alvo.
Gerar o segredo (a resposta é a única vez que ele aparece em texto claro):
POST /api/webchat/channels/{channelId}/embed-secret
→ { "channelId": "...", "embedSecret": "..." }DELETE na mesma rota remove o segredo e reabre o canal para acesso anônimo.
O prefixo /api não é opcional
O host público da API serve dois processos, roteados por caminho: /api/* vai para o flow-ai-core e /webchat/* vai para o gateway Socket.io. Chamar /webchat/channels/... (sem /api) cai no gateway, que não tem handler HTTP fora do /webchat/socket.io — a requisição fica pendurada sem resposta, sem 404 e sem erro. Se uma chamada de administração de canal nunca responde, confira o prefixo antes de qualquer outra coisa.
O token
JWT HS256 assinado pelo servidor da aplicação que hospeda o iframe — nunca pelo browser, que não pode ter o segredo.
typescript
jwt.sign(
{
sub: "matricula-42", // identidade estável de quem usa o chat
name: "Fulano", // vira o nome do Contact
context: { deviceId: "...", clienteId: "136893" },
flowId: "opcional", // precisa ser do mesmo router
},
embedSecret,
{ algorithm: "HS256", expiresIn: 300 },
)O gateway fixa algorithms: ["HS256"] e exige exp. Emita o token na abertura do chat, com validade curta: um token vazado vale por poucos minutos e só para aquele contexto.
A URL do iframe leva o token em ?t=:
html
<iframe src="https://studio.flow-ai.nmulti.tech/chat/{channelId}?t={token}"
style="width:100%;height:600px;border:0"></iframe>Trocar o contexto exige recarregar
O socket entra na sala do sessionId no handshake. Se a aplicação hospedeira trocar o ?t= sem remontar o iframe, o socket continua na sala antiga. Recarregue o iframe ao mudar de alvo.
Contexto → variáveis de sessão
Cada chave de context vira uma variável de sessão, disponível já no primeiro bloco do flow — o agente e as tools leem com {{deviceId}}, {{clienteId}} etc.
Chaves que o interpolador do engine não conseguiria ler (com hífen, @, ou começando por dígito) são descartadas no handshake, em vez de virarem variável morta.
Uma conversa por contexto
A impressão digital do contexto entra no sessionId (-ctx{hash}). Isso significa:
- mesmo contexto ⇒ mesma sessão: reabrir o chat no mesmo alvo continua a conversa
- contexto diferente ⇒ sessão nova, semeada com o contexto novo
- contato e chat continuam os mesmos: o histórico é por pessoa, não por alvo
Separar as chaves não é cosmético. O executor do agente carrega a sessão, chama o LLM (segundos) e regrava o SessionState inteiro. Se as duas aberturas dividissem a chave, o turno ainda em voo do alvo anterior sobrescreveria a conversa nova ao terminar — e o assistente voltaria a responder sobre o alvo errado, sem nenhum sinal de erro. Com chaves distintas isso é estruturalmente impossível.
O contexto precisa ser estável
A impressão digital cobre todas as chaves do contexto. Se a aplicação hospedeira mandar algo que muda a cada abertura (timestamp, id de requisição), toda abertura vira uma conversa nova e ninguém consegue retomar a anterior. Mande o que identifica o alvo, não a abertura.
Flow por canal
WebChatChannel.initialFlowId sobrepõe o flow inicial do router só para aquele canal — é como dois pontos de embed do mesmo router entram em flows diferentes:
PUT /api/webchat/channels/{channelId}/initial-flow { "flowId": "..." | null }null volta a herdar do router. O token também pode pedir um flowId por abertura; ele é validado contra o router da sessão e ignorado se for de outro.
Emitir um token para teste
Antes de a aplicação hospedeira existir:
bash
pnpm --filter flow-ai-core webchat:token -- \
--channel {channelId} --sub 42 --name "Fulano" \
--ctx deviceId=ABC-Router-0001 --ctx clienteId=136893Imprime o token e a URL pronta do iframe.
Canal de teste
Cada Router pode ter um canal WebChat de teste (isTestChannel: true, nome "Teste"). Ele não é criado junto com o Router: é criado sob demanda por getOrCreateTestChannel(routerId) no flow-ai-core — acionado pela rota GET /routers/:routerId/webchat/test ou automaticamente ao publicar um flow de teste (POST /flows/:flowId/publish-test). Se o canal de teste já existir, ele é reaproveitado. É usado pelo builder do flow-ai-core-ui para testar flows em tempo real durante o desenvolvimento, sem precisar criar um canal de produção ou configurar WhatsApp.
O canal de teste se comporta identicamente ao canal de produção — mesma autenticação, mesmo pipeline, mesmos streams. A única restrição operacional: um canal de teste não pode ser removido via delete (retorna erro 400).
Comparativo WhatsApp × WebChat
| Aspecto | WebChat | |
|---|---|---|
| SessionId | wa-{phoneNumberId}-{phone} | wc-{channelId}-{userId} |
| Inbound stream | stream:incoming | stream:incoming |
| Outbound stream | stream:outgoing-meta | stream:outgoing-chat |
| Consumer group (outgoing) | send | webchat |
| Identidade do contato | Número de telefone real | sessionKey bcrypt |
| Criação do Contact | Orchestrator (1ª mensagem) | Gateway (auth) |
| Cache de roteamento | wa:router:{phoneNumberId} | wc:channel:{channelId} |
| Cache TTL | sem TTL | sem TTL |
| Delivery | Meta Graph API | Socket.io room |
| Confirmação de leitura | wamid → HMAC webhook | N/A |
Arquivos relevantes
| Arquivo | Papel |
|---|---|
packages/flow-ai-database/prisma/schema.prisma | Modelos WebChatChannel, WebChatIdentity |
packages/flow-ai-types/src/webchat.ts | WebChatAuthRequest, WebChatAuthResponse, WebChatChannelConfig, WebChatAppearance (re-exportados por src/index.ts) |
packages/flow-ai-redis/src/cache.ts | getWcChannel/setWcChannel/deleteWcChannel e getWcIntent/setWcIntent |
services/flow-ai-webchat-gateway/src/auth.ts | Negociação de identidade (anônima por bcrypt, externa por token) |
services/flow-ai-webchat-gateway/src/embed-token.ts | Verificação do token de embed, intent e derivação do sessionId |
services/flow-ai-webchat-gateway/src/gateway.ts | Socket.io server + publicação em stream:incoming |
services/flow-ai-webchat-gateway/src/consumer.ts | Consumo de stream:outgoing-chat + entrega ao socket |
services/flow-ai-orchestrator/src/handlers/incoming.ts | Criação de sessão WebChat (lookup via wc:channel) |
services/flow-ai-engine/src/runtime/publish.ts | Roteamento wc- → stream:outgoing-chat |
services/flow-ai-core/src/http/routes/webchat-channel.routes.ts | Rotas HTTP de canais WebChat (list, create, test, appearance, publish-test) |
services/flow-ai-core/src/services/webchat-channel.service.ts | WebChatChannelService: CRUD, getOrCreateTestChannel, publishTest |
services/flow-ai-core/src/services/webchat-channel-cache.ts | syncWebChatChannelCache / removeWebChatChannelCache (sync do wc:channel) |