A rede social BlueSky tem registrado um grande aumento de usuários brasileiros recentemente, impulsionado pelo bloqueio do X (que ainda é amplamente conhecido como Twitter, apesar da mudança para “X”). A plataforma utiliza o protocolo atproto, e para quem deseja entender melhor como ele funciona, recomendo conferir esta thread no BlueSky que oferece uma curta explicação sobre o assunto.

Apesar de ser uma rede social emergente, o BlueSky já conta com uma API, permitindo que os usuários interajam com a plataforma para automatizar postagens e acessar a timeline entre outras operações.

Vamos explorar a API do BlueSky e aprender como acessá-la utilizando PHP para criar uma postagem. O primeiro passo é instalar a dependência Guzzle no projeto, que facilitará a interação com a API.

composer require guzzlehttp/guzzle

Antes de realizar qualquer operação na API, é necessário autenticar-se com usuário e senha. Por motivos de segurança, é importante criar uma senha de aplicativo. Para isso, acesse Configurações > Avançado > Senhas de Aplicativos > Adicionar Senha de Aplicativo, insira um nome para identificar o aplicativo e clique em copiar. Assim, a senha gerada estará pronta para uso.

Tela de Criação de Senha de Aplicativo para o BlueSky

Criando uma Sessão de Autenticação no BlueSky

Agora vamos criar uma sessão de autenticação para obter o token de acesso. Para simplificar, desenvolveremos uma função dedicada à autenticação, permitindo reutilizá-la em outros exemplos e operações futuras.

function auth($username, $password)  {
    $baseURL = "https://bsky.social/xrpc/";
    $url = $baseURL . 'com.atproto.server.createSession';

    $client = new Client();
    $headers = [
        'Content-Type' => 'application/json',
        'Accept' => 'application/json'
    ];

    $body = json_encode([
        "identifier" => $username,
        "password" => $password
    ]);

    try{
        $request = new Request('POST', $url, $headers, $body);
        $res = $client->sendAsync($request)->wait();
        
        if($res->getStatusCode() === 200) {
            $data = json_decode( (string) $res->getBody()->__toString(), true);
            return [
                'accessJwt' => $data['accessJwt'],
                'did' => $data['did']
            ];
        }
    }catch(\Exception $e) {
        throw new \Exception('Erro ao realizar a autenticação');
    }
}

Nas linhas 2 e 3, configuramos a URL para realizar a requisição. Utilizamos a URL base do BlueSky, combinada com a operação desejada, que neste caso é com.atproto.server.createSession.

Nas linhas 5 a 9, criamos o cliente HTTP do Guzzle para realizar a requisição e definimos um array com os headers necessários para essa requisição.

Nas linhas 11 a 14, montamos o corpo da requisição em formato JSON, contendo as informações de identifier e password (senha criada para o aplicativo). O identifier refere-se ao handle do usuário, como @rodrigoaramburu.bsky.social, ou ao domínio personalizado, como @botecodigital.dev.br.

Na linha 17, configuramos uma requisição POST, inserindo a URL, os headers e o corpo em formato JSON. Em seguida, na linha 18, utilizamos o cliente HTTP criado anteriormente para executar a requisição.

Na linha 20, verificamos se o Status Code da resposta é 200, confirmando que a requisição foi bem-sucedida. Em seguida, convertemos o corpo da resposta para string e, como o conteúdo está em formato JSON, o transformamos em um array. Retornamos os valores dos campos accessJwt (token de acesso utilizado para futuras requisições) e did (identificador de longo prazo da conta, necessário para realizar ações como postagens).

Postando uma Mensagem no BlueSky pela API

Vamos agora realizar uma postagem simples.

['accessJwt' => $accessJwt, 'did' => $did] = auth('botecodigital.dev.br', 'xxxx-xxxx-xxxx-xxxx');

$baseURL = "https://bsky.social/xrpc/";
$url = $baseURL . 'com.atproto.repo.createRecord';

