Skip to content

Canal Instagram

O canal Instagram integra o Instagram Messaging (Direct) e a moderação de comentários de posts/lives à mesma infraestrutura de flows, sessões, helpdesk e agentes de IA usada pelo WhatsApp. Assim como o canal WebChat, ele reaproveita todo o pipeline downstream — a diferença está no gateway HTTP, no cache de roteamento e nos streams de saída.

O serviço responsável é o flow-ai-ig-api. Ele é o análogo Instagram do flow-ai-meta-api: valida o webhook, normaliza mensagens recebidas para o pipeline compartilhado e envia respostas via Graph API. Não tem Prisma — fala só com Redis.


Arquitetura do flow-ai-ig-api

Meta (Instagram) ──webhook──► flow-ai-ig-api (porta 4445)

      ┌───────────────────────────┼───────────────────────────┐
      │ DMs (entry.messaging)     │ comentários (entry.changes)│ envio (Graph API)
      ▼                           ▼                            ▲
 stream:incoming            stream:ig-comments          stream:outgoing-ig
      │                           │  (consumo local:            │ (grupo `send`)
      ▼                           │   grupo `ig-comments`)       │
 flow-ai-orchestrator             ▼                            flow-ai-ig-api
      │                     stream:outgoing-ig  ─────────────────┘
      ▼ stream:flow         stream:ig-comment-activations ──► flow-ai-core (régua)
 flow-ai-engine

O serviço sobe um Fastify 5 com Swagger, error handler e as rotas de webhook/health, e inicia dois consumers de stream (bootstrap.ts):

  • consumeOutgoingIg() — grupo send sobre stream:outgoing-ig, envia DMs/respostas via Graph API.
  • consumeIgComments() — grupo ig-comments sobre stream:ig-comments, faz a moderação de comentários.

Como todo serviço da plataforma, o index.ts faz await initTelemetry("ig-api") e só então importa o bootstrap.ts dinamicamente, para que http/fastify/ioredis sejam carregados já instrumentados.

Configuração (config/env.ts)

VariávelDefaultPapel
IG_API_PORT4445Porta HTTP do gateway.
IG_VERIFY_TOKEN— (obrigatória)String estática comparada com hub.verify_token no handshake do webhook.
IG_META_API_VERSIONv21.0Versão da Graph API usada nas URLs.
IG_META_GRAPH_BASE_URLhttps://graph.facebook.comBase da Graph API. Para tokens Instagram Login (IGAA…), setar https://graph.instagram.com.
REDIS_URL— (obrigatória)Conexão Redis (streams + caches ig:*).

Nota: não há CRYPTO_SECRET nem Prisma neste serviço. O access token chega ao flow-ai-ig-api já decifrado via cache ig:credentials:{igUserId}, que o flow-ai-core popula. O flow-ai-ig-api nunca toca no Postgres nem no texto cifrado.

Caches Redis (ig:*, keyados por igUserId)

Todos são gravados exclusivamente pelo flow-ai-core (no boot via warmupCache e ao criar/atualizar a InstagramAccount) e apenas lidos pelos demais serviços.

ChavePayload (flow-ai-types/instagram.ts)Leitor
ig:credentials:{igUserId}InstagramCredentials{ igUserId, accessToken (decifrado), pageId, username, routerId }flow-ai-ig-api (envio)
ig:router:{igUserId}InstagramRouterConfig{ igUserId, routerId, initialFlowId, sessionExpiryMs? }flow-ai-orchestrator (roteamento inbound)
ig:webhook:{igUserId}InstagramWebhookConfig{ igUserId, routerId, appSecret }flow-ai-ig-api (assinatura)
ig:comment-triggers:{igUserId}InstagramCommentTriggersConfig{ igUserId, routerId, triggers[] }flow-ai-ig-api (moderação)

Chaves efêmeras da moderação de comentários (detalhadas mais abaixo): ig:comment-handled:{igUserId}:{commentId}, ig:comment-intent:{igUserId}:{from}, ig:comment-destination:{igUserId}:{from} e ig:pending-comment-reply:{sessionId}.

