🌈

【Go】Bluesky へ投稿する Webhook を作った話

2024/02/14に公開

はじめに

BlueskyAPI の学習のために Bluesky へ投稿できる Webhook を作成しました。
ブログの投稿連携やリリース通知などに活用できると思います。
ソースコードも公開しているので誰かの助けになれば幸いです。

https://github.com/7oh2020/simple-bluesky-webhook

BlueskyAPI について

公式ドキュメントには各種プログラミング言語での書き方が記載されており、多くのライブラリやアプリケーションが活発に開発されています。
今回は Go 言語と indigo という Bluesky の公式パッケージを利用しました。
以下は技術的な話になります。

注意事項

スパムや自動フォローなどの負荷をかける行為はガイドラインで禁止されています。
Bluesky API を使う前に、必ずガイドラインに目を通してください。

前提知識

https://atproto.com/docs

BlueskyAPI には多くの用語が登場しますが最初は以下の 7 つだけ覚えれば OK です。

  • AT プロトコル: Bluesky で使われるプロトコル
  • XRPC: AT プロトコルで使われる HTTP API
  • Lexicon: AT プロトコルで使われるスキーマ定義言語
  • データリポジトリ: アカウントが 1 つずつ持っているデータの置き場所。署名されており誰でも検証できる。
  • DID: データリポジトリを識別するための ID
  • NSID: データリポジトリ内の Lexicon のタイプ(投稿やいいねなど)を識別するための ID
  • ハンドル: alice.host.comのようなアカウントを識別するための DNS 名。名前解決することで DID などを参照できる。

プロジェクトのセットアップ

Go 言語のセットアップと indigo のインストールを行います。

indigoは Bluesky 公式のリポジトリです。
ビルドしてコマンドから使うこともできますが今回は Go 言語から import して使います。

go get github.com/bluesky-social/indigo

認証について

BlueskyAPI の認証にはアクセストークンとリフレッシュトークンが使われます。
アクセストークンは数時間で期限切れになり、更新の際にリフレッシュトークンが必要になります。

セッションのリフレッシュセッションの削除の際はリフレッシュトークンをアクセストークンとして扱います。そうしないと次のようなエラーが発生します。
XRPC ERROR 400: InvalidToken: Bad token scope

API を使う

以下のような API を Wrap するモジュールを作成すると便利です。
API を直接呼び出すよりも変更時の影響が少なく済みます。

/api/api.go
package api

import (
	"context"
	"fmt"
	"time"

	"github.com/bluesky-social/indigo/api/atproto"
	"github.com/bluesky-social/indigo/api/bsky"
	lexutil "github.com/bluesky-social/indigo/lex/util"
	"github.com/bluesky-social/indigo/util"
	"github.com/bluesky-social/indigo/util/cliutil"
	"github.com/bluesky-social/indigo/xrpc"
)

// 接続先ホスト
const DefaultHost = "https://bsky.social"

// BlueskyAPIのラッパー
type BlueskyAPI struct {
	// XRPCクライアント
	client *xrpc.Client
}

func NewBlueskyAPI() *BlueskyAPI {
	return &BlueskyAPI{}
}

// セッションを作成し、クライアントを返します。
func (ba *BlueskyAPI) CreateSession(handle, pass string) error {
	client := &xrpc.Client{
		Client: cliutil.NewHttpClient(),
		Host:   DefaultHost,
	}

	// 入力パラメータを作成する
	params := &atproto.ServerCreateSession_Input{
		Identifier: handle,
		Password:   pass,
	}

	// セッションを作成する
	ses, err := atproto.ServerCreateSession(context.TODO(), client, params)
	if err != nil {
		return err
	}

	// 取得したセッション情報をクライアントにセットする
	client.Auth = &xrpc.AuthInfo{
		AccessJwt:  ses.AccessJwt,
		RefreshJwt: ses.RefreshJwt,
		Handle:     ses.Handle,
		Did:        ses.Did,
	}
	ba.client = client

	return nil
}

// 現在のセッションをリフレッシュします。
func (ba *BlueskyAPI) RefreshSession() error {
	if ba.client == nil {
		return fmt.Errorf("client not created")
	}

	// リフレッシュトークンをセットする
	ba.client.Auth.AccessJwt = ba.client.Auth.RefreshJwt

	// 新しいセッションを取得する
	ses, err := atproto.ServerRefreshSession(context.TODO(), ba.client)
	if err != nil {
		return err
	}

	// 取得したセッション情報をクライアントにセットする
	ba.client.Auth = &xrpc.AuthInfo{
		AccessJwt:  ses.AccessJwt,
		RefreshJwt: ses.RefreshJwt,
		Handle:     ses.Handle,
		Did:        ses.Did,
	}

	return nil
}

// 現在のセッションを削除します。
func (ba *BlueskyAPI) DeleteSession() error {
	if ba.client == nil {
		return fmt.Errorf("client not created")
	}

	// リフレッシュトークンをセットする
	ba.client.Auth.AccessJwt = ba.client.Auth.RefreshJwt

	// セッションを削除する
	if err := atproto.ServerDeleteSession(context.TODO(), ba.client); err != nil {
		return err
	}
	return nil
}