$text = "Hello World! Acesse https://www.botecodigital.dev.br";

$headers = [
    'Content-Type' => 'application/json',
    'Accept' => 'application/json',
    'Authorization' => "Bearer {$accessJwt}"
];
$body = json_encode([
    "repo" => $did,
    "collection"=> "app.bsky.feed.post",
    "record" => [
        '$type' => 'app.bsky.feed.post',
        'text' => $text,
        'createdAt' => (new \DateTime('now'))->format(\DateTime::ATOM) 
    ],
]);

$client = new Client();
$request = new Request('POST', $url, $headers, $body);
$res = $client->sendAsync($request)->wait();

if($res->getStatusCode() === 200){
    echo "Postado com sucesso!";
}

Na primeira linha, realizamos a autenticação utilizando a função que desenvolvemos anteriormente. Esta função retorna um array contendo o accessToken e o did da conta. Em seguida, desestruturamos esses valores em variáveis, $accessToken e $did, para utilizá-los posteriormente.

Nas linhas 3 e 4, construímos a URL da requisição utilizando a operação com.atproto.repo.createRecord para criar um novo registro. Este registro representa o conteúdo a ser publicado, que, neste caso, consiste em um texto simples.

Na linha 6, definimos uma variável que contém o texto a ser publicado. Em seguida, nas linhas 8 a 12, construímos um array com os cabeçalhos da requisição. Essa configuração é semelhante à anterior, mas agora incluímos o cabeçalho Authorization, passando o accessToken recebido durante o processo de autenticação.

Nas linhas 13 a 21, elaboramos o corpo JSON da requisição, que inclui os campos: repo (o DID da conta ou o handle), collection e record, um objeto JSON representando a postagem a ser realizada. Dentro do objeto record, definimos os campos: text (o texto a ser publicado), $type (o tipo de postagem) e createdAt (a data de criação da postagem).

Nas linhas 23 a 25, estabelecemos o cliente HTTP e criamos a requisição POST, enviando-a com a URL, os cabeçalhos e o corpo da requisição. Se o código de status retornado for 200, isso indica que a postagem foi realizada com sucesso.

Exemplo de post feito através da api do BlueSky

Facets: adicionando links, metions e tags nas mensagens

Como observado acima, a postagem foi realizada, mas o link é exibido como texto, em vez de um link clicável. Isso acontece porque o BlueSky utiliza Rich Text para formatar links, menções e tags. Para resolver isso, precisamos incluir um array de objetos facets na nossa postagem, especificando quais partes do texto devem ser formatadas e o tipo de informação que cada uma representa.

O objeto facet possui três atributos essenciais: $type, que deve ser definido como ‘app.bsky.richtext.facet‘; index, o intervalo de texto a ser aplicado a formatação, que contém dois subatributos, byteStart e byteEnd. O byteStart representa a posição inicial (inclusiva) e o byteEnd a posição final (exclusiva) da parte do texto, sendo importante notar que alguns caracteres em UTF-8 ocupam mais de um byte. Por último, o atributo features é um array que inclui informações sobre o tipo de formatação a ser aplicada ao texto selecionado no index, juntamente com dados correspondentes à decoração, como a URL do link.

Vamos aplicar três tipos de formatação ao texto ‘Olá! Acesse Boteco Digital e siga @botecodigital.dev.br #tagtest’. Abaixo, apresentamos o índice de cada letra da frase, facilitando a compreensão dos intervalos de formatação.

Ola!acesseBotecoD
012345678910111213141516171819
igita l esiga@botec
2021222324252627282930313233343536374839
odigital.dev.br #tag
4041424344454647484950515253545556575859
test
6061626364656667686970717273747576777879
Posições dos caracteres do texto: “Ola! acesse Boteco Digital e siga @botecodigital.dev.br #tagtest” para referência.
['accessJwt' => $accessJwt, 'did' => $did] = auth('botecodigital.dev.br', 'xxxx-xxxx-xxxx-xxxx');