Nota: o igUserId canônico é o user_id da conta de negócio — o mesmo valor que a Meta envia em entry.id no webhook e que o envio usa em /{ig-user-id}/messages. Ele é derivado automaticamente do token no connect (resolveUser), não digitado. Editar o Router ou publicar um flow não refresca os caches ig:* — depois de definir o flow inicial, reinicie o flow-ai-core ou re-salve a conta Instagram para repopular ig:router.


Webhook: verificação e assinatura

O flow-ai-ig-api expõe dois endpoints (http/routes/webhook.ts):

  • GET /webhook — handshake da Meta. Exige hub.mode === "subscribe" e compara hub.verify_token com env.IG_VERIFY_TOKEN (comparação constante via crypto.timingSafeEqual). Sucesso ecoa hub.challenge com 200; token errado → 403.
  • POST /webhook — recepção de eventos. O corpo é parseado como Buffer cru (addContentTypeParser) para preservar bytes na verificação HMAC.

O fluxo do POST é:

  1. Ignora payloads cujo object !== "instagram" (200 imediato).
  2. Resolve o igUserId de entry[0].id e busca ig:webhook:{igUserId}. Ausente → 403 (conta não registrada).
  3. Valida x-hub-signature-256 como sha256=HMAC-SHA256(rawBody, appSecret) com o appSecret do cache. Falha → 403.
  4. Responde 200 imediatamente e processa o payload após o reply (void processIncomingEvents(...)), para não segurar o webhook da Meta.

O processamento assíncrono separa dois tipos de evento por entry:

  • entry.messaging[] → DMs, postbacks, reações. Normalizados e publicados em stream:incoming.
  • entry.changes[] com field ∈ { comments, live_comments } → comentários. Normalizados em IgCommentStreamEvent e publicados em stream:ig-comments.

DMs recebidas → stream:incoming

toIncomingMessages() converte cada messaging do webhook em WhatsAppIncomingMessage[] — o pipeline downstream é modelado em torno do WhatsApp, então a normalização mapeia tudo para esse shape:

Evento IGNormalização
Postback (botão de carrossel)interactive.button_reply com id = payload (para o engine extrair content = payload, igual a um botão WA)
Quick reply tocadainteractive.button_reply com id = payload, title = texto do chip
Textotype: "text", text.body
Anexo image/audio/videotipo de mídia correspondente, com a url do anexo em id
is_echo (mensagem da própria conta)ignorado
Reação, read receiptsem mensagem gerada — ignorado

Reply/citação (msg.reply_to) vira context: { id, from: igUserId }, reaproveitado pelo desk para o preview de reply.

O evento publicado em stream:incoming usa os slots de transporte genéricos do IncomingStreamEvent (que continua tipado como WhatsApp):

ts
const event: IncomingStreamEvent = {
  channelType: "instagram",
  sessionId: `ig-${igUserId}-${from}`,   // from = IGSID do cliente
  phoneNumberId: igUserId,               // slot reusado: igUserId da conta
  from,                                  // IGSID do cliente
  contactName: from,
  messages,                              // WhatsAppIncomingMessage[]
  timestamp: receivedAt,
}

Nota: apesar do comentário no tipo IncomingStreamEvent sugerir ig-{igUserId}, o sessionId real inclui o remetente: ig-{igUserId}-{from} (webhook.ts). É esse valor que o flow-ai-engine usa como prefixo para escolher o stream de saída.

Roteamento inbound (orchestrator)

O flow-ai-orchestrator trata channelType === "instagram" no handlers/incoming.ts:

  • resolve ig:router por phoneNumberId (o igUserId do negócio), não por from (o cliente) — a config só é aceita se routingConfig.igUserId === phoneNumberId;
  • na primeira mensagem, cria o Contact com phoneNumber sintético ig-{IGSID} e origin: "instagram", e um Chat escopado ao routerId;
  • cria a SessionState com source: "instagram", mode: "flow" e flowId = initialFlowId.

