⚡ AutomationsAI|Portal de Cursos →

Verificando acesso...

MÓDULO 3.1

🦋 Bluesky — postando no AT Protocol

A rede social mais fácil de automatizar: sem aprovação de app, sem OAuth labiríntico. Em 30 minutos você cria conta, instala o SDK, faz login programático e publica texto, imagens e links com facets.

6
Tópicos
30
Minutos
Básico
Nível
Prático
Tipo
1

👤 Criar conta + App Password

O Bluesky é a rede mais amigável a desenvolvedores: não precisa aprovação, não tem fila de espera para API, e a autenticação é por App Password — uma senha derivada que você revoga sem afetar o login principal. Diferente do X/Twitter, ninguém precisa "aceitar" seu projeto.

Crie a conta em bsky.app com e-mail e senha. Seu identificador (handle) vai ser algo como seu-nome.bsky.social — esse é o "usuário" que o SDK pede no login.

Depois vá em Settings → Privacy and security → App Passwords → Add App Password. Dê um nome descritivo (ex.: mkblogs-bot) e copie a string gerada. Ela aparece uma única vez — guarde no .env imediatamente.

# .env — formato da App Password é xxxx-xxxx-xxxx-xxxx
BSKY_HANDLE=seu-nome.bsky.social
BSKY_APP_PASSWORD=abcd-efgh-ijkl-mnop

# Endpoint do PDS (Personal Data Server) — padrão Bluesky
BSKY_SERVICE=https://bsky.social

🔐 Dica prática

App Passwords não dão acesso a trocar a senha principal nem deletar a conta — só ao protocolo de publicação. Crie uma por integração: assim revogar o token do bot não desloga você do app oficial.

Conceitos-chave

Handle

Identificador público no formato user.bsky.social.

App Password

Credencial derivada, escopo limitado, revogável.

PDS

Personal Data Server que hospeda seus posts.

DID

Decentralized Identifier único da conta.

2

📦 Instalar @atproto/api

O SDK oficial do AT Protocol é o @atproto/api, mantido pela Bluesky Social. Tem types TypeScript completos, suporta browser e Node, e abstrai os endpoints XRPC numa classe BskyAgent com métodos de alto nível.

# Iniciar projeto Node + TypeScript
mkdir bsky-bot && cd bsky-bot
npm init -y
npm install @atproto/api
npm install -D typescript @types/node tsx dotenv
npx tsc --init

Configure o tsconfig.json com "target": "ES2022" e "module": "NodeNext" para suportar top-level await e ESM nativo. O import básico do SDK fica assim:

// src/client.ts
import { BskyAgent, RichText } from "@atproto/api";
import "dotenv/config";

// Cliente apontando para o PDS padrão
export const agent = new BskyAgent({
  service: process.env.BSKY_SERVICE ?? "https://bsky.social",
});

// Helpers de tipagem
export type PostRecord = {
  text: string;
  createdAt: string;
  facets?: unknown[];
  embed?: unknown;
};
// package.json — scripts úteis
{
  "scripts": {
    "post": "tsx src/post.ts",
    "image": "tsx src/post-image.ts",
    "rich": "tsx src/post-rich.ts"
  }
}

💡 Dica prática

Use tsx em vez de ts-node — é mais rápido, suporta ESM out-of-the-box e não exige configuração extra. Para produção, transpile com tsc e rode o .js com Node puro.

Conceitos-chave

@atproto/api

SDK oficial, isomórfico (Node + browser).

BskyAgent

Classe principal com sessão e métodos de alto nível.

XRPC

Protocolo RPC sobre HTTP usado pelo AT Protocol.

tsx

Runner TypeScript leve, ideal para scripts.

3

🔑 Login programático

O método agent.login() troca handle + App Password por um par de tokens (accessJwt e refreshJwt). O SDK mantém esses tokens em memória e os renova automaticamente nas chamadas seguintes — você só precisa logar uma vez por execução.

// src/login.ts
import { agent } from "./client.js";

async function authenticate(): Promise<void> {
  const handle = process.env.BSKY_HANDLE;
  const password = process.env.BSKY_APP_PASSWORD;

  if (!handle || !password) {
    throw new Error("BSKY_HANDLE e BSKY_APP_PASSWORD são obrigatórios");
  }

  const result = await agent.login({
    identifier: handle,
    password,
  });

  console.log("✅ Logado como:", result.data.handle);
  console.log("   DID:", result.data.did);
  console.log("   Email:", result.data.email);
}

authenticate().catch((err) => {
  console.error("❌ Falha no login:", err);
  process.exit(1);
});

Para reutilizar a sessão entre execuções (evitando login repetido em filas), persista o objeto agent.session e restaure com resumeSession():

// src/session-cache.ts
import fs from "node:fs/promises";
import { agent } from "./client.js";
import type { AtpSessionData } from "@atproto/api";

const SESSION_FILE = ".bsky-session.json";

