年越し数分前にメールを受信。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
}
必要な項目はdid
とaccessJwt
の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参照)。createdAt
はISO 8601形式で投稿日時を。
これでtext
に記入した文字列がBlueskyに投稿される。投稿に成功すると返答として次のJSONが返ってくる。
{
"uri":"at://did:plc:cy5cvxauukbyrballccs42h2/app.bsky.feed.post/3khvltvmjnj2n",
"cid":"bafyreih3qz2vwf2rmjseidmuwdmjsk773eyfy4gfncwxgz6xeplrd7y63m"
}
uri
がAT Protocol上でのURIで、cid
が投稿を内部参照するためのコンテンツIDとのこと(参照リンク)。
なお、エラーがあればerror
とmessage
というキーを持つ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メソッド)"でリクエストすれば新たなaccessJwt
やrefreshJwt
などが得られることがわかる。ということで次の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でコンテナイメージも配布されているので自分のサーバーを立ち上げることも可能。
ざっとこんな感じで遊んだ。なにか不備や補足があればコメントください。