Nesta página
  1. 0. Chaves que você vai juntar
  2. 1. Pré-requisitos
  3. 2. Supabase
  4. 2.1 Projeto
  5. 2.2 Tabelas e funções
  6. 2.3 Limpeza automática
  7. 2.4 Regras de palavra-chave
  8. 3. Meta
  9. 3.1 Conta de desenvolvedor
  10. 3.2 Criar o app
  11. 3.3 Instagram App Secret
  12. 3.4 Testadores
  13. 3.5 Liberar mensagens na conta profissional
  14. 3.6 Gerar o token
  15. 3.7 Descobrir o IG_USER_ID
  16. 3.8 Inscrever a conta no campo comments
  17. 3.9 Publicar o app
  18. 4. Edge Function ig-webhook
  19. 4.1 CLI
  20. 4.2 Código
  21. 4.3 Secrets e deploy
  22. 4.4 Testar a verificação
  23. 4.5 Registrar o webhook na Meta
  24. 5. n8n local
  25. 5.1 Docker
  26. 5.2 Credencial do Supabase
  27. 5.3 Workflow IG - Enviar DMs
  28. 5.4 Workflow IG - Renovar token
  29. 5.5 Gravar o token no banco
  30. 6. Testes
  31. 6.1 Token
  32. 6.2 Webhook com evento falso
  33. 6.3 n8n
  34. 6.4 Teste real
  35. 7. Erros que já apareceram
  36. 8. Consultas úteis
  37. 9. Limites

DM automática por comentário no Instagram

19 mininstagramn8n

Alguém comenta uma palavra-chave no seu post. Essa pessoa recebe uma DM, e o comentário ganha uma resposta pública. O Supabase recebe o webhook da Meta. O n8n, rodando na sua máquina, lê a fila, envia a mensagem e responde o comentário.

O uso é na sua própria conta profissional. Não precisa de análise do app nem de verificação do negócio. Para atender conta de cliente, a análise passa a ser obrigatória.

0. Chaves que você vai juntar

Nenhum desses valores entra no Git, em print ou em repositório público.

ChaveOnde pegarOnde usar
SUPABASE_URLSupabase → Project Settings → APIn8n, no nó Config
SUPABASE_SECRET_KEYProject Settings → API Keys. sb_secret_... ou a chave legacy service_roleCredencial do n8n
PROJECT_REFProject Settings → General → Reference IDCLI do Supabase
META_APP_SECRETMeta → app → Instagram → Configuração da API com login do Instagram → Instagram App SecretSecret da Edge Function
META_VERIFY_TOKENVocê gera no passo 1Secret da Edge Function e painel da Meta
IG_ACCESS_TOKENA mesma página da Meta → Gerar tokens de acessoTabela ig_settings
IG_USER_IDPasso 3.7Secret da Edge Function e n8n
GRAPH_VERSIONv26.0n8n e comandos
N8N_ENCRYPTION_KEYVocê gera no passo 1docker-compose.yml

1. Pré-requisitos

  • Docker Desktop.
  • Node.js 18 ou mais novo.
  • Conta do Instagram profissional, comercial ou criador. Instagram → Configurações → Tipo de conta e ferramentas → Mudar para conta profissional.
  • Uma segunda conta do Instagram para comentar nos testes.

Gere dois valores aleatórios. Um vira META_VERIFY_TOKEN. O outro vira N8N_ENCRYPTION_KEY. Rode o comando duas vezes:

gerar.ps1 · powershell
node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"

2. Supabase

2.1 Projeto

Crie um projeto em supabase.com e anote SUPABASE_URL, SUPABASE_SECRET_KEY e PROJECT_REF.

2.2 Tabelas e funções

No SQL Editor, New query, rode o bloco inteiro. Ele cria a fila, as regras, a tabela do token e as funções que o n8n chama. A função de reserva usa trava de linha para dois processos não pegarem o mesmo comentário.