$baseURL = "https://bsky.social/xrpc/";
$url = $baseURL . 'com.atproto.repo.createRecord';

$headers = [
    'Content-Type' => 'application/json',
    'Accept' => 'application/json',
    'Authorization' => "Bearer {$accessJwt}"
];

$handlerToDid = function($handle) use($headers, $baseURL){
    $url = $baseURL . "com.atproto.identity.resolveHandle?handle=$handle";

    $client = new Client();
    $request = new Request('GET', $url, $headers);
    $data = $client->send($request)->getBody()->getContents();

    return json_decode($data, true)['did'];
};

$text = "Ola! acesse Boteco Digital e siga @botecodigital.dev.br #tagtest ";

$facets = [
        [
            '$type' => 'app.bsky.richtext.facet',
            "index" => [
                "byteStart" => 12,
                "byteEnd" => 26
            ],
            'features' => [[
                "uri" => 'https://www.botecodigital.dev.br',
                '$type' => "app.bsky.richtext.facet#link"
            ]],
        ],
        [
            '$type' => 'app.bsky.richtext.facet',
            "index" => [
                "byteStart" => 34,
                "byteEnd" => 55
            ],
            'features' => [[
                "did" => $handlerToDid('botecodigital.dev.br'),
                '$type' => "app.bsky.richtext.facet#mention"
            ]],
        ],
        [
            '$type' => 'app.bsky.richtext.facet',
            "index" => [
                "byteStart" => 56,
                "byteEnd" => 64
            ],
            'features' => [[
                "tag" => 'tagtest',
                '$type' => "app.bsky.richtext.facet#tag"
            ]],
        ],
];

$body = json_encode([
    "repo" => $did,
    "collection"=> "app.bsky.feed.post",
    "record" => [
        '$type' => 'app.bsky.feed.post',
        'text' => $text,
        'createdAt' => (new \DateTime('now'))->format(\DateTime::ATOM) ,
        'facets' => $facets
    ],
]);

$client = new Client();
$request = new Request('POST', $url, $headers, $body);
$res = $client->sendAsync($request)->wait();

if($res->getStatusCode() === 200){
    echo "Postado com sucesso!";
}

Na linha 24, criamos um array contendo os facets que serão aplicados ao texto. Este array inclui três elementos, sendo o primeiro dedicado ao link. Nas linhas 27 a 30, configuramos o atributo index para selecionar o intervalo de bytes de 12 a 26. No atributo features, que deve ser um array, definimos $type como ‘app.bsky.richtext.facet#link‘ e incluímos a URL desejada para o link.

Como segundo elemento do array de facets, nas linhas 36 a 46, configuramos a decoração da menção. O atributo index seleciona o intervalo de bytes de 34 a 55, e no atributo features, definimos $type como ‘app.bsky.richtext.facet#mention‘. É importante ressaltar que, para a menção, precisamos do did, que não aceita o handle do usuário. Para resolver isso, criamos uma função nas linhas 12 a 20 que realiza uma requisição ao endpoint com.atproto.identity.resolveHandle, passando o handle desejado como parâmetro de consulta. O retorno deste endpoint será um objeto JSON contendo o atributo did, que, em seguida, convertendo o JSON para um array, retornamos o atributo necessário. Então chamamos a função e o retorno dela será o valor de did.

Como terceiro elemento do array de facets, nas linhas 47 a 57, configuramos a decoração da tag. No atributo index, selecionamos o intervalo de bytes de 56 a 64. No atributo features, definimos $type como ‘app.bsky.richtext.facet#tag‘ e, para o atributo tag, utilizamos o valor ‘tagtest‘.

Após a definição de todos os facets, adicionamos o array criado ao record na linha 67, integrando as decorações necessárias para a formatação do texto.

Como é evidente, não faremos as decorações manualmente contando letras uma a uma. A melhor abordagem é criar funções de parse utilizando expressões regulares ou técnicas semelhantes. Veja o exemplo a seguir.

