Postando no BlueSky através da API com PHP
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.
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.
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.
O | l | a | ! | a | c | e | s | s | e | B | o | t | e | c | o | D | |||
0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 |
i | g | i | t | a | l | e | s | i | g | a | @ | b | o | t | e | c | |||
20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 48 | 39 |
o | d | i | g | i | t | a | l | . | d | e | v | . | b | r | # | t | a | g | |
40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 |
t | e | s | t | ||||||||||||||||
60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 |
['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! 😉