tabelas.sql · sql
create table if not exists public.ig_keyword_rules (
  id           bigint generated always as identity primary key,
  keyword      text not null,
  match_mode   text not null default 'word' check (match_mode in ('word','exact')),
  media_id     text,
  message_text text not null,
  priority     int  not null default 100,
  active       boolean not null default true,
  created_at   timestamptz not null default now()
);

create table if not exists public.ig_comment_queue (
  id                 bigint generated always as identity primary key,
  comment_id         text not null unique,
  ig_account_id      text not null,
  commenter_id       text,
  username           text,
  comment_text       text,
  media_id           text,
  rule_id            bigint references public.ig_keyword_rules(id) on delete set null,
  message_text       text not null,
  status             text not null default 'pending'
                     check (status in ('pending','processing','sent','skipped','failed')),
  attempts           int  not null default 0,
  max_attempts       int  not null default 4,
  send_after         timestamptz not null default now(),
  locked_at          timestamptz,
  comment_created_at timestamptz not null,
  sent_message_id    text,
  last_error         text,
  created_at         timestamptz not null default now(),
  processed_at       timestamptz,
  updated_at         timestamptz not null default now()
);

create index if not exists ig_queue_pending_idx
  on public.ig_comment_queue (send_after) where status = 'pending';
create index if not exists ig_queue_processing_idx
  on public.ig_comment_queue (locked_at) where status = 'processing';

create table if not exists public.ig_settings (
  key        text primary key,
  value      text not null,
  updated_at timestamptz not null default now()
);

alter table public.ig_keyword_rules enable row level security;
alter table public.ig_comment_queue enable row level security;
alter table public.ig_settings      enable row level security;

create or replace function public.claim_ig_comments(batch_size int default 5)
returns setof public.ig_comment_queue
language sql
as $$
  update public.ig_comment_queue q
     set status = 'processing', attempts = q.attempts + 1,
         locked_at = now(), updated_at = now()
   where q.id in (
     select id from public.ig_comment_queue
      where status = 'pending' and send_after <= now() and attempts < max_attempts
      order by send_after
      limit batch_size
      for update skip locked
   )
  returning q.*;
$$;

create or replace function public.maintain_ig_queue(window_hours int default 160, stuck_minutes int default 5)
returns jsonb
language plpgsql
as $$
declare n_expired int; n_reaped int;
begin
  update public.ig_comment_queue
     set status = 'failed', last_error = 'janela da Private Reply expirada', updated_at = now()
   where status = 'pending'
     and comment_created_at < now() - make_interval(hours => window_hours);
  get diagnostics n_expired = row_count;

  update public.ig_comment_queue
     set status = case when attempts >= max_attempts then 'failed' else 'pending' end,
         last_error = 'travado em processing; liberado', locked_at = null, updated_at = now()
   where status = 'processing'
     and locked_at < now() - make_interval(mins => stuck_minutes);
  get diagnostics n_reaped = row_count;

  return jsonb_build_object('expired', n_expired, 'reaped', n_reaped);
end;
$$;

create or replace function public.skip_ig_comment(cid text)
returns boolean
language sql
as $$
  with upd as (
    update public.ig_comment_queue
       set status = 'skipped', updated_at = now()
     where comment_id = cid and status = 'pending'
    returning 1
  )
  select exists (select 1 from upd);
$$;

revoke execute on function public.claim_ig_comments(int)      from public, anon, authenticated;
revoke execute on function public.maintain_ig_queue(int, int) from public, anon, authenticated;
revoke execute on function public.skip_ig_comment(text)       from public, anon, authenticated;

2.3 Limpeza automática

Opcional. Apaga da fila o que já foi enviado, pulado ou falhou, depois de 90 dias.

limpeza.sql · sql
create extension if not exists pg_cron;

select cron.schedule('ig-cleanup', '7 4 * * *', $$
  delete from public.ig_comment_queue
   where status in ('sent','skipped','failed')
     and updated_at < now() - interval '90 days';
$$);

2.4 Regras de palavra-chave

Cada palavra vira uma linha. message_text é o texto da DM. media_id vazio vale para qualquer post. Preenchido, vale só para aquele vídeo e ganha da regra global.