$handlerToDid = function($handle) use($headers, $baseURL){
    $url = $baseURL . "com.atproto.identity.resolveHandle?handle=$handle";

    $client = new Client();
    $request = new Request('GET', $url, $headers);
    $data = $client->send($request)->getBody()->getContents();

    return json_decode($data, true)['did'];
};

$parse_link = function($text){
    $regex = "#\bhttps?://[^\s()<>]+(?:\([\w\d]+\)|([^[:punct:]\s]|/))#";

    $facets = [];
    preg_match_all($regex, $text, $matches);
    foreach($matches[0] as $url) {
        $facets[] = [
            "index" => [
                "byteStart" => strpos($text,$url),
                "byteEnd" => strpos($text,$url) + strlen($url)
            ],
            'features' => [[
                "uri" => $url,
                '$type' => "app.bsky.richtext.facet#link"
            ]],
        ];
    };
    return $facets;
};

$parse_mention =  function($text) use($handlerToDid){
    $regex = '#(^|\s|\()(@)(?<handle>[a-zA-Z0-9.-]+)(\b)#';
    $facets = [];
    preg_match_all($regex, $text, $matches);
    
    foreach($matches['handle'] as $mention) {
        $facets[] = [
            "index" => [
                "byteStart" => strpos($text, '@' . $mention) - 1,
                "byteEnd" => strpos($text,  '@' . $mention) + strlen($mention) + 1
            ],
            'features' => [[
                "did" => $handlerToDid($mention),
                '$type' => "app.bsky.richtext.facet#mention"
            ]],
        ];
    };
    return $facets;
};

$parse_tags = function($text){
    $regex = '%(^|\s|\()(#)(?<tag>[a-zA-Z0-9]+)(\b)%';
    $facets = [];
    preg_match_all($regex, $text, $matches);
    
    foreach($matches['tag'] as $tag) {
        $facets[] = [
            "index" => [
                "byteStart" => strpos($text, '#' . $tag) - 1,
                "byteEnd" => strpos($text,  '#' . $tag) + strlen($tag) + 1
            ],
            'features' => [[
                "tag" => $tag,
                '$type' => "app.bsky.richtext.facet#tag"
            ]],
        ];
    };
    return $facets;
};

$text = "Ola! acesse https://www.botecodigital.dev.br e http://www.google.com.br." . 
        "Siga(ou não) @botecodigital.dev.br e @rodrigoaramburu.bsky.social ".
        "#tagtest1 #tagtest2";

$body = json_encode([
    "repo" => $did,
    "collection"=> "app.bsky.feed.post",
    "record" => [
        '$type' => 'app.bsky.feed.post',
        'text' => $text,
        'createdAt' => (new \DateTime('now'))->format(\DateTime::ATOM) ,
        'facets' => array_merge( 
            $parse_link($text), 
            $parse_mention($text), 
            $parse_tags($text) 
        )
    ],
]);

Seguindo a mesma lógica, poderíamos desenvolver funções de parse para mensagens que contêm marcações em Markdown ou outros tipos de marcação.

Postando uma imagem no BlueSky com a API

Para enviar uma imagem junto com a postagem, seguimos alguns passos adicionais. Primeiro, precisamos capturar o conteúdo da imagem e seu tipo MIME. Em seguida, enviamos a imagem para o servidor, que retorna um JSON com as informações do arquivo enviado. Por fim, adicionamos esses dados à postagem. Vamos ver um exemplo.

$body = file_get_contents('imagem-upload1.jpg');
$mime = mime_content_type('imagem-upload1.jpg');

$headers = [
    'Content-Type' => $mime,
    'Accept' => 'application/json',
    'Authorization' => "Bearer {$accessJwt}"
];

$url = $baseURL . 'com.atproto.repo.uploadBlob';
$request = new Request('POST', $url, $headers, $body);
$res = $client->sendAsync($request)->wait();