Se ig:router estiver ausente/inválido ou o Router não tiver flow inicial, as mensagens são persistidas no Chat mas nenhum flow é iniciado. O restante do inbound é idêntico ao WhatsApp — veja Inbound completo.


Saída → stream:outgoing-ig

O flow-ai-engine escolhe o stream de saída pelo prefixo do sessionId (runtime/publish.ts): ig-STREAMS.OUTGOING_IG. O mesmo vale para o flow-ai-core na saída do atendimento humano. O flow-ai-ig-api consome stream:outgoing-ig no grupo send (consumers/outgoing.ts).

Para cada OutgoingStreamEvent:

  1. igUserId = event.phoneNumberId; resolve ig:credentials:{igUserId}. Cache miss → não acka (a entry fica pendente até o warm-up).
  2. Recipient cru: alguns produtores (helpdesk) usam o phoneNumber sintético ig-{IGSID}; o consumer remove o prefixo ig- do to antes de enviar, pois a Graph API exige o IGSID puro em recipient.id.
  3. Envia cada mensagem via InstagramClient e publica StatusStreamEvent (sent com o message_id, ou send_failed com o erro).

Tipos outgoing nativos do Instagram

O engine monta o conteúdo por canal (tipos WA como button/list são convertidos antes de chegar — veja a tabela ChannelTypeMapping e a conversão multicanal). Os tipos nativos do IG (flow-ai-types/instagram.ts) são:

ts
// igQuickReply — vira message.quick_replies (content_type "text"; até 13)
type InstagramOutgoingQuickReplyMessage = {
  type: "igQuickReply"
  body: string
  quickReplies: { title: string; payload: string }[]
}

// igCarousel — vira attachment generic template (até 10 cards, até 3 botões/card)
type InstagramOutgoingCarouselMessage = {
  type: "igCarousel"
  elements: {
    title: string
    subtitle?: string
    imageUrl?: string
    buttons?: { title: string; url?: string; payload?: string }[]  // url→web_url, senão payload→postback
  }[]
}

O InstagramClient (instagram/client.ts) valida o tipo contra o catálogo compartilhado CHANNEL_CONTENT_TYPES.instagram; mídia (image/audio/video/document) é enviada como attachment com URL pública (o IG não usa Media IDs do WhatsApp).


Moderação de comentários (keyword → DM / resposta pública)

Comentários de post/live não chegam por entry.messaging — chegam por entry.changes[] com field: "comments" ou "live_comments". O flow-ai-ig-api normaliza cada mudança em IgCommentStreamEvent e publica num stream dedicado (stream:ig-comments), deixando o stream:incoming (DM) intacto:

ts
type IgCommentStreamEvent = {
  channelType: "instagram"
  source: "comments" | "live_comments"
  igUserId: string        // conta de negócio (entry.id)
  commentId: string       // alvo de Private Reply / resposta pública
  parentId?: string       // presente quando é resposta a outro comentário
  mediaId: string         // post comentado — chave do gatilho
  text: string
  fromId: string          // IGSID do autor
  fromUsername?: string
  timestamp: number
}

O handling (consumers/ig-comments.ts, grupo ig-comments)

O consumer roda dentro do próprio flow-ai-ig-api e, para cada comentário:

  1. Guardas de loop/autoria. Ignora comentários sem autor, respostas a comentários (têm parentId — o escopo é comentário de topo, e nossa resposta pública é sempre um filho, então nunca reprocessamos o que o bot publica) e comentários da própria conta (fromId === igUserId e comparação de username contra ig:credentials).
  2. Resolve gatilhos em ig:comment-triggers:{igUserId}, filtrando por mediaId.
  3. Match de palavra-chave — case- e acento-insensível (normalize), com matchType:
    • contains — o comentário contém a keyword;
    • exact — o comentário normalizado é exatamente a keyword.
  4. Idempotência. claimIgComment() = SET NX EX 7d em ig:comment-handled:{igUserId}:{commentId}. Age 1×/comentário (alinhado à janela de 7 dias da Private Reply da Meta). Se já reivindicado → duplicate.
  5. Dispara a abertura e (quando há) arma o destino.