regra.sql · sql
-- Qualquer post
insert into public.ig_keyword_rules (keyword, message_text)
values ('quero', 'Oi! Aqui está o link: https://seu-link');

-- Só um post específico. Ganha da regra global.
-- insert into public.ig_keyword_rules (keyword, media_id, message_text, priority)
-- values ('ebook', 'ID_DA_MIDIA', 'Aqui está o ebook: https://...', 10);
  • word, o padrão: a palavra aparece inteira. Eu QUERO! casa. queroooo não casa. não quero casa.
  • exact: o comentário inteiro é só a palavra.
  • Entre duas regras globais que casam, vence a de priority menor. Regra com media_id vence a global.

3. Meta

3.1 Conta de desenvolvedor

Em developers.facebook.com, entre com a sua conta Meta. Começar. Confirme e-mail e telefone.

3.2 Criar o app

  1. Meus apps → Criar app.
  2. Nome e e-mail de contato.
  3. Caso de uso: Gerenciar mensagens e conteúdo no Instagram.
  4. Portfólio empresarial: pode pular.
  5. Criar app.

3.3 Instagram App Secret

  1. Casos de uso → Personalizar, no Instagram → Configuração da API com login do Instagram.
  2. Instagram App Secret → Mostrar → copie. Esse valor é o META_APP_SECRET.

3.4 Testadores

  1. Funções do app → Funções → Adicionar pessoas → Testador do Instagram.
  2. Adicione a sua conta profissional e a conta de teste.
  3. Em cada conta, aceite em instagram.com → Configurações → Apps e sites → Convites de testador.

3.5 Liberar mensagens na conta profissional

No app do Instagram: Configurações → Mensagens e respostas a stories → Controles de mensagens → Ferramentas conectadas → permitir acesso.

3.6 Gerar o token

  1. Configuração da API com login do Instagram → Gerar tokens de acesso → Adicionar conta.
  2. Entre com a conta profissional no pop-up e autorize tudo.
  3. Gerar token ao lado da conta. Esse valor é o IG_ACCESS_TOKEN. Vale 60 dias. O n8n renova.

3.7 Descobrir o IG_USER_ID

Troque IG_ACCESS_TOKEN pelo token gerado. Use o campo user_id da resposta.

user-id.ps1 · powershell
curl.exe "https://graph.instagram.com/v26.0/me?fields=user_id,username&access_token=IG_ACCESS_TOKEN"

3.8 Inscrever a conta no campo comments

Assinar o campo no painel não inscreve a conta. Sem este passo, nenhum comentário real chega. O POST substitui a lista inteira, então mande comments e messages juntos.

inscrever.ps1 · powershell
curl.exe -X POST "https://graph.instagram.com/v26.0/me/subscribed_apps?subscribed_fields=comments,messages&access_token=IG_ACCESS_TOKEN"

O esperado é {"success":true}. Confira:

inscritos.ps1 · powershell
curl.exe "https://graph.instagram.com/v26.0/me/subscribed_apps?access_token=IG_ACCESS_TOKEN"

A resposta precisa listar comments e messages.

3.9 Publicar o app

Publicar tira o app do modo de desenvolvimento. Esse modo bloqueia os eventos reais. Não é a análise do app: não pede CNPJ, verificação nem vídeo.

  1. Crie uma página pública simples, por exemplo na Vercel, com a política de privacidade e as instruções de exclusão de dados.
  2. Configurações do app → Básico: URL da política, URL de exclusão de dados, categoria e ícone de 1024×1024. Salve.
  3. No menu lateral, Publicar. No painel antigo, a chave é Modo do app → Live.

Este app processa comentários da conta @suaconta para enviar respostas automáticas por mensagem. Os dados não são compartilhados com terceiros. Para excluir seus dados, envie e-mail para seu@email.com.

4. Edge Function ig-webhook

4.1 CLI

Em uma pasta nova:

cli.ps1 · powershell
npx supabase login
npx supabase init
npx supabase link --project-ref PROJECT_REF
npx supabase functions new ig-webhook

4.2 Código