$image = json_decode($res->getBody()->__toString(),true)['blob'];


$text = "Exemplo de Upload de Imagem";
$body = json_encode([
    "repo" => $did,
    "collection"=> "app.bsky.feed.post",
    "record" => [
        '$type' => 'app.bsky.feed.post',
        'text' => $text,
        'createdAt' => (new \DateTime('now'))->format(\DateTime::ATOM) ,
        'embed' => [
            '$type' => 'app.bsky.embed.images',
            'images' => [
                [
                    'alt' => 'Imagem de Exemplo',
                    'image' => $image,   
                ]
            ]
        ]
    ]
]);

$url = $baseURL . 'com.atproto.repo.createRecord';
$headers = [
    'Content-Type' => 'application/json',
    'Accept' => 'application/json',
    'Authorization' => "Bearer {$accessJwt}"
];
$request = new Request('POST', $url, $headers, $body);
$res = $client->sendAsync($request)->wait();

if($res->getStatusCode() === 200){
    echo "Postado com sucesso!";
}

Para começar, iremos enviar a imagem para o Blue Sky. Na linha 1, capturamos o conteúdo da imagem usando a função file_get_contents e, em seguida, obtemos seu tipo MIME com a função mime_content_type. Nas linhas 4 a 8, criamos um array contendo as informações do cabeçalho, onde o Content-Type será o MIME da imagem. Na linha 10, configuramos a URL com o endpoint com.atproto.repo.uploadBlob. Em seguida, realizamos a requisição e, no corpo JSON da resposta, extraímos o campo blob, que contém as informações da imagem enviada. Veja abaixo o JSON da resposta da requisição.

{
   "blob":{
      "$type":"blob",
      "ref":{
         "$link":"bafkr3idcvltvud4pho5jjptblpyws3vlbwkzpumvaagj7djrzgmqoxl72q"
      },
      "mimeType":"image/jpeg",
      "size":41320
   }
}

Em seguida, criamos o corpo da requisição para a postagem, assim como fizemos anteriormente. Na linha 25, adicionamos o atributo embed com as informações da imagem. No campo $type, definimos como ‘app.bsky.embed.images‘, e no campo images, atribuirmos um array contendo cada imagem enviada. Cada objeto dentro do array possui dois campos: alt, que representa o texto descritivo da imagem, e blob, que é o valor retornado pela requisição anterior ao endpoint com.atproto.repo.uploadBlob.

Link Cards

Os link cards são links para sites comuns em redes sociais, que apresentam uma imagem, um título e uma descrição. Normalmente, esses cards são gerados automaticamente a partir das meta tags presentes no site. No BlueSky, precisamos criar esses link cards manualmente e incluí-los em nossa postagem. Para isso, devemos acessar o site e recuperar os dados das meta tags. Veja o exemplo abaixo, onde criamos uma função para realizar essa tarefa.

['accessJwt' => $accessJwt, 'did' => $did] = auth('botecodigital.dev.br', 'xxxx-xxxx-xxxx-xxxx');

$baseURL = "https://bsky.social/xrpc/";

