Lambdaカクテル

京都在住Webエンジニアの日記です

Invite link for Scalaわいわいランド

田舎の昼のサイレンbotをActivityPubで実装する(マストドンにアカウントを認識してもらう編)

田舎の昼のサイレン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.devsiren.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-Typeapplication/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": "後述"
  }
}

いっこうに肝心の投稿内容が出てこないが、そういうもんである。ここにはメタ情報しか出現しない。

@contextidtypeinboxがたしか必須だったはずである(厳密に確認していない)。このユーザの投稿情報を見たいので、outboxも定義しておく。

publicKeyは何かというと、このサーバが別のサーバにリクエストを飛ばすときに、本当にそのサーバからのリクエストかどうかを認証するためのもの。今回はサーバからはリクエストを発射しないので、いったんここでは割愛する。ダミーテキストを入れておけばよい。

また、このエンドポイントのContent-Typeapplication/activity+json; charset=utf-8である必要がある。application/activity+jsonapplication/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-Typeapplication/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-Typeapplication/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-Typeapplication/activity+json; charset=utf-8である必要がある。

まとめ

ここまで実装してHTTPサーバを建て、外部からドメイン名で疎通できるようにすると、外部からユーザとして認識されるようになるはずだ。次回は、フォローできるようにしたり、定時に投稿する仕組みを作っていく。

参考文献

www.w3.org

qiita.com

docs.joinmastodon.org

socialhub.activitypub.rocks

socialhub.activitypub.rocks

zenn.dev

blog.joinmastodon.org

★記事をRTしてもらえると喜びます
Webアプリケーション開発関連の記事を投稿しています.読者になってみませんか?