Substitua supabase/functions/ig-webhook/index.ts por este arquivo. Ele confere a assinatura da Meta, ignora o seu próprio comentário, escolhe a regra e grava a fila. Comentário repetido não entra de novo.

index.ts · ts
import { createClient } from "npm:@supabase/supabase-js@2";

const supabase = createClient(
  Deno.env.get("SUPABASE_URL")!,
  Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!,
);

const VERIFY_TOKEN = Deno.env.get("META_VERIFY_TOKEN")!;
const APP_SECRET = Deno.env.get("META_APP_SECRET")!;
const OWN_IG_USER_ID = Deno.env.get("OWN_IG_USER_ID") ?? "";
const JITTER_SECONDS = Number(Deno.env.get("SEND_JITTER_SECONDS") ?? "20");

const hmacKey = crypto.subtle.importKey(
  "raw", new TextEncoder().encode(APP_SECRET),
  { name: "HMAC", hash: "SHA-256" }, false, ["verify"],
);

function hexToBytes(hex: string): Uint8Array | null {
  if (!/^[0-9a-f]{64}$/i.test(hex)) return null;
  const out = new Uint8Array(32);
  for (let i = 0; i < 32; i++) out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);
  return out;
}

async function validSignature(raw: Uint8Array, header: string | null): Promise<boolean> {
  if (!header?.startsWith("sha256=")) return false;
  const sig = hexToBytes(header.slice(7));
  if (!sig) return false;
  return crypto.subtle.verify("HMAC", await hmacKey, sig, raw);
}

function normalize(s: string): string {
  return s.normalize("NFD").replace(/\p{M}/gu, "").toLowerCase()
    .replace(/[^\p{L}\p{N}\s]/gu, " ").replace(/\s+/g, " ").trim();
}

type Rule = {
  id: number; keyword: string; match_mode: "word" | "exact";
  media_id: string | null; message_text: string; priority: number;
};

function matches(text: string, rule: Rule): boolean {
  const kw = normalize(rule.keyword);
  if (!kw) return false;
  if (rule.match_mode === "exact") return text === kw;
  return ` ${text} `.includes(` ${kw} `);
}

function pickRule(rules: Rule[], text: string, mediaId: string | null): Rule | null {
  return rules
    .filter((r) => r.media_id === null || r.media_id === mediaId)
    .filter((r) => matches(text, r))
    .sort((a, b) => {
      const spec = Number(b.media_id !== null) - Number(a.media_id !== null);
      return spec !== 0 ? spec : a.priority - b.priority;
    })[0] ?? null;
}

function eventDate(time: unknown): Date {
  const n = Number(time);
  if (!Number.isFinite(n) || n <= 0) return new Date();
  return new Date(n > 1e12 ? n : n * 1000);
}

Deno.serve(async (req) => {
  if (req.method === "GET") {
    const url = new URL(req.url);
    if (url.searchParams.get("hub.mode") === "subscribe" &&
        url.searchParams.get("hub.verify_token") === VERIFY_TOKEN) {
      return new Response(url.searchParams.get("hub.challenge") ?? "", { status: 200 });
    }
    return new Response("forbidden", { status: 403 });
  }

  if (req.method !== "POST") return new Response("method not allowed", { status: 405 });

  const raw = new Uint8Array(await req.arrayBuffer());
  if (!(await validSignature(raw, req.headers.get("x-hub-signature-256")))) {
    return new Response("invalid signature", { status: 401 });
  }

  let body: any;
  try { body = JSON.parse(new TextDecoder().decode(raw)); }
  catch { return new Response("bad json", { status: 400 }); }

  const { data: rules, error: rulesErr } = await supabase
    .from("ig_keyword_rules")
    .select("id, keyword, match_mode, media_id, message_text, priority")
    .eq("active", true);
  if (rulesErr) return new Response("db error", { status: 500 });

  const rows: Record<string, unknown>[] = [];

  for (const entry of body.entry ?? []) {
    const accountId = String(entry.id ?? "");
    const createdAt = eventDate(entry.time);

    for (const change of entry.changes ?? []) {
      if (change.field !== "comments") continue;
      const v = change.value ?? {};
      const commentId = v.id ? String(v.id) : "";
      const fromId = String(v.from?.id ?? "");
      if (!commentId || !fromId) continue;
      if (fromId === accountId || fromId === OWN_IG_USER_ID) continue;

      const mediaId = v.media?.id ? String(v.media.id) : null;
      const rule = pickRule((rules ?? []) as Rule[], normalize(String(v.text ?? "")), mediaId);
      if (!rule) continue;

      rows.push({
        comment_id: commentId,
        ig_account_id: accountId,
        commenter_id: fromId,
        username: v.from?.username ?? null,
        comment_text: v.text ?? null,
        media_id: mediaId,
        rule_id: rule.id,
        message_text: rule.message_text,
        comment_created_at: createdAt.toISOString(),
        send_after: new Date(Date.now() + Math.random() * JITTER_SECONDS * 1000).toISOString(),
      });
    }
  }

  if (rows.length > 0) {
    const { error } = await supabase
      .from("ig_comment_queue")
      .upsert(rows, { onConflict: "comment_id", ignoreDuplicates: true });
    if (error) return new Response("db error", { status: 500 });
  }

  return new Response("ok", { status: 200 });
});