$getLinkCard = function($url) use($accessJwt, $baseURL){
    $title = "";
    $description = "";
    
    $doc = new DOMDocument();
 
    libxml_use_internal_errors(true);
    $doc->loadHTMLFile($url);
    libxml_use_internal_errors(false);
    $xpath = new DOMXPath($doc);
 
    $title_tag = $xpath->query('//meta[@property="og:title"]/@content');
    if ($title_tag->length > 0) {
        $title = $title_tag[0]->nodeValue;
    }
 
    $description_tag = $xpath->query('//meta[@property="og:description"]/@content');
    if ($description_tag->length > 0) {
        $description = $description_tag[0]->nodeValue;
    }
 
    $image_tag = $xpath->query('//meta[@property="og:image"]/@content');
    $image = null;
    if ($image_tag->length > 0) {
        $img_url = $image_tag[0]->nodeValue;
        
        if (!parse_url($img_url, PHP_URL_SCHEME)) {
            $img_url = $url . $img_url;
        }
         
        $body = file_get_contents($img_url);
        $mime = mime_content_type($img_url);

        $headers = [
            'Content-Type' => $mime,
            'Accept' => 'application/json',
            'Authorization' => "Bearer {$accessJwt}"
        ];

        $url = $baseURL . 'com.atproto.repo.uploadBlob';
        $request = new Request('POST', $url, $headers, $body);
        $client = new Client();
        $res = $client->sendAsync($request)->wait();
        $image = json_decode($res->getBody()->__toString(),true)['blob'];
    }
 
    $embed =  [
        '$type' => 'app.bsky.embed.external',
        'external' => [
            'uri' => $url,
            'title' => $title,
            'description' => $description,
            'thumb' => $image,
        ],
    
    ];
 
    return $embed;
};


$url = $baseURL . 'com.atproto.repo.createRecord';

$text = "Hello World! Acesse https://www.botecodigital.dev.br";

$headers = [
    'Content-Type' => 'application/json',
    'Accept' => 'application/json',
    'Authorization' => "Bearer {$accessJwt}"
];
$body = json_encode([
    "repo" => $did,
    "collection"=> "app.bsky.feed.post",
    "record" => [
        '$type' => 'app.bsky.feed.post',
        'text' => $text,
        'createdAt' => (new \DateTime('now'))->format(\DateTime::ATOM) ,
        'embed' => $getLinkCard('https://www.botecodigital.dev.br'),
    ],
]);

$client = new Client();
$request = new Request('POST', $url, $headers, $body);
$res = $client->sendAsync($request)->wait();

if($res->getStatusCode() === 200){
    echo "Postado com sucesso!";
}

Na linha 5, criamos a função getLinkCard, que recebe uma URL, acessa essa URL, obtém os dados das meta tags e retorna um array com as informações necessárias para incluir o link card na postagem. Nas linhas 6 e 7, definimos as variáveis para armazenar os valores que iremos buscar nas tags.

Na linha 9, criamos o objeto DOMDocument para realizar o parsing da página. Em seguida, na linha 12, carregamos a página a partir da URL e, na linha 14, criamos um objeto DOMXPath para permitir a seleção das tags da página.

Na linha 16, buscamos a meta tag que possui a property igual a ‘og:title‘, que é utilizada pelas páginas para definir o título nas redes sociais. Na linha seguinte, verificamos se a busca pela tag retornou um elemento. Se sim, extraímos o nodeValue e o armazenamos na variável $titulo.

Das linhas 21 a 24, seguimos o mesmo procedimento utilizado para o título, mas agora buscamos a descrição da página, extraindo o valor correspondente da meta tag e armazenando na variável $description.

Para recuperar a URL da imagem, seguimos nas linhas 26 a 29 o mesmo processo do título e descrição. Como a URL pode ser relativa, nas linhas 31 a 33 verificamos essa condição e a concatenamos com a base para garantir que a URL esteja completa.

Das linhas 35 a 48, enviamos a imagem para o servidor, seguindo o mesmo procedimento utilizado anteriormente. Em seguida, extraímos o blob da resposta para utilizá-lo posteriormente.

Agora, criamos o objeto embed que será adicionado à nossa postagem. Esse objeto contém o campo $type, que deve ser definido como ‘app.bsky.embed.external‘, e o campo external, que é um objeto com os seguintes atributos: uri (o endereço do link), title e description (recuperados das tags da página), além de thumb (o blob da imagem que enviamos).

Para finalizar, adicionamos o campo embed (linha 82) ao record da postagem, definindo seu valor como o retorno da função que criamos anteriormente.

Bom pessoal, era isso! Agora podemos criar um pequeno bot para automatizar nossas postagens. Mas atenção: devemos ter cuidado para não inundar essa nova rede com bots! 😉