m13o

2023-11-07 Tue 23:51
feed-generatorを使ってBlueskyのCustomFeedを作るBluesky Docker

Blueskyのカスタムフィードを作ってみたくなったので、nodeとかDockerとかミリシラですが、自分のサーバーで運用できる状態を構築してみました。

httpsアクセスのための準備

SkyFeedのジェネレーター等を使わず自前のサーバーで運用する場合、サーバーはhttpsプロトコル経由でアクセスできる必要があります。となると、TLSサーバーが必要証明書になるので、certbotを使って証明書を発行しておきます。なお、諸事情により、DNS認証です。

まずは、以下のようなDockerfileを用意します。ローカル環境に入れたくなかったのでdocker経由ですが、ローカル環境に既にcertbotがある場合はそれを利用するでも問題ありません。

FROM ubuntu:latest
RUN apt update -y && apt install -y certbot

Dockerfileと同じ階層にletsencryptディレクトリを作ります。

mkdir letsencrypt

docker経由で証明書の生成します。

docker build -t certbot ./
docker run -it -v "./letsencrypt:/etc/letsencrypt" --name certbot certbot:latest /bin/bash

# ここからはコンテナの中の操作
docker:/# certbot --manual --preferred-challenges dns certonly -d customfeed.example.com

# メールアドレスの入力を求められる
Saving debug log to /var/log/letsencrypt/letsencrypt.log
Enter email address (used for urgent renewal and security notices)
(Enter 'c' to cancel): ${YOUR_EMAIL_ADDRESS}

# 規約の同意等
 - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
Please read the Terms of Service at
https://letsencrypt.org/documents/LE-SA-v1.3-September-21-2022.pdf. You must
agree in order to register with the ACME server. Do you agree?
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
(Y)es/(N)o: y

- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
Would you be willing, once your first certificate is successfully issued, to
share your email address with the Electronic Frontier Foundation, a founding
partner of the Let's Encrypt project and the non-profit organization that
develops Certbot? We'd like to send you email about our work encrypting the web,
EFF news, campaigns, and ways to support digital freedom.
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
(Y)es/(N)o: y
Account registered.
Requesting a certificate for customfeed.example.com

- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
Please deploy a DNS TXT record under the name:

_acme-challenge.customfeed.example.com.

with the following value:

${ACME_CHALLENGE_TEXT} # 長ったらしい英数字のテキスト

Before continuing, verify the TXT record has been deployed. Depending on the DNS
provider, this may take some time, from a few seconds to multiple minutes. You can
check if it has finished deploying with aid of online tools, such as the Google
Admin Toolbox: https://toolbox.googleapps.com/apps/dig/#TXT/_acme-challenge.coustomfeed.example.com.
Look for one or more bolded line(s) below the line ';ANSWER'. It should show the
value(s) you've just added.

- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
Press Enter to Continue

ここまで来たら、自身のドメインのDNS管理画面をブラウザで開き、TXTレコードに_acme-challenge.${YOUR_DOMAIN}と表示されている長い英数字の文字列を設定します。少し待ってから、ターミナルに戻り、Enterを叩きます。

なお、CloudflareのDNSを利用している場合、ドメインのルートやレベル1のサブドメインであれば、Universal SSLが肩代わりしてくれます。ただし、レベル2以上のサブドメインはFreeプランでは対応できないので、ClodflareのDNS上ではProxyを無効にした上で上記のような対応をする必要があります。

Successfully received certificate.
Certificate is saved at: /etc/letsencrypt/live/customfeed.example.com/fullchain.pem
Key is saved at:         /etc/letsencrypt/live/customfeed.example.com/privkey.pem

成功すれば、上記のような出力がされ証明書が発行されます。このパスは更新時の差し替えの手間を軽減するためにシンボリックリンクになっていますが、今は実態が欲しいので、シンボリックリンクの先にある実態を取得しておきます。

feed-generatorの取得

bluesky-socialのGitHubリポジトリから、feed-generatorをcloneします。が、直接ソースコードを改変する事になるので、forkした方がいいかもしれません。ここではそのままcloneします。

git clone [email protected]:bluesky-social/feed-generator.git

環境変数の設定

.env.exampleを.envにリネームします。コピーして.envを作っても良いです。この中に、feed-generatorが利用する環境変数を設定します。定義されている環境変数の内、必須で設定する必要があるのは以下の3つです。