4.3 Secrets e deploy

No PowerShell a barra no fim da linha não continua o comando. Troque valor pelos valores reais, tudo em uma linha. SUPABASE_URL e SUPABASE_SERVICE_ROLE_KEY já existem na função. Não defina de novo. Se mudar um secret depois, publique outra vez.

secrets.ps1 · powershell
npx supabase secrets set META_VERIFY_TOKEN="valor" META_APP_SECRET="valor" OWN_IG_USER_ID="valor" SEND_JITTER_SECONDS="20"
npx supabase secrets list
npx supabase functions deploy ig-webhook --no-verify-jwt

4.4 Testar a verificação

verificar.ps1 · powershell
curl.exe "https://PROJECT_REF.supabase.co/functions/v1/ig-webhook?hub.mode=subscribe&hub.verify_token=META_VERIFY_TOKEN&hub.challenge=12345"

O esperado é 12345. Se vier 403, o token na URL não é o mesmo secret.

4.5 Registrar o webhook na Meta

  1. Configuração da API com login do Instagram → Configurar webhooks.
  2. URL de callback: https://PROJECT_REF.supabase.co/functions/v1/ig-webhook.
  3. Token de verificação: o META_VERIFY_TOKEN.
  4. Verificar e salvar.
  5. Na linha comments, ligue Assinar. Testar não basta.
  6. Na seção de tokens, ligue a assinatura de webhook ao lado da sua conta.

5. n8n local

5.1 Docker

Crie a pasta n8n e o arquivo docker-compose.yml. Na linha N8N_ENCRYPTION_KEY, o valor depois do igual é o hex gerado no passo 1. Não troque essa chave depois de criar credenciais. No Docker Desktop, Settings → General → Start Docker Desktop when you sign in.

docker-compose.yml · yaml
services:
  n8n:
    image: docker.n8n.io/n8nio/n8n
    restart: unless-stopped
    ports:
      - "5678:5678"
    environment:
      - N8N_ENCRYPTION_KEY=N8N_ENCRYPTION_KEY
      - GENERIC_TIMEZONE=America/Fortaleza
      - TZ=America/Fortaleza
      - N8N_SECURE_COOKIE=false
      - EXECUTIONS_DATA_SAVE_ON_SUCCESS=none
      - EXECUTIONS_DATA_PRUNE=true
      - EXECUTIONS_DATA_MAX_AGE=72
    volumes:
      - n8n_data:/home/node/.n8n

volumes:
  n8n_data:
subir.ps1 · powershell
docker compose up -d

Abra http://localhost:5678 e crie o usuário.

5.2 Credencial do Supabase

Credentials → Add credential → Custom Auth. Nome: Supabase Service.

Chave que começa com sb_secret_ :

credencial-sb-secret.json · json
{ "headers": { "apikey": "SUPABASE_SECRET_KEY" } }

