Blueskyで遊ぶ

年越し数分前にメールを受信。Blueskyの招待コードが届いた。年越し早々アカウントをつくって初投稿なんかしてみる。Blueskyが独自開発したプロトコル“AT Protocol”(以降ATP)についても知りたかったので、APIを試したりソースコードを読んでみたりした所感をここにまとめる。

注: BlueskyとAT Protocolは現在鋭意開発中につき、今後の仕様変更に伴い下記の方法が使えなくなる場合あり!

APIを試す

公式ブログではHTTPieを利用して次のコマンドが書いてある。

http post https://bsky.social/xrpc/com.atproto.server.createSession \
  identifier="$BLUESKY_HANDLE" \
  password="$BLUESKY_APP_PASSWORD"

HTTPieの方が読みやすいのだが、気分的にcURLでリクエストしたかったので次のコマンドを使った。コンソールに平文でパスワードを入力したくなかったのでまずは送信データとなるJSONファイルをつくる。

touch cred.json

このファイルに次のフィールドを記入。

{
  "identifier": "{HANDLE_NAME}",
  "password": "{PASSWORD}"
}

HANDLE_NAME(ハンドル名)は@マーク以降を入力(e.g.: seeker5084.bsky.social)。PASSWORDはログインパスワード。

これをcURLで次のサーバーに送信。

curl https://bsky.social/xrpc/com.atproto.server.createSession\
  --request POST\
  --header "Content-Type: application/json"\
  --data @cred.json

cURLは--dataに続いて@付きでファイル名を指定すると、ファイルの中身をサーバーに送信してくれる。

もちろん、「共有コンソールじゃないし気にしないっ!」という方は直接JSONを打っても良いし、何度も試したい方は環境変数に設定しても良い(公式ブログはこの方法)。

直打ちの場合:

curl https://bsky.social/xrpc/com.atproto.server.createSession\
  --request POST\
  --header "Content-Type: application/json"\
  --data '{"identifier": "{HANDLE_NAME}", "password": "{PASSWORD}"}'

環境変数の場合:

# .bashrcや.zshrcなどに以下を記入
BLUESKY_HANDLE="{HANDLE_NAME}"
BLUESKY_APP_PASSWORD="{PASSWORD}"
curl https://bsky.social/xrpc/com.atproto.server.createSession\
  --request POST\
  --header "Content-Type: application/json"\
  --data "{\"identifier\": \"$BLUESKY_HANDLE\", \"password\": \"$BLUESKY_APP_PASSWORD\"}"

この返信として次のようなJSONが返ってくる(参照リンク)。

{
  "didDoc": {
    "@context": [
      "https://www.w3.org/ns/did/v1",
      "https://w3id.org/security/multikey/v1",
      "https://w3id.org/security/suites/secp256k1-2019/v1"
    ],
    "id": "did:plc:cy5cvxauukbyrballccs42h2",
    "alsoKnownAs": [
      "at://seeker5084.bsky.social"
    ],
    "verificationMethod": [
      {
        "id": "did:plc:cy5cvxauukbyrballccs42h2#atproto",
        "type": "Multikey",
        "controller": "did:plc:cy5cvxauukbyrballccs42h2",
        "publicKeyMultibase": "zQ3shP4BXWRMnuuLFMzF1RzHA5VKNuroG3tngsmCMKbnqtyMA"
      }
    ],
    "service": [
      {
        "id": "#atproto_pds",
        "type": "AtprotoPersonalDataServer",
        "serviceEndpoint": "https://shiitake.us-east.host.bsky.network"
      }
    ]
  },
  "handle": "seeker5084.bsky.social",
  "did": "did:plc:cy5cvxauukbyrballccs42h2",
  "accessJwt": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhY2Nlc3MiOiIxMjMn0NTY3ODkwIn0.hX50QBqRmH91s8vbGvjblVu6CcQEkw5jcT26UBIBBEQ",
  "refreshJwt": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyZWZyZXNoIjoiMTIzNDU2Nzg5MCJ9.RHLssrrY0Qt7Lsv2UGJTkpXFtd2tSiE5viZh3hGXnYo",
  "email": "mail@address.com",
  "emailConfirmed": true
}

必要な項目はdidaccessJwtの2つ。accessJwtがアクセストークン(参照リンク)。これをリクエストヘッダにくっつけて、投稿データとともにPOSTする。

