nostr と Bluesky に7つ bot を作り k8s で稼働させた
はじめに
最近 nostr と Bluesky に7つ bot を稼働させたので、その仕組みと Kubernetes での運用方法をメモとして残しておきたい。
bot の一覧
今回作った bot は以下の通り。
ボット名 | 生息地 | 特徴 |
---|---|---|
俳句bot | nostr | 人の発言を俳句判定 |
Golang News | nostr | Goに関するニュースを投稿 |
NIPs Changes | nostr | NIP(nostr仕様)の変更を投稿 |
名言画像bot | nostr | 投稿を画像にして返信 |
俳句bot | Bluesky | 人の発言を俳句判定 |
マルコフ連鎖bot | Bluesky | タイムラインの単語でマルコフ連鎖 |
ジョイマンbot | Bluesky | ジョイマンぽい投稿 |
俳句bot (nostr)
nostr の日本リレーを監視し、投稿を 575 または 57577 判定し、引用でお知らせする。狙った俳句ではなく、天然物の俳句がマッチするとウケが良い。
Go で実装。内部では go-haiku を使って俳句を判定。監視は日本語の投稿が流れる日本のリレーをお借りしている。普通の Go アプリなので golan:1.20-alpine でビルドして scratch でイメージ作成。
# syntax=docker/dockerfile:1.4
FROM golang:1.20-alpine AS build-dev
WORKDIR /go/src/app
COPY --link go.mod go.sum ./
RUN apk add --no-cache upx || \
go version && \
go mod download
COPY --link . .
RUN CGO_ENABLED=0 go install -buildvcs=false -trimpath -ldflags '-w -s'
RUN [ -e /usr/bin/upx ] && upx /go/bin/nostr-haikubot || echo
FROM scratch
COPY --link --from=build-dev /go/bin/nostr-haikubot /go/bin/nostr-haikubot
COPY /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
CMD ["/go/bin/nostr-haikubot"]
ミソは ca-certificates.crt をコピーすること。
Golang News (nostr)
Reddit の subreddit r/golang のフィードを5分毎に監視して投稿。
こちらは algia という CLI をシェルから使って実装。
#!/bin/sh
set -e
/usr/bin/curl -s -H "User-Agent: Chrome" "https://www.reddit.com/r/golang.json" | \
/usr/bin/jq -c '.data.children[].data' | \
/go/bin/ocinosql-dedup -V -k url -hashkey | \
/go/bin/jsonargs -f /go/bin/algia -V n "{{.title}} #golang_news" "{{.url}}"
実装内容としては、curl で Reddit の JSON を取得、その JSON を jq で整形し、dedup コマンド の Oracle Cloud NoSQL 版 ocinosql-dedup で既存投稿を省き、jsonargs で xargs ぽくコマンドを実行。algia にtext/template 形式でポスト内容を指定する、といった物です。
もともとは、VPS で稼働していたけれど VPS を少しでも楽にしたかったので Kubernetes で動かすために、ローカル DB を使って重複を省くコマンド dedup の NoSQL 版として ocinosql-dedup を開発しました。
シェルや Oracle Cloud NoSQL を使うので、こちらは scratch という訳にもいかず debian:bookworm-slim で。
FROM golang:1.20-alpine AS build-dev
WORKDIR /app
COPY . .
RUN CGO_ENABLED=0 GOBIN=/go/bin go install -buildvcs=false -trimpath -ldflags '-w -s' github.com/mattn/algia@latest
RUN CGO_ENABLED=0 GOBIN=/go/bin go install -buildvcs=false -trimpath -ldflags '-w -s' github.com/mattn/jsonargs@latest
RUN CGO_ENABLED=0 GOBIN=/go/bin go install -buildvcs=false -trimpath -ldflags '-w -s' github.com/mattn/ocinosql-dedup@latest
RUN CGO_ENABLED=0 GOBIN=/go/bin go install -buildvcs=false -trimpath -ldflags '-w -s' github.com/carlmjohnson/feed2json/cmd/feed2json@latest
FROM debian:bookworm-slim AS stage
RUN apt update
RUN apt install -y curl jq
RUN apt clean all
COPY /go/bin/algia /go/bin/algia
COPY /go/bin/jsonargs /go/bin/jsonargs
COPY /go/bin/feed2json /go/bin/feed2json
COPY /go/bin/ocinosql-dedup /go/bin/ocinosql-dedup
COPY /app/job.sh /job.sh
ENTRYPOINT ["/bin/sh", "-c"]
CMD ["/job.sh"]
ocinosql-dedup は ~/.oci
を、algia は ~/.config/algia
から設定情報を読み取るので、configmap を作って volumeMount する形式を取りました。実行は CronJob で5分間刻み。
NIPs Changes (nostr)
nostr の仕様(NIPS)が管理されているリポジトリ nips のコミット内容を5分毎に監視して投稿。
こちらも仕組みは Golang News と同じだが、ソースが Atom フィードなので feed2json を使って JSON 化している。
#!/bin/sh
set -e
/usr/bin/curl -s https://github.com/nostr-protocol/nips/commits/master.atom | \
/go/bin/feed2json | \
/usr/bin/jq -c '.items[]' | \
/go/bin/ocinosql-dedup -V -k id -hashkey | \
/go/bin/jsonargs -f /go/bin/algia -V n "{{.title}} #nips_changes" "{{.url}}"
名言画像bot (nostr)
Twitter でたまに見かけるアレ。名言をツイートした人のリプライに #makeitquote
を返信すると、さらにその返信で元のツイートを画像化した物が投稿される。
nostr-makeitquote は画像化も含め全て Go で実装。実行は前述の Golang News や NIPs Changes の様に、5分毎の検索と投稿にしました。こちらはリアルタイム性があった方が面白いので、常駐形式にしても良かったのだが、僕の Oracle Cloud のお財布事情が怪しくなって来たので CronJob にしてあります。
俳句bot (Bluesky)
こちらは nostr の俳句 bot を Bluesky に移植した物。Bluesky にはストリーム API があるので、グローバルの投稿を監視しつつ俳句判定を行う。今の所、処理できる程度のスピードだが、今後ユーザが増えてグローバルの投稿量が激しくなると全てのポストを俳句判定するのは難しくなるのではと思ってる。
nostr 版の俳句 bot はもともと日本のリレーを使っていたので、入力が日本語に限定されているのですが、Bluesky では英語の発言の方が当然多く、英単語をカタカナ読みして俳句判定する機能をサポートしている俳句 bot (Bluesky版) は、まれに空気を読まない引用を付けてしまう事がありました。(以下は Kato さんでの例)
多くの外国人ユーザも笑って許してくれていましたが、さすがにこちらの気が引けてしまったので、現在は日本語が含まれていない場合には判定しない様にしてあります。
マルコフ連鎖bot (Bluesky)
一定間隔でタイムラインを監視し、内容をトークナイズしてツリーに起こし先頭単語からマルコフ連鎖を行う bot です。うまく行くと本当の人間が言っている様な発言が投稿されます。
ただまれにヤバい投稿を行ってしまう事があるので要注意です。
こちらはシェルを使わない形式なので、CronJob から直接起動しています。
ジョイマンbot (Bluesky)
唯一、Python で実装しました。以下の記事を参考にして、入力となる文章の末尾を母音化し、それにマッチする単語(Wikipedia のタイトル一覧) を付けて投稿する bot です。
記事の内容だとわりと誤爆があるのですが、母音判定だけでなく子音も判定要素に含める事で精度を上げています。
以下ソース。こちらはまだ改良の余地がありリポジトリに乗せていません。
import random
import pandas as pd
import pykakasi
from janome.tokenizer import Tokenizer
from atprototools import Session
import os
import sys
kks = pykakasi.kakasi()
def romanize(text):
result = ''
for i in kks.convert(text):
result += i['hepburn']
return result
def extract_vowel(text):
return ''.join([i for i in text if i in ['a', 'i', 'u', 'e', 'o', 'n']])
def joyman_bot(input_text, df):
t = Tokenizer()
is_method = list(filter(lambda x: x.part_of_speech.split(',')[
0] == '助詞', t.tokenize(input_text)))
input_romanize = romanize(input_text)
for pos in [5, 4, 3, 2]:
matched = df[
(df['vowel1'].replace('・', '')
.str.endswith(input_romanize) == False)
& (df['vowel1'].str.endswith(input_romanize[-pos:], na=False))
& (abs(df['vowel2'].str.len()
- len(extract_vowel(input_romanize))) < 3)
]['title'].to_list()
if len(matched) > 0:
break
good = matched
if len(is_method) > 0 and random.random() > 0:
base = extract_vowel(input_romanize)
for s in matched:
if len(list(filter(
lambda x: x.part_of_speech.split(',')[0] == '助詞',
t.tokenize(s)))) > 0:
continue
ss = [s for s in kks.convert(s)]
while len(ss) > 0:
ps = ss.pop()
k = extract_vowel(ps['hepburn'])
if not base.endswith(k):
break
base = base[:len(k)]
if len(ss) > 0:
orig = kks.convert(s)
good.append(
''.join([s['orig'] for s in ss]) +
is_method[0].surface +
''.join([s['orig'] for s in orig[-len(ss):]]))
return '{} {}'.format(input_text, random.choice(good))
if len(sys.argv) >= 2:
arg = ' '.join(sys.argv[1:])
else:
with open(os.path.join(
os.path.dirname(__file__), 'input')) as f:
arg = random.choice(f.readlines()).strip()
result = joyman_bot(arg, pd.read_csv(os.path.join(
os.path.dirname(__file__), 'raw_data.csv')))
print(result)
USERNAME = os.environ.get("JOYMANBOT_USERNAME")
PASSWORD = os.environ.get("JOYMANBOT_PASSWORD")
if len(result) > 0 and USERNAME and PASSWORD:
session = Session(USERNAME, PASSWORD)
session.post_skoot(result)
まとめ
ここ1ヶ月半くらいで作った7つの SNS bot の実装と、運用方法をご紹介しました。どちらも出来たばかりの SNS です。僕のスタンスとしては
SNS が盛り上がるかどうかは、開発者が遊べる余地をどれくらい用意しているかが重要
だと思っていて、Twitter もそれにより大きくなったと思っています。nostr や Bluesky がこれから大きくなるかは、開発者の皆さんがどれくらい遊ぶかにより左右されていると行っても過言ではないかもしれません。ぜひいろいろな bot を作り、運用して遊んでみましょう。(技術遊びは勉強の一貫です)
Discussion