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.
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.
| Programa | zero. O openreply é software livre, licença MIT. |
|---|---|
| Servidor | R$ 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ínio | o que você já paga. Um subdomínio não custa nada a mais. |
| A Meta | zero. Ela não cobra nada por isso. |
| Envio de email do login | zero no plano gratuito do Resend, que sobra para uma pessoa entrar no painel |
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 IA faz por você | Só você pode fazer |
|---|---|
| Instalar e configurar tudo | Criar a conta de desenvolvedor na Meta |
| Subir o servidor | Publicar o app |
| Conectar as peças | Aceitar um convite pelo aplicativo do Instagram, no celular |
| Criar as campanhas | Ligar um interruptor nas configurações do Instagram |
| Consertar quando quebra | Contratar 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.
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.
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.
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:
| Como aparece na tela | Onde 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:
Configurações > Mensagens e respostas dos stories > Controles de mensagens >
Ferramentas conectadas > Permitir acesso a mensagensComo 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.
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.
A parte que é do agente
Cinco prompts, um por fase. Você cola, ele executa, e você confere pelo olho.
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.
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.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.
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.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ê.
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.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.
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.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.
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.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.
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.
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.
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ê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.
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ínioChave SSH, primeiro acesso, conferência do Docker e o registro A do subdomínio.
- A2. O código, os patches, o ambiente e a stackO clone, as duas correções, os segredos, o .env comentado, o docker-compose.prod.yml, o Caddyfile e a subida.
- A3. Conectar a conta, e provar que subiuOs endereços no painel da Meta, o handshake do webhook e as três provas.
- A4. A campanha, no padrão card com botãoOs campos, os textos que rodaram, o link rastreado e o ritmo da varredura.
- A5. Operar por linha de comandoO scripts/campanha.ts inteiro, com criar, ligar e numeros.
- A6. Como a API da Meta funciona neste fluxoRotas, escopos, limites de volume e as regras duras, na letra da documentação.
- A7. Rodar na sua máquina antes, e migrar depoisO caminho local com túnel, e como levar o histórico do banco para o servidor.
- A8. As duas falhas que não dão erro, vistas por dentroA saída crua do app em Development, e o comando de re-assinar uma conta já conectada.
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":
| SO | Ubuntu 24.04 LTS com Docker |
| Painel | nenhum. cPanel, Plesk e CyberPanel tomam as portas 80 e 443, que o Caddy precisa. |
| Apps | nenhum. Nada de Dokploy, Coolify ou CapRover: a stack já é Docker Compose. |
| Acesso | chave 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:
ssh-keygen -t ed25519 -f ~/.ssh/dm-instagram -N "" -C "dm-instagram"
cat ~/.ssh/dm-instagram.pubCole 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
ssh -i ~/.ssh/dm-instagram root@<IP_DO_VPS>
docker --version
docker compose versionDocker version 27.x.x, build xxxxxxx
Docker Compose version v2.x.xSe falhar
Permission denied (publickey): a chave que você colou na contratação não é a mesma que o-iaponta. Confira comcat ~/.ssh/dm-instagram.pubcontra o painel.docker: command not found: o template escolhido não era o com Docker. Instale comcurl -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.
dig +short dm.seudominio.com.br<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.
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
mkdir -p /opt && cd /opt
git clone https://github.com/diwenne/openreply.git dm-instagram
cd /opt/dm-instagramO 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.
grep -n "subscribed_fields" lib/meta/client.tsSe já tiver messaging_postbacks, pule para A2.3. Se vier assim, o patch é necessário:
760: subscribed_fields: ["comments", "messages"],sed -i 's/subscribed_fields: \["comments", "messages"\]/subscribed_fields: ["comments", "messages", "messaging_postbacks"]/' lib/meta/client.ts
grep -n "subscribed_fields" lib/meta/client.ts760: 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.
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.
grep -n "const data = await response.json();" lib/meta/oauth.tsEm exchangeCodeForToken, depois da linha que o grep achou:
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:
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_TOKENSe o openssl não existir
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 >.
# 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=72DATABASE_URLeREDIS_URLusam nomes de serviço do compose, nuncalocalhost, que ali é o próprio container.ALLOWED_EMAILSnã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=0deixa o backfill desligado: só comentário novo é atendido. Você sobe esse número em A4.4.
É 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:
# 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:
webeworkerusam a mesma imagem, mudando só ocommand. 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-stoppedem tudo, para o servidor voltar sozinho de um reboot.
A2.7 Escrever o 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
cd /opt/dm-instagram
docker compose -f docker-compose.prod.yml config -q && echo "compose ok"compose okQualquer 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.
docker compose -f docker-compose.prod.yml up -d --build
docker compose -f docker-compose.prod.yml psNAME 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 UpA checagem que prova que os quatro processos se enxergam:
curl -s https://dm.seudominio.com.br/api/health{"status":"ok","checks":{"database":{"status":"ok"},"redis":{"status":"ok","detail":"PONG"},
"queue":{"status":"ok"},"worker":{"healthy":true}}}Se falhar
- O
curltrava ou dá timeout. A porta 443 não está aberta: confira o firewall no painel da Hostinger e oufw statusno 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. webemRestarting. A migration falhou. Leia o log.worker.healthyvemfalse. O worker não conectou no Redis: confiraREDIS_URL=redis://redis:6379no.env.
docker compose -f docker-compose.prod.yml logs --tail=50 web
docker compose -f docker-compose.prod.yml logs --tail=50 caddyConectar 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:
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/privacySe 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:
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"999888Se vier isto, com HTTP 403
{"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:
docker compose -f docker-compose.prod.yml logs web | grep "permissoes concedidas"[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
docker compose -f docker-compose.prod.yml exec -T postgres \
psql -U postgres -d openreply \
-c 'select username, "instagramId", "webhookSubscribed", "tokenExpiresAt" from "InstagramAccount";' username | instagramId | webhookSubscribed | tokenExpiresAt
---------------+-----------------+-------------------+------------------------
seu_usuario | 178414xxxxxxxxx | t | 2026-11-03 21:14:02+00Confirmam 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:
GET https://graph.instagram.com/<VERSAO>/<IG_ID>/subscribed_apps{"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.
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:
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 linkCom 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
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ô:
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:
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:
aqui ó ( ˶ˆᗜˆ˵ )
https://seudominio.com.br/sua-paginaO 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.
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 reply5 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.
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.
/**
* 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
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>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 cmf2k9x0000abcdefA 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.
docker compose -f docker-compose.prod.yml exec -T web \
npx tsx scripts/campanha.ts numerosSENT: 128
SKIPPED: 9Se 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: rodecampanha.ts postse passe o id cru em vez do link.Cannot find module '@/lib/...': o arquivo foi criado fora descripts/, ou a imagem não foi reconstruída depois de criá-lo.
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.
instagram_business_basic
instagram_business_manage_comments
instagram_business_manage_messages
instagram_business_manage_insightsOs escopos antigos (instagram_basic e companhia) são da rota do Facebook Login e foram descontinuados em 27 de janeiro de 2025.
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:
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 STORYReels 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
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.
POST https://graph.instagram.com/<VERSAO>/<IG_ID>/messages
Content-Type: application/json
Authorization: Bearer <TOKEN>
{"recipient":{"comment_id":"<COMMENT_ID>"},"message":{"text":"<TEXTO>"}}{"recipient_id": "...", "message_id": "..."}As quatro regras duras, e cada uma molda o produto:
- 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.
- 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.
- A DM cai na aba Pedidos de quem ainda não segue, não na caixa de entrada. É o maior ralo do mecanismo.
- 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.
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:
GET https://graph.instagram.com/<VERSAO>/<IGSID>?fields=is_user_follow_businessCampos do endpoint: name, username, profile_pic, follower_count, is_user_follow_business, is_business_follow_user, is_verified_user.
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
| Limite | Valor |
|---|---|
| Private replies (post e reel) | 750 por hora por conta |
| Send API (texto, links) | 100 chamadas por segundo por conta |
| Instagram Platform, geral | 4800 vezes o número de impressões, por 24 horas |
| Nível do aplicativo | 200 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.
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
git clone https://github.com/diwenne/openreply.git dm-instagram
cd dm-instagram
npm ciUse o .env de A2.5 com DATABASE_URL e REDIS_URL em localhost, e acrescente o script do worker:
"worker:dev": "tsx --env-file=.env worker/dm-worker.ts"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 terminalTOK=$(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"999888A7.2 Expor para a internet com um túnel
Túnel efêmero da Cloudflare, sem conta:
cloudflared tunnel --url http://localhost:3000Ele 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:
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é:
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.sqlO dump traz a _prisma_migrations junto, então o migrate deploy do start não reaplica nada. Suba a stack e siga o A3.
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 buildenpm start), que é o que roda no VPS de qualquer jeito.npm run devsó emlocalhostdireto.
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 otsxnão lê.envsozinho. O Next lê, por isso a web funciona e o worker não.- Conserto
O
worker:devdo A.1, com--env-file=.env. Em produção o env vem do compose, e onpm run workeroriginal 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
pkillnão existe nesse ambiente, o erro foi engolido pelo2>/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 WindowsGet-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*' }).CountCom um worker rodando a contagem esperada é 2, porque o
tsxabre um processo filho. - Lição
Comando de parada se verifica, não se presume: um
2>/dev/nullali esconde exatamente o erro que importa.
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:
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=0Nã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):
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.