みなさんはまだXで消耗してますか?Blueskyに移住中の小野マトペです。
Go言語でBlueskyにポストを投稿するコードを書いたのですが、ベータ版とあってドキュメントも少なくやや難儀したので、メモとして残します。記事を通じて、Blueskyのアーキテクチャのユニークさも少しだけ伝われば良いなと思います。
クライアントには、Blueskyの公式Goリポジトリ github.com/bluesky-social/indigo
のクライアント実装を使います。ただし、開発中で、今後使い方が変わる可能性もあるので気をつけてください。
概要
Bluesky は、大規模分散ソーシャルアプリケーションのための汎用連合プロトコル AT Protocol 上に構築されるアプリケーション実装であるという建て付けです。AT Protocolでは、クライアントやサーバーは XRPC というHTTPベースの独自のRPCによって相互に通信します。
そのため、indigo
内のパッケージも、xrpc
api/atproto
api/bsky
の階層に応じて分割されています。(AT Protocolはatproto、Blueskyはbskyと略されることが一般的です)
github.com/bluesky-social/indigo/xrpc
- XRPC関連実装
github.com/bluesky-social/indigo/api/atproto
- ATProtocol関連RPCスキーマ
github.com/bluesky-social/indigo/api/bsky
- Bluesky特有のRPCスキーマ
XRPCのスキーマはLexiconというスキーマ定義言語によって定義されています。github.com/bluesky-social/atproto リポジトリにあるスキーマ定義ファイルから、atprotoとbskyの各種プログラミング言語用の実装がジェネレートされます。そのGo版実装が indigoリポジトリの/apiディレクトリ にあるので、今回はこれを使うことにします。
install
とりあえず始めましょう。
$ go get github.com/bluesky-social/indigo
認証
なにはなくともまずは認証です。
ユーザー認証はAT Protocolレベルの操作です。 ハンドル名とパスワードをInputパラメータとして AT ProtocolレベルLexiconの atproto.ServerCreateSession
を呼び出すと認証セッションが生成され、JWTなどのセッション情報が返却されます。このセッション情報を xrpc.Client
構造体の Auth
フィールドに設定することで、そのClientを認証セッションと共に使うことができるようになります。
package main import ( "context" "github.com/bluesky-social/indigo/api/atproto" "github.com/bluesky-social/indigo/xrpc" ) func main() { cli := &xrpc.Client{ Host: "https://bsky.social", } input := &atproto.ServerCreateSession_Input{ Identifier: "[your-bluesky-handle]", Password: "[your-password]", } output, err := atproto.ServerCreateSession(context.TODO(), cli, input) if err != nil { log.Fatal(err) } cli.Auth = &xrpc.AuthInfo{ AccessJwt: output.AccessJwt, RefreshJwt: output.RefreshJwt, Handle: output.Handle, Did: output.Did, } _ = cli }
プロフィール取得
セッションを開始したら、さっそくユーザーのプロフィールを取得してみましょう。
先ほどのセッション作成は AT Protocol レベルのLexiconでしたが、プロフィール取得はBlueskyレベルのLexiconなので、 github.com/bluesky-social/indigo/api/bsky
パッケージをimportします。引数に私のハンドルの "matope.bsky.social" を指定します。
profile, err := bsky.ActorGetProfile(context.TODO(), cli, "matope.bsky.social") if err != nil { log.Fatal(err) } pp.Print(profile)
profile をお好きなpretty printerでダンプすると、こんな感じの詳細プロファイルビューが取れます。
&bsky.ActorDefs_ProfileViewDetailed{ Avatar: &"https://cdn.bsky.social/imgproxy/0BAuv8Ek6y_0lY2sdnnPb03ZGzYSCVWLa_r9pohTqVY/rs:fill:1000:1000:1:0/plain/bafkreic4qii34cqymxz5en2m5vtff7duvdggp2ljrn2g46gc4d3zr6cv5y@jpeg", Banner: &"https://cdn.bsky.social/imgproxy/Uymx6dZXSfo7fTSbfop5Xa73mOGqT-7mMftziXq0sJI/rs:fill:3000:1000:1:0/plain/bafkreiadta7acsqzwhif3amdgax63ch4cx7knrub7nxhgebvq6d6i5colq@jpeg", Description: &"Software Engineer / Golang / Liberalist / ADHD診断済み / Long-COVID療養中\nhttps://twitter.com/ono_matope", Did: "did:plc:kzxl37blybhp7kvn2clme7j2", DisplayName: &"小野マトペ", FollowersCount: &1501, FollowsCount: &1157, Handle: "matope.bsky.social", IndexedAt: &"2023-08-16T00:26:28.486Z", Labels: []*atproto.LabelDefs_Label{}, PostsCount: &1628, Viewer: &bsky.ActorDefs_ViewerState{ BlockedBy: &false, Blocking: (*string)(nil), FollowedBy: (*string)(nil), Following: (*string)(nil), Muted: &false, MutedByList: (*bsky.GraphDefs_ListViewBasic)(nil), }, }.
ユーザー投稿取得
つぎはユーザー投稿の取得です。
func bsky.FeedGetAuthorFeed(ctx context.Context, c xrpc.Client, actor string, cursor string, limit int64) (bsky.FeedGetAuthorFeed_Output, error)
actor引数は、"matope.bsky.social" のようなハンドルか、またはDID(ユーザーの不変の識別子。CreateSession
やActorGetProfile
で取得できます)で指定します。
feed, err := bsky.FeedGetAuthorFeed(context.TODO(), cli, ”matope.bsky.social", "", 10) if err != nil { return err }
出力はこんな感じになります。
&bsky.FeedGetAuthorFeed_Output{ Cursor: &"1692252773757::bafyreihuynzpx5457nryn5b4hllhpknl4fihgt4knhhieactxrlqc7ooyq", Feed: []*bsky.FeedDefs_FeedViewPost{ &bsky.FeedDefs_FeedViewPost{ Post: &bsky.FeedDefs_PostView{ LexiconTypeID: "", Author: &bsky.ActorDefs_ProfileViewBasic{ Avatar: &"https://cdn.bsky.social/imgproxy/0BAuv8Ek6y_0lY2sdnnPb03ZGzYSCVWLa_r9pohTqVY/rs:fill:1000:1000:1:0/plain/bafkreic4qii34cqymxz5en2m5vtff7duvdggp2ljrn2g46gc4d3zr6cv5y@jpeg", Did: "did:plc:kzxl37blybhp7kvn2clme7j2", DisplayName: &"小野マトペ", Handle: "matope.bsky.social", Labels: []*atproto.LabelDefs_Label{}, Viewer: &bsky.ActorDefs_ViewerState{ BlockedBy: &false, Blocking: (*string)(nil), FollowedBy: (*string)(nil), Following: (*string)(nil), Muted: &false, MutedByList: (*bsky.GraphDefs_ListViewBasic)(nil), }, }, Cid: "bafyreiborxodz3x66y32744ymmh6sqmv4np5konehhp5qcjb3g4mhcbco4", Embed: (*bsky.FeedDefs_PostView_Embed)(nil), IndexedAt: "2023-08-17T07:04:05.876Z", Labels: []*atproto.LabelDefs_Label{}, LikeCount: &0, Record: &util.LexiconTypeDecoder{ Val: &bsky.FeedPost{ LexiconTypeID: "app.bsky.feed.post", CreatedAt: "2023-08-17T07:04:05.496Z", Embed: (*bsky.FeedPost_Embed)(nil), Entities: []*bsky.FeedPost_Entity{}, Facets: []*bsky.RichtextFacet{}, Langs: []string{ "ja", }, Reply: (*bsky.FeedPost_ReplyRef)(nil), Text: "テスト", }, }, ReplyCount: &0, RepostCount: &0, Uri: "at://did:plc:kzxl37blybhp7kvn2clme7j2/app.bsky.feed.post/3k5564cyinh2j", Viewer: &bsky.FeedDefs_ViewerState{ Like: (*string)(nil), Repost: (*string)(nil), }, }, Reason: (*bsky.FeedDefs_FeedViewPost_Reason)(nil), Reply: (*bsky.FeedDefs_ReplyRef)(nil), }, ...
タイムライン取得
タイムラインの取得は、 bsky.FeedGetTimeline
RPCメソッドを使います。
func bsky.FeedGetTimeline(ctx context.Context, c xrpc.Client, algorithm string, cursor string, limit int64) (bsky.FeedGetTimeline_Output, error)
algorithm
引数があるのが興味深いですが、とりあえず空文字で大丈夫なようです。
tl, err := bsky.FeedGetTimeline(context.TODO(), cli, "", "", 10) if err != nil { return err }
ポストの投稿
次に、Blueskyにポストを投稿してみましょう。ここまではbskyで定義されていたLexiconを使っていましたが、ポストの投稿では、atprotoレベルのLexiconを使います。
import ( // ... lexutil "github.com/bluesky-social/indigo/lex/util" ) // ... input := &atproto.RepoCreateRecord_Input{ Collection: "app.bsky.feed.post", Repo: cli.Auth.Did, // "matope.bsky.social" のDID Record: &lexutil.LexiconTypeDecoder{&bsky.FeedPost{ Val: &bsky.FeedPost{ Text: text, CreatedAt: time.Now().Format(util.ISO8601), Langs: []string{"ja"}, }}, }}, } resp, err := atproto.RepoCreateRecord(context.TODO(), cli, input) if err != nil { return err } pp.Print(resp)
コード的には atproto.RepoCreateRecord
関数を使い、Input構造体に
- Repo → ユーザーのDID
- Collection → "app.bsky.feed.post"
- Record →
bsky.FeedPost
構造体
をセットしています。Repo、Collection、Recordといったキーワードがユニークですね。AT Protocolでは、あるユーザーの持つデータはサーバー上の、ユーザーごとのリポジトリに格納されます。リポジトリはDAG-CBORというコーデックでエンコードされたレコードを保持するキーバリューデータベースです。ここでは、
「matope.bsky.social
リポジトリの app.bsky.feed.post
コレクション以下に、(サーバサイドで決定されるタイムスタンプをキーとして)、app.bsky.feed.post
スキーマに従うレコードを作成せよ」
という操作を行なっているわけです。
Langsはポスト言語の設定です。Blueskyは投稿ごとにコンテンツの言語メタデータを(複数)設定できるので、ここで設定しておきます。値は(これを入れておかないと、閲覧者のコンテンツ言語と異なる場合に "Translate this post" リンクが出てしまう)
作成したRecordの識別子情報が出力されました。(ここではCIDとURIが何であるかには踏み込みません。私がよく分かっていないからですが…)
&atproto.RepoCreateRecord_Output{ Cid: "bafyreiffpevc6ew665lqhocz2226q2runa7ubpwqzipszm7moy76zm4mja", Uri: "at://did:plc:kzxl37blybhp7kvn2clme7j2/app.bsky.feed.post/3k52rbvnqfg2b", }.
いいね
上で作成した投稿にいいねをつけてみましょう。下のコードをよく見てください。先ほとど同じ、 atproto.RepoCreateRecord
RPCメソッドを呼び出していて、また、 Collection
の指定が app.bsky.feed.like
になり、 Record
の中身が bsky.FeedLike
型になっています。このRecordのSubjectに、先ほど得られたCIDとURIをコピペします。
func like(cli *xrpc.Client) error { input := &atproto.RepoCreateRecord_Input{ Collection: "app.bsky.feed.like", Repo: cli.Auth.Did, Record: &lexutil.LexiconTypeDecoder{ Val: &bsky.FeedLike{ CreatedAt: time.Now().Format(util.ISO8601), Subject: &atproto.RepoStrongRef{ Uri: "at://did:plc:kzxl37blybhp7kvn2clme7j2/app.bsky.feed.post/3k52rbvnqfg2b", Cid: "bafyreiffpevc6ew665lqhocz2226q2runa7ubpwqzipszm7moy76zm4mja", }, }, }, } out, err := atproto.RepoCreateRecord(context.TODO(), cli, input) if err != nil { return err } pp.Print(out)
いいねができました。
つまり、投稿も、いいねも、AT Protocolとしてはリポジトリ上のレコード作成という点で等価であり、違いはパス(コレクション+キー)とデータ定義だけなのです。ちなみに、フォローも同じくRepoCreateRecord
Lexiconで、app.bsky.graph.follow
コレクションへのレコードを作成します。
- 投稿
- コレクション:
app.bksy.feed.post
- スキーマ:
bsky.FeedPost
- コレクション:
- いいね
- コレクション:
app.bksy.feed.like
- スキーマ:
bsky.FeedLike
- コレクション:
- フォロー
- コレクション:
app.bsky.graph.follow
- スキーマ:
bsky.GraphFollow
- コレクション:
いかがでしたか?このように、分散レポジトリ/コレクション/レコードの階層的データ構造によってATProtocolがプラットフォームとしての拡張性・相互運用性を実現していることの一端をご理解いただけたのではないでしょうか。それではよいブルスコライフを。