FEEDGEN_LISTENHOST
リッスンするホスト名。今回はdockerコンテナ内での利用になるので、feed-generatorを動かすコンテナの名前を記述する必要があります(localhostではない)。
FEEDGEN_HOSTNAME
feed-generatorを稼動させるサーバーのホスト名。今回の例でいうと、customfeed.example.com。
FEEDGEN_PUBLISHER_DID
自身のdid。

自身のdidはコメントにあるように、以下へアクセスした時に戻ってくる値です。

https://bsky.social/xrpc/com.atproto.identity.resolveHandle?handle=${YOUR_HANDLE}

加えて、取得したフィードを永続化させたい場合は、FEEDGEN_SQLITE_LOCATIONにsqliteのdbへのファイルパスを記述します。今回は/db/feed.dbとします。

上記ふまえて.envを記述すると以下のようになります。

# Whichever port you want to run this on 
FEEDGEN_PORT=3000

# Change this to use a different bind address
FEEDGEN_LISTENHOST="feed-generator"

# Set to something like db.sqlite to store persistently
FEEDGEN_SQLITE_LOCATION="/db/feed.db"

# Don't change unless you're working in a different environment than the primary Bluesky network
FEEDGEN_SUBSCRIPTION_ENDPOINT="wss://bsky.social"

# Set this to the hostname that you intend to run the service at
FEEDGEN_HOSTNAME="customfeed.example.com"

# Set this to the DID of the account you'll use to publish the feed
# You can find your accounts DID by going to
# https://bsky.social/xrpc/com.atproto.identity.resolveHandle?handle=${YOUR_HANDLE}
FEEDGEN_PUBLISHER_DID="did:plc:abcde...."

# Only use this if you want a service did different from did:web
# FEEDGEN_SERVICE_DID="did:plc:abcde..."

# Delay between reconnect attempts to the firehose subscription endpoint (in milliseconds)
FEEDGEN_SUBSCRIPTION_RECONNECT_DELAY=3000

カスタムフィードの構築

ここでは、"bird"という文字列が含まれるポストを取得するカスタムフィードを作る事にします。

まずは、src/algosディレクトリにあるwhats-alf.tsをコピーして、bird.tsを作ります。そして、"whats-alf"となっているshortname変数を"bird"に変更します。

// max 15 chars
- export const shortname = 'whats-alf'
+ export const shortname = 'bird'