Chave legacy service_role, a que começa com eyJ:

credencial-service-role.json · json
{ "headers": { "apikey": "SUPABASE_SECRET_KEY", "Authorization": "Bearer SUPABASE_SECRET_KEY" } }

5.3 Workflow IG - Enviar DMs

Ligue os nós nesta ordem. O IF abre dois caminhos: todo resultado atualiza a fila, e só o envio certo segue para a resposta pública.

nos.txt · text
Schedule Trigger → Config → Manutenção → Buscar token → Reservar lote → Enviar Private Reply → Classificar resultado
                                                                                                 ├→ Atualizar fila
                                                                                                 └→ IF (sent) → Gerar resposta → Responder comentário

Schedule Trigger. Trigger Interval: Seconds. Seconds Between Triggers: 20.

Config. Edit Fields, Set, Manual Mapping, todos String. supabase_url é a SUPABASE_URL sem barra no fim. graph_base é https://graph.instagram.com. graph_version é v26.0. ig_user_id é o IG_USER_ID.

Manutenção. HTTP Request, POST. URL:

manutencao.txt · text
{{ $('Config').first().json.supabase_url }}/rest/v1/rpc/maintain_ig_queue

Authentication: Generic Credential Type → Custom Auth → Supabase Service. Send Body, JSON: { "window_hours": 160, "stuck_minutes": 5 }.

Buscar token. HTTP Request em GET, sem corpo. Se este nó ficar POST com corpo, o Supabase procura uma coluna que não existe.

buscar-token.txt · text
{{ $('Config').first().json.supabase_url }}/rest/v1/ig_settings?key=eq.ig_access_token&select=value

Reservar lote. HTTP Request, POST. Always Output Data desligado.

reservar.txt · text
{{ $('Config').first().json.supabase_url }}/rest/v1/rpc/claim_ig_comments

Corpo JSON: { "batch_size": 3 }.

Enviar Private Reply. HTTP Request, POST. Authentication: None. Header Authorization com o valor Bearer {{ $('Buscar token').first().json.value }}. Include Response Headers and Status ligado. Never Error ligado. Batching: 1 item por lote, intervalo de 2000 ms.

private-reply-url.txt · text
{{ $('Config').first().json.graph_base }}/{{ $('Config').first().json.graph_version }}/{{ $('Config').first().json.ig_user_id }}/messages

Corpo em modo expressão:

private-reply-body.txt · text
{{ JSON.stringify({ recipient: { comment_id: $json.comment_id }, message: { text: $json.message_text } }) }}

Classificar resultado. Code, Run Once for Each Item.

classificar.js · js
const job = $('Reservar lote').item.json;
const res = $json;
const status = Number(res.statusCode ?? 0);
const body = res.body ?? {};
const now = new Date().toISOString();

const TRANSIENT = [1, 2, 4, 17, 32, 341, 613];
const AUTH = [102, 190];

if (status >= 200 && status < 300) {
  return { json: { id: job.id, comment_id: job.comment_id, username: job.username, outcome: 'sent', patch: {
    status: 'sent', sent_message_id: body.message_id ?? null,
    processed_at: now, locked_at: null, last_error: null, updated_at: now,
  } } };
}

const err = body.error ?? {};
const code = Number(err.code);
const errText = JSON.stringify(err).slice(0, 1000);

if (AUTH.includes(code)) {
  return { json: { id: job.id, outcome: 'auth_error', patch: {
    status: 'pending', attempts: Math.max(job.attempts - 1, 0),
    locked_at: null, last_error: 'auth: ' + errText, updated_at: now,
  } } };
}

const transient = err.is_transient === true || TRANSIENT.includes(code) || status === 0 || status >= 500;
const exhausted = job.attempts >= job.max_attempts;

if (transient && !exhausted) {
  const backoffMin = Math.min(2 ** job.attempts, 30);
  return { json: { id: job.id, outcome: 'retry', patch: {
    status: 'pending', send_after: new Date(Date.now() + backoffMin * 60000).toISOString(),
    locked_at: null, last_error: errText, updated_at: now,
  } } };
}

