Appearance
Multicanal — WhatsApp, WebChat e Instagram
A plataforma atende três canais — WhatsApp, WebChat e Instagram — através de um único pipeline. Do orchestrator para dentro (sessão, flow, helpdesk, agente de IA), nenhum serviço sabe qual canal originou a mensagem: todos processam o mesmo IncomingStreamEvent publicado no mesmo stream:incoming. A divergência entre canais existe apenas nas bordas — o gateway que recebe/envia, o mecanismo de identidade, os caches de roteamento/credencial/webhook e o stream de saída.
Este guia consolida onde os canais convergem (o contrato compartilhado, o roteamento por prefixo de sessionId) e onde divergem (a tabela comparativa por canal), além dos gaps conhecidos.
Para o passo a passo do caminho inbound veja Fluxo inbound completo; para o outbound e o fan-out persist/send veja Fluxo outbound completo; para os detalhes do canal WebChat veja Canal WebChat; para o canal Instagram em profundidade (webhook, DMs e moderação de comentários) veja Canal Instagram.
Visão geral
WhatsApp Instagram WebChat (browser)
│ HTTP webhook │ HTTP webhook │ Socket.io
▼ ▼ ▼
flow-ai-meta-api flow-ai-ig-api flow-ai-webchat-gateway
└────────────────┴─────────────────┘
│ stream:incoming (IncomingStreamEvent — sempre WhatsApp-shaped)
▼
flow-ai-orchestrator ← resolve roteamento POR CANAL, cria SessionState
│ stream:flow | stream:helpdesk | stream:agent (por session.mode)
▼
flow-ai-engine │ flow-ai-core │ flow-ai-agent ← nunca sabem o canal
│ stream:outgoing-{meta,chat,ig} (escolhido pelo PREFIXO do sessionId)
▼
gateway do canal ← envia ao usuárioO ponto de convergência é o stream:incoming. O ponto de divergência de saída é o prefixo do sessionId, que seleciona um dos três streams de outbound. Entre esses dois pontos, o miolo do sistema é agnóstico de canal.
O contrato "WhatsApp-shaped"
O canal WhatsApp foi o primeiro implementado, e seu formato virou o contrato interno de toda a plataforma. WebChat e Instagram foram encaixados nesse molde em vez de introduzir tipos paralelos por canal.
A consequência concreta está em IncomingStreamEvent (packages/flow-ai-types/src/stream-events.ts):
ts
export type IncomingStreamEvent = {
/** Canal de origem do evento. */
channelType: "whatsapp" | "webchat" | "instagram"
/** WhatsApp: `wa-{phoneNumberId}-{from}`. WebChat: `wc-{channelId}-{userId}`. Instagram: `ig-{igUserId}-{from}`. */
sessionId: string
/**
* WhatsApp: ID do número de WhatsApp Business.
* WebChat: ID do WebChatChannel prefixado com `wc-` (ex: `wc-{channelId}`).
* Instagram: igUserId do contato.
*/
phoneNumberId: string
/** WhatsApp: E.164 sem `+`. WebChat: userId gerado pelo gateway. Instagram: IGSID. */
from: string
contactName: string
/** Mensagens recebidas neste evento — SEMPRE WhatsAppIncomingMessage[]. */
messages: WhatsAppIncomingMessage[]
timestamp: number
}O campo messages é declarado como WhatsAppIncomingMessage[] independentemente do canal. Os gateways de WebChat e Instagram normalizam a mensagem nativa deles para essa forma antes de publicar. Da mesma maneira, os campos abaixo são reaproveitados como slots genéricos de transporte, não como semântica literal de WhatsApp:
| Campo | WhatsApp (semântica literal) | WebChat (slot) | Instagram (slot) |
|---|---|---|---|
phoneNumberId | ID do número WhatsApp Business | wc-{channelId} | igUserId da conta |
from | telefone E.164 sem + | userId gerado no gateway | IGSID do contato |
whatsApp.contact | contato WhatsApp real | contato sintético do WebChat | contato sintético do IG |
O mesmo padrão continua no OutgoingStreamEvent (to, phoneNumberId) e no SessionState.whatsApp.contact — todos obrigatórios e reutilizados por todos os canais.
Por que reaproveitar em vez de criar tipos por canal? Manter uma única forma canônica significa que o orchestrator, o engine, o helpdesk e o agente de IA não precisam de ramificações por canal na maior parte do código. O custo é que os nomes (whatsApp, phoneNumberId) mentem para WebChat/Instagram — são compreendidos como "slot de transporte", não como "campo do WhatsApp".
Nota: o JSDoc de
IncomingStreamEventdescreve o Instagram comoig-{igUserId}, mas oflow-ai-ig-apiemite de fatoig-${igUserId}-${from}(services/flow-ai-ig-api/src/http/routes/webhook.ts:308), comfromsendo o IGSID do contato. O formato com-{from}é o que roda em produção — sem ele, todos os contatos que falam com a mesma conta de negócio colidiriam na mesma sessão. Este guia usa o formato de produção.
Roteamento por prefixo de sessionId
O sessionId é derivado deterministicamente por canal, e o prefixo (wa-, wc-, ig-) é o único seletor de canal para toda a cadeia de saída. Nenhuma configuração adicional, nenhum lookup de banco — o prefixo já carrega o canal.
A função canônica está no engine (services/flow-ai-engine/src/runtime/publish.ts):
ts
export function resolveContentChannel(sessionId: string): ChannelKey {
if (sessionId.startsWith("ig-")) return "ig"
if (sessionId.startsWith("wc-")) return "wc"
return "wa"
}Ela cumpre dois papéis:
- Escolher o stream de saída. No
publishContentdo engine:
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"- Escolher a chave de conteúdo (
wa/ig/wc) doContentMapa montar, e — para mídia — decidir se a URL viramediaIdda Meta ou fica como URL pública direta (ver Convergência e divergência de conteúdo).
Nota: a mesma convenção de prefixo aparece duplicada em pelo menos quatro pontos —
engine/src/runtime/publish.ts, o handler de inatividade do engine, a saída de helpdesk doflow-ai-coree oflow-ai-agent(src/tools/response.ts). Não há uma fonte única compartilhada da regra de seleção de stream; alterá-la exige tocar em todos esses pontos (é justamente onde nasce o gap #2, adiante).
Nota: o JSDoc do módulo
publish.ts(e vários CLAUDE.md de serviço) ainda descreve a publicação emstream:outgoing. Esse stream é legado/morto — não tem produtores nem consumidores vivos. As três streams reais de saída sãostream:outgoing-meta,stream:outgoing-chatestream:outgoing-ig.
Tabela comparativa por canal
Tudo que diverge entre os canais, lado a lado. Onde WhatsApp é a semântica literal, WebChat e Instagram reaproveitam ou não têm equivalente (N/A).
| Aspecto | WebChat | ||
|---|---|---|---|
| Gateway | flow-ai-meta-api | flow-ai-webchat-gateway | flow-ai-ig-api |
| Transporte de entrada | HTTP POST /webhook | Socket.io (/webchat/socket.io) | HTTP POST /webhook |
channelType / source | "whatsapp" | "webchat" | "instagram" |
Formato do sessionId | wa-{phoneNumberId}-{from} | wc-{channelId}-{userId} | ig-{igUserId}-{from} |
| Cache de roteamento inbound | wa:router:{phoneNumberId} (getWaRouter) | wc:channel:{channelId} (getWcChannel, TTL 3600s) | ig:router:{igUserId} (getIgRouter) |
| Cache de credencial (outbound) | wa:credentials:{phoneNumberId} | N/A — entrega via Socket.io room, sem token externo | ig:credentials:{igUserId} |
| Cache de webhook (assinatura) | wa:webhook:{phoneNumberId} (HMAC-SHA256) | N/A — auth por sessionKey (bcrypt), sem HMAC | ig:webhook:{igUserId} (HMAC-SHA256) |
| Handshake / verify token | Redis meta:verify-token:{token} | — (sem handshake) | env IG_VERIFY_TOKEN (estático) |
| Identidade do contato | telefone E.164 real | sessionKey bcrypt + WebChatIdentity | IGSID |
Contact.phoneNumber | telefone E.164 real | wc-{channelId}-{userId} (== sessionId) | ig-{IGSID} (não igual ao sessionId) |
| Criação do Contact | orchestrator (1ª mensagem) | gateway (no auth) | orchestrator (1ª mensagem) |
| Stream de saída | stream:outgoing-meta | stream:outgoing-chat | stream:outgoing-ig |
| Consumer group (saída) | send (meta-api) + persist (orchestrator) | webchat (gateway) + persist (orchestrator) | send (ig-api); persist registrado mas nunca lido (ver gaps) |
| Entrega | Meta Cloud API | Socket.io room wc-{channelId}-{userId} | Instagram Graph API |
| Tipos de conteúdo suportados | text, image, audio, video, document, buttons, list, contacts | text, image, audio, video, document, buttons, list (sem contacts) | text, image, audio, video, document, igQuickReply, igCarousel |
Observações sobre a tabela:
- WebChat não tem cache de credencial nem de webhook. A entrega é para uma sala Socket.io no próprio gateway (nenhum token externo a decifrar), e a autenticação é por
sessionKeybcrypt contrawc:channel:{channelId}(nenhuma assinatura HMAC de webhook). Só existe o cache de roteamentowc:channel. Contact.phoneNumberdiverge dosessionIdno Instagram. No WebChat os dois são idênticos (wc-{channelId}-{userId}), o que permite reencontrar o Contact por telefone. No Instagram, o Contact é persistido comoig-{IGSID}(só o IGSID, sem o igUserId), enquanto osessionIdéig-{igUserId}-{from}(verservices/flow-ai-orchestrator/src/handlers/incoming.ts).- Verify token: assimetria WA × IG. No WhatsApp, verify token e app secret vêm ambos do Redis (multi-tenant no mesmo processo). No Instagram, o app secret da assinatura vem do Redis (
ig:webhook:{igUserId}), mas o verify token do handshake é uma string estática do env (IG_VERIFY_TOKEN).
Convergência e divergência de conteúdo
Cobertura por canal — CHANNEL_CONTENT_TYPES
A fonte única de verdade dos tipos de ContentMessage que cada canal entrega nativamente vive em packages/flow-ai-types/src/channel-content-types.ts:
ts
export type ContentChannel = "whatsapp" | "instagram" | "webchat"
export const CHANNEL_CONTENT_TYPES = {
whatsapp: ["text", "image", "audio", "video", "document", "buttons", "list", "contacts"],
instagram: ["text", "image", "audio", "video", "document", "igQuickReply", "igCarousel"],
webchat: ["text", "image", "audio", "video", "document", "buttons", "list"],
} as const satisfies Record<ContentChannel, readonly string[]>
/** Chave correspondente em `ContentMap` para cada canal de entrega. */
export const CONTENT_MAP_KEY_BY_CHANNEL = {
whatsapp: "wa",
instagram: "ig",
webchat: "wc",
} as const satisfies Record<ContentChannel, "wa" | "ig" | "wc">Diferenças notáveis:
- WhatsApp cobre todo o
ContentMessageinterativo, incluindocontacts(cartão de contato). - WebChat espelha o WhatsApp exceto
contacts— a UI de chat não renderiza cartão de contato. - Instagram troca os interativos do WhatsApp (
buttons/list) pelos nativos de IG (igQuickReply/igCarousel), e não temcontacts. Oflow-ai-ig-apivalida contra esse conjunto emIG_SUPPORTED_TYPES(services/flow-ai-ig-api/src/instagram/client.ts) e rejeita qualquer outro tipo — a conversão precisa acontecer antes de chegar ao gateway.
Esse catálogo é consumido em três lugares: o gateway de cada canal (o que ele realmente envia), a tabela ChannelTypeMapping no flow-ai-core-ui e a conversão entre canais (checkChannelCoverage / channel-content) no flow-ai-core.
Nota:
typingé uma diretiva de controle (flag no evento de saída), não um conteúdo mapeável — por isso fica fora do catálogo.
ContentMap e conversão via ChannelTypeMapping
Cada bloco do flow guarda conteúdo por canal num ContentMap (packages/flow-ai-types/src/content.ts):
ts
export type ContentMap = {
wa: ContentMessage[] // WhatsApp (Meta API)
ig: ContentMessage[] // Instagram Direct
wc: ContentMessage[] // Webchat
}O operador edita o conteúdo wa (o canal-base) e o flow-ai-core auto-sincroniza ig/wc a partir dele, dirigido pela tabela ChannelTypeMapping (services/flow-ai-core/src/services/channel-content.ts). A regra por item:
- equivalente fiel (
toType === fromType, ex.:text→text, e todo tipo que o canal destino também entrega nativamente) → copia 1:1; - reshape para texto (ex.:
buttons/listnum canal sem interativos) → compõe um texto plano com as opções; - reshape nativo (ex.:
buttons → igQuickReplyno IG) → converte para o tipo nativo equivalente; - sem equivalente (
toType: nullou reshape ainda não implementado) → o item não é portado (sem placeholder/conteúdo). A ausência é sinalizada à parte na UI.
A auto-sincronização preenche apenas o que ainda não foi editado manualmente (itens marcados __edited são preservados).
Divergência de mídia: mediaId vs URL pública
O mesmo ContentMessage de mídia é publicado de formas diferentes por canal. No convertContent do engine (publish.ts), a resolução de mídia depende do canal:
ts
// WhatsApp envia mídia por mediaId (upload na API de Mídia da Meta). IG/Webchat
// aceitam a URL pública direta — o campo `mediaId` carrega a URL nesses canais.
const resolveMedia = (url: string): Promise<string> | string =>
channel === "wa" ? resolveMediaUrl(url, event.phoneNumberId, ctx.activeFlow.flowId) : urlOu seja: para WhatsApp a URL é enviada à Meta e vira um mediaId; para Instagram/WebChat o campo mediaId do OutgoingMessage transporta a URL pública direta (o buildMessageBody do ig-api a usa como attachment.payload.url). O nome do campo (mediaId) é, mais uma vez, um slot WhatsApp-shaped reaproveitado.
Onde cada canal é resolvido no orchestrator
O roteamento inicial de uma nova sessão é a única parte do orchestrator com ramificação explícita por channelType (services/flow-ai-orchestrator/src/handlers/incoming.ts):
| Canal | Cache consultado | Helper | Config |
|---|---|---|---|
wa:router:{phoneNumberId} | getWaRouter | WhatsAppRouterConfig | |
ig:router:{igUserId} | getIgRouter | InstagramRouterConfig | |
| WebChat | wc:channel:{channelId} | getWcChannel | WebChatChannelConfig |
Resolvido o routerId + initialFlowId, o SessionState criado é idêntico entre canais, exceto pelo campo source ("whatsapp" | "webchat" | "instagram"). A partir daí o roteamento é por session.mode, não por canal:
session.mode | Destino | Evento |
|---|---|---|
"flow" | stream:flow | FlowStreamUserInputEvent (ou FlowStreamExternalJumpEvent) |
"human" | stream:helpdesk | HelpdeskMessageEvent |
"agent" | stream:agent | AgentStreamEvent |
Todos os três destinos carregam de novo o contrato WhatsApp-shaped (messages: WhatsAppIncomingMessage[], phoneNumberId, to). O detalhamento desse caminho está em Fluxo inbound completo.
Gaps conhecidos
Comportamentos reais que divergem do esperado. Documentados aqui como estão no código — não como deveriam ser.
1. Outbound do Instagram não é persistido no Postgres
O grupo de consumo persist do stream:outgoing-ig está registrado (em STREAM_GROUP_MAP), mas nenhum serviço o consome. O orchestrator persiste stream:outgoing-meta e stream:outgoing-chat, mas não stream:outgoing-ig. Efeito prático: mensagens enviadas pelo canal Instagram são entregues (o ig-api consome o grupo send), mas não ficam no histórico de Message do Postgres.
2. Agente de IA em sessão Instagram responde no canal errado
O flow-ai-agent seleciona o stream de saída sem tratar o prefixo ig- (services/flow-ai-agent/src/tools/response.ts:27):
ts
const streamName = event.sessionId.startsWith("wc-") ? STREAMS.OUTGOING_CHAT : STREAMS.OUTGOING_METAUm agente de IA atendendo uma sessão ig-* cai no else e publica em stream:outgoing-meta — o gateway errado. A resposta do agente não chega ao contato do Instagram. (O engine de flow, por outro lado, roteia IG corretamente via resolveContentChannel. A divergência vem justamente da regra de seleção de stream estar duplicada e não compartilhada — ver a nota em Roteamento por prefixo.)
3. stream:outgoing legado
O STREAMS.OUTGOING (stream:outgoing) é uma constante morta: não está no STREAM_GROUP_MAP, não tem produtores nem consumidores. Vários JSDoc e CLAUDE.md ainda o citam. As streams vivas são as três stream:outgoing-{meta,chat,ig}.
Arquivos relevantes
| Arquivo | Papel |
|---|---|
packages/flow-ai-types/src/stream-events.ts | IncomingStreamEvent (contrato WhatsApp-shaped), OutgoingStreamEvent |
packages/flow-ai-types/src/channel-content-types.ts | ContentChannel, CHANNEL_CONTENT_TYPES, CONTENT_MAP_KEY_BY_CHANNEL, isContentTypeSupported |
packages/flow-ai-types/src/content.ts | ContentMap (wa/ig/wc), ContentMessage |
packages/flow-ai-types/src/session.ts | SessionState.source, campos WhatsApp-shaped reaproveitados |
services/flow-ai-orchestrator/src/handlers/incoming.ts | Ramificação por channelType: roteamento, Contact/Chat, criação da sessão |
services/flow-ai-engine/src/runtime/publish.ts | resolveContentChannel, seleção de stream:outgoing-*, mídia mediaId vs URL |
services/flow-ai-agent/src/tools/response.ts | Saída do agente de IA (gap #2 — não roteia ig-) |
services/flow-ai-core/src/services/channel-content.ts | Conversão entre canais via ChannelTypeMapping (loadTypeMap, convertMessage, syncChannel) |
services/flow-ai-ig-api/src/instagram/client.ts | IG_SUPPORTED_TYPES, buildMessageBody (rejeita tipos não nativos de IG) |
services/flow-ai-ig-api/src/http/routes/webhook.ts | Instagram: sessionId de produção ig-{igUserId}-{from} |
services/flow-ai-webchat-gateway/src/gateway.ts | WebChat: auth Socket.io e publicação em stream:incoming |
packages/flow-ai-redis/src/cache.ts | getWaRouter / getIgRouter / getWcChannel e caches wa:* / ig:* / wc:* |
packages/flow-ai-redis/src/constants.ts | STREAMS, STREAM_GROUP_MAP (gap #1 e #3) |