// 指定アカウントのプロフィールを取得します。
func (ba *BlueskyAPI) GetProfile(handle string) (*bsky.ActorDefs_ProfileViewDetailed, error) {
	if ba.client == nil {
		return nil, fmt.Errorf("client not created")
	}

	// プロフィールを取得する
	profile, err := bsky.ActorGetProfile(context.TODO(), ba.client, handle)
	if err != nil {
		return nil, err
	}
	return profile, nil
}

// 現在のセッションでtextを投稿します。
func (ba *BlueskyAPI) CreatePost(text string) error {
	if ba.client == nil {
		return fmt.Errorf("client not created")
	}

	// 入力パラメータを作成する
	params := &atproto.RepoCreateRecord_Input{
		// コレクションのNSID(名前空間識別子)
		Collection: "app.bsky.feed.post",
		// リポジトリのDIDもしくはハンドル
		Repo: ba.client.Auth.Did,
		Record: &lexutil.LexiconTypeDecoder{
			Val: &bsky.FeedPost{
				// 本文テキスト
				Text: text,
				// 作成日時
				CreatedAt: time.Now().Format(util.ISO8601),
				// 言語(複数指定可能)
				Langs: []string{"ja"},
			},
		},
	}

	// テキストを投稿する
	_, err := atproto.RepoCreateRecord(context.TODO(), ba.client, params)
	if err != nil {
		return err
	}
	return nil
}

API をテストする

セッションの作成や更新ができることやセッションの削除後にトークンが無効化されることなどをテストします。
BlueskyAPI には時間あたりのレート制限があるので問題ないと思いますが、外部サーバーなので一応スリープを入れています。

/api/api_test.go
package api

import (
	"testing"
	"time"

	"github.com/7oh2020/simple-bluesky-webhook/config"

	"github.com/stretchr/testify/require"
)

func TestApi(t *testing.T) {
	if testing.Short() {
		t.Skip()
	}

	cfg, err := config.GetConfig()
	require.NoError(t, err, "エラーが発生しないこと")

	duration := time.Second
	ba := NewBlueskyAPI()

	// セッションを作成する
	err1 := ba.CreateSession(cfg.Handle, cfg.Pass)
	require.NoError(t, err1, "エラーが発生しないこと")

	time.Sleep(duration)

	// セッション作成後のトークンを使用する
	p1, err2 := ba.GetProfile(ba.client.Auth.Handle)
	require.NoError(t, err2, "エラーが発生しないこと")
	require.Equal(t, ba.client.Auth.Handle, p1.Handle, "プロフィールが取得できること")

	AccessJwt := ba.client.Auth.AccessJwt
	RefreshJwt := ba.client.Auth.RefreshJwt

	time.Sleep(duration)

	// セッションをリフレッシュする
	err3 := ba.RefreshSession()
	require.NoError(t, err3, "エラーが発生しないこと")
	require.NotEqual(t, AccessJwt, ba.client.Auth.AccessJwt, "アクセストークンが更新されていること")
	require.NotEqual(t, RefreshJwt, ba.client.Auth.RefreshJwt, "リフレッシュトークンが更新されていること")

	time.Sleep(duration)

	// セッションリフレッシュ後のトークンを使用する
	p2, err4 := ba.GetProfile(ba.client.Auth.Handle)
	require.NoError(t, err4, "エラーが発生しないこと")
	require.Equal(t, ba.client.Auth.Handle, p2.Handle, "プロフィールが取得できること")

	time.Sleep(duration)

	// セッションを削除する
	err5 := ba.DeleteSession()
	require.NoError(t, err5, "エラーが発生しないこと")

	time.Sleep(duration)

	// セッション削除後のトークンを使用する
	_, err6 := ba.GetProfile(ba.client.Auth.Handle)
	require.EqualError(t, err6, "XRPC ERROR 400: InvalidToken: Bad token scope", "認証エラーになること")
}

Webhook にする

API の疎通確認ができたらあとは Webhook サーバーを作成するだけです。
Webhook は単なる POST リクエストなので標準パッケージのnet/httpパッケージで簡単に作れます。

WebhookURL はランダムな文字列をパスに加えることで第三者から推測しにくくなります。
サーバーの起動時にランダムな token を生成し、 http://localhost:8080/webhook/ + tokenのような WebhookURL にしています。
安全のためにさらに認証用のパラメータを加えてもいいと思います。

トークンを生成するモジュールは以下の通りです。

/webhook/token.go
package webhook

import (
	"crypto/rand"
	"encoding/base64"
)

// URLセーフなランダム文字列を返します。
func GenerateToken(size int) (string, error) {
	b := make([]byte, size)
	if _, err := rand.Read(b); err != nil {
		return "", err
	}
	token := base64.URLEncoding.EncodeToString(b)

	return token, nil
}

また、WebhookURL から受け取る POST データをバリデーションしています。
現在 Bluesky では全角半角問わず 300 文字まで投稿可能になっています。

package validation

import (
	"fmt"
)

// textを検証します。
func ValidateText(text string) error {
	if text == "" {
		return fmt.Errorf("can not be blank")
	}
	if len([]rune(text)) > 300 {
		return fmt.Errorf("length must be less than or equal to 300")
	}
	return nil
}

その他詳しくはソースコード全体をご確認ください。

おわりに

BlueskyAPI は開発者にとってフレンドリーで体験が良いと思いました。
それと Go 言語はシングルバイナリでどんな環境でも動作するのが便利ですね。
ここまで読んで頂きありがとうございます。

Discussion