export const handler = async (ctx: AppContext, params: QueryParams) => {

また、同じディレクトリにあるindex.tsを開き、whats-alf.ts由来のものをbird.ts由来に変更します。

- import * as whatsAlf from './whats-alf'
+ import * as bird from './bird'

type AlgoHandler = (ctx: AppContext, params: QueryParams) => Promise<AlgoOutput>
const algos: Record<string, AlgoHandler> = {
-   [whatsAlf.shortname]: whatsAlf.handler,
+   [bird.shortname]: bird.handler,
}

そして、srcディレクトリのsubscription.tsを開き、"bird"が含まれる投稿を取得するフィルターを作ります。

const postsToCreate = ops.posts.creates
      .filter((create) => {
-         // only alf-related posts
-         return create.record.text.toLowerCase().includes('alf')
+        return create.record.text.toLowerCase().includes('bird')
      })
      .map((create) => {

Dockerfileの作成

feed-generatorをDockerコンテナ内で稼動させるためのDockerfileを作成します。プロダクション向けに構築したかったので、NODE_ENVをproductionにしたり、npm installにdevをオミットするオプションを付けたりしています。

FROM node:18-alpine
WORKDIR /build
COPY ./feed-generator /build

RUN npm install
RUN npm run build


FROM node:18-alpine
ENV NODE_ENV production
WORKDIR /app
RUN apk add --no-cache tini
COPY --chown=node:node --from=0 /build/dist /app
COPY --chown=node:node --from=0 /build/package.json /app/package.json
COPY --chown=node:node --from=0 /build/.env /app/.env

RUN npm install --omit=dev

USER node
ENTRYPOINT ["/sbin/tini", "--"]
CMD ["node", "/app/index.js"]


docker-compose.ymlの作成

feed-generatorのサーバーとリバースプロキシとなるnginxの設定を記述します。今回は自前でproxyの設定を構築するのが面倒だったので、jwilder/nginx-proxyを利用しました。

vhost.d/customfeed.example.comファイルを作り、そこにnginx-proxyが要求するVIRTUAL_HOSTの設定を記述します。

location /xrpc {
    proxy_pass http://feed-generator:3000/xrpc;

    #CORS
    add_header Access-Control-Allow-Origin "*";
    add_header Access-Control-Allow-Methods "POST, GET, OPTIONS";
    add_header Access-Control-Allow-Headers "Origin, Authorization, Accept";
    add_header Access-Control-Allow-Credentials true;
}

locationをルート(/)にすると、nginx-proxy本体が持っている設定とカニバってしまうため、/xrpcをlocationにしています。また、Dockerのネットワーク上に構築されるため、pxory_passはlocalhostではなく、FEEDGEN_LISTENHOST同様、feed-generatorのコンテナ名を設定します。

次に、TLS証明書を格納するためのcertsディレクトリを用意し、先に作成したfullchain.pemとprivatekey.pemをnginx-proxyのVIRTUAL_HOST名を冠する形で、各々customfeed.example.com.crt、customfeed.example.com.keyにリネームしてcertsディレクトリ内に設置します。

準備が整ったので、docker-compose.ymlを記述します。

version: "3.8"

services:
  feed-generator:
    container_name: feed-generator
    build:
      context: ./
      dockerfile: ./Dockerfile
    volumes:
      - ./db:/db
    environment:
      - VIRTUAL_HOST=customfeed.example.com
      - VIRTUAL_PORT=3000
    networks:
      - default
  nginx-proxy:
    container_name: nginx-proxy
    image: jwilder/nginx-proxy:alpine
    volumes:
      - /var/run/docker.sock:/tmp/docker.sock:ro
      - ./vhost.d:/etc/nginx/vhost.d:ro
      - ./certs:/etc/nginx/certs:ro
    restart: always
    environment:
      - ENABLE_IPV6=true
      - TZ=Asia/Tokyo
    networks:
      - default
    ports:
      - 80:80
      - 443:443

networks:
  default:

Custom Feedサーバーを立ち上げる

docker-compose up -d

を実行して、サーバーが立ち上がれば成功です。

https://{FEEDGEN_HOSTNAME}/xrpc/app.bsky.feed.getFeedSkeleton?feed=at://{FEEDGEN_PUBLISHER_DID}/app.bsky.feed.generator/{algos/bird.tsに記述したshortname} 

にアクセスしてjsonが返ってくる事を確認します。

Feedのpublish

Blueskyからアクセスしてもらうために、Feedをpublishする必要があります。publishの設定のためfeed-generatorのscripts/publishFeedGen.tsを改変します。

// YOUR bluesky handle
// Ex: user.bsky.social
- const handle = ''
+ const handle = {Blueskyのユーザー名}

// YOUR bluesky password, or preferably an App Password (found in your client settings)
// Ex: abcd-1234-efgh-5678
- const password = ''
+ const password = {AppPassword}

// A short name for the record that will show in urls
// Lowercase with no spaces.
// Ex: whats-hot
- const recordName = ''
+ const recordName = 'bird' // algos/bird.tsのshortnameと同じ値にする必要がある

// A display name for your feed
// Ex: What's Hot
- const displayName = ''
+ const displayName = 'Bird' // フィードの表示名

// (Optional) A description of your feed
// Ex: Top trending content from the whole network
- const description = ''
+ const description = 'Posts include bird' // フィードの説明

// (Optional) The path to an image to be used as your feed's avatar
// Ex: ~/path/to/avatar.jpeg
const avatar: string = ''

AppPasswordを直接.tsファイルに記述する事に抵抗が芽生えるので、環境変数経由で読み取るように改変するのが良いと思います。

npm install
npm run publishFeed

してもいいんですが、今回はこれもdocker経由で行います。

FROM node:18-alpine
WORKDIR /publish
COPY feed-generator /publish

RUN npm install

CMD ["npm", "run", "publishFeed"]

このようなDockerfileをfeed-generatorディレクトリの1つ上の階層に用意します。あとは、docker build & runでpublishされます。

docker build -t publish_feed -f Dockerfile .
docker run publish_feed
All done 🎉

が表示されれば、publishは成功です。お疲れ様でした。

2024年2月12日 追記

追記を忘れていたのですが、環境変数FEEDGEN_SUBSCRIPTION_ENDPOINTに指定するURIが、2023年11月半ばに変更されました。 現在は以下のURIを指定しないと正常に動きません。

FEEDGEN_SUBSCRIPTION_ENDPOINT="wss://bsky.network"