Runbook de produção · Instagram

Comentário vira DM automática

Guia de execução para um agente com terminal: do app na Meta ao servidor no ar, comando por comando.

Custo mensal
R$ 26,39 a R$ 56,90
Software
openreply (MIT)
Tempo
2h

O que fica sendo seu

Nunca mais pague por ManyChat

Em planos avançados o custo pode subir até R$ 5 mil por ano. Aqui, o que você paga é o servidor.

É tudo seu.

O painel, por dentro

É este o painel que fica rodando na sua conta

O painel é seu, e sai do jeito que você quiser: a interface pode ser mudada, reorganizada e traduzida inteira para o português. Os prints abaixo são de uma instalação em produção, sem maquiagem.

01campanhas
Tela de campanhas do painel, com três campanhas listadas. Cada uma mostra o interruptor de ligar e desligar, a palavra-chave em etiqueta, o texto da resposta pública, o link rastreado e uma linha de números com runs, CTR, enviadas, puladas, falhas e cliques. A última campanha da lista está com 1286 disparos.
A lista de campanhas. Cada post armado vira uma linha, com o interruptor de ligar e desligar à direita e os números de envio embaixo.
02campanha nova
Editor de campanha nova. À esquerda, a grade das mídias do perfil para escolher o post, mais as opções de valer para qualquer post ou reel e para o próximo post publicado, e o campo da condição da palavra-chave. À direita, um preview de celular mostrando como a DM chega.
Armar uma campanha. Você escolhe o post na grade das suas mídias, ou deixa valendo para qualquer post e para o próximo que publicar. O preview de celular mostra a DM antes de ela existir.
03campanha ao vivo
Campanha ao vivo. Em cima, os cartões de números: 30 envios, 8 cliques, CTR de 26,7 por cento e nenhuma falha. Ao lado, a configuração inteira da campanha: as palavras-chave, as seis variações de resposta pública que são sorteadas, o texto da DM de abertura e o rótulo do botão.
Uma campanha rodando. Envios, cliques, CTR e falhas de um lado; do outro, a configuração inteira, incluindo as seis variações de resposta pública que o sistema sorteia.
04métricas do perfil
Painel de métricas do Instagram, com os números de visualizações, alcance, curtidas, comentários, salvamentos e compartilhamentos, um gráfico de seguidores ao longo do tempo e uma tabela com o desempenho dos posts.
As métricas do perfil. Visualizações, alcance, curtidas, comentários, salvamentos e compartilhamentos, mais o gráfico de seguidores e a tabela de posts, no mesmo lugar das campanhas.

Antes de começar

Não tente fazer esse passo a passo sem usar uma IA como o Claude Code ou o Codex (ChatGPT). Isso tudo foi feito pra você fazer a IA trabalhar pra você e executar o passo a passo.

Tem só algumas partes que você manualmente precisa preencher alguns dados, o resto é a IA que faz pra você.

Difícil? Eu faço pra você

O guia é gratuito e está inteiro aqui: um agente com terminal executa ele do começo ao fim.

Se você é infoprodutor, capta lead todo dia e prefere não montar nada disso, eu implemento na sua conta e te entrego rodando.

Em pouco menos de algumas horas você pode ter em mãos o seu próprio ManyChat, configurado por mim.

Seis perguntas rápidas. Nada do que você responder é enviado ou salvo.

01

O que isso vai te custar de verdade

Dinheiro, tempo, e a parte que ninguém faz no seu lugar.

1.1 O dinheiro

Não tem mensalidade de software. O que você paga é o servidor onde isso roda, e a conta dele não cresce quando a sua audiência cresce.

O custo mensal do que este guia monta
Programazero. O openreply é software livre, licença MIT.
ServidorR$ 26,39 a R$ 56,90 por mês, conforme o plano. O piso é o KVM 1 da Hostinger no plano anual (preço de 09/2026)
Domínioo que você já paga. Um subdomínio não custa nada a mais.
A Metazero. Ela não cobra nada por isso.
Envio de email do loginzero no plano gratuito do Resend, que sobra para uma pessoa entrar no painel
Por que não uma ferramenta pronta

A conta de uma ferramenta de mercado cresce junto com a sua audiência, porque ela cobra por contato: em plano avançado isso passa de R$ 5 mil por ano. A conta do servidor não cresce com nada.

1.2 O tempo

Umas 2 horas do começo ao fim, contando as esperas. A maior parte desse tempo é o seu agente trabalhando enquanto você faz outra coisa. O que consome o seu tempo de verdade são os cliques da seção 03, e a espera do domínio, que pode levar alguns minutos para começar a responder no endereço novo.

1.3 Quem faz o quê

A divisão do trabalho, sem eufemismo
A IA faz por vocêSó você pode fazer
Instalar e configurar tudoCriar a conta de desenvolvedor na Meta
Subir o servidorPublicar o app
Conectar as peçasAceitar um convite pelo aplicativo do Instagram, no celular
Criar as campanhasLigar um interruptor nas configurações do Instagram
Consertar quando quebraContratar o servidor e apontar o domínio

A coluna da direita é clique em painel, não é código. Mesmo assim é onde a maior parte das pessoas trava, porque são telas da Meta e ninguém gosta delas.

1.4 O que você precisa ter antes de começar

Nada disso se resolve no meio do caminho:

  • Uma conta do Instagram Business ou Creator. Conta pessoal não serve, e a troca é no próprio aplicativo, em Configurações, Tipo de conta.
  • Uma conta do Facebook. A Meta não deixa ninguém virar desenvolvedor só pelo Instagram.
  • Um domínio que seja seu, com acesso ao painel onde se editam os endereços dele.
  • Uma conta no Resend (resend.com), que é quem manda o email do seu login no painel. É gratuita.
  • Um cartão para o servidor.
  • Um celular com o aplicativo do Instagram logado na sua conta. Dois passos da seção 03 só existem lá.
  • Uma IA com terminal (Claude Code ou Codex) na sua máquina. É ela que executa tudo.
02

As decisões que só você toma

Cinco perguntas de negócio, antes de qualquer execução. Responda todas antes de abrir a próxima seção.

2.1 Qual conta do Instagram vai receber isso

Precisa ser Business ou Creator. Conta pessoal não tem como receber automação nenhuma, e nem aparece para a Meta como conta de negócio.

Se a sua ainda é pessoal, a troca leva um minuto no aplicativo e não apaga nada. O que muda depois: o perfil ganha as métricas, e passa a poder ser conectado a aplicativos.

2.2 Qual é a palavra-chave

É a palavra que a pessoa comenta para receber o link. Escolha uma que ninguém escreveria por acaso no seu post: EU QUERO, ACESSO, GUIA. Palavra comum demais faz o sistema responder gente que só estava conversando.

Uma palavra por campanha, e você pode ter quantas campanhas quiser, uma para cada post.

2.3 O que você entrega

O link que chega na DM. Ele precisa existir antes: página de captura, checkout, PDF, grupo, o que for. Se você ainda não tem a página, faça ela primeiro, porque o resto aqui é encanamento e encanamento não entrega nada sozinho.

2.4 Você vai exigir que a pessoa te siga

Dá para exigir, e converte seguidor de verdade. Só que o preço é este, e ele não é opcional:

  • Você precisa anunciar a exigência na legenda do post. Prometer o link no comentário e cobrar o follow depois é quebra de regra da Meta, e a conta paga por isso.
  • A DM de quem ainda não te segue cai na aba Pedidos, que muita gente nem sabe que existe.
  • A checagem só é possível depois que a pessoa toca no botão da DM, então o caminho dela fica com duas ações em vez de uma, e parte das pessoas some no meio.

Começar sem exigir e ligar a exigência depois, num post feito para isso, é o caminho mais seguro.

2.5 Qual endereço o seu painel vai ter

O sistema mora num endereço na internet, e ele é seu. O jeito barato é um subdomínio de um domínio que você já tem, como dm.seudominio.com.br: não custa nada a mais e leva um minuto. Se você não tem domínio nenhum, registre um antes.

Escolha o endereço agora e anote. Ele é pedido três vezes daqui em diante, e trocar depois dá trabalho.

03

A parte que é sua, e nenhuma IA faz por você

Sete cliques em telas de terceiros. Cada um diz o que você deve ver quando der certo.

Não peça para a sua IA clicar nestas telas

Navegação automatizada nas telas da Meta já disparou pedido de confirmação de conta e bloqueio temporário. Aqui quem clica é você, e o guia é curto de propósito.

3.1 Criar o app na Meta

Em developers.facebook.com/apps, criando um app do tipo Business e escolhendo o caso de uso que na tela está escrito "Manage messaging and content on Instagram", que quer dizer cuidar das mensagens e do conteúdo do Instagram. Não marque mais nada.

Como você sabe que funcionou: o app aparece na sua lista de aplicativos, e no menu esquerdo dele existe um item chamado Instagram.

Se você já tem outro app da Meta em uso

Crie um app novo, em vez de acrescentar permissões ao que já existe. Permissão nova obriga a reautorizar, e isso derruba integração que estava funcionando.

3.2 Guardar as três senhas do app

O app tem três valores que o seu sistema vai usar para provar que é você. Na tela eles aparecem em inglês, com estes nomes:

Onde cada valor fica no painel da Meta
Como aparece na telaOnde fica, e o que éComo o seu agente chama
"Instagram app ID"menu Instagram, tela "API setup". É o número que identifica o seu app.INSTAGRAM_APP_ID
"Instagram app secret"mesma tela. É a senha desse app.INSTAGRAM_APP_SECRET
"Chave Secreta do Aplicativo"Configurações do app, Básico. É uma segunda senha, do lado Facebook.FACEBOOK_APP_SECRET

A terceira coluna é o apelido que esses valores têm dentro do sistema. Você não precisa dela: ela existe para o seu agente saber onde cada um vai.

