Appearance
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-engineO 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()— gruposendsobrestream:outgoing-ig, envia DMs/respostas via Graph API.consumeIgComments()— grupoig-commentssobrestream: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ável | Default | Papel |
|---|---|---|
IG_API_PORT | 4445 | Porta HTTP do gateway. |
IG_VERIFY_TOKEN | — (obrigatória) | String estática comparada com hub.verify_token no handshake do webhook. |
IG_META_API_VERSION | v21.0 | Versão da Graph API usada nas URLs. |
IG_META_GRAPH_BASE_URL | https://graph.facebook.com | Base 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_SECRETnem Prisma neste serviço. O access token chega aoflow-ai-ig-apijá decifrado via cacheig:credentials:{igUserId}, que oflow-ai-corepopula. Oflow-ai-ig-apinunca 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.
| Chave | Payload (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
igUserIdcanônico é ouser_idda conta de negócio — o mesmo valor que a Meta envia ementry.idno 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 cachesig:*— depois de definir o flow inicial, reinicie oflow-ai-coreou re-salve a conta Instagram para repopularig:router.
Webhook: verificação e assinatura
O flow-ai-ig-api expõe dois endpoints (http/routes/webhook.ts):
GET /webhook— handshake da Meta. Exigehub.mode === "subscribe"e comparahub.verify_tokencomenv.IG_VERIFY_TOKEN(comparação constante viacrypto.timingSafeEqual). Sucesso ecoahub.challengecom 200; token errado → 403.POST /webhook— recepção de eventos. O corpo é parseado comoBuffercru (addContentTypeParser) para preservar bytes na verificação HMAC.
O fluxo do POST é:
- Ignora payloads cujo
object !== "instagram"(200 imediato). - Resolve o
igUserIddeentry[0].ide buscaig:webhook:{igUserId}. Ausente → 403 (conta não registrada). - Valida
x-hub-signature-256comosha256=HMAC-SHA256(rawBody, appSecret)com oappSecretdo cache. Falha → 403. - 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 emstream:incoming.entry.changes[]comfield ∈ { comments, live_comments }→ comentários. Normalizados emIgCommentStreamEvente publicados emstream: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 IG | Normalizaçã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 tocada | interactive.button_reply com id = payload, title = texto do chip |
| Texto | type: "text", text.body |
Anexo image/audio/video | tipo de mídia correspondente, com a url do anexo em id |
is_echo (mensagem da própria conta) | ignorado |
| Reação, read receipt | sem 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
IncomingStreamEventsugeririg-{igUserId}, osessionIdreal inclui o remetente:ig-{igUserId}-{from}(webhook.ts). É esse valor que oflow-ai-engineusa 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:routerporphoneNumberId(oigUserIddo negócio), não porfrom(o cliente) — a config só é aceita seroutingConfig.igUserId === phoneNumberId; - na primeira mensagem, cria o
ContactcomphoneNumbersintéticoig-{IGSID}eorigin: "instagram", e umChatescopado aorouterId; - cria a
SessionStatecomsource: "instagram",mode: "flow"eflowId = 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:
igUserId = event.phoneNumberId; resolveig:credentials:{igUserId}. Cache miss → não acka (a entry fica pendente até o warm-up).- Recipient cru: alguns produtores (helpdesk) usam o
phoneNumbersintéticoig-{IGSID}; o consumer remove o prefixoig-dotoantes de enviar, pois a Graph API exige o IGSID puro emrecipient.id. - Envia cada mensagem via
InstagramCliente publicaStatusStreamEvent(sentcom omessage_id, ousend_failedcom 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:
- 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 === igUserIde comparação deusernamecontraig:credentials). - Resolve gatilhos em
ig:comment-triggers:{igUserId}, filtrando pormediaId. - Match de palavra-chave — case- e acento-insensível (
normalize), commatchType:contains— o comentário contém a keyword;exact— o comentário normalizado é exatamente a keyword.
- Idempotência.
claimIgComment()=SET NX EX 7demig:comment-handled:{igUserId}:{commentId}. Age 1×/comentário (alinhado à janela de 7 dias da Private Reply da Meta). Se já reivindicado →duplicate. - 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):
| Valor | Efeito |
|---|---|
none | Sem abertura — só faz sentido com destino flow/flow_block (caminho legado, o flow dirige a 1ª mensagem). |
text | DM de texto (dmText). |
image | DM de imagem (actionUrl, URL pública). O IG não anexa legenda na mesma mensagem. |
link | DM de texto (dmText, opcional) + URL clicável, enviados como texto\nURL. |
Nota: abertura
image/linksemactionUrl(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):
| Valor | Efeito |
|---|---|
none | Sem destino — a abertura é one-shot. |
human | Handoff para a fila targetQueueId (agente opcional targetUserId) na 1ª resposta. |
flow | Abre o flow flowId (null = inicial do router) do início. |
flow_block | Entra 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):
- Arma o destino (idempotente):
- Caminho novo (abertura + destino
human/flow/flow_block): gravaig:comment-destination:{igUserId}:{from}(setIgCommentDestination, TTL 7d), sem inbound sintético. - Caminho legado (
openingType: none+ destinoflow/flow_block): gravaig:comment-intent:{igUserId}:{from}(setIgCommentIntent, TTL 5min) + publica um inbound sintético (id: ig-comment-{commentId}) emstream:incoming, deixando o próprio flow dirigir a 1ª mensagem.
- Caminho novo (abertura + destino
- Dispara (não-idempotente): quando
openingType !== "none", publica a abertura emstream:outgoing-igcomigCommentTarget: { commentId, mode: "private" }. - Resposta pública (best-effort): se houver
publicReplyText, publica outra saída comigCommentTarget.mode: "public". - Régua: publica um
IgCommentActivationStreamEventemstream: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-destinationAmbos os take* são GETDEL atômicos (consumo único). Com o InstagramCommentIntent resolvido:
human→ cria sessãomode: "human"e publicaHelpdeskHandoffEventcompresetQueueId/presetUserId(a abertura já foi enviada pelo ig-api). Veja Atendimento humano.flow→ abre o flow do início (sessãomode: "flow"semcurrentBlockId).flow_block→ publica umFlowStreamEventexternalJumpcomexecuteOnEntry: true. Pré-setarcurrentBlockIdNÃO publica o bloco — o engine trataria a resposta como input dele; por isso oexternalJump.- legado (
isSyntheticComment) → setaig: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:
mode | Chamada (InstagramClient) | Endpoint | Restrição |
|---|---|---|---|
private | sendPrivateReply | POST /{igUserId}/messages com recipient.comment_id | 1×/comentário, até 7 dias após o comentário |
public | sendPublicReply | POST /{commentId}/replies | Só texto — outros tipos → unsupported_public_reply_type |
| (sem target) | send | POST /{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
persistestá registrado parastream:outgoing-ignosetupStreams, mas o consumer de persistência do orchestrator só lêoutgoing-meta+outgoing-chat. Na prática as DMs são enviadas (e oStatusStreamEventé emitido), mas nenhuma linhaMessagede 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-agentnunca publica emstream:outgoing-ig— só emoutgoing-meta/outgoing-chat. Um agente de IA (mode: "agent") atendendo numa sessãoig-publica a resposta emstream:outgoing-meta, que oflow-ai-ig-apinã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
imageexigeactionUrl(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 oflow-ai-coreou 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
| Aspecto | ||
|---|---|---|
| Gateway | flow-ai-meta-api (4444) | flow-ai-ig-api (4445) |
| SessionId | wa-{phoneNumberId}-{phone} | ig-{igUserId}-{from} |
| Inbound stream | stream:incoming | stream:incoming |
| Outbound stream | stream:outgoing-meta | stream:outgoing-ig |
| Consumer group (envio) | send | send |
phoneNumberId (slot) | ID do número WhatsApp Business | igUserId da conta |
from | E.164 sem + | IGSID do cliente |
| Cache de roteamento | wa:router:{phoneNumberId} | ig:router:{igUserId} |
| Cache de assinatura | wa:webhook:{phoneNumberId} | ig:webhook:{igUserId} |
| Verify token | meta:verify-token:{verifyToken} (por router) | IG_VERIFY_TOKEN (env, estático) |
| Outbound persistido | Sim (grupo persist) | Não (grupo registrado, não consumido) |
| Moderação de comentários | N/A | stream:ig-comments + régua |
Arquivos relevantes
| Arquivo | Papel |
|---|---|
services/flow-ai-ig-api/src/http/routes/webhook.ts | Handshake, verificação de assinatura e normalização inbound (DM → stream:incoming, comentário → stream:ig-comments) |
services/flow-ai-ig-api/src/consumers/outgoing.ts | Consumo de stream:outgoing-ig (grupo send), recipient cru, igCommentTarget, pending-comment-reply |
services/flow-ai-ig-api/src/consumers/ig-comments.ts | Moderação: match de keyword, idempotência, abertura + destino, régua |
services/flow-ai-ig-api/src/instagram/client.ts | Cliente Graph API (send, sendPrivateReply, sendPublicReply, igQuickReply/igCarousel) |
services/flow-ai-ig-api/src/config/env.ts | Env do serviço (IG_API_PORT 4445, IG_VERIFY_TOKEN, base URL da Graph API) |
services/flow-ai-ig-api/src/bootstrap.ts | Bootstrap Fastify + dois consumers (sem Prisma) |
services/flow-ai-orchestrator/src/handlers/incoming.ts | Roteamento inbound IG, consumo GETDEL de ig:comment-intent/ig:comment-destination, externalJump |
packages/flow-ai-types/src/instagram.ts | Contratos: caches ig:*, InstagramCommentTrigger/Intent, tipos outgoing |
packages/flow-ai-types/src/stream-events.ts | IgCommentStreamEvent, IgCommentActivationStreamEvent, IncomingStreamEvent |
packages/flow-ai-redis/src/cache.ts | Helpers getIg*/setIg*, claimIgComment, takeIgCommentDestination/takeIgCommentIntent |
packages/flow-ai-database/prisma/schema.prisma | InstagramAccount, InstagramCommentTrigger, InstagramCommentActivation |