Régua de gatilhos: abertura + destino

Cada InstagramCommentTrigger tem dois eixos ortogonais — a mensagem de abertura (enviada imediatamente como Private Reply quando a keyword casa) e o destino (que só ativa quando a pessoa responde ao DM de abertura, abrindo a janela da Meta):

ts
type InstagramCommentMatchType = "contains" | "exact"
type InstagramCommentOpeningType = "none" | "text" | "image" | "link"
type InstagramCommentDestination = "none" | "human" | "flow" | "flow_block"

type InstagramCommentTrigger = {
  id: string
  mediaId: string
  keywords: string[]
  matchType: InstagramCommentMatchType
  openingType: InstagramCommentOpeningType   // Private Reply imediato
  destination: InstagramCommentDestination    // ativa na 1ª resposta real
  flowId: string | null      // flow/flow_block; null = flow inicial do router
  blockId: string | null     // flow_block
  actionUrl: string | null   // URL da imagem (image) ou do link (link)
  targetQueueId: string | null   // fila (human)
  targetUserId: string | null    // agente específico (human, opcional)
  dmText: string             // corpo (text) ou texto antes da URL (link)
  publicReplyText: string | null // resposta pública opcional (todos os modos)
}

Abertura (openingType, Private Reply imediato ao comentário):

ValorEfeito
noneSem abertura — só faz sentido com destino flow/flow_block (caminho legado, o flow dirige a 1ª mensagem).
textDM de texto (dmText).
imageDM de imagem (actionUrl, URL pública). O IG não anexa legenda na mesma mensagem.
linkDM de texto (dmText, opcional) + URL clicável, enviados como texto\nURL.

Nota: abertura image/link sem actionUrl (drift de cache) libera o claim e não dispara nada — a Meta rejeita texto vazio.

Destino (destination, ativa na primeira resposta real do contato):

ValorEfeito
noneSem destino — a abertura é one-shot.
humanHandoff para a fila targetQueueId (agente opcional targetUserId) na 1ª resposta.
flowAbre o flow flowId (null = inicial do router) do início.
flow_blockEntra no bloco blockId do flow via externalJump com executeOnEntry.

Mecânica idempotente do disparo

A ordem importa: escritas idempotentes primeiro, disparo não-idempotente por último. Se algo lançar antes do disparo, liberar o claim é sempre seguro (não há Private Reply duplicada numa reentrega da Meta):

  1. Arma o destino (idempotente):
    • Caminho novo (abertura + destino human/flow/flow_block): grava ig:comment-destination:{igUserId}:{from} (setIgCommentDestination, TTL 7d), sem inbound sintético.
    • Caminho legado (openingType: none + destino flow/flow_block): grava ig:comment-intent:{igUserId}:{from} (setIgCommentIntent, TTL 5min) + publica um inbound sintético (id: ig-comment-{commentId}) em stream:incoming, deixando o próprio flow dirigir a 1ª mensagem.
  2. Dispara (não-idempotente): quando openingType !== "none", publica a abertura em stream:outgoing-ig com igCommentTarget: { commentId, mode: "private" }.
  3. Resposta pública (best-effort): se houver publicReplyText, publica outra saída com igCommentTarget.mode: "public".
  4. Régua: publica um IgCommentActivationStreamEvent em stream:ig-comment-activations.

Se a leitura de gatilhos falhar (ex.: Redis indisponível), a entry não é ackada — a recuperação vem do reenvio do webhook pela Meta (não há reclaim de PEL neste serviço).

Intent consumido pelo orchestrator (GETDEL)

Os dois caminhos convergem no flow-ai-orchestrator, que decide qual chave consumir pela presença de um inbound sintético:

