開発視点から見る⁠新しい分散型SNS「Bluesky」AT Protocolの可能性

2023年4月初旬、日本で盛り上がりの兆しを見せ始めた分散型SNS「Bluesky⁠⁠。本記事では、Blueskyのクライアント開発を行う筆者が、開発視点から見たBluesky、そのコア技術であるAT Protocolについて解説します。

なお、Bluesky誕生の背景や基本機能などについては、syui氏の記事ネクストブレイク分散型SNSの大本命、Blueskyを先取り!を併せてご覧ください。

Bluesky をコマンドラインから扱う

Blueskyクライアント開発のきっかけ

Blueskyにはすでにクライアントが存在します。BlueskyのオフィシャルからはiOSアプリが、また Android向けにはkamakuraさんが開発している青雲というアプリが提供されています。

通常はこれらのクライアントから投稿をすることでBlueskyを使えます。

筆者がBlueskyのアカウントを作成したのはまだiOSアプリしか存在しないころでした。またWebサイトからもログインすることはできませんでした。

筆者はAndroidユーザですのでBlueskyを体験することができません。しかし筆者はどうしてもBlueskyを体験したくなり、Blueskyプロトコルを調べることにしました。するとBlueskyチームがBlueskyを扱うためのライブラリをいくつか公開しているのを見つけました。

  • https://github.com/bluesky-social/atproto
  • https://github.com/bluesky-social/indigo

atprotoはTypeScriptで、またindigoはGo言語で書かれたBlueskyとAT Protocolを扱うためのライブラリです。筆者はGo言語をメインで使っているのでindigoを使ってCLIアプリケーション「bsky」を作り始めることにしました。

AT Protocolとは

AT Protocolは複数のプロトコルの集合の総称です。

The AT Protocol

Personal Data Server(PDS)とそれらのデータを同期するCrawing Indexerにより構成されます。各PDSはドメインによって名前付けされており、筆者が現在アカウントを持っているのはbsky.socialというPDSになります。

Crawling IndexerとPDSの関係
図

Blueskyクライアントが実行するデータ操作は、自分が所属するPDSのサーバに対して行います。

