Skip to content

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ário

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

CampoWhatsApp (semântica literal)WebChat (slot)Instagram (slot)
phoneNumberIdID do número WhatsApp Businesswc-{channelId}igUserId da conta
fromtelefone E.164 sem +userId gerado no gatewayIGSID do contato
whatsApp.contactcontato WhatsApp realcontato sintético do WebChatcontato 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 IncomingStreamEvent descreve o Instagram como ig-{igUserId}, mas o flow-ai-ig-api emite de fato ig-${igUserId}-${from} (services/flow-ai-ig-api/src/http/routes/webhook.ts:308), com from sendo 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:

  1. Escolher o stream de saída. No publishContent do 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"
  1. Escolher a chave de conteúdo (wa / ig / wc) do ContentMap a montar, e — para mídia — decidir se a URL vira mediaId da 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 do flow-ai-core e o flow-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 em stream:outgoing. Esse stream é legado/morto — não tem produtores nem consumidores vivos. As três streams reais de saída são stream:outgoing-meta, stream:outgoing-chat e stream: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).

AspectoWhatsAppWebChatInstagram
Gatewayflow-ai-meta-apiflow-ai-webchat-gatewayflow-ai-ig-api
Transporte de entradaHTTP POST /webhookSocket.io (/webchat/socket.io)HTTP POST /webhook
channelType / source"whatsapp""webchat""instagram"
Formato do sessionIdwa-{phoneNumberId}-{from}wc-{channelId}-{userId}ig-{igUserId}-{from}
Cache de roteamento inboundwa: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 externoig:credentials:{igUserId}
Cache de webhook (assinatura)wa:webhook:{phoneNumberId} (HMAC-SHA256)N/A — auth por sessionKey (bcrypt), sem HMACig:webhook:{igUserId} (HMAC-SHA256)
Handshake / verify tokenRedis meta:verify-token:{token}— (sem handshake)env IG_VERIFY_TOKEN (estático)
Identidade do contatotelefone E.164 realsessionKey bcrypt + WebChatIdentityIGSID
Contact.phoneNumbertelefone E.164 realwc-{channelId}-{userId} (== sessionId)ig-{IGSID} (não igual ao sessionId)
Criação do Contactorchestrator (1ª mensagem)gateway (no auth)orchestrator (1ª mensagem)
Stream de saídastream:outgoing-metastream:outgoing-chatstream: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)
EntregaMeta Cloud APISocket.io room wc-{channelId}-{userId}Instagram Graph API
Tipos de conteúdo suportadostext, image, audio, video, document, buttons, list, contactstext, 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 sessionKey bcrypt contra wc:channel:{channelId} (nenhuma assinatura HMAC de webhook). Só existe o cache de roteamento wc:channel.
  • Contact.phoneNumber diverge do sessionId no 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 como ig-{IGSID} (só o IGSID, sem o igUserId), enquanto o sessionId é ig-{igUserId}-{from} (ver services/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 ContentMessage interativo, incluindo contacts (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 tem contacts. O flow-ai-ig-api valida contra esse conjunto em IG_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/list num canal sem interativos) → compõe um texto plano com as opções;
  • reshape nativo (ex.: buttons → igQuickReply no IG) → converte para o tipo nativo equivalente;
  • sem equivalente (toType: null ou 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) : url

Ou 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):

CanalCache consultadoHelperConfig
WhatsAppwa:router:{phoneNumberId}getWaRouterWhatsAppRouterConfig
Instagramig:router:{igUserId}getIgRouterInstagramRouterConfig
WebChatwc:channel:{channelId}getWcChannelWebChatChannelConfig

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.modeDestinoEvento
"flow"stream:flowFlowStreamUserInputEvent (ou FlowStreamExternalJumpEvent)
"human"stream:helpdeskHelpdeskMessageEvent
"agent"stream:agentAgentStreamEvent

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_META

Um 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

ArquivoPapel
packages/flow-ai-types/src/stream-events.tsIncomingStreamEvent (contrato WhatsApp-shaped), OutgoingStreamEvent
packages/flow-ai-types/src/channel-content-types.tsContentChannel, CHANNEL_CONTENT_TYPES, CONTENT_MAP_KEY_BY_CHANNEL, isContentTypeSupported
packages/flow-ai-types/src/content.tsContentMap (wa/ig/wc), ContentMessage
packages/flow-ai-types/src/session.tsSessionState.source, campos WhatsApp-shaped reaproveitados
services/flow-ai-orchestrator/src/handlers/incoming.tsRamificação por channelType: roteamento, Contact/Chat, criação da sessão
services/flow-ai-engine/src/runtime/publish.tsresolveContentChannel, seleção de stream:outgoing-*, mídia mediaId vs URL
services/flow-ai-agent/src/tools/response.tsSaída do agente de IA (gap #2 — não roteia ig-)
services/flow-ai-core/src/services/channel-content.tsConversão entre canais via ChannelTypeMapping (loadTypeMap, convertMessage, syncChannel)
services/flow-ai-ig-api/src/instagram/client.tsIG_SUPPORTED_TYPES, buildMessageBody (rejeita tipos não nativos de IG)
services/flow-ai-ig-api/src/http/routes/webhook.tsInstagram: sessionId de produção ig-{igUserId}-{from}
services/flow-ai-webchat-gateway/src/gateway.tsWebChat: auth Socket.io e publicação em stream:incoming
packages/flow-ai-redis/src/cache.tsgetWaRouter / getIgRouter / getWcChannel e caches wa:* / ig:* / wc:*
packages/flow-ai-redis/src/constants.tsSTREAMS, STREAM_GROUP_MAP (gap #1 e #3)

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