Os três são senhas. Não mande para ninguém, não cole em post, em story nem em grupo. Passar para o seu agente, na sua máquina, na hora em que ele pedir, tudo bem: é para lá que eles vão de qualquer jeito.

Como você sabe que funcionou: você tem os três valores copiados em algum lugar seguro, e nenhum deles está em branco.

3.3 Adicionar a sua conta como testadora, e aceitar pelo celular

Dentro do app, adicione a sua conta profissional do Instagram como testadora. Isso manda um convite, e o convite não chega por email: ele fica dentro do aplicativo do Instagram, no celular, em Configurações, Aplicativos e sites, Convites.

Como você sabe que funcionou: no celular, o convite sumiu da lista de pendentes, e no painel da Meta a conta aparece como testadora aceita.

3.4 Ligar o acesso a mensagens no aplicativo

É um interruptor, no seu celular, e ele vem desligado. Desligado, ele derruba a integração inteira sem dar erro nenhum:

caminho dentro do aplicativo do Instagram
Configurações > Mensagens e respostas dos stories > Controles de mensagens >
Ferramentas conectadas > Permitir acesso a mensagens

Como você sabe que funcionou: o interruptor "Permitir acesso a mensagens" está ligado.

3.5 Contratar o servidor e apontar o domínio

Antes de contratar, peça ao seu agente a fase 1 da seção 04: ela gera a chave de acesso que você vai colar no campo de chave SSH da contratação. Com a chave na mão, contrate:

O servidor: Hostinger KVM 1

Folga de sobra para tudo que o sistema sobe ali dentro. Preço do plano anual, de 09/2026.

Contratar o VPS na Hostinger
Plano
KVM 1
Recursos
1 vCPU, 4 GB RAM
Disco
50 GB NVMe
Tráfego
4 TB
Preço
R$ 26,39 por mês

Na contratação são quatro escolhas, e três delas são "nenhum": o sistema que na tela aparece como "Ubuntu 24.04 with Docker" (é o pacote que já vem com o que o seu agente precisa), painel de controle nenhum, aplicativo pré-instalado nenhum, e acesso pela chave que o seu agente gerou.

Depois, no painel do seu domínio, crie um registro do tipo A do subdomínio que você escolheu em 2.5 apontando para o número de IP que a hospedagem te mostrou. É o que faz o endereço abrir no seu servidor.

Como você sabe que funcionou: a hospedagem mostra o servidor como ativo com um número de IP, e o painel do domínio lista o subdomínio novo apontando para esse mesmo número.

3.6 Preencher o endereço da política de privacidade

Em Configurações, Básico, o campo URL da Política de Privacidade é obrigatório para publicar o app. O sistema já serve essa página sozinho, no seu endereço, com /privacy no fim.

Quando fazer este passo

A Meta tenta abrir esse endereço na hora de salvar, então ele só é aceito depois que o seu agente terminar a fase 2 da seção 04 e o painel estiver no ar. Se der erro agora, deixe em branco, siga, e volte aqui depois.

Como você sabe que funcionou: o campo salvou sem reclamar, e o endereço abre uma página de política quando você cola ele no navegador.

3.7 Publicar o app

Este é o passo que, se faltar, faz tudo parecer quebrado sem dar erro nenhum: nenhum comentário chega no sistema, e nada na tela diz por quê. É a armadilha mais cara deste guia.

Menu esquerdo, item Publicar. Com os campos obrigatórios preenchidos, o botão azul habilita.

  • Publicar não expõe você nem o seu app para ninguém. É só o que faz a Meta passar a entregar os comentários de quem não faz parte do app, ou seja, o seu público.
  • Ignore a tela de casos de uso que fica marcando "0 de 1 chamadas de API obrigatórias". Isso é revisão para quem vende o app para terceiros, e não é o seu caso.

Como você sabe que funcionou: o aviso "Não publicado" sumiu do menu esquerdo. Confirme que sumiu, com o olho, antes de seguir.

04

A parte que é do agente

Cinco prompts, um por fase. Você cola, ele executa, e você confere pelo olho.

Como usar esta seção

Abra o Claude Code ou o Codex numa pasta vazia do seu computador e cole o prompt da fase 1. Cada bloco tem botão de copiar. Onde estiver escrito algo entre colchetes, troque pelo seu dado antes de mandar.

Não pule fase, e não cole a próxima antes de conferir o sinal da anterior. Se uma fase não fechar, a seção 06 tem o que colar para consertar.

4.1 Fase 1: preparar a sua máquina

O que vai existir depois disso: a chave de acesso do seu servidor, criada na sua máquina, pronta para ser colada na hora de contratar a hospedagem.

cole isto no seu agente
Você vai montar comigo, do zero, um sistema que responde comentário do
Instagram e manda a DM automática com o link. Eu não sou programadora:
quem executa é você. Fale comigo em português, sem jargão, e me pergunte
sempre que precisar de um dado que só eu tenho.

Nesta primeira etapa você só prepara a minha máquina, porque o servidor
ainda não existe.

O que eu preciso que exista no fim:

1. Conferir se esta máquina já tem git e ssh funcionando, e instalar o
   que faltar. Me avise antes de instalar qualquer coisa.
2. Gerar uma chave de acesso do tipo ed25519, dedicada a este projeto,
   em ~/.ssh/dm-instagram, sem frase secreta. É sem senha de propósito:
   com senha, todo comando automatizado pararia pra pedir a frase.
3. Me mostrar na tela o conteúdo da chave pública (o arquivo terminado
   em .pub), inteiro, pra eu copiar.
4. Anotar num arquivo do projeto onde as duas chaves ficaram.

Regras: não apague nem sobrescreva nenhuma chave que já exista sem me
perguntar, e não tente entrar em servidor nenhum agora.

Quando terminar, me diga em português o que você fez e o que eu devo ver
na tela, e me lembre de que o próximo passo é meu: contratar o servidor
e colar essa chave pública no campo de chave SSH da contratação.
Como você sabe que funcionou

O agente mostra na tela uma linha comprida começando com ssh-ed25519. É ela que vai no campo de chave da contratação, em 3.5.

4.2 Fase 2: subir o sistema no servidor

O que vai existir depois disso: o seu painel no ar, no seu endereço, pedindo o seu email para entrar.

cole isto no seu agente
O servidor já existe e é meu. Você vai subir nele o sistema inteiro. Eu
não vou digitar comando: quem entra no servidor é você, pela chave que
gerou na etapa anterior.

Os meus dados:

- servidor: [o número de IP que a hospedagem me mostrou]
- endereço público do painel: [o meu subdomínio, por exemplo
  dm.seudominio.com.br]
- email que vai poder entrar no painel: [o meu email]

Os segredos (a chave do serviço de email e as três credenciais do app da
Meta) não estão escritos aqui de propósito. Peça um por um, na hora de
escrever o arquivo de configuração, e não repita nenhum deles na tela
depois disso.

O que eu preciso que exista no fim: o painel abrindo no meu endereço,
com cadeado de segurança, pedindo o meu email pra entrar.

Como fazer, e as armadilhas que você precisa conhecer antes:

- O código é o openreply, licença MIT. Clone
  https://github.com/diwenne/openreply.git em /opt/dm-instagram no
  servidor.
- Antes de subir, confira o arquivo lib/meta/client.ts: a lista
  subscribed_fields precisa ter "comments", "messages" E
  "messaging_postbacks". O projeto original não traz o terceiro, e sem
  ele o toque no botão da DM nunca chega. Corrija e confira de novo.
- Em lib/meta/oauth.ts, logue as permissões concedidas assim que a
  resposta do login da Meta chega. É isso que me deixa provar depois que
  a permissão certa foi dada.
- A stack é docker compose com cinco serviços: o proxy que cuida do
  endereço seguro, o site, o trabalhador que processa a fila, o banco de
  dados e o cache.
- O site e o trabalhador usam a MESMA imagem, mudando só o comando. Não
  construa duas imagens diferentes.
- A migração do banco roda no START do site, nunca durante a construção
  da imagem. Durante a construção o banco não existe e tudo quebra.
- Só o proxy expõe porta pra internet, a 80 e a 443. Nenhum outro
  serviço expõe nada pra fora.
- Todos os serviços com reinício automático (restart: unless-stopped).
  Comentário perdido não volta.
- A ENCRYPTION_KEY é gerada uma vez e nunca mais muda. Ela é o que abre
  o acesso do Instagram guardado no banco: trocando, tudo que já foi
  salvo vira lixo.
- Comece com a varredura de comentários antigos DESLIGADA
  (COMMENT_POLL_MAX_PER_SWEEP=0). Ligada, ela pode disparar DM pro
  histórico inteiro de comentários antes de eu conferir qualquer coisa.
- O certificado é automático, mas só sai se o meu subdomínio já estiver
  apontado pro IP do servidor. Confira o apontamento ANTES de subir; se
  não estiver de pé, me avise e espere.

Regras: me pergunte antes de apagar qualquer coisa, e não invente valor
de configuração que eu não te dei.

Quando terminar, me diga em português o que você fez e o que exatamente
eu devo ver ao abrir o meu endereço no navegador.
Como você sabe que funcionou

Você abre o seu endereço no navegador, aparece o cadeado de segurança do lado da barra de endereço, e a página pede o seu email. Você pede o link de acesso, o email chega, e o painel abre vazio.

4.3 Fase 3: conectar a sua conta do Instagram

O que vai existir depois disso: a sua conta do Instagram aparecendo conectada dentro do painel, com foto e arroba.

Esta fase tem um pedaço seu no meio: o agente escreve os cinco valores e você cola no painel da Meta. Ele espera você.