APIは名前空間で分けられた型と操作が定義されており、JSONを扱うXRPCと呼ばれるRPC(リモートプロシージャコール)を使って、投稿やその削除、いいねやRepost(TwitterでいうRT⁠⁠、画像のアップロードなどが実行できるようになっています。型や関数はLexiconと呼ばれるスキーマで定義されており、Blueskyオフィシャルが提供しているBlueskyクライアントのコードのほどんどはこのスキーマから自動生成されたものです。

Lexicon

Lexiconとは

サンプルを紹介します。次の関数を見てください。

名前空間com.exampleにある関数getProfileは引数にオブジェクトを取り、そのオブジェクトはuserをキーに、そして文字列の値を持つとします。さらに戻り値はname、did、displayNameなどのキーを持ち、それぞれ文字列の値を持つとします。

await client.com.example.getProfile({user: 'bob.com'})
// => {name: 'bob.com', did: 'did:plc:1234', displayName: '...', ...}

このgetProfileの定義は次のようになります。

{
  "lexicon": 1,
  "id": "com.example.getProfile",
  "type": "query",
  "parameters": {
    "user": {"type": "string", "required": true}
  },
  "output": {
    "encoding": "application/json",
    "schema": {
      "type": "object",
      "required": ["did", "name"],
      "properties": {
        "did": {"type": "string"},
        "name": {"type": "string"},
        "displayName": {"type": "string", "maxLength": 64},
        "description": {"type": "string", "maxLength": 256}
      }
    }
  }
}

この例では、JavaScriptのコードを示しましたが、すべてのクライアントライブラリはLexiconから自動生成されるため、ほぼ同じインターフェースを提供しています。前述したindigoもほぼ同じ名前のインターフェースでアクセスすることができます。

たとえば、Blueskyでユーザのプロフィール情報を得るAPI app.bsky.actor.getProfileのLexiconを確認しましょう。

{
  "lexicon": 1,
  "id": "app.bsky.actor.getProfiles",
  "defs": {
    "main": {
      "type": "query",
      "parameters": {
        "type": "params",
        "required": ["actors"],
        "properties": {
          "actors": {
            "type": "array",
            "items": {"type": "string", "format": "at-identifier"},
            "maxLength": 25
          }
        }
      },
      "output": {
        "encoding": "application/json",
        "schema": {
          "type": "object",
          "required": ["profiles"],
          "properties": {
            "profiles": {
              "type": "array",
              "items": {"type": "ref", "ref": "app.bsky.actor.defs#profileViewDetailed"}
            }
          }
        }
      }
    }
  }
}

TypeScriptのインターフェースは次のようになっています。

export default function (server: Server, ctx: AppContext) {
  server.app.bsky.actor.getProfile({
    auth: authOptionalVerifier,
    handler: async ({ auth, params }) => {
      const { actor } = params
      const requester = auth.credentials.did
      const { db, services } = ctx
      const actorService = services.actor(db)

      const actorRes = await actorService.getActor(actor, true)

      if (!actorRes) {
        throw new InvalidRequestError('Profile not found')
      }
      if (softDeleted(actorRes)) {
        throw new InvalidRequestError(
          'Account has been taken down',
          'AccountTakedown',
        )
      }

      return {
        encoding: 'application/json',
        body: await actorService.views.profileDetailed(actorRes, requester),
      }
    },
  })
}

またGo言語のインターフェースは次のようになっています。

func ActorGetProfile(ctx context.Context, c *xrpc.Client, actor string) (*ActorDefs_ProfileViewDetailed, error) {
	var out ActorDefs_ProfileViewDetailed

	params := map[string]interface{}{
		"actor": actor,
	}
	if err := c.Do(ctx, xrpc.Query, "", "app.bsky.actor.getProfile", params, nil, &out); err != nil {
		return nil, err
	}

	return &out, nil
}

このように、Lexiconさえ理解できていれば、言語が違っていてもおおよそ同じインターフェースを扱うことができるのが特徴です。

APIを呼び出してみる

実際にGo言語からユーザのプロフィール情報を得るコードを実装してみましょう。まずはクライアントを作ります。Blueskyのクライアントは、JWTを使って認証を行います。

セッションを作るにはcom.atproto.server.createSessionを呼び出します。indigoにはこれをラップしたcomatproto.ServerCreateSessionが用意されています[1]

xrpcc := &xrpc.Client{
	Client: cliutil.NewHttpClient(),
	Host:   cfg.Host,
	Auth:   &xrpc.AuthInfo{Handle: cfg.Handle},
}

auth, err := comatproto.ServerCreateSession(context.TODO(), xrpcc, &comatproto.ServerCreateSession_Input{
	Identifier: &xrpcc.Auth.Handle,
	Password:   cfg.Password,
})
if err != nil {
	return nil, fmt.Errorf("cannot create session: %w", err)
}
xrpcc.Auth.Did = auth.Did
xrpcc.Auth.AccessJwt = auth.AccessJwt
xrpcc.Auth.RefreshJwt = auth.RefreshJwt

このauthはセッション作成後、しばらくは再利用が可能なため、筆者が開発したbskyではJSONファイルとして保存しており、次回起動時に再利用しています。またセッションが揮発してしまわないように、次回クライアントを作成する際にはリフレッシュする必要があります。

xrpcc := &xrpc.Client{
	Client: cliutil.NewHttpClient(),
	Host:   cfg.Host,
	Auth:   &xrpc.AuthInfo{Handle: cfg.Handle},
	Auth:   auth, // JSON から読み込んだ auth
}

refresh, err := comatproto.ServerRefreshSession(context.TODO(), xrpcc)
if err != nil {
	return nil, fmt.Errorf("cannot refresh session: %w", err)
}
xrpcc.Auth.Did = refresh.Did
xrpcc.Auth.AccessJwt = refresh.AccessJwt
xrpcc.Auth.RefreshJwt = refresh.RefreshJwt

残念ながら、現在のAT Protocolにはパスワードを用いた認証方法しか用意されていないため、破棄可能なAPIトークンや、二段階認証といった認証方式は扱えません。こちらは今後に期待したいと思います。では実際にgetProfileを呼び出します。

profile, err := bsky.ActorGetProfile(context.TODO(), xrpcc, "mattn.bsky.social")
if err != nil {
	return fmt.Errorf("cannot get profile: %w", err)
}
fmt.Printf("Did: %s\n", profile.Did)
fmt.Printf("Handle: %s\n", profile.Handle)
fmt.Printf("DisplayName: %s\n", stringp(profile.DisplayName))
fmt.Printf("Description: %s\n", stringp(profile.Description))
fmt.Printf("Follows: %d\n", int64p(profile.FollowsCount))
fmt.Printf("Followers: %d\n", int64p(profile.FollowersCount))

実行すると次のように出力されます。

Did: did:plc:ituhatvv5pyz4rwsj4hfrslh
Handle: mattn.bsky.social
DisplayName: mattn
Description: Long-time #Golang user&contributor, #GoogleDevExpert Go, (略)
Follows: 291
Followers: 316

このdidは、Blueskyの中で筆者のアカウントを識別するIDであり、万が一、筆者が bsky.socialとは別のサーバに引っ越したとしても引き続き同じデータを利用可能とするための、永続的なIDとなります。その際にはドメインは変わってしまいますが現在の投稿内容やフォローやフォロワーといった状態も引き継がれます。

Blueskyではユーザを指定する際にはmattn.bsky.socialといったドメイン形式ではなく、did形式(上記のdid:plc:ituhatvv5pyz4rwsj4hfrslhを指定すると良いでしょう。

bsky

このLexiconから生成されたインターフェースを呼び出すことで、Blueskyを操作できるようにと、実装したコマンドラインクライアントがbskyです。

bsky

前述のとおり、筆者はAndroidユーザですのでBlueskyにログインすることはできませんでした。しかし、iOSを使う周りの皆が楽しそうに見えてしまい、どうしてもBlueskyをやりたくなったため、bskyを開発しました。

bskyでできるおもなことを紹介します。

  • プロフィールの表示、更新
  • タイムラインの表示
  • スレッドの表示
  • 発言の投稿、削除
  • 発言にいいねを付ける/発言にいいねを付けたユーザを見る
  • 発言をリポストする/発言をリポストしたユーザを見る
  • ユーザをフォローする
  • フォローしたユーザを見る
  • フォロワーを見る
  • 通知を見る

もちろん筆者はログインできないため、これらは自分だけでテストをすることはできませんでした。画像のアップロードが正しくできたかどうかは、Blueskyにいる他のユーザの皆さんに「ちゃんと見えてます?」と確認(これもbskyから投稿)して開発を進めました。

当然ながら、ありがとうの意味の「いいね」もbskyから付けました。後にBlueskyがWeb画面をベータ公開し、実際に自分が投稿した発言や画像がちゃんと表示されたのを見たときには感動しました。

bskyは現状Blueskyでできるほとんどの機能をコマンドラインから実行することができます。また、表示を行うコマンドには-jsonオプションを付与することでJSONを出力することができます。

よってjqなどのツールと組み合わせれば、シェルスクリプトから呼び出すことが可能です。

jq

応用例①

たとえば、Blueskyでフォローされたユーザを一括でフォロー返ししたいとしましょう。フォロー返しするのに画面からポチポチするのは面倒ですね。bskyを使えば以下のコマンドで実行できます。

#!/bin/bash

set -e

/usr/bin/diff \
        <(bsky followers -json | sort)  \
        <(bsky follows -json | sort)  | \
    /usr/bin/grep '^>' | /usr/bin/sed 's/^> //' | \
    /usr/bin/jq -r -s '.[].did' |\
    xargs bsky follow

このスクリプトは、bashのプロセス置換という機能を使うことで、bskyコマンドのJSON出力結果を2つ得て、あたかもファイルのようにdiffコマンドへ渡すものです。

bskyのJSON出力は、NDJSON(改行がセパレータの1行ごとに出力されるJSON形式)のため、ソートしたりdiffに渡したりできます。

diffコマンドはフォロワーとフォローの差分を調べ、増えた分を>の接頭文字を付けて出力します。これをgrepで絞り込み、さらに加工した後でjqに渡しています。

jqの結果は、フォローしていないユーザのdid一覧になります。あとはxargsコマンドを使い、bsky followに渡しています。

応用例②

もう1つ応用例をご紹介します。次の例はソーシャルニュースサイトRedditの投稿をBlueskyに転送するスクリプトです。

#!/bin/sh

set -e

URL=https://www.reddit.com/r/golang.json

/usr/bin/curl -s -H "User-Agent: Chrome" $URL | \
    /usr/bin/jq -c '.data.children[].data'    | \
    dedup -k id | \
    jsonargs -f bsky post "{{.title}} #golang_news" "{{.url}}"

curlでJSONをダウンロードし、jqで1行ごとの情報に整形します。

dedupコマンドは標準入力から1行ごとのJSONを受け取り、そのまま標準出力します。ただし入力のJSONのidというキーに重複があれば、そのJSONを出力から省くようになっています。

jsonargsコマンドは標準入力から1行ごとのJSONを受け取り、引数にてテンプレート形式で指定し値を得られるようにするコマンドです。言ってみればxargsのJSON版と言えるでしょう。

このスクリプトを実行すると、Redditの新着記事がBlueskyに投稿されるようになります。あとはこのスクリプトをcronを使って定期的に実行するようにしておけば、ニュース投稿Botができあがります。

  • https://github.com/mattn/dedup
  • https://github.com/mattn/jsonargs

おわりに

Blueskyを構成するAT Protocolを実装面から確認し、実際にRPCを呼び出す例を示しました。また筆者が実装したCLIコマンドbskyを使って、簡単なスクリプトを実装する例を紹介しました。

bsky コマンドを使うことで、BlueskyのBotを簡単に実装できるようになります。何を実装するかはアイデア次第です。ぜひいろいろと応用して便利なものを作ってみてください。

おすすめ記事

記事・ニュース一覧