公式GoクライアントindigoでBlueskyのAPIを使ってみた

みなさんはまだ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
    • 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(ユーザーの不変の識別子。CreateSessionActorGetProfileで取得できます)で指定します。

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がプラットフォームとしての拡張性・相互運用性を実現していることの一端をご理解いただけたのではないでしょうか。それではよいブルスコライフを。