return { json: { id: job.id, outcome: 'failed', patch: {
  status: 'failed', locked_at: null, last_error: errText, processed_at: now, updated_at: now,
} } };

Atualizar fila. HTTP Request, PATCH. Header Prefer = return=minimal. Corpo em expressão: {{ JSON.stringify($json.patch) }}.

atualizar-fila.txt · text
{{ $('Config').first().json.supabase_url }}/rest/v1/ig_comment_queue?id=eq.{{ $json.id }}

Enviada? IF. {{ $json.outcome }} é igual a sent.

Gerar resposta. Code, Run Once for Each Item, na saída verdadeira do IF. A frase pública muda a cada comentário.

gerar-resposta.js · js
const aberturas = ["", "Oi! ", "Opa! ", "Prontinho! ", "Feito! ", "Olá! ", "Show! ", "Perfeito! ", "Beleza! ", "Pronto! "];
const frases = [
  "te mandei na DM", "enviei no seu direct", "acabei de mandar no privado", "olha seu direct",
  "corre lá na DM", "mandei tudo no direct", "dá uma olhada na DM", "te chamei no privado",
  "já está no seu direct", "confere a DM",
];
const finais = ["", "!", " 😉", " 📩", " 🚀", " 🙌", "! Qualquer dúvida é só chamar", ". Se não aparecer, olha em Solicitações"];

const pick = (arr) => arr[Math.floor(Math.random() * arr.length)];

let abertura = pick(aberturas);
let frase = pick(frases);
const final = pick(finais);

const username = $json.username;
if (username && Math.random() < 0.5) abertura = `@${username} `;
if (!abertura) frase = frase.charAt(0).toUpperCase() + frase.slice(1);

return { json: { ...$json, reply_text: `${abertura}${frase}${final}` } };

Responder comentário. HTTP Request, POST. Never Error ligado. O comment_id sai do nó Reservar lote. Se a URL usar um id vazio, a API responde que o objeto replies não existe.

responder-url.txt · text
{{ $('Config').first().json.graph_base }}/{{ $('Config').first().json.graph_version }}/{{ $('Reservar lote').item.json.comment_id }}/replies

Header Authorization: Bearer {{ $('Buscar token').first().json.value }}. Corpo em expressão: {{ JSON.stringify({ message: $json.reply_text }) }}.

Salve e ative o workflow. A chave se chama Active, ou o botão Publish, conforme a versão. O botão Execute workflow só roda uma vez.

5.4 Workflow IG - Renovar token

Schedule Trigger semanal, segunda, 3h. Config com supabase_url igual à SUPABASE_URL.

Buscar token, GET, Custom Auth Supabase Service:

renovar-buscar.txt · text
{{ $('Config').first().json.supabase_url }}/rest/v1/ig_settings?key=eq.ig_access_token&select=value

Renovar, GET https://graph.instagram.com/refresh_access_token. Query: grant_type = ig_refresh_token, access_token = {{ $json.value }}.

Salvar token, PATCH. Header Prefer = return=minimal. Corpo em expressão: {{ JSON.stringify({ value: $json.access_token, updated_at: new Date().toISOString() }) }}.

salvar-token.txt · text
{{ $('Config').first().json.supabase_url }}/rest/v1/ig_settings?key=eq.ig_access_token

Salve e ative.

5.5 Gravar o token no banco

Troque IG_ACCESS_TOKEN pelo token do passo 3.6. Este é o único lugar em que o token fica. Não cole em código, em print ou no Git.

gravar-token.sql · sql
insert into public.ig_settings (key, value)
values ('ig_access_token', 'IG_ACCESS_TOKEN')
on conflict (key) do update set value = excluded.value, updated_at = now();

6. Testes

6.1 Token

testar-token.ps1 · powershell
curl.exe "https://graph.instagram.com/v26.0/me?fields=user_id,username&access_token=IG_ACCESS_TOKEN"

6.2 Webhook com evento falso

Arquivo teste-webhook.mjs. Troque os dois placeholders pelos valores reais.

teste-webhook.mjs · js
import crypto from "node:crypto";