export async function ensureSession(): Promise<void> {
  try {
    const raw = await fs.readFile(SESSION_FILE, "utf8");
    const session = JSON.parse(raw) as AtpSessionData;
    await agent.resumeSession(session);
    console.log("♻️  Sessão restaurada");
  } catch {
    await agent.login({
      identifier: process.env.BSKY_HANDLE!,
      password: process.env.BSKY_APP_PASSWORD!,
    });
    await fs.writeFile(SESSION_FILE, JSON.stringify(agent.session));
    console.log("🆕 Nova sessão criada");
  }
}

✓ O que FAZER

  • Validar variáveis de ambiente antes do login().
  • Persistir agent.session em jobs longos.
  • Capturar erro 401 e refazer o login automaticamente.
  • Logar o did nos logs (handle pode mudar).

✗ O que NÃO fazer

  • Logar de novo a cada post — desperdiça tokens.
  • Usar a senha principal no lugar da App Password.
  • Commitar .bsky-session.json no git.
  • Compartilhar a App Password entre integrações.

Conceitos-chave

accessJwt

Token curto, autoriza chamadas autenticadas.

refreshJwt

Token longo, renova o access expirado.

resumeSession

Restaura sessão sem novo handshake.

identifier

Handle ou e-mail — o SDK aceita ambos.

4

📝 Publicar texto

O método agent.post() recebe um record que segue o schema app.bsky.feed.post. Os dois campos obrigatórios são text (até 300 caracteres) e createdAt em ISO 8601.

// src/post.ts
import { agent } from "./client.js";
import { ensureSession } from "./session-cache.js";

async function publish(): Promise<void> {
  await ensureSession();

  const result = await agent.post({
    text: "Primeiro post via @atproto/api 🦋",
    createdAt: new Date().toISOString(),
  });

  console.log("📤 Post publicado");
  console.log("   URI:", result.uri);
  console.log("   CID:", result.cid);
}

publish().catch(console.error);

