Bluesky
X(旧Twitter)のAPI規制が強いのでBluesky向けのBotを作成することにしました。
BlueskyのAPIにもRate Limitsは存在するのですが、Xのものに比べて格段に緩いので、今後Botを作成するのであればBlueskyのBotは検討の余地があると思います。
技術スタック
BluskyのSDKはTypeScript向けのものがあるのでそれを利用しましょう。一応Python向けのもありますがCloudflare Workersで動作させたいのでそっちの方がいいと思います。
- TypeScript
- Bun/Node.js
- Hono
- Cloudflare Workers
- Cloudflare R2
今回は上記の技術スタックを採用しました。
Cloudflare WorkersにはCron Eventsという定期実行の仕組みがあるので、それを利用することで定期的にBotを動作させることができます。
Cloudflare R2は必ずしも必要ではないのですが、今回は採用した将棋Botでデータを毎回ソースにアクセスするのではなくR2に保存しておくことで最初の一回以外は負荷をかけないように対策しました。
仕様
- 毎週データ更新
- 毎朝九時に対局情報
- 毎朝八時に詰将棋情報
- 十五分ごとに局面情報
をポストするようにしました。
データはあれば参照したものがあればR2から、なければソースから取得してR2に保存するようになっています。
毎週のデータ取得は本来は不要なのですが、一応更新するようにしています
[triggers]crons = ["0 0 * * 1", "*/15 0-15 * * *", "0 0 * * *", "0 23 * * *"]これを実行するためのwrangler.tomlは上のようになります。
Cron EventsはUTC基準なので日本時間であるJSTに変換するには+9する必要があります。
よって"0 23 * * *"は日本時間で23+9 mod 24 = 8となり、毎朝八時に実行することを意味するわけです。
コード解説
では実際に使われているコードを解説します。
本当はレポジトリを公開したかったのですが、認証に関するアルゴリズムを含むため残念ながら公開することができません。
ただし、基本的なコードはテンプレートレポジトリで公開しているので、こちらを利用してください。
export const scheduled = async (event: ScheduledController, env: Bindings, ctx: ExecutionContext): Promise<void> => { switch (event.cron) { case '0 0 * * *': break case '0 23 * * *': break case '*/15 0-15 * * *': break case '0 0 * * 1': break default: break }}wrangler.tomlでは複数のスケジュールを定義することができないので、ScheduledControllerのevent.cronを利用して分岐します。
Bluesky SDK
bun add @atproto/apiで追加できます。ドキュメントも豊富でわかりやすいです。
Botの実装
ログインするにあたってIDとパスワードが必要です。ただし、一般的なパスワードではなくアプリパスワードが必要になります。
アプリパスワードはこのリンクから発行できます。一度発行すると二度と表示されないので、必ずどこかに保存しておきましょう。
また、このIDとパスワードを.dev.varsに書き込みます。
BLUESKY_IDENTIFIER=BLUESKY_APP_PASSWORD=ログイン
import { AtpAgent, type ComAtprotoRepoStrongRef, RichText } from '@atproto/api'
const agent = new AtpAgent({ service: 'https://bsky.social'})
export const post = async (env: Bindings): Promise<void> => { // ここに処理を書きます}
export const scheduled = async (event: ScheduledController, env: Bindings, ctx: ExecutionContext): Promise<void> => { await agent.login({ identifier: env.BLUESKY_IDENTIFIER, password: env.BLUESKY_APP_PASSWORD }) switch (event.cron) { case '0 0 * * *': break case '0 23 * * *': break case '*/15 0-15 * * *': break case '0 0 * * 1': break default: ctx.waitUntil(post(env)) break }}BskyAgentは非推奨ですのでAtpAgentを使うようにしてください。正しくBindingsを定義していればこのようにしてログインができます。Envが使えればもうちょっと便利だと思うのですが、使い方がわかっていません。
await post(env)と書いても動作させることはできるのですが、Cloudflare WorkersはCPU時間の制限があるのでこの書き方をすると処理が重い場合にタイム・アウトしてしまいます。
ところがctx.waitUntil(post(env))とすればWorkers自体が終了しても裏で処理が実行できます。
リッチテキスト
ただのテキストではなくハイパーリンクやハッシュタグを利用したい場合には一度RichTextを経由する必要があります。
const richText: RichText = new RichText({ text: 'ハッシュタグやリンクを含むテキスト' })await richText.detectFacets(agent) // 忘れずに実行しようreturn agent.post({ text: richText.text, facets: richText.facets})ちなみにテキストにハッシュタグやリンクが含まれていなくても大丈夫です。
画像投稿
const result = agent.uploadBlob(image, { encoding: 'image/png' })const richText: RichText = new RichText({ text: 'ハッシュタグやリンクを含むテキスト' })await richText.detectFacets(agent) // 忘れずに実行しようreturn agent.post({ text: richText.text, facets: richText.facets, embed: { $type: 'app.bsky.embed.images', images: [{ alt: '', image: result.data.blob, aspectRatio: { width: 600, height: 550 } }]})画像はそのままポストに埋め込むのではなく、事前にアップロードすることが必要になります。また、配列を利用すれば読ん枚までアップロードできます。
uploadBlob()に渡す引数はUint8ArrayですのでBufferを利用するなりでうまく変換してください。encodingに関してはimage/pngの他にimage/jpegなども指定できるみたいですが、最高画質でアップロードしたいならimage/png一択です。
動画もどうやらSDK的には対応しているのですが、サーバーが未対応なのかアップロードしようとすると失敗します。
リプライ
リプライするにはparentとrootを指定する必要があります。試していないのでわからないのですがrootはリプライツリーが始まった一番最初のポストである必要があるかもしれません。
parentはいわゆるリプライ先になるので、A->B->Cのようなリプライツリーを作りたい場合には、まず最初にリプライを指定せずにポストしてAのuriとcidを取得し、それをparantとrootに設定してポストすることでBを作成。最後にparentにB, rootにBを指定してポストすることでCを作成するといった感じです。ちょっとめんどくさいですが、理にはかなっています。
uriとcidの値はagent.post()の返り値として受け取ることができます。
まとめ
BlueskyのSDKを触ってみましたが、Xとは異なる仕様のため難しく感じる箇所もありました。
ただ、アプリパスワードを使って簡単に認証が行える点、レートリミットが比較的緩い点、デベロッパーアカウントの開設が不要な点などが便利でした。SDKに関してはまだまだ改善する余地があると思いますが、今後の発展性について期待できる内容でした。
記事は以上。