const APP_SECRET = "META_APP_SECRET";
const URL = "https://PROJECT_REF.supabase.co/functions/v1/ig-webhook";

const now = Math.floor(Date.now() / 1000);
const body = JSON.stringify({
  object: "instagram",
  entry: [{ id: "111", time: now, changes: [{ field: "comments", value: {
    id: `teste_${now}`, text: "Eu QUERO!", from: { id: "999", username: "teste" }, media: { id: "media_1" },
  } }] }],
});

const sig = crypto.createHmac("sha256", APP_SECRET).update(body).digest("hex");
const res = await fetch(URL, {
  method: "POST",
  headers: { "Content-Type": "application/json", "X-Hub-Signature-256": `sha256=${sig}` },
  body,
});
console.log(res.status, await res.text());
rodar-teste.ps1 · powershell
node teste-webhook.mjs

O esperado é 200 ok e uma linha pending:

ver-fila.sql · sql
select comment_id, status, send_after from public.ig_comment_queue order by id desc limit 5;

6.3 n8n

Espere cerca de 20 segundos e rode Execute workflow uma vez. A linha de teste deve virar failed, com erro de parâmetro, porque o comentário é falso. Isso valida a credencial, a reserva, a chamada à API e a atualização da fila.

6.4 Teste real

  1. Com outra conta, não a profissional, comente a palavra-chave em um post.
  2. Supabase → Edge Functions → ig-webhook → Invocations: deve aparecer uma chamada 200.
  3. A linha nasce pending e, em menos de um minuto, vira sent.
  4. A DM chega na conta que comentou. Se ela não segue você, a mensagem cai em Solicitações. O comentário recebe a resposta pública.

7. Erros que já apareceram

ErroCausaSolução
curl do PowerShell dá erro vermelhocurl é apelido do Invoke-WebRequestUse curl.exe
openssl não existeWindowsUse o Node do passo 1 e o script do 6.2
Comando com barra no fim quebraPowerShell não continua linha com barraEscreva em uma linha
403 no teste de verificaçãoA URL ainda tem o texto META_VERIFY_TOKENUse o valor real
Could not find the stuck_minutes column of ig_settingsBuscar token ficou POST, copiado da ManutençãoBuscar token em GET, sem corpo
Workflow só roda no botãoWorkflow não ativadoLigar Active ou Publish
Teste da Meta chega, comentário real nãoConta inscrita só em messagesPasso 3.8
Nenhum evento real chegaApp em modo de desenvolvimentoPasso 3.9
Object with ID replies does not existcomment_id vazio na URLUse $('Reservar lote').item.json.comment_id
401 no ig-webhookSegredo erradoUse o Instagram App Secret do passo 3.3
last_error começa com auth:Token inválido ou expiradoGere outro no passo 3.6 e regrave no 5.5

8. Consultas úteis

consultas.sql · sql
-- Situação da fila
select status, count(*) from public.ig_comment_queue group by status;

-- Pendentes atrasados. n8n parado ou token quebrado.
select count(*) from public.ig_comment_queue
 where status = 'pending' and send_after < now() - interval '10 minutes';

-- Falhas
select comment_id, username, last_error, updated_at
  from public.ig_comment_queue where status = 'failed'
 order by updated_at desc limit 20;

-- Pular um comentário que você respondeu à mão
select public.skip_ig_comment('ID_DO_COMENTARIO');

-- Desativar regra
update public.ig_keyword_rules set active = false where id = 1;

9. Limites

  • A DM, a Private Reply, só pode ser enviada até 7 dias depois do comentário, uma vez por comentário.
  • Depois disso, outra mensagem só existe se a pessoa responder, e dentro de 24 horas dessa resposta.
  • Se a pessoa não segue a conta, a DM vai para Solicitações.
  • O envio só acontece com o PC e o Docker ligados. O que chegar com o PC desligado sai quando ele voltar, dentro dos 7 dias.
  • A API não permite curtir comentários.
  • Uso só na sua conta. Para clientes, a Meta exige a análise do app com verificação do negócio.