田舎の昼のサイレンbotというbotがある。正午にウゥーーーーとつぶやくだけの、謎のbotである。
https://twitter.com/Noon_Siren_bot
最近Twitterもあやういので、練習もかねてActivityPubに移植し、マストドンやMisskeyから閲覧できるようにしようと思った(元のbotの作者は私ではない)。
ActivityPubは仕様はコンパクトだがやることは結構ある。順をおってActivityPubサーバを実装していこう。
最終的にマストドンなどのヨソのサーバから認識されるまでが第一弾になる。フォロワーを管理したり、定刻に投稿したりするのはまた次回だ。
この記事によって↑のようなアカウントが認識されるようになった。ただし、フォローしてもなにも反応はない。
テンプレートのコードが残りまくってるが、ソースコードはここ。 https://github.com/windymelt/akka-ap-siren
雑務
ActivityPubをやるまえにやらなければならないことがいくつかある。
HTTPサーバを用意する
ActivityPubはHTTPの上で動くプロトコルなので、なにか適当なHTTPサーバの実装を用意すること。今回はScalaで実装したかったのとAkkaの素振りをしたかった都合上、Akka HTTPを採用したが、なんでもよい。
ドメインを用意する
ドメインがないと検証が大変なので、Cloudflareとかでドメインを用意する。今回はcapslock.dev
を使って、サブドメインsiren.capslock.dev
を今回の目的に使う。
HTTPSまわりのアレコレ
Cloudflare Tunnelを使ってTLS終端をやってもらうことにした。サーバではHTTPを喋るだけでよく、CF Tunnelによって勝手にsiren.capslock.dev
にHTTPSが露出する。
設定方法は略。なんとかしてHTTPSで特定のドメインにHTTPサーバを露出させられればよい。
nodeinfoを実装する
ヨソのサーバからはあなたのサーバが何者なのか全然わからない。そこで、一般的に/nodeinfo/2.1
というパスに問合せがやってくる。これをnodeinfoプロトコルと呼ぶ(はず)。
ぶっちゃけnodeinfoがなんなのかはよくわからないが、2.1はnodeinfoのバージョンである。
GETに対して以下のようなJSONを、application/json
として返却すればよい。
{ "openRegistrations": false, "protocols": [ "activitypub" ], "software": { "name": "siren", "version": "0.1.0" }, "usage": { "users": { "total": 1 } }, "version": "2.1" }
フィールドの意味は勘で分かると思うが、サーバが抱えているユーザの総数がtotal
フィールドとして吐き出されるので、そこだけ注意すると良い。
host-metaを実装する
host-metaとはRFC 6415である。色々用途があるようだが、今回は「webfinger (後述) はここのエンドポイントで使えます」という事だけを教えるメタなプロトコルだと思っておけばよい。
/host-meta
に対するGETに、以下のようなXML文書を返却するようにする:
<?xml version="1.0"?> <XRD xmlns="http://docs.oasis-open.org/ns/xri/xrd-1.0"> <Link rel="lrdd" type="application/xrd+xml" template="https://siren.capslock.dev/.well-known/webfinger?resource={uri}" /> </XRD>
このXML上で示されている/.well-known/webfinger?resource={uri}
は、これから実装する。
webfingerを実装する
webfingerとは、RFC 7033である。このプロトコルではDNSっぽいことが行われ、siren@siren.capslock.dev
はsiren.capslock.dev
にアクセスしたらアクターの情報が得られますよ、といった情報が獲得できる。これだけだと一見無駄なプロトコルだが、siren@capslock.dev
にリプライすると実際のホストはsiren.capslock.dev
で行う、といったことができて見た目が良くなる。今回はそういうことはやらないが、webfingerの実装は必須だ。メールのMXレコードみたいなもんだと思っておけば良い。
webfingerは、/.well-known/webfinger?resource=acct:siren@siren.capslock.dev
の形でGETされる。resource
クエリパラメータがacct:id@host
の形になっていることを確認し、以下のようなJSONを返却する。
{ "subject": "acct:siren@siren.capslock.dev", "links": [ { "rel": "self", "type": "application/activity+json", "href": "https://siren.capslock.dev/actor" } ] }
アクターの情報を得るにはhttps://siren.capslock.dev/actor
を見てください、ということを言っている。https://siren.capslock.dev/actor
はこれから作成するエンドポイントで、今回はactor
という身も蓋もない名前だが、実際は/user/windymelt
とか/user/john
みたいな、ユーザに対応するアクターのエンドポイントが人数分ある、といった形になる。また、エンドポイントのパスはどこでもよく、自由に構成してよい。ActivityPubはパス構造を束縛するようなプロトコルではない。あくまで今回はユーザが1つだけあるという体裁なので、/actor
という素朴なパスを使っているというだけのことだ。
今回は1つしかアクターがないので、resource=acct:siren@siren.capslock.dev
になっているかどうかだけ判定して結果を返却している。
また、webfingerではContent-Type
はapplication/jrd+json; charset=utf-8
である必要がある。JRDとはJSON Resource Descriptorの略だそうな。
ここまでアクターアクターと繰り返し強調してきたが、アクターはActivityPubの中心的なモデルで、SNSでいうところのユーザを表現している。このアクターが主体となり、メッセージを投稿したり、いいねを行ったり、返信したりする。その人の投稿は対応するアクターのoutboxを見に行くし、その人にメッセージを送るには対応するアクターのinboxにPOSTを投げる、という感じになっている。じゃあinboxとかoutboxの場所はどこにあるの、という疑問が出てくるが、それはアクターエンドポイントが提供する仕組みになっている。
- アクターがたくさんいる
- アクターはinboxというエンドポイントを各自持っている
- アクターはoutboxというエンドポイントを各自持っている
アクターエンドポイントを実装する
アクターエンドポイントは、そのサーバにいるアクター(ユーザ、主体)に関する情報を返却するエンドポイントである。アクターエンドポイントはアクターごとに定義されるため、「そのサーバのアクターのエンドポイント」のような概念ではないことに注意する必要がある。あくまで1アクター1エンドポイントである。
さて、今回はアクターエンドポイントは/actor
なので、このパスへのGETに以下のようなJSONを返すようにする:
{ "@context": [ "https://www.w3.org/ns/activitystreams", "https://w3id.org/security/v1" ], "id": "https://siren.capslock.dev/actor", "type": "Person", "preferredUsername": "siren", "inbox": "https://siren.capslock.dev/inbox", "outbox": "https://siren.capslock.dev/outbox", "publicKey": { "id": "https://siren.capslock.dev/actor#main-key", "owner": "https://siren.capslock.dev/actor", "publicKeyPem": "後述" } }
いっこうに肝心の投稿内容が出てこないが、そういうもんである。ここにはメタ情報しか出現しない。
@context
、id
、type
、inbox
がたしか必須だったはずである(厳密に確認していない)。このユーザの投稿情報を見たいので、outbox
も定義しておく。
publicKey
は何かというと、このサーバが別のサーバにリクエストを飛ばすときに、本当にそのサーバからのリクエストかどうかを認証するためのもの。今回はサーバからはリクエストを発射しないので、いったんここでは割愛する。ダミーテキストを入れておけばよい。
また、このエンドポイントのContent-Type
はapplication/activity+json; charset=utf-8
である必要がある。application/activity+json
はapplication/ld+json; profile="https://www.w3.org/ns/activitystreams
と等価である。
inboxを実装する
このアクターに対する操作は今回は定義しないので、inboxは適当な値を返す。
{ "@context": "https://www.w3.org/ns/activitystreams", "summary": "inbox of siren", "type": "OrderedCollection", "totalItems": 0, "orderedItems": [] }
もしかすると、GETに対して""
を返すだけで良いかもしれない。本当は、アクターに対してなんらかのアクションをするときにPOSTがここに飛んでくる(いいねやリプライをすると飛んでくる)。
このエンドポイントのContent-Type
はapplication/activity+json; charset=utf-8
である必要がある。
outboxを実装する
outboxをGETすると、このアクターが行った発信の履歴が得られる。要するに過去の投稿が見られるというわけだ。
今回は固定で1件投稿しているように見せかけたい。
以下のようなJSONを返すようにする:
{ "@context": "https://www.w3.org/ns/activitystreams", "summary": "outbox of siren", "type": "OrderedCollection", "totalItems": 1, "orderedItems": [ { "@context": "https://www.w3.org/ns/activitystreams", "type": "Create", "id": "https://siren.capslock.dev/post/activities/act-yyyy-mm-dd.create.json", "url": "https://siren.capslock.dev/post/activities/act-yyyy-mm-dd.create.json", "published": "2023-01-16T06:48:29Z", "to": [ "http://siren.capslock.dev/followers", "https://www.w3.org/ns/activitystreams#Public" ], "actor": "http://siren.capslock.dev/actor", "object": { "@context": "https://www.w3.org/ns/activitystreams", "type": "Note", "id": "https://siren.capslock.dev/items/20230116-064829.note.json", "url": "https://siren.capslock.dev/items/20230116-064829.note.json", "published": "2023-01-16T06:48:29Z", "to": [ "https://siren.capslock.dev/followers", "https://www.w3.org/ns/activitystreams#Public" ], "attributedTo": "https://siren.capslock.dev/actor", "content": "ウゥーーーーーーーーーー" } } ] }
このエンドポイントのContent-Type
はapplication/activity+json; charset=utf-8
である必要がある。
データ構造が面倒だが、ActivityPubの仕様書を読むと、あまり難しくない。ちょっとフィールドが多いので面喰らうが、こわくない。
各種パーマリンクを実装する
さきほどのoutboxの実装に、以下のようなリンクが含まれている:
https://siren.capslock.dev/post/activities/act-yyyy-mm-dd.create.json
https://siren.capslock.dev/items/20230116-064829.note.json
これらにアクセスしたときに、実際にそのデータが得られるようにエンドポイントを追加しておく:
例えば/items/20230116-064829.note.json
の場合:
{ "@context": "https://www.w3.org/ns/activitystreams", "type": "Note", "id": "https://siren.capslock.dev/items/20230116-064829.note.json", "url": "https://siren.capslock.dev/items/20230116-064829.note.json", "published": "2023-01-16T06:48:29Z", "to": [ "https://siren.capslock.dev/followers", "https://www.w3.org/ns/activitystreams#Public" ], "attributedTo": "https://siren.capslock.dev/actor", "content": "ウゥーーーーーーーーーー" }
このようなパーマリンク的なデータを返せばよい。
このエンドポイントのContent-Type
はapplication/activity+json; charset=utf-8
である必要がある。
まとめ
ここまで実装してHTTPサーバを建て、外部からドメイン名で疎通できるようにすると、外部からユーザとして認識されるようになるはずだ。次回は、フォローできるようにしたり、定時に投稿する仕組みを作っていく。