【Go】Bluesky へ投稿する Webhook を作った話
はじめに
BlueskyAPI の学習のために Bluesky へ投稿できる Webhook を作成しました。
ブログの投稿連携やリリース通知などに活用できると思います。
ソースコードも公開しているので誰かの助けになれば幸いです。
BlueskyAPI について
公式ドキュメントには各種プログラミング言語での書き方が記載されており、多くのライブラリやアプリケーションが活発に開発されています。
今回は Go 言語と indigo という Bluesky の公式パッケージを利用しました。
以下は技術的な話になります。
注意事項
スパムや自動フォローなどの負荷をかける行為はガイドラインで禁止されています。
Bluesky API を使う前に、必ずガイドラインに目を通してください。
- 利用規約: https://bsky.social/about/support/tos#who-can-use
- コミュニティガイドライン: https://bsky.social/about/support/community-guidelines
- 開発者ガイドライン: https://www.docs.bsky.app/docs/support/developer-guidelines
前提知識
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 を直接呼び出すよりも変更時の影響が少なく済みます。
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 には時間あたりのレート制限があるので問題ないと思いますが、外部サーバーなので一応スリープを入れています。
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 にしています。
安全のためにさらに認証用のパラメータを加えてもいいと思います。
トークンを生成するモジュールは以下の通りです。
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