O retorno traz uri (formato at://did:plc:.../app.bsky.feed.post/<rkey>) e cid (hash do conteúdo). Guarde os dois se quiser deletar ou referenciar o post depois.

// Validação de tamanho antes de publicar
const MAX_GRAPHEMES = 300;

function assertPostable(text: string): void {
  const length = [...new Intl.Segmenter().segment(text)].length;
  if (length > MAX_GRAPHEMES) {
    throw new Error(`Post tem ${length} grafemas (máx ${MAX_GRAPHEMES})`);
  }
}

assertPostable("Texto que cabe no limite ✨");
await agent.post({
  text: "Texto que cabe no limite ✨",
  createdAt: new Date().toISOString(),
  langs: ["pt-BR"],
});

⚠️ Dica prática

O limite do Bluesky é em grafemas, não bytes nem code units. Um emoji composto (👨‍👩‍👧) conta como 1, mas "".length retorna 8. Use Intl.Segmenter para contar certo.

Conceitos-chave

Record

Objeto tipado seguindo um Lexicon do AT Protocol.

AT-URI

Identificador único do post no formato at://.

CID

Content ID — hash IPFS do conteúdo imutável.

Grafema

Unidade visual percebida (uma "letra" para o usuário).

5

🖼️ Upload de imagem

Imagens no Bluesky são blobs separados do post. O fluxo é em duas etapas: primeiro uploadBlob() envia os bytes e devolve uma referência; depois você inclui essa referência no campo embed do post.

Limites: até 4 imagens por post, 1 MB por blob (após otimização), formatos image/jpeg, image/png e image/webp. Alt text é obrigatório para acessibilidade.

// src/post-image.ts
import fs from "node:fs/promises";
import { agent } from "./client.js";
import { ensureSession } from "./session-cache.js";

type UploadedImage = {
  alt: string;
  image: {
    $type: "blob";
    ref: { $link: string };
    mimeType: string;
    size: number;
  };
};

async function uploadImage(
  path: string,
  mimeType: string,
  alt: string,
): Promise<UploadedImage> {
  const bytes = await fs.readFile(path);

  if (bytes.byteLength > 1_000_000) {
    throw new Error(`Imagem ${path} excede 1 MB (${bytes.byteLength} bytes)`);
  }

  const { data } = await agent.uploadBlob(bytes, { encoding: mimeType });

  return {
    alt,
    image: data.blob as UploadedImage["image"],
  };
}

async function publish(): Promise<void> {
  await ensureSession();

  const hero = await uploadImage(
    "./assets/capa.jpg",
    "image/jpeg",
    "Logo do projeto MkBlogs sobre fundo escuro",
  );

  const result = await agent.post({
    text: "Lançamento oficial 🚀",
    createdAt: new Date().toISOString(),
    embed: {
      $type: "app.bsky.embed.images",
      images: [hero],
    },
  });

  console.log("📤 Post com imagem:", result.uri);
}

publish().catch(console.error);

Para múltiplas imagens, faça Promise.all() dos uploads e passe o array no images:

const uploads = await Promise.all([
  uploadImage("./a.jpg", "image/jpeg", "Captura da dashboard"),
  uploadImage("./b.png", "image/png",  "Gráfico de engajamento"),
  uploadImage("./c.webp","image/webp", "Print da configuração"),
]);

await agent.post({
  text: "Três capturas do release de hoje",
  createdAt: new Date().toISOString(),
  embed: {
    $type: "app.bsky.embed.images",
    images: uploads,
  },
});

Dica prática

O alt não é decorativo — leitores de tela dependem dele e o algoritmo do Bluesky usa o texto para ranqueamento. Descreva o que aparece, não "imagem" ou "foto". Imagem sem alt útil é tratada como spam por moderadores.

Conceitos-chave

Blob

Binário (imagem/vídeo) referenciado por CID.

Embed

Conteúdo anexo: imagens, link, quote ou record.

MIME type

Necessário no encoding do uploadBlob.

Alt text

Descrição textual para acessibilidade e indexação.

6

🔗 RichText e detecção de links

Diferente do Twitter, o Bluesky não detecta links automaticamente. URLs e menções viram texto puro a menos que você anexe facets — marcações posicionais que dizem ao cliente "esses bytes são um link, esses são uma menção".

A classe RichText do SDK faz o trabalho pesado: detecta padrões, calcula os offsets em UTF-8 (não UTF-16!) e gera o array de facets pronto para anexar ao post.

// src/post-rich.ts
import { RichText } from "@atproto/api";
import { agent } from "./client.js";
import { ensureSession } from "./session-cache.js";

async function publish(): Promise<void> {
  await ensureSession();

  const rt = new RichText({
    text: "Lançamos a v1! Conheça em https://mkblogs.dev " +
          "e siga @automationsai.net.bsky.social para novidades. #devtools",
  });

  // Detecta URLs, menções e hashtags + valida tamanho em grafemas
  await rt.detectFacets(agent);

  if (rt.graphemeLength > 300) {
    throw new Error(`Texto tem ${rt.graphemeLength} grafemas`);
  }

  const result = await agent.post({
    text: rt.text,
    facets: rt.facets,
    createdAt: new Date().toISOString(),
    langs: ["pt-BR"],
  });

  console.log("📤 Post rico publicado:", result.uri);
  console.log("   Facets detectadas:", rt.facets?.length ?? 0);
}

publish().catch(console.error);

Internamente, cada facet tem um index com byteStart/byteEnd e uma lista de features (link, mention, tag). Se quiser construir manualmente:

// Facet manual — útil quando o texto é gerado dinamicamente
const text = "Leia mais em mkblogs.dev";
const url = "https://mkblogs.dev";
const target = "mkblogs.dev";

const byteStart = new TextEncoder().encode(
  text.slice(0, text.indexOf(target)),
).byteLength;
const byteEnd = byteStart + new TextEncoder().encode(target).byteLength;

await agent.post({
  text,
  createdAt: new Date().toISOString(),
  facets: [
    {
      index: { byteStart, byteEnd },
      features: [
        { $type: "app.bsky.richtext.facet#link", uri: url },
      ],
    },
  ],
});

Para link preview (card com título, descrição e thumbnail), use embed.external com um blob de imagem opcional:

await agent.post({
  text: "Novo guia publicado 🦋",
  createdAt: new Date().toISOString(),
  embed: {
    $type: "app.bsky.embed.external",
    external: {
      uri: "https://mkblogs.dev/bluesky-guide",
      title: "Guia completo do Bluesky API",
      description: "Do zero ao primeiro bot em 30 minutos.",
      // thumb opcional: blob retornado por uploadBlob()
    },
  },
});

✓ O que FAZER

  • Usar RichText.detectFacets() para link/menção/hashtag automáticos.
  • Calcular offsets em bytes UTF-8, nunca em chars JS.
  • Validar graphemeLength <= 300 antes do post.
  • Anexar card de preview com embed.external.

✗ O que NÃO fazer

  • Esperar autodetecção sem chamar detectFacets().
  • Usar str.indexOf() direto como byteStart.
  • Colocar tanto images quanto external num único embed.
  • Misturar text com markdown — Bluesky renderiza puro.

Conceitos-chave

Facet

Marcação posicional sobre um trecho do texto.

RichText

Helper que detecta links, menções e hashtags.

UTF-8 offsets

Posição em bytes, não em chars JavaScript.

embed.external

Card de preview com título, descrição e thumb.

🎯 Resumo do Módulo

Conta + App Password criada — handle user.bsky.social e credencial revogável guardada no .env.
SDK @atproto/api instalado — projeto Node + TypeScript com tsx e BskyAgent configurado.
Login programático funcionandoagent.login() com persistência de sessão via resumeSession().
Texto publicadoagent.post() com validação de 300 grafemas via Intl.Segmenter.
Upload de imagem dominado — fluxo blob + embed, até 4 imagens, alt text obrigatório.
RichText e facets — links, menções e hashtags via detectFacets(), card de preview com embed.external.

Próximo Módulo:

3.2 — Mastodon: instâncias, tokens e a Fediverse API