ts
const isSyntheticComment = newMessages.some((m) => m.id.startsWith("ig-comment-"))
const rawIntent = isSyntheticComment
  ? await takeIgCommentIntent(phoneNumberId, from)        // legado: GETDEL ig:comment-intent
  : await takeIgCommentDestination(phoneNumberId, from)   // novo: GETDEL ig:comment-destination

Ambos os take* são GETDEL atômicos (consumo único). Com o InstagramCommentIntent resolvido:

  • human → cria sessão mode: "human" e publica HelpdeskHandoffEvent com presetQueueId/presetUserId (a abertura já foi enviada pelo ig-api). Veja Atendimento humano.
  • flow → abre o flow do início (sessão mode: "flow" sem currentBlockId).
  • flow_block → publica um FlowStreamEvent externalJump com executeOnEntry: true. Pré-setar currentBlockId NÃO publica o bloco — o engine trataria a resposta como input dele; por isso o externalJump.
  • legado (isSyntheticComment) → seta ig:pending-comment-reply:{sessionId} para que a 1ª saída do flow vá como Private Reply.

Private Reply vs. resposta pública (igCommentTarget)

O consumer de envio inspeciona event.igCommentTarget para escolher a chamada da Graph API:

modeChamada (InstagramClient)EndpointRestrição
privatesendPrivateReplyPOST /{igUserId}/messages com recipient.comment_id1×/comentário, até 7 dias após o comentário
publicsendPublicReplyPOST /{commentId}/repliesSó texto — outros tipos → unsupported_public_reply_type
(sem target)sendPOST /{igUserId}/messages com recipient.id (IGSID)DM normal

Respostas a comentário (igCommentTarget presente) são one-shot de moderação: não têm Chat associado, então o consumer de envio pula o StatusStreamEvent (persistir Message com chatId sentinela quebraria o FK, virando poison-pill em stream:status). A exceção é o caminho legado com ig:pending-comment-reply: aí a sessão tem Chat real, a 1ª saída vai como Private Reply e persiste normalmente.


Modelos de dados

Três modelos no schema.prisma (todos escopados ao Router via InstagramAccount):

prisma
model InstagramAccount {
  id                   String   @id @default(cuid())
  routerId             String
  igUserId             String   @unique
  pageId               String?
  username             String?
  accessTokenEncrypted String   // AES — decifrado só no core, cacheado em ig:credentials
  igAppSecretEncrypted String?
  active               Boolean  @default(true)
  commentTriggers      InstagramCommentTrigger[]
  commentActivations   InstagramCommentActivation[]
  @@map("instagram_accounts")
}

model InstagramCommentTrigger {
  id                 String   @id @default(cuid())
  instagramAccountId String
  mediaId            String
  keywords           String[]
  matchType          String   @default("contains")   // contains | exact
  openingType        String   @default("none")       // none | text | image | link
  destination        String   @default("none")       // none | human | flow | flow_block
  flowId             String?
  blockId            String?
  actionUrl          String?
  targetQueueId      String?
  targetUserId       String?
  dmText             String
  // ...
  @@map("instagram_comment_triggers")
}

model InstagramCommentActivation {   // log append-only da régua
  id                    String   @id @default(cuid())
  instagramAccountId    String
  triggerId             String?  // referência fraca — o gatilho pode ser removido
  mediaId               String
  commentId             String
  commenterIgsid        String
  matchedKeyword        String
  dmDispatched          Boolean  @default(true)
  publicReplyDispatched Boolean  @default(false)
  @@unique([instagramAccountId, commentId])   // 1 ativação por comentário
  @@map("instagram_comment_activations")
}

O flow-ai-core consome stream:ig-comment-activations (grupo core-ig-comment-activations), resolve o instagramAccountId a partir do igUserId e persiste a régua. A validação de invariantes ação↔campos (flow publicado do mesmo router, fila ativa, agente pertencente à fila) roda no flow-ai-core sobre o registro final.


Gaps conhecidos