curl https://bsky.social/xrpc/com.atproto.repo.createRecord\
  --request POST\
  --header "Content-Type: application/json"\
  --header "Authorization: Bearer {ACCESS_TOKEN}"\
  --data "{\
    \"repo\": \"did:plc:cy5cvxauukbyrballccs42h2\", \
    \"collection\": \"app.bsky.feed.post\", \
    \"record\": {\
      \"$type\": \"app.bsky.feed.post\", \
      \"text\": \"Hello from API 🪂\", \
      \"createdAt\": \"`date -u +"%Y-%m-%dT%H:%M:%SZ"`\"\
    }\
  }"

ACCESS_TOKENの部分をaccessJwtの値に置き換える。

repoは先の返信にあったdidの値に(もしくは“ハンドル名”でも大丈夫: サプリ3参照)。createdAtISO 8601形式で投稿日時を。

これでtextに記入した文字列がBlueskyに投稿される。投稿に成功すると返答として次のJSONが返ってくる。

{
  "uri":"at://did:plc:cy5cvxauukbyrballccs42h2/app.bsky.feed.post/3khvltvmjnj2n",
  "cid":"bafyreih3qz2vwf2rmjseidmuwdmjsk773eyfy4gfncwxgz6xeplrd7y63m"
}

uriがAT Protocol上でのURIで、cidが投稿を内部参照するためのコンテンツIDとのこと(参照リンク)。

なお、エラーがあればerrormessageというキーを持つJSONが返ってくる。例えば期限切れのアクセストークンを使うと次のようなJSONが来る。

{
  "error": "ExpiredToken",
  "message": "Token has expired"
}

ATPのURI

さて、ATPでのURIの書式はat://に続けてDID、Collection、Record Keyとなっている。

ATP | DID                            | COLLECTION       | RKEY
at://did:plc:cy5cvxauukbyrballccs42h2/app.bsky.feed.post/3khvltvmjnj2n

DIDはユーザーID、Collectionは投稿先(と書いても良いはず)、そしてuri末尾のセグメント(上の例だと3khvltvmjnj2n)はRecord Key(通称rkey)と呼ばれる13文字のASCII文字列。BlueskyではTimestamp Identifier(TID)という投稿時刻を元に生成されたキーが使われている(つまりrKeyだけで投稿時刻が簡易的に分かる仕組み)。

Webブラウザ経由(HTTP経由)でBlueskyの投稿を見ると、URLは次のようになっている。

https://bsky.app/profile/seeker5084.bsky.social/post/3khvltvmjnj2n

/profile/に続いてハンドル名、/post/に続いてこのrKeyを繋げることでHTTPのURLを作ることができる仕組みだ(参照リンク)。

むろん、ハンドル名の部分はDIDでも良い。

iOSのShortcutでも試す

iOS/iPadOSで扱えるShortcutもこしらえたので試したい方はぜひ。

Shortcut追加時のセットアップで、Blueskyのハンドル名とログインパスワードを入力すれば使える(デフォルト言語はjaのままで良い)。

JSON形式でAPIからの返答を楽しみたい方にはアプリ『Jayson』がおすすめ。

補足

サプリ1

今回送信したJSONは次のようなものだった。

{
  "repo": "did:plc:cy5cvxauukbyrballccs42h2",
  "collection": "app.bsky.feed.post",
  "record": {
    "$type": "app.bsky.feed.post",
    "text": "Hello from API 🪂\nhttps://storange.jp is a plain-text.",
    "createdAt": "2024-01-01T06:25:45.667Z"
  }
}

この場合textにURLが含まれていてもプレーンテキストとして扱われ、リンクとして機能しない。URLをリンクとして機能させたい場合、同様にメンションやタグなども認識してほしい場合はfacetsキーにそれらを別途記述する必要がある。次のように。

{
  "repo": "did:plc:cy5cvxauukbyrballccs42h2",
  "collection": "app.bsky.feed.post",
  "record": {
    "$type": "app.bsky.feed.post",
    "text": "Hello Bluesky🦋\nHello 2024📮\n\n#NewYearCard\nstorange.jp",
    "facets": [
      {
        "index": {
          "byteStart": 34,
          "byteEnd": 46
        },
        "features": [
          {
            "tag": "NewYearCard",
            "$type": "app.bsky.richtext.facet#tag"
          }
        ]
      },
      {
        "index": {
          "byteStart": 47,
          "byteEnd": 58
        },
        "features": [
          {
            "uri": "https://storange.jp",
            "$type": "app.bsky.richtext.facet#link"
          }
        ]
      }
    ],
    "createdAt": "2024-01-01T06:25:45.667Z",
    "langs": [
      "en"
    ]
  }
}

