Skip to content

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-engine

O 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 OK

Nota: um sessionKey inválido ou uma identidade inexistente não caem para o fluxo de nova visita. negotiateIdentity lança um erro (auth.ts), o try/catch do gateway o captura e emite auth_error { message: "erro interno" } — a conexão não é autenticada. Uma nova identidade só é gerada quando o cliente conecta sem userId/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:   nenhum
typescript
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 o channelId no auth e devolve channelName/appearance ao cliente
  • flow-ai-orchestrator — resolve routerId e initialFlowId para 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 embed

E 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:

  • message com { text: string } → mensagem de texto (type: "text")
  • message_interactive com { 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 processado

O 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 segredoCom segredo
Acessoanônimo, qualquer um com a URLsó com token assinado válido
Identidadecuid no localStorage do navegadorclaim sub do token
Contextonão aceitaclaim context do token
Uso típicocanal de teste do builderembed 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=136893

Imprime 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

AspectoWhatsAppWebChat
SessionIdwa-{phoneNumberId}-{phone}wc-{channelId}-{userId}
Inbound streamstream:incomingstream:incoming
Outbound streamstream:outgoing-metastream:outgoing-chat
Consumer group (outgoing)sendwebchat
Identidade do contatoNúmero de telefone realsessionKey bcrypt
Criação do ContactOrchestrator (1ª mensagem)Gateway (auth)
Cache de roteamentowa:router:{phoneNumberId}wc:channel:{channelId}
Cache TTLsem TTLsem TTL
DeliveryMeta Graph APISocket.io room
Confirmação de leiturawamid → HMAC webhookN/A

Arquivos relevantes

ArquivoPapel
packages/flow-ai-database/prisma/schema.prismaModelos WebChatChannel, WebChatIdentity
packages/flow-ai-types/src/webchat.tsWebChatAuthRequest, WebChatAuthResponse, WebChatChannelConfig, WebChatAppearance (re-exportados por src/index.ts)
packages/flow-ai-redis/src/cache.tsgetWcChannel/setWcChannel/deleteWcChannel e getWcIntent/setWcIntent
services/flow-ai-webchat-gateway/src/auth.tsNegociação de identidade (anônima por bcrypt, externa por token)
services/flow-ai-webchat-gateway/src/embed-token.tsVerificação do token de embed, intent e derivação do sessionId
services/flow-ai-webchat-gateway/src/gateway.tsSocket.io server + publicação em stream:incoming
services/flow-ai-webchat-gateway/src/consumer.tsConsumo de stream:outgoing-chat + entrega ao socket
services/flow-ai-orchestrator/src/handlers/incoming.tsCriação de sessão WebChat (lookup via wc:channel)
services/flow-ai-engine/src/runtime/publish.tsRoteamento wc-stream:outgoing-chat
services/flow-ai-core/src/http/routes/webchat-channel.routes.tsRotas HTTP de canais WebChat (list, create, test, appearance, publish-test)
services/flow-ai-core/src/services/webchat-channel.service.tsWebChatChannelService: CRUD, getOrCreateTestChannel, publishTest
services/flow-ai-core/src/services/webchat-channel-cache.tssyncWebChatChannelCache / removeWebChatChannelCache (sync do wc:channel)

flow-ai — plataforma proprietária de atendimento via WhatsApp