Nota: saída IG não é persistida. O grupo persist está registrado para stream:outgoing-ig no setupStreams, mas o consumer de persistência do orchestrator só lê outgoing-meta + outgoing-chat. Na prática as DMs são enviadas (e o StatusStreamEvent é emitido), mas nenhuma linha Message de saída é gravada no Postgres pelo orchestrator para o canal Instagram.

Nota: agente de IA responde no canal errado em sessões IG. O flow-ai-agent nunca publica em stream:outgoing-ig — só em outgoing-meta/outgoing-chat. Um agente de IA (mode: "agent") atendendo numa sessão ig- publica a resposta em stream:outgoing-meta, que o flow-ai-ig-api não consome. O caminho de flow/humano do canal IG funciona; o de agente de IA não. Veja Agentes de IA.

Outros pontos abertos herdados do runbook do canal:

  • Upload de imagem no painel. A abertura image exige actionUrl (URL pública) — não há storage (MinIO/S3) nem serving estático no projeto. Enquanto isso, cole a URL.
  • ig:* não refresca sozinho. Editar o Router / publicar flow não repopula os caches — reinicie o flow-ai-core ou re-salve a conta IG.
  • Monitoramento (core-ui) via socket. A página de Monitoring é servida por HTTPS e o socket do core é HTTP → mixed content bloqueia; requer core sob HTTPS ou core-ui em HTTP.

WhatsApp × Instagram

AspectoWhatsAppInstagram
Gatewayflow-ai-meta-api (4444)flow-ai-ig-api (4445)
SessionIdwa-{phoneNumberId}-{phone}ig-{igUserId}-{from}
Inbound streamstream:incomingstream:incoming
Outbound streamstream:outgoing-metastream:outgoing-ig
Consumer group (envio)sendsend
phoneNumberId (slot)ID do número WhatsApp BusinessigUserId da conta
fromE.164 sem +IGSID do cliente
Cache de roteamentowa:router:{phoneNumberId}ig:router:{igUserId}
Cache de assinaturawa:webhook:{phoneNumberId}ig:webhook:{igUserId}
Verify tokenmeta:verify-token:{verifyToken} (por router)IG_VERIFY_TOKEN (env, estático)
Outbound persistidoSim (grupo persist)Não (grupo registrado, não consumido)
Moderação de comentáriosN/Astream:ig-comments + régua

Arquivos relevantes

ArquivoPapel
services/flow-ai-ig-api/src/http/routes/webhook.tsHandshake, verificação de assinatura e normalização inbound (DM → stream:incoming, comentário → stream:ig-comments)
services/flow-ai-ig-api/src/consumers/outgoing.tsConsumo de stream:outgoing-ig (grupo send), recipient cru, igCommentTarget, pending-comment-reply
services/flow-ai-ig-api/src/consumers/ig-comments.tsModeração: match de keyword, idempotência, abertura + destino, régua
services/flow-ai-ig-api/src/instagram/client.tsCliente Graph API (send, sendPrivateReply, sendPublicReply, igQuickReply/igCarousel)
services/flow-ai-ig-api/src/config/env.tsEnv do serviço (IG_API_PORT 4445, IG_VERIFY_TOKEN, base URL da Graph API)
services/flow-ai-ig-api/src/bootstrap.tsBootstrap Fastify + dois consumers (sem Prisma)
services/flow-ai-orchestrator/src/handlers/incoming.tsRoteamento inbound IG, consumo GETDEL de ig:comment-intent/ig:comment-destination, externalJump
packages/flow-ai-types/src/instagram.tsContratos: caches ig:*, InstagramCommentTrigger/Intent, tipos outgoing
packages/flow-ai-types/src/stream-events.tsIgCommentStreamEvent, IgCommentActivationStreamEvent, IncomingStreamEvent
packages/flow-ai-redis/src/cache.tsHelpers getIg*/setIg*, claimIgComment, takeIgCommentDestination/takeIgCommentIntent
packages/flow-ai-database/prisma/schema.prismaInstagramAccount, InstagramCommentTrigger, InstagramCommentActivation

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