このように「UTF-8で数えて何バイト目から何バイト目までが“NewYearCard”という文字列のタグ(app.bsky.richtext.facet#tag)ですよ」ということを伝える必要がある(記事執筆時点でタグはプレーンテキストとして表示されるがデータ的にはきちんとタグとして保持されているので今後のアップデートでタグフィードへのリンク付与など活用されて行くことだろう)。

リンクに関しては表示文字列とリンク先URLを別々に指定できるメリットはあるが、これは閲覧者からするとフィッシングリンクなどに注意したい仕様でもある。

同様に、画像などのメディアやリンクカード(主にOGP)を投稿したい場合はembedキーに、事前にアップロードした結果のBlobを書き込む必要がある。このあたり、GitHubにあるPythonの実装例はとても参考になる。気が向けば別の記録としてこのWeblogに記すやも知れぬ。

なお、上の例ではしれっとlangsも追記した。このように投稿言語を英語(en)や日本語(ja)など指定することでユーザーが言語別に探しやすくなったりもするので、langsの追記は推奨したい。

サプリ2

ATPはLexiconというフォーマット表現が活用されていることが特徴。これはBlueskyが独自開発したもので実態はJSONデータ。Lexiconによって、ATP上でやり取りされるあらゆるデータのフォーマットが定義されている。Collection app.bsky.feed.postもLexiconで定義されている。

記事執筆現在はcom.atproto(doc)とapp.bsky(doc)の2種類が定義されている。

例えばセッション作成時に使ったAPIはcom.atproto.server.createSessionというLexiconで定義されている。GitHubのコードを見に行くと、"type"は"procedure"すなわちHTTPのPOSTメソッドのみ受け付けていて、inputとして何を渡すとoutputとして何が返って来るのかがよく分かる。ちなみに"type"が"query"の場合はGETメソッドのみ受け付けを意味する。

サプリ3

ATPではハンドル名とDIDがDNSによって紐付けされている(参照リンク)。どちらもユーザーを一意に表現する“ID”である。IPネットワーク風に言い換えるとハンドル名が人に読みやすいURLだとすれば、DIDは機械が扱いやすいIPアドレスと言ったところか。

DIDの利点はハンドル名と違ってユーザーによる変更がなされないこと。ユーザーがハンドル名を変更しても、紐づいているDIDは常に一意なので、各投稿やプロフィールといったURIも変わらず参照できる。

サプリ4

アクセストークンであるaccessJwtの有効期限は数分程度とのこと(ソースコード上はデフォルトで120分)なので、もし期限切れした場合はより有効期限の長いrefreshJwt(デフォルト値90日)を使ってaccessJwtを再発行できる。

Lexiconcom.atproto.server.refreshSession(GitHub)を見ると"procedure(POSTメソッド)"でリクエストすれば新たなaccessJwtrefreshJwtなどが得られることがわかる。ということで次のcURLを投げてみる。

curl https://bsky.social/xrpc/com.atproto.server.refreshSession\
  --request POST\
  --header "Content-Type: application/json"\
  --header "Authorization: Bearer {REFRESH_JWT}"

通常APIへリクエストするときはAuthorizationというヘッダーにBearer {accessJwt}を付与してリクエストするところを、ここではrefreshJwtにするだけ。これで新たなトークンを発行してもらえる。

createSessionで発行する場合との違いは都度ハンドル名とログインパスワードを必要としないこと。これはBlueskyアプリを作るときなどに重宝し、refreshJwtが有効な間はアプリ起動時に毎回ログインする手前が省ける仕組みである。

サプリ5

BlueskyではサーバーのことをPDS(Personal Data Server)と呼んでいる。GitHubでコンテナイメージも配布されているので自分のサーバーを立ち上げることも可能。

ざっとこんな感じで遊んだ。なにか不備や補足があればコメントください。

4309695219576980807 https://www.storange.jp/2024/01/playing-with-bluesky.html https://www.storange.jp/2024/01/playing-with-bluesky.html Blueskyで遊ぶ 2024-01-19T15:08:00+09:00 https://www.storange.jp/2024/01/playing-with-bluesky.html Hideyuki Tabata 200 200 72 72