cole isto no seu agente
O sistema está no ar, mas ainda não conhece a minha conta do Instagram.
Esta etapa tem uma parte que é minha, e você vai me guiar nela.

O meu endereço: [o meu subdomínio, por exemplo dm.seudominio.com.br]

Antes de tudo: não abra nem preencha nada nas telas da Meta por mim, nem
com navegador automatizado. Elas respondem a isso com pedido de
confirmação de conta e bloqueio temporário. Quem clica lá sou eu.

O que eu preciso de você, nesta ordem:

1. Escrever na tela, em lista, os valores exatos que eu tenho que colar
   no painel de desenvolvedor da Meta: o endereço de retorno do login, o
   endereço do webhook, o token de verificação que está na configuração
   do servidor, os campos que precisam ficar assinados (comments,
   messages e messaging_postbacks) e o endereço da política de
   privacidade. Escreva cada um completo, pra eu copiar sem pensar.
2. Me avisar de uma coisa que a Meta faz e ninguém espera: toda vez que
   o endereço do webhook muda, ela apaga o token de verificação e deixa
   o campo em branco. Se eu mexer nesse endereço, o token precisa ser
   colado de novo.
3. Esperar eu dizer que colei e salvei.
4. Conferir, do lado do servidor, se a Meta aceitou: o teste de
   verificação do webhook tem que devolver o número que você mandar.
5. Me mandar entrar no painel e apertar o botão de conectar a conta do
   Instagram, e esperar eu terminar.
6. Depois que eu conectar, conferir nos registros do servidor quais
   permissões foram realmente concedidas, e conferir se a minha conta
   ficou assinada nos três campos. Se faltar messaging_postbacks, assine
   de novo pela API, sem me pedir pra desconectar a conta.

Uma regra que vale pra sempre, a partir de agora: nunca desconecte a
minha conta pelo painel. Desconectar apaga em cascata as campanhas e o
histórico de envio, e é o histórico que impede a mesma pessoa de receber
a DM duas vezes.

Quando terminar, me diga em português o que ficou pronto e o que eu devo
ver na tela do painel.
Como você sabe que funcionou

No painel, a sua conta aparece com foto, arroba e o número de seguidores. E o agente te diz, por escrito, que as três permissões ficaram assinadas.

4.4 Fase 4: montar a primeira campanha

O que vai existir depois disso: uma campanha pronta e desligada, com os textos que você aprovou palavra por palavra.

cole isto no seu agente
Agora a primeira campanha. Ela nasce desligada de propósito: eu quero
conferir tudo antes de qualquer pessoa receber alguma coisa.

Os meus dados:

- o post ou reel: [o link do post, por exemplo
  https://www.instagram.com/reel/XXXXXXXXX/]
- a palavra-chave: [a palavra que a pessoa vai comentar, por exemplo
  QUERO]
- o link que eu entrego: [o link da minha página, por exemplo
  https://seudominio.com.br/a-minha-pagina]
- o texto do botão da DM: [até 20 caracteres, por exemplo QUERO O LINK]

Monte a campanha com este desenho. Não é escolha de estilo, é o que a
plataforma permite:

- A resposta pública no comentário tem variações de texto, sorteadas,
  pra não repetir a mesma frase embaixo de todo comentário.
- A primeira DM NÃO leva o link. Ela leva um cartão com um botão, e só o
  toque no botão libera o link. É uma DM por comentário, e não existe
  segunda: se o link for na primeira, eu queimo a única chance.
- A primeira DM precisa dizer que é automática. Uma linha curta basta.
- Deixe a exigência de seguir desligada por enquanto.
- Deixe a campanha PAUSADA no fim.

Sobre ritmo, e isto é um teto real: o aplicativo tem 200 chamadas por
hora no total, e cada comentário gasta umas três. O ritmo que vai valer
depois é de 5 comentários antigos a cada 5 minutos, com janela de busca
de no máximo 72 horas. Deixe isso anotado, mas NÃO ligue agora:
enquanto eu não disser que conferi os textos, a varredura de comentários
antigos fica em zero, pra ela não disparar DM pro histórico do post.

Quando terminar, me diga em português os textos que a pessoa vai
receber, palavra por palavra, e me diga onde eu vejo essa campanha no
painel.
Como você sabe que funcionou

A campanha aparece na lista do painel com o interruptor desligado, e o preview de celular mostra a DM exatamente como a pessoa vai receber.

4.5 Fase 5: colocar no ar

O que vai existir depois disso: a automação rodando, testada por você com um comentário de verdade.

cole isto no seu agente
Conferi os textos. Agora é colocar no ar, com um teste de verdade antes
de eu anunciar isso pra qualquer pessoa.

O que eu preciso que aconteça, nesta ordem:

1. Ligar a campanha.
2. Me mandar comentar a palavra-chave no post de uma OUTRA conta do
   Instagram, que não seja a minha, pra o teste ser igual ao de uma
   pessoa de fora.
3. Esperar, e me perguntar se a DM chegou no celular dessa outra conta.
   Se essa conta ainda não me segue, a DM cai na aba Pedidos, não na
   caixa de entrada. Me lembre de procurar lá.
4. Me mandar tocar no botão da DM e conferir se o link chega.
5. Só depois que isso funcionar, ligar a varredura de comentários
   antigos no ritmo combinado, 5 a cada 5 minutos.
6. Conferir que os cinco serviços voltam sozinhos se o servidor
   reiniciar.
7. Me deixar por escrito, num arquivo, três coisas: como ligar e
   desligar uma campanha, como ver os números, e o que nunca fazer
   (desconectar a conta, e trocar a chave de criptografia).

Se em algum desses passos nada acontecer, não mude o código no escuro:
me diga o que você viu e o que pretende tentar, antes de tentar.

Quando terminar, me diga em português o que está no ar e o que eu devo
ver no painel daqui a uma hora.
Como você sabe que funcionou

Você comenta a palavra-chave de outra conta, a DM com o botão chega no celular, você toca no botão e o link chega. Depois disso, os números começam a subir na tela da campanha.

05

As regras do Instagram que mudam o que você promete

Nenhuma delas tem conserto no código. Elas decidem o que cabe na legenda do seu post.

Cada uma destas é regra da plataforma, e cada uma custa caro quando é esquecida na hora de prometer alguma coisa para o seu público.

  • Uma DM por comentário, e só uma.Não existe segunda mensagem no mesmo comentário. É por isso que a primeira mensagem é um cartão com botão, e não o link: mandando o link direto, você queima a única chance que tinha.
  • Sete dias, e acabou.Comentário mais velho que isso não recebe DM nenhuma. Post antigo que voltou a bombar responde ao comentário novo, nunca ao que ficou para trás.
  • A DM cai na aba Pedidos de quem não te segue.Não na caixa de entrada. É o maior ralo do mecanismo, e é a razão de o botão existir: o toque tira a conversa dos Pedidos.
  • O toque no botão abre uma janela de 24 horas.Dentro dela você pode mandar o que quiser, inclusive oferta. Fora dela, silêncio até a pessoa falar de novo.
  • Comentar não é consentimento.A Meta só deixa você checar se a pessoa te segue depois que ela interage na DM. Por isso o gate de seguir exige duas ações dela, e não uma.
  • A primeira DM precisa dizer que é automática.Uma linha curta resolve. É regra escrita, e vale para qualquer automação de mensagem.
  • Exigiu seguir, anuncia na legenda.Prometer o link e cobrar o follow depois cai na regra de não entregar o que foi prometido. A conta que faz isso paga em alcance.
  • O volume tem teto, e é mais baixo do que parece.Um app de uso próprio tem 200 chamadas por hora no total, e cada comentário gasta umas três. Na prática são uns 60 comentários por hora. Post que estoura não perde ninguém, só demora mais a atender a fila.
06

Quando travar

O que você está vendo, por que acontece, e o que colar para o seu agente resolver.

6.1 Ninguém recebe nada, e não aparece erro nenhum

O que você está vendo

O post tem comentários com a palavra-chave, a campanha está ligada, o painel está no ar, e nada acontece. Nenhuma mensagem de erro em lugar nenhum.

Por que

O app na Meta não foi publicado. Nesse estado a Meta entrega só os comentários de quem tem papel dentro do app, o que exclui exatamente o seu público, e ela faz isso em silêncio, respondendo normalmente a tudo.

O que colar pro seu agente
app não publicado
Os comentários do meu post não estão chegando no sistema, e não aparece
erro nenhum. Confira comigo se o meu aplicativo na Meta está publicado
(em modo Live) e não em desenvolvimento. Me diga onde exatamente eu
clico pra ver isso, sem abrir a tela por mim. Depois que eu publicar,
confira do seu lado se os comentários passaram a chegar.

É a armadilha mais cara deste guia, e a única que não dá sinal nenhum. Antes de investigar qualquer outra coisa, confirme que o aviso "Não publicado" sumiu do menu esquerdo do app.

6.2 A DM chega, a pessoa toca no botão, e nada acontece

O que você está vendo

O cartão com o botão chegou, o toque acontece, e o link nunca é entregue. Nenhum erro para ninguém.

Por que

O toque no botão é um tipo de aviso que a Meta só manda se a sua conta estiver assinada nele. O projeto original não assina esse tipo, e sem a correção o aviso nunca chega no seu servidor.

O que colar pro seu agente
o toque no botão não chega
A DM chega, a pessoa toca no botão e não acontece nada. Confira se a
minha conta está assinada no campo messaging_postbacks, além de comments
e messages. Se não estiver, assine de novo pela API, sem me pedir pra
desconectar a conta, e confira que o toque passou a chegar.

6.3 O seu endereço não abre, ou abre com aviso de site inseguro

O que você está vendo

O navegador não encontra o endereço, ou abre uma tela vermelha dizendo que a conexão não é particular.

Por que

O subdomínio ainda não aponta para o servidor, ou aponta para o número errado. O certificado de segurança é emitido automaticamente, mas só sai depois que o endereço responde no lugar certo.

O que colar pro seu agente
endereço ou certificado
O meu endereço não abre, ou abre com aviso de certificado inválido.
Confira se o meu subdomínio está apontando pro IP do servidor e se o
proxy conseguiu emitir o certificado. Me diga em português o que está
errado e o que eu preciso corrigir no painel do meu domínio.

6.4 Funcionava, e parou depois que você mexeu no painel da Meta

O que você está vendo

Os comentários pararam de chegar logo depois de você editar, nas configurações do app, o endereço para onde a Meta manda os avisos. Na tela ele fica em Webhooks e se chama "Callback URL".

Por que

Toda vez que esse endereço muda, a Meta apaga o código do campo ao lado (na tela, "Verify token", que é a senha com que ela se identifica no seu servidor) e deixa ele em branco, sem avisar. Sem esse código, ela para de entregar.

O que colar pro seu agente
o código de verificação foi apagado
Parei de receber comentários depois de mexer no endereço do webhook no
painel da Meta. Me passe de novo o token de verificação que está na
configuração do servidor, pra eu colar no campo que ficou em branco, e
depois confira do seu lado se a verificação voltou a responder.

6.5 Aparece um erro falando em papel de desenvolvedor

O que você está vendo

Uma mensagem em inglês com as palavras "Insufficient Developer Role", em geral na hora de conectar a conta.

Por que

O convite de conta testadora não foi aceito. Ele não chega por email: mora dentro do aplicativo do Instagram e é fácil de nunca ver.

O que colar pro seu agente
convite não aceito
Apareceu um erro dizendo que falta papel de desenvolvedor ("Insufficient
Developer Role"). Isso quer dizer que o convite de conta testadora não
foi aceito. Me diga o caminho exato dentro do aplicativo do Instagram
pra eu achar e aceitar esse convite, e depois confira se o acesso passou
a funcionar.

6.6 A campanha e o histórico sumiram depois de reconectar a conta

O que você está vendo

Você desconectou e reconectou a conta no painel, e a campanha não está mais lá. Os números também não.

Por que

Desconectar apaga em cascata tudo que estava pendurado naquela conta: campanhas e histórico de envio. Reconectar cria uma conta nova, do zero, para o sistema.

O que colar pro seu agente

Não tem conserto: é recriar a campanha, e o histórico não volta. Como é o histórico que impede a mesma pessoa de receber a DM duas vezes, quem comentou antes pode receber de novo. A regra que evita isso é uma só: nunca desconecte a conta com campanha ativa.

6.7 Alguma outra coisa parou, e você não sabe o quê

O que você está vendo

Qualquer sintoma que não está nesta lista.

O que colar pro seu agente
diagnóstico geral, antes de mexer em qualquer coisa
Uma coisa parou de funcionar e eu não sei o que é. Sem mudar nada ainda,
olhe o estado do sistema no servidor e me diga em português: os cinco
serviços estão de pé, o banco e a fila respondem, a minha conta do
Instagram continua conectada, e a campanha está ligada. Depois me diga
qual é a sua melhor hipótese e o que você quer tentar primeiro.

6.8 O que este guia não resolve, e você vai precisar resolver

Três pendências reais, ditas na cara. Nenhuma impede o sistema de rodar, e todas cobram um dia.

  • Cópia de segurança do banco.Os seus dados vivem só nesse servidor. Peça ao seu agente uma cópia automática guardada fora dele: é a diferença entre um susto e uma perda.
  • A exigência de seguir em produção.O sistema já sabe fazer, mas ela só deve entrar num post cuja legenda anuncie a exigência.
  • Atualizar o programa depois.Como duas correções foram feitas por cima do projeto original, atualizar exige alguém que saiba resolver o encontro das duas versões. Não é automático.
Uma linha honesta

Se você travar aqui e o seu agente não resolver em duas ou três tentativas, é sinal de que essa parte é técnica de verdade. Nesse caso existe o atalho da seção de oferta lá em cima, e não tem nada de errado em usar.

AP

Apêndice técnico

Isto aqui é para o seu agente ler, ou para você, se quiser entender o que está acontecendo por baixo. Não é preciso ler nada disto para montar.

O que tem aqui

Tudo que o corpo do guia deixou de fora por ser técnico está aqui inteiro, sem resumo: os comandos, os arquivos de configuração, as duas correções no código, o programa de linha de comando das campanhas, e o caminho de rodar na sua própria máquina antes.

Se você contratou uma IA para montar isso, mande ela ler esta parte.

O que cada parte do apêndice tem

A1

O servidor, o acesso e o domínio

A chave de acesso, o primeiro login e o apontamento do subdomínio, comando por comando.

A1.1 A configuração na contratação

Quatro escolhas, três delas "nenhum":

O que marcar na contratação
SOUbuntu 24.04 LTS com Docker
Painelnenhum. cPanel, Plesk e CyberPanel tomam as portas 80 e 443, que o Caddy precisa.
Appsnenhum. Nada de Dokploy, Coolify ou CapRover: a stack já é Docker Compose.
Acessochave SSH ed25519 dedicada, gerada no passo A1.2 antes de finalizar a compra.

A1.2 Gerar a chave SSH, antes de finalizar

Na sua máquina, não no servidor:

terminal, na sua máquina
ssh-keygen -t ed25519 -f ~/.ssh/dm-instagram -N "" -C "dm-instagram"
cat ~/.ssh/dm-instagram.pub

Cole a pública no campo de chave SSH da contratação. Sem senha de propósito: com senha, todo comando automatizado pararia para pedir a frase.

A1.3 Primeiro acesso e conferência do Docker

terminal, na sua máquina
ssh -i ~/.ssh/dm-instagram root@<IP_DO_VPS>
docker --version
docker compose version
Saída esperada
Docker version 27.x.x, build xxxxxxx
Docker Compose version v2.x.x

Se falhar

  • Permission denied (publickey): a chave que você colou na contratação não é a mesma que o -i aponta. Confira com cat ~/.ssh/dm-instagram.pub contra o painel.
  • docker: command not found: o template escolhido não era o com Docker. Instale com curl -fsSL https://get.docker.com | sh.
  • Conexão que trava: o servidor ainda está provisionando.

A1.4 Apontar o subdomínio para o servidor

Um registro A do subdomínio escolhido em 2.5 para o IP do VPS, propagado antes de subir a stack: o Caddy pede o certificado TLS no primeiro acesso.

terminal, na sua máquina
dig +short dm.seudominio.com.br
Saída esperada
<IP_DO_VPS>

Se a saída vier vazia

Não propagou, ou está no lugar errado. Repita o dig: com DNS errado, o Caddy gasta tentativas de emissão de certificado.

A2

O código, os patches, o ambiente e a stack

Clonar, corrigir, escrever o .env, o docker-compose.prod.yml e o Caddyfile, e subir.

A2.1 Clonar o openreply

terminal, no servidor
mkdir -p /opt && cd /opt
git clone https://github.com/diwenne/openreply.git dm-instagram
cd /opt/dm-instagram

O nome da pasta é livre, e aparece em todos os comandos seguintes.

A2.2 Patch 1, obrigatório: assinar messaging_postbacks

O upstream assina só comments e messages. O toque chega por messaging_postbacks, e sem ele o fluxo morre em silêncio.

Isto é checagem, não edição cega: o upstream pode já ter corrigido.

terminal, no servidor
grep -n "subscribed_fields" lib/meta/client.ts

Se já tiver messaging_postbacks, pule para A2.3. Se vier assim, o patch é necessário:

Saída que exige o patch
760:        subscribed_fields: ["comments", "messages"],
terminal, no servidor
sed -i 's/subscribed_fields: \["comments", "messages"\]/subscribed_fields: ["comments", "messages", "messaging_postbacks"]/' lib/meta/client.ts
grep -n "subscribed_fields" lib/meta/client.ts
Saída esperada da segunda conferência
760:        subscribed_fields: ["comments", "messages", "messaging_postbacks"],

Se o sed não casar

O upstream mudou a formatação. Edite a linha que o grep apontou, dentro de subscribeInstagramAccountToWebhooks, e confira com o grep de novo.

A ordem importa

Aplique antes do primeiro up --build: a imagem congela o lib/ no build. Com o patch antes da primeira conexão, a re-assinatura de A8.2 nunca é necessária.

A2.3 Patch 2, recomendado: logar as permissões concedidas

A tela de consentimento deixa desligar permissão individual, e a que falta não vira erro: vira aresta devolvendo array vazio depois, com comments como vítima usual.

terminal, no servidor
grep -n "const data = await response.json();" lib/meta/oauth.ts

Em exchangeCodeForToken, depois da linha que o grep achou:

lib/meta/oauth.ts
  const data = await response.json();
+ console.log(
+   "[Instagram OAuth] permissoes concedidas:",
+   JSON.stringify(data.permissions ?? null)
+ );
  return {
    accessToken: data.access_token,

A2.4 Gerar os segredos

Com o openssl que o Ubuntu já traz:

terminal, no servidor
openssl rand -base64 32   # serve pro NEXTAUTH_SECRET e pro CRON_SECRET
openssl rand -hex 32      # serve pro ENCRYPTION_KEY (64 caracteres)
openssl rand -hex 16      # serve pro WEBHOOK_VERIFY_TOKEN

Se o openssl não existir

alternativa pelo Docker
docker run --rm node:20-slim node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"

A2.5 Escrever o .env de produção

Substitua os valores entre < e >.

/opt/dm-instagram/.env
# endereço público do painel, o mesmo que vai no Caddyfile
NEXTAUTH_URL=https://dm.seudominio.com.br
NEXTAUTH_SECRET=<openssl rand -base64 32>
CRON_SECRET=<openssl rand -base64 32>
ENCRYPTION_KEY=<openssl rand -hex 32>

# nomes de serviço do compose, nunca localhost
DATABASE_URL=postgresql://postgres:postgres@postgres:5432/openreply
REDIS_URL=redis://redis:6379

# login por magic link. sem allowlist, qualquer um que chegar na URL
# pública pede um link e ganha o próprio workspace.
ALLOWED_EMAILS=voce@exemplo.com
RESEND_API_KEY=re_xxxxxxxxxxxxxxxxxxxx
EMAIL_FROM=onboarding@resend.dev

# Meta
META_GRAPH_API_VERSION=v25.0
INSTAGRAM_APP_ID=<do painel da Meta>
INSTAGRAM_APP_SECRET=<do painel da Meta>
FACEBOOK_APP_SECRET=<do painel da Meta>
WEBHOOK_VERIFY_TOKEN=<openssl rand -hex 16>

# ritmo da varredura de comentários antigos.
# 0 desliga o backfill: só comentário novo, via webhook.
COMMENT_POLL_MAX_PER_SWEEP=0
COMMENT_POLL_INTERVAL_MS=300000
COMMENT_POLL_LOOKBACK_HOURS=72
  • DATABASE_URL e REDIS_URL usam nomes de serviço do compose, nunca localhost, que ali é o próprio container.
  • ALLOWED_EMAILS não é opcional na prática: sem ele, qualquer um que chegue na URL pública pede um magic link e ganha o próprio workspace.
  • onboarding@resend.dev é o remetente de teste do Resend e só entrega no email do dono da conta. Para uso próprio basta.
  • COMMENT_POLL_MAX_PER_SWEEP=0 deixa o backfill desligado: só comentário novo é atendido. Você sobe esse número em A4.4.
A chave que não pode mudar nunca

É com ela que o token do Instagram é cifrado no banco: trocá-la deixa o token ilegível e a conta conectada vira lixo. Guarde uma cópia fora do servidor.

A2.6 Escrever o docker-compose.prod.yml

O repositório traz o Dockerfile, mas não o compose de produção. Crie este, exatamente:

/opt/dm-instagram/docker-compose.prod.yml
# Stack de producao: caddy (TLS), web, worker, postgres, redis.
# Subir com: docker compose -f docker-compose.prod.yml up -d --build
services:
  caddy:
    image: caddy:2-alpine
    restart: unless-stopped
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./Caddyfile:/etc/caddy/Caddyfile:ro
      - caddydata:/data
      - caddyconfig:/config
    depends_on:
      - web

  web:
    build: .
    restart: unless-stopped
    env_file: .env
    command: sh -c "npx prisma migrate deploy && npm run start"
    depends_on:
      postgres:
        condition: service_healthy
      redis:
        condition: service_healthy
    expose:
      - "3000"

  worker:
    build: .
    restart: unless-stopped
    env_file: .env
    command: ["npm", "run", "worker"]
    depends_on:
      postgres:
        condition: service_healthy
      redis:
        condition: service_healthy

  postgres:
    image: postgres:16
    restart: unless-stopped
    environment:
      POSTGRES_DB: openreply
      POSTGRES_USER: postgres
      POSTGRES_PASSWORD: postgres
    volumes:
      - pgdata:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U postgres"]
      interval: 5s
      timeout: 5s
      retries: 5

  redis:
    image: redis:7-alpine
    restart: unless-stopped
    volumes:
      - redisdata:/data
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 5s
      timeout: 5s
      retries: 5

volumes:
  pgdata:
  redisdata:
  caddydata:
  caddyconfig:

O que ele resolve:

  • web e worker usam a mesma imagem, mudando só o command. O worker é processo longo, e não cabe em função serverless.
  • A migration roda no start do web, nunca no build, porque durante o build o banco ainda não existe na rede.
  • Só o Caddy expõe porta, e o TLS é automático. Postgres e Redis ficam na rede interna.
  • restart: unless-stopped em tudo, para o servidor voltar sozinho de um reboot.

A2.7 Escrever o Caddyfile

/opt/dm-instagram/Caddyfile
dm.seudominio.com.br {
	encode zstd gzip
	reverse_proxy web:3000
}

Troque dm.seudominio.com.br pelo seu subdomínio.

A2.8 Validar antes de subir

terminal, no servidor
cd /opt/dm-instagram
docker compose -f docker-compose.prod.yml config -q && echo "compose ok"
Saída esperada
compose ok

Qualquer outra saída é erro de YAML, e o comando diz a linha.

A2.9 Subir a stack

O primeiro build compila o Next e leva alguns minutos.

terminal, no servidor
docker compose -f docker-compose.prod.yml up -d --build
docker compose -f docker-compose.prod.yml ps
Saída esperada de ps
NAME                     SERVICE    STATUS         PORTS
dm-instagram-caddy-1     caddy      Up (healthy)   0.0.0.0:80->80/tcp, 0.0.0.0:443->443/tcp
dm-instagram-postgres-1  postgres   Up (healthy)
dm-instagram-redis-1     redis      Up (healthy)
dm-instagram-web-1       web        Up             3000/tcp
dm-instagram-worker-1    worker     Up

A checagem que prova que os quatro processos se enxergam:

terminal, no servidor ou na sua máquina
curl -s https://dm.seudominio.com.br/api/health
Saída esperada
{"status":"ok","checks":{"database":{"status":"ok"},"redis":{"status":"ok","detail":"PONG"},
"queue":{"status":"ok"},"worker":{"healthy":true}}}

Se falhar

  • O curl trava ou dá timeout. A porta 443 não está aberta: confira o firewall no painel da Hostinger e o ufw status no servidor. Se estiver ativo, ufw allow 80,443/tcp.
  • Erro de certificado TLS. DNS não propagado, ou registro A em outro IP. Corrija em A1.4 e reinicie o Caddy com docker compose -f docker-compose.prod.yml restart caddy.
  • web em Restarting. A migration falhou. Leia o log.
  • worker.healthy vem false. O worker não conectou no Redis: confira REDIS_URL=redis://redis:6379 no .env.
terminal, no servidor
docker compose -f docker-compose.prod.yml logs --tail=50 web
docker compose -f docker-compose.prod.yml logs --tail=50 caddy
A3

Conectar a conta, e provar que subiu

Os endereços no painel da Meta, o handshake do webhook e as três provas de que a conta ficou certa.

A3.1 Registrar OAuth e webhook no painel da Meta

No painel do app, menu Instagram, tela de API setup:

campos a preencher no painel da Meta
OAuth redirect:            https://dm.seudominio.com.br/api/instagram/callback
Webhook callback:          https://dm.seudominio.com.br/api/webhook
Verify token:              o valor de WEBHOOK_VERIFY_TOKEN do seu .env
Campos assinados:          comments, messages, messaging_postbacks
Política de privacidade:   https://dm.seudominio.com.br/privacy

Se você pulou 3.6 e 3.7, faça os dois agora. O app precisa estar publicado, e a 6.1 mostra o que acontece quando não está.

A3.2 Provar o handshake do webhook

É o que a Meta faz ao registrar a URL. Rode antes de salvar no painel:

terminal, no servidor
TOK=$(grep WEBHOOK_VERIFY_TOKEN /opt/dm-instagram/.env | cut -d= -f2)
curl -s "https://dm.seudominio.com.br/api/webhook?hub.mode=subscribe&hub.verify_token=$TOK&hub.challenge=999888"
Saída esperada
999888

Se vier isto, com HTTP 403

resposta de token errado
{"success":false,"error":"Verification failed"}

O token do painel não é o do .env. Copie do arquivo, e lembre que a Meta limpa esse campo toda vez que a URL de callback muda.

A3.3 Conectar a conta do Instagram

Abra o domínio, peça o magic link com o email de ALLOWED_EMAILS, vá em /settings, conecte o Instagram e autorize.

Nunca use o botão de desconectar

Desconectar apaga campanhas e logs em cascata, sem volta (6.6). Para reautorizar escopos, refaça o OAuth por cima, clicando em conectar de novo.

A3.4 Prova 1: as permissões que foram realmente concedidas

A linha do patch 2, e a checagem mais barata do guia:

terminal, no servidor
docker compose -f docker-compose.prod.yml logs web | grep "permissoes concedidas"
Saída esperada
[Instagram OAuth] permissoes concedidas: ["instagram_business_basic","instagram_business_manage_comments","instagram_business_manage_messages","instagram_business_manage_insights"]

Se faltar algum escopo na lista

A permissão foi desligada na tela de consentimento. Refaça o OAuth por cima, aceitando todas, e não use o botão de desconectar.

A3.5 Prova 2: o que o banco registrou

terminal, no servidor
docker compose -f docker-compose.prod.yml exec -T postgres \
  psql -U postgres -d openreply \
  -c 'select username, "instagramId", "webhookSubscribed", "tokenExpiresAt" from "InstagramAccount";'
Saída esperada
   username    |   instagramId   | webhookSubscribed |     tokenExpiresAt
---------------+-----------------+-------------------+------------------------
 seu_usuario   | 178414xxxxxxxxx | t                 | 2026-11-03 21:14:02+00

Confirmam a conexão o webhookSubscribed = t e um tokenExpiresAt daqui a uns 60 dias. O token se renova sozinho pelo cron.

A3.6 Prova 3: o que a Meta acha que assinou

O banco registra o que o app achou que fez. Esta é a leitura do outro lado, opcional porque exige um token em mãos:

chamada opcional, com token
GET https://graph.instagram.com/<VERSAO>/<IG_ID>/subscribed_apps
Saída esperada, com os três campos
{"data":[{"id":"...","subscribed_fields":["comments","messages","messaging_postbacks"]}]}

Sem messaging_postbacks na lista, o patch 1 não pegou: volte a A2.2, reconstrua a imagem e reconecte a conta.

A4

A campanha, no padrão card com botão

Os quatro campos, os textos que rodaram de verdade e o ritmo da varredura.

A4.1 Por que não mandar o link direto

Mandar o link na private reply gasta a única mensagem que você tem: sem o toque, a janela de 24 horas não abre, a conversa não sai dos Pedidos e o follow não pode ser checado. O padrão certo é o opening DM:

os quatro campos da campanha
openingDmEnabled       = true
openingDmMessage       = o texto do card
openingDmButtonLabel   = o rótulo do botão (máximo 20 caracteres)
dmMessage              = a mensagem de revelação, com o link

Com requireFollow = false o botão carrega reveal:<id>; com true, followcheck:<id>, e o toque consulta is_user_follow_business antes de entregar. Com null da Meta, fail-open, para seguidor de verdade não ficar preso.

A4.2 Os textos que rodaram de verdade

Isto é um exemplo que rodou, não um template

Os textos abaixo carregam a voz de um perfil específico. Escreva os seus: o que importa é o formato, o comprimento e a linha de declaração de automação.

Resposta pública, 6 variações sorteadas para não parecer robô:

publicReplyMessages
te mandei na dm :3
tá na sua dm ( ˶ˆᗜˆ˵ )
acabei de mandar ali ٩(ˊᗜˋ)و
voa no direct que tá lá (。•̀ᴗ-)✧
mandei~ (・∀・)
olha a dm ✧ ( ˶ˆ ᗜ ˆ˵ )

O card, que é a private reply e a única mensagem garantida:

openingDmMessage
oi {username} :3
toca no botão que eu mando os comandos
(msg automática)

{username} vira o @ de quem comentou, e (msg automática) cumpre a exigência de declarar a automação. Curto de propósito: texto longo em DM não é lido.

A revelação, depois do toque:

dmMessage
aqui ó ( ˶ˆᗜˆ˵ )

https://seudominio.com.br/sua-pagina

O rótulo tem teto de 20 caracteres, imposto pela Meta. O que rodou foi QUERO OS COMANDOS, com 17.

A4.3 Link rastreado ou link cru

O botão aponta para NEXTAUTH_URL/r/<slug>, e com domínio fixo é a escolha certa: a URL é gravada na DM no envio, e domínio que não muda nunca quebra o link.

A4.4 O ritmo da varredura

Duas variáveis controlam os comentários anteriores à automação. Mudá-las no .env exige docker compose -f docker-compose.prod.yml up -d.

/opt/dm-instagram/.env
COMMENT_POLL_MAX_PER_SWEEP=5        # comentários antigos por passada
COMMENT_POLL_INTERVAL_MS=300000     # 5 minutos entre passadas
COMMENT_POLL_LOOKBACK_HOURS=72      # teto útil 168, que é a janela da private reply

5 por passada a cada 5 minutos dá 60 comentários por hora, aproximadamente 180 chamadas, abaixo do teto de 200. O padrão do upstream é 30, que daria 360 e levaria erro da Meta.

=0 desliga o backfill: só comentário novo, e é o modo do primeiro teste. A varredura olha 72 horas por padrão, com teto útil de 168, a janela da private reply.

A5

Operar por linha de comando

O scripts/campanha.ts inteiro, e como armar, ligar e medir uma campanha sem abrir o painel.

A5.1 Criar o arquivo

Ele só importa de caminhos que já existem no upstream, e roda no container web pelo tsx, que já está na imagem.

/opt/dm-instagram/scripts/campanha.ts
/**
 * CLI de campanhas: cria, lista, liga e desliga automação sem abrir o painel.
 *
 * Roda dentro do container web em produção:
 *   docker compose -f docker-compose.prod.yml exec -T web npx tsx scripts/campanha.ts <comando> [flags]
 *
 * Comandos:
 *   posts [n]        lista os posts recentes com id, data e nº de comentários
 *   listar           lista as campanhas com estado e contadores
 *   numeros          total de envios por status
 *   criar [flags]    cria uma campanha (nasce PAUSADA, salvo --ativar)
 *   ligar <id>       ativa uma campanha
 *   desligar <id>    pausa uma campanha
 *
 * Flags de criar:
 *   --post <id|url>     id da mídia ou o link do post/reel (obrigatório, ou --proximo)
 *   --proximo           arma pro próximo post publicado, em vez de um post específico
 *   --qualquer-post     vale pra qualquer post recente do perfil (varre as 10 últimas mídias)
 *   --dm                também dispara quando a palavra chega por DM direta
 *   --palavras "a,b"    palavras-chave separadas por vírgula (obrigatório)
 *   --link <url>        destino entregue depois do toque (obrigatório)
 *   --nome "..."        nome da campanha (default: derivado das palavras)
 *   --abertura "..."    texto do primeiro card (default abaixo)
 *   --botao "..."       rótulo do botão, teto de 20 caracteres da Meta
 *   --revelacao "..."   texto entregue depois do toque, use {link} pro endereço
 *   --publicas "a|b|c"  variações da resposta pública, separadas por barra
 *   --follow            exige seguir antes de entregar o link
 *   --ativar            já nasce ativa
 */

import { prisma } from "@/lib/db/client";
import { decryptToken } from "@/lib/meta/oauth";
import { generateTrackedLinkSlug } from "@/lib/tracking/server";

const ABERTURA_PADRAO = `oi {username} :3
toca no botão que eu te mando na hora
(msg automática)`;

const REVELACAO_PADRAO = `aqui ó ( ˶ˆᗜˆ˵ )

{link}`;

const PUBLICAS_PADRAO = [
  "te mandei na dm :3",
  "tá na sua dm ( ˶ˆᗜˆ˵ )",
  "acabei de mandar ali ٩(ˊᗜˋ)و",
  "voa no direct que tá lá (。•̀ᴗ-)✧",
  "mandei~ (・∀・)",
  "olha a dm ✧ ( ˶ˆ ᗜ ˆ˵ )",
];

const FOLLOW_PADRAO = `antes de mandar, um favor: me segue lá :3
depois toca de novo que o link vem`;

interface Flags {
  [key: string]: string | boolean;
}

function parseFlags(argv: string[]): { positional: string[]; flags: Flags } {
  const positional: string[] = [];
  const flags: Flags = {};
  for (let i = 0; i < argv.length; i++) {
    const a = argv[i];
    if (a.startsWith("--")) {
      const key = a.slice(2);
      const next = argv[i + 1];
      if (next && !next.startsWith("--")) {
        flags[key] = next;
        i++;
      } else {
        flags[key] = true;
      }
    } else {
      positional.push(a);
    }
  }
  return { positional, flags };
}

function str(flags: Flags, key: string): string | undefined {
  const v = flags[key];
  return typeof v === "string" ? v : undefined;
}

async function contaConectada() {
  const acc = await prisma.instagramAccount.findFirst();
  if (!acc) throw new Error("nenhuma conta do Instagram conectada");
  return acc;
}

async function listarMidias(limite: number) {
  const acc = await contaConectada();
  const token = decryptToken(acc.accessToken);
  const url = new URL(
    `https://graph.instagram.com/${process.env.META_GRAPH_API_VERSION ?? "v25.0"}/${acc.instagramId}/media`
  );
  url.searchParams.set(
    "fields",
    "id,caption,media_product_type,permalink,timestamp,comments_count"
  );
  url.searchParams.set("limit", String(limite));
  const res = await fetch(url, { headers: { Authorization: `Bearer ${token}` } });
  const json = (await res.json()) as {
    data?: Array<{
      id: string;
      caption?: string;
      permalink?: string;
      timestamp?: string;
      comments_count?: number;
      media_product_type?: string;
    }>;
    error?: { message: string };
  };
  if (json.error) throw new Error(`Meta: ${json.error.message}`);
  return json.data ?? [];
}

/** Aceita o id cru da mídia ou o link do post, resolvendo pelo permalink. */
async function resolverPost(alvo: string): Promise<string> {
  if (/^\d+$/.test(alvo)) return alvo;
  const midias = await listarMidias(50);
  const limpo = alvo.split("?")[0].replace(/\/$/, "");
  const achou = midias.find(
    (m) => (m.permalink ?? "").replace(/\/$/, "") === limpo
  );
  if (!achou) {
    throw new Error(
      `não achei esse post entre as 50 mídias mais recentes: ${alvo}. rode "posts" e passe o id.`
    );
  }
  return achou.id;
}

async function cmdPosts(n: number) {
  for (const m of await listarMidias(n)) {
    const cap = (m.caption ?? "").replace(/\s+/g, " ").slice(0, 55);
    console.log(
      `${m.id} | ${m.timestamp?.slice(0, 10)} | ${String(m.comments_count ?? 0).padStart(4)} coments | ${m.media_product_type} | ${cap}`
    );
  }
}

async function cmdListar() {
  const campanhas = await prisma.automation.findMany({
    orderBy: { createdAt: "desc" },
    include: { _count: { select: { dmLogs: true } }, trackedLinks: true },
  });
  if (campanhas.length === 0) {
    console.log("nenhuma campanha");
    return;
  }
  for (const c of campanhas) {
    const alvo = c.matchAnyPost
      ? "qualquer post"
      : c.pendingNextReel
        ? "próximo post"
        : c.postId;
    console.log(
      `${c.id} | ${c.isActive ? "ATIVA " : "pausada"} | ${c.keywords.join(",")} | post ${alvo} | ${c._count.dmLogs} envios | follow:${c.requireFollow ? "sim" : "nao"} | ${c.name}`
    );
  }
}

async function cmdNumeros() {
  const linhas = await prisma.dmLog.groupBy({
    by: ["status"],
    _count: { _all: true },
  });
  if (linhas.length === 0) {
    console.log("nenhum envio ainda");
    return;
  }
  for (const l of linhas) console.log(`${l.status}: ${l._count._all}`);
}

async function cmdCriar(flags: Flags) {
  const acc = await contaConectada();

  const palavrasBrutas = str(flags, "palavras");
  if (!palavrasBrutas) throw new Error("falta --palavras");
  const keywords = palavrasBrutas
    .split(",")
    .map((k) => k.trim())
    .filter(Boolean);
  if (keywords.length === 0) throw new Error("--palavras veio vazio");

  const link = str(flags, "link");
  if (!link) throw new Error("falta --link");
  try {
    new URL(link);
  } catch {
    throw new Error(`--link não é uma URL válida: ${link}`);
  }

  const proximo = flags.proximo === true;
  const qualquerPost = flags["qualquer-post"] === true;
  const dmTrigger = flags.dm === true;
  const alvoPost = str(flags, "post");
  if (proximo && qualquerPost) {
    throw new Error("--proximo e --qualquer-post são excludentes: escolha um.");
  }
  if (!proximo && !qualquerPost && !alvoPost) {
    throw new Error("falta --post (ou use --proximo, ou --qualquer-post)");
  }
  const postId = proximo || qualquerPost ? null : await resolverPost(alvoPost as string);

  const botao = (str(flags, "botao") ?? "QUERO O LINK").slice(0, 20);
  const abertura = str(flags, "abertura") ?? ABERTURA_PADRAO;
  const revelacao = str(flags, "revelacao") ?? REVELACAO_PADRAO;
  const publicas = str(flags, "publicas")
    ? (str(flags, "publicas") as string).split("|").map((p) => p.trim()).filter(Boolean)
    : PUBLICAS_PADRAO;
  const exigeFollow = flags.follow === true;
  const nome =
    str(flags, "nome") ??
    `${keywords[0]} ${new Date().toLocaleDateString("pt-BR", { day: "2-digit", month: "2-digit" })}`;

  if (qualquerPost) {
    const conflito = await prisma.automation.findFirst({
      where: { matchAnyPost: true, keywords: { hasSome: keywords } },
    });
    if (conflito) {
      throw new Error(
        `já existe a campanha de qualquer-post "${conflito.name}" (${conflito.id}) com palavra em comum. duas campanhas disputando o mesmo comentário é bug garantido.`
      );
    }
  }

  if (postId) {
    const jaTem = await prisma.automation.findFirst({ where: { postId } });
    if (jaTem) {
      throw new Error(
        `esse post já tem a campanha "${jaTem.name}" (${jaTem.id}). duas campanhas no mesmo post disputam o mesmo comentário.`
      );
    }
  }

  const criada = await prisma.automation.create({
    data: {
      workspaceId: acc.workspaceId,
      instagramAccountId: acc.id,
      name: nome,
      postId,
      pendingNextReel: proximo,
      matchAnyPost: qualquerPost,
      dmTriggerEnabled: dmTrigger,
      keywords,
      wholeWordMatch: true,
      openingDmEnabled: true,
      openingDmMessage: abertura,
      openingDmButtonLabel: botao,
      dmMessage: revelacao,
      linkButtonLabel: botao,
      publicReplyEnabled: publicas.length > 0,
      publicReplyMessages: publicas,
      requireFollow: exigeFollow,
      followPromptMessage: exigeFollow ? FOLLOW_PADRAO : null,
      followPromptButtonLabel: exigeFollow ? "JÁ SEGUI" : null,
      isActive: flags.ativar === true,
      trackedLinks: {
        create: [
          {
            workspaceId: acc.workspaceId,
            slug: generateTrackedLinkSlug(),
            label: "Primary campaign link",
            destinationUrl: link,
          },
        ],
      },
    },
    include: { trackedLinks: true },
  });

  console.log(`criada: ${criada.id}`);
  console.log(`nome: ${criada.name}`);
  console.log(
    `post: ${criada.postId ?? (criada.matchAnyPost ? "(qualquer post recente)" : "(próximo publicado)")}`
  );
  console.log(`gatilho por dm: ${criada.dmTriggerEnabled ? "sim" : "não"}`);
  console.log(`palavras: ${criada.keywords.join(", ")}`);
  console.log(`botão: ${criada.openingDmButtonLabel}`);
  console.log(`gate de follow: ${criada.requireFollow ? "sim" : "não"}`);
  console.log(`link rastreado: /r/${criada.trackedLinks[0]?.slug} -> ${link}`);
  console.log(`estado: ${criada.isActive ? "ATIVA" : "PAUSADA"}`);
  if (!criada.isActive) {
    console.log(`para ligar: campanha.ts ligar ${criada.id}`);
  }
}

async function cmdToggle(id: string, ativa: boolean) {
  const c = await prisma.automation.update({
    where: { id },
    data: { isActive: ativa },
  });
  console.log(`${c.name}: ${c.isActive ? "ATIVA" : "pausada"}`);
}

async function main() {
  const [comando, ...resto] = process.argv.slice(2);
  const { positional, flags } = parseFlags(resto);

  switch (comando) {
    case "posts":
      await cmdPosts(Number(positional[0] ?? 12));
      break;
    case "listar":
      await cmdListar();
      break;
    case "numeros":
      await cmdNumeros();
      break;
    case "criar":
      await cmdCriar(flags);
      break;
    case "ligar":
      if (!positional[0]) throw new Error("falta o id da campanha");
      await cmdToggle(positional[0], true);
      break;
    case "desligar":
      if (!positional[0]) throw new Error("falta o id da campanha");
      await cmdToggle(positional[0], false);
      break;
    default:
      console.log(
        "comandos: posts [n] | listar | numeros | criar [flags] | ligar <id> | desligar <id>"
      );
  }
}

main()
  .then(() => process.exit(0))
  .catch((e: unknown) => {
    console.error("ERRO:", e instanceof Error ? e.message : e);
    process.exit(1);
  });

Depois de criar o arquivo, reconstrua a imagem: docker compose -f docker-compose.prod.yml up -d --build web.

A5.2 Armar uma campanha

terminal, no servidor
cd /opt/dm-instagram

# lista os posts recentes com id, data e número de comentários
docker compose -f docker-compose.prod.yml exec -T web \
  npx tsx scripts/campanha.ts posts

# cria a campanha (nasce PAUSADA de propósito)
docker compose -f docker-compose.prod.yml exec -T web \
  npx tsx scripts/campanha.ts criar \
  --post https://www.instagram.com/reel/XXXXXXXXX/ \
  --palavras "SUAPALAVRA" \
  --link https://seudominio.com.br/sua-pagina \
  --botao "QUERO O LINK"

# só depois de conferir, liga
docker compose -f docker-compose.prod.yml exec -T web \
  npx tsx scripts/campanha.ts ligar <id-da-campanha>
Saída esperada de criar
criada: cmf2k9x0000abcdef
nome: SUAPALAVRA 04/09
post: 18012345678901234
gatilho por dm: não
palavras: SUAPALAVRA
botão: QUERO O LINK
gate de follow: não
link rastreado: /r/a1b2c3 -> https://seudominio.com.br/sua-pagina
estado: PAUSADA
para ligar: campanha.ts ligar cmf2k9x0000abcdef

A campanha nasce pausada de propósito: ligar dispara DM para gente real e não tem volta. O --post aceita o id ou o link, resolvendo pelo permalink entre as 50 mídias mais recentes.

Os outros são listar, numeros e desligar.

terminal, no servidor
docker compose -f docker-compose.prod.yml exec -T web \
  npx tsx scripts/campanha.ts numeros
Saída esperada
SENT: 128
SKIPPED: 9

Se falhar

  • nenhuma conta do Instagram conectada: a fase 3 da seção 04 não terminou.
  • não achei esse post entre as 50 mídias mais recentes: rode campanha.ts posts e passe o id cru em vez do link.
  • Cannot find module '@/lib/...': o arquivo foi criado fora de scripts/, ou a imagem não foi reconstruída depois de criá-lo.
A6

Como a API da Meta funciona neste fluxo

As rotas, os limites e as regras duras na forma em que a documentação da Meta as escreve.

A6.1 A rota e os escopos

A rota é a Instagram API with Instagram Login (host graph.instagram.com), que não exige Página do Facebook.

escopos pedidos no OAuth
instagram_business_basic
instagram_business_manage_comments
instagram_business_manage_messages
instagram_business_manage_insights

Os escopos antigos (instagram_basic e companhia) são da rota do Facebook Login e foram descontinuados em 27 de janeiro de 2025.

App Review não é necessário aqui

Conta própria, adicionada ao app como testadora, é Standard Access e não pede revisão. App Review só entra com terceiros.

A6.2 O webhook de comentário

Campo comments, objeto instagram. O payload já traz tudo:

campos do payload de comments
value.id                         id do comentário
value.from.id                    id do autor (escopo do Instagram)
value.from.username              @ do autor
value.text                       texto do comentário
value.media.id                   id do post
value.media.media_product_type   FEED, REELS, AD ou STORY

Reels chega pelo mesmo campo; live tem o seu (live_comments) e só durante a transmissão. Para o toque no botão chegar, assine também messaging_postbacks (ver A8.2).

A6.3 A resposta pública no comentário

endpoint da resposta pública
POST https://graph.instagram.com/<VERSAO>/<IG_COMMENT_ID>/replies?message=<TEXTO>
Authorization: Bearer <TOKEN>

Sem limite documentado de respostas por comentário. O teto é o rate limit de A6.6.

A6.4 A private reply, e as quatro regras duras

O único jeito de falar com quem nunca te mandou mensagem.

endpoint da private reply
POST https://graph.instagram.com/<VERSAO>/<IG_ID>/messages
Content-Type: application/json
Authorization: Bearer <TOKEN>

{"recipient":{"comment_id":"<COMMENT_ID>"},"message":{"text":"<TEXTO>"}}
Saída esperada
{"recipient_id": "...", "message_id": "..."}

As quatro regras duras, e cada uma molda o produto:

  1. Uma única private reply por comentário. Não existe segunda chance dentro do mesmo comentário. Essa mensagem tem que carregar o botão.
  2. Sete dias. A private reply sai em até 7 dias do comentário (em live, só durante a transmissão). Mais velho que isso, sobra só a resposta pública.
  3. A DM cai na aba Pedidos de quem ainda não segue, não na caixa de entrada. É o maior ralo do mecanismo.
  4. A conversa só continua se a pessoa responder. O toque conta como resposta e abre a janela de 24 horas, dentro da qual a conta pode mandar o que quiser.

A6.5 O botão, e por que ele é obrigatório

Button template, tipos web_url e postback, de 1 a 3 botões, título truncado em 20 caracteres pela Meta e corpo até 640.

endpoint do card com botão
POST https://graph.instagram.com/<VERSAO>/me/messages

{"recipient":{"id":"<IGSID>"},
 "message":{"attachment":{"type":"template","payload":{
   "template_type":"button",
   "text":"...",
   "buttons":[{"type":"postback","title":"QUERO O LINK","payload":"reveal:<id>"}]}}}}

A checagem de follow existe:

checagem de follow
GET https://graph.instagram.com/<VERSAO>/<IGSID>?fields=is_user_follow_business

Campos do endpoint: name, username, profile_pic, follower_count, is_user_follow_business, is_business_follow_user, is_verified_user.

A trava que decide a arquitetura

Na letra da documentação: comentar não concede consentimento. Ele vem quando a pessoa manda mensagem, clica num icebreaker ou no menu persistente. Sem isso, erro de acesso ao perfil.

Ou seja: é impossível saber se a pessoa te segue quando ela comenta, e não existe endpoint de lista de seguidores. É isso que torna o botão obrigatório: ele abre a janela de 24 horas, tira a DM dos Pedidos e autoriza a checagem.

A6.6 Os limites de volume, e qual deles morde primeiro

Tetos da plataforma
LimiteValor
Private replies (post e reel)750 por hora por conta
Send API (texto, links)100 chamadas por segundo por conta
Instagram Platform, geral4800 vezes o número de impressões, por 24 horas
Nível do aplicativo200 chamadas por hora vezes o número de usuários do app

Num app de uso próprio o número de usuários é 1: são 200 chamadas por hora no total. Cada comentário gasta aproximadamente 3, o que dá um teto real de uns 60 por hora. É esse número que dita o ritmo em A4.4, não os 750.

A6.7 As regras de política

  • A primeira DM tem que declarar que é automação: "Automated chat experiences must disclose that a person is interacting with an automated service". Uma linha como (msg automática) resolve.
  • Prometer o conteúdo por comentário e cobrar o follow depois cai na cláusula de não entregar a experiência prometida. A exigência de seguir precisa estar na legenda do post.
  • Não existe regra escrita proibindo follow gating. O risco é de alcance: entra na leitura de engagement bait, que a Meta trata com redução de distribuição.
A7

Rodar na sua máquina antes, e migrar depois

O caminho opcional de testar local, com o túnel, e como levar o histórico para o servidor.

A7.1 Subir na própria máquina

terminal, na sua máquina
git clone https://github.com/diwenne/openreply.git dm-instagram
cd dm-instagram
npm ci

Use o .env de A2.5 com DATABASE_URL e REDIS_URL em localhost, e acrescente o script do worker:

package.json, em scripts
"worker:dev": "tsx --env-file=.env worker/dm-worker.ts"
terminal, na sua máquina
docker compose up -d          # postgres + redis
npx prisma migrate deploy
npm run db:generate

npm run build
npm start                     # web na porta 3000
npm run worker:dev            # worker, em outro terminal
terminal, na sua máquina
TOK=$(grep WEBHOOK_VERIFY_TOKEN .env | cut -d= -f2)
curl -s "http://localhost:3000/api/webhook?hub.mode=subscribe&hub.verify_token=$TOK&hub.challenge=999888"
Saída esperada
999888

A7.2 Expor para a internet com um túnel

Túnel efêmero da Cloudflare, sem conta:

terminal, na sua máquina
cloudflared tunnel --url http://localhost:3000

Ele imprime um https://algo.trycloudflare.com descartável, que morre com o processo e obriga a reconfigurar OAuth e webhook. Ajuste NEXTAUTH_URL no .env. E com túnel use o link cru na revelação, nunca o rastreado: quando o túnel morre, o link morre na caixa de entrada de quem recebeu.

A7.3 Levar o histórico do banco local para o servidor

O dedupe vive no histórico do Postgres local: migrar sem ele é mandar a mesma DM duas vezes.

Com o worker local parado, senão duas máquinas mandam DM ao mesmo tempo:

terminal, na sua máquina
docker exec <container-postgres-local> \
  pg_dump -U postgres -d openreply --no-owner --no-acl > openreply.sql

scp -i ~/.ssh/dm-instagram openreply.sql root@<IP_DO_VPS>:/opt/dm-instagram/

No servidor, com só postgres e redis de pé:

terminal, no servidor
cd /opt/dm-instagram
docker compose -f docker-compose.prod.yml up -d postgres redis

docker compose -f docker-compose.prod.yml exec -T postgres \
  psql -U postgres -d openreply -c "drop schema public cascade; create schema public;"

docker compose -f docker-compose.prod.yml exec -T postgres \
  psql -U postgres -d openreply -q < openreply.sql

O dump traz a _prisma_migrations junto, então o migrate deploy do start não reaplica nada. Suba a stack e siga o A3.

A ordem do corte, se você já estava atendendo gente

Health, handshake no domínio novo, as três URLs no painel da Meta, o verify token colado de novo, e só então derrubar o túnel e os containers locais. Por último, conte WebhookEvent duas vezes com intervalo, com a máquina antiga desligada. A sessão do painel não sobrevive à troca de domínio: o primeiro acesso pede um magic link novo.

A7.4 O painel não hidrata rodando npm run dev atrás de túnel

Sintoma

Layout completo e skeletons cinzas que nunca saem. Os chunks respondem 200, o console fica limpo, e nenhuma chamada de API sai do navegador.

Causa

O canal de HMR do Turbopack não sobrevive ao túnel, e a hidratação nunca acontece. Sem erro no console, parece bug de dados.

Conserto

Build de produção (npm run build e npm start), que é o que roda no VPS de qualquer jeito. npm run dev só em localhost direto.

A7.5 npm run worker não carrega o .env

Sintoma

O worker sobe e parece vivo, mas loga:

log do worker
[DM Worker] Comment reconciliation failed: DATABASE_URL environment variable is required
Causa

O script do upstream é tsx worker/dm-worker.ts, e o tsx não lê .env sozinho. O Next lê, por isso a web funciona e o worker não.

Conserto

O worker:dev do A.1, com --env-file=.env. Em produção o env vem do compose, e o npm run worker original serve.

A7.6 pkill não existe no Git Bash do Windows, e falha em silêncio

Sintoma

Uma campanha com teto de 5 comentários por varredura mandou 25 DMs.

Causa

pkill não existe nesse ambiente, o erro foi engolido pelo 2>/dev/null, e os workers se acumularam: cinco, cada um com a própria varredura de 5, dão 25 envios.

Conserto

Matar por PowerShell e conferir que sobrou zero antes de subir outro:

PowerShell, no Windows
Get-CimInstance Win32_Process -Filter "Name='node.exe'" |
  Where-Object { $_.CommandLine -like '*dm-worker*' } |
  ForEach-Object { Stop-Process -Id $_.ProcessId -Force }

(Get-CimInstance Win32_Process -Filter "Name='node.exe'" |
  Where-Object { $_.CommandLine -like '*dm-worker*' }).Count

Com um worker rodando a contagem esperada é 2, porque o tsx abre um processo filho.

Lição

Comando de parada se verifica, não se presume: um 2>/dev/null ali esconde exatamente o erro que importa.

A8

As duas falhas que não dão erro, vistas por dentro

O que o terminal mostra quando o app não está publicado, e o comando que reassina uma conta já conectada.

A8.1 App em Development: contagem certa, lista vazia

A API responde HTTP 200 para tudo, o comments_count vem correto, e a lista de comentários vem vazia em todas as páginas, com os cursores avançando normalmente:

contagem certa, lista vazia
reel de hoje       comments_count=168   devolvidos=0
post de 02/09      comments_count=2     devolvidos=0
post de 28/07      comments_count=50    devolvidos=0

Não há erro nem código de falha, o token funciona, os escopos estão concedidos e o subscribed_apps confirma a assinatura. Trocar de versão da API não muda nada. A causa é o app em Development: nesse modo a Meta não entrega webhook e só devolve dados de quem tem papel no app, o que exclui exatamente quem comenta. O conserto é publicar, no passo 3.7, e os comentários voltam na chamada seguinte.

A8.2 Re-assinar uma conta já conectada

O patch 1 de A2.2 previne o problema do toque no botão, mas só vale para conexões feitas depois dele. Se a conta já estava conectada, re-assine sem desconectar (desconectar apaga campanhas e histórico em cascata):

re-assinar uma conta já conectada
POST https://graph.instagram.com/<VERSAO>/<IG_ID>/subscribed_apps
{"subscribed_fields":["comments","messages","messaging_postbacks"]}

Confirme com um GET no mesmo endpoint, como em A3.6.

Pergunta 1 de 6

Você já paga alguma ferramenta de automação de DM hoje?

Nada aqui é salvo nem enviado: as respostas ficam no seu navegador e somem quando você fecha.