みなさんはまだXで消耗してますか?Blueskyに移住中の小野マトペです。
Go言語でBlueskyにポストを投稿するコードを書いたのですが、ベータ版とあってドキュメントも少なくやや難儀したので、メモとして残します。記事を通じて、Blueskyのアーキテクチャ のユニークさも少しだけ伝われば良いなと思います。
クライアントには、Blueskyの公式Goリポジトリ github.com/bluesky-social/indigo
のクライアント実装を使います。ただし、開発中で、今後使い方が変わる可能性もあるので気をつけてください。
github.com
概要
Bluesky は、大規模分散ソーシャルアプリケーションのための汎用連合プロトコル AT Protocol 上に構築されるアプリケーション実装であるという建て付けです。AT Protocolでは、クライアントやサーバーは XRPC というHTTPベースの独自のRPCによって相互に通信します。
https://atproto.com/guides/overview
そのため、indigo
内のパッケージも、xrpc
api/atproto
api/bsky
の階層に応じて分割されています。(AT Protocolはatproto、Blueskyはbskyと略されることが一般的です)
github.com/bluesky-social/indigo/xrpc
github.com/bluesky-social/indigo/api/atproto
github.com/bluesky-social/indigo/api/bsky
XRPCのスキーマ はLexicon というスキーマ 定義言語によって定義されています。github.com/bluesky-social/atproto リポジトリ にあるスキーマ 定義ファイルから、atprotoとbskyの各種プログラミング言語 用の実装がジェネレートされます。そのGo版実装が
indigoリポジトリの/apiディレクトリ にあるので、今回はこれを使うことにします。
atproto.com
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,
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がプラットフォームとしての拡張性・相互運用性を実現していることの一端をご理解いただけたのではないでしょうか。それではよいブルスコライフを。