宣伝。日経LinuxにてLinuxの基礎?を紹介する漫画「シス管系女子」を連載させていただいています。 以下の特設サイトにて、単行本まんがでわかるLinux シス管系女子の試し読みが可能!
このエントリはチャットボット Advent Calendarとのクロスポストです。(→Qiitaの方の投稿)
チャットボット Advent Calendarをご覧になっているような方々はフレームワークやニューラルネットでディープラーニングなAIといった最新のbot事情に関心の強い方が多いと思うのですが、今日の記事は時代に逆行しまくって、Bashスクリプトで昔ながらの人工無脳botを作りましたというお話です。
この記事では以下のことを書いています。
実際の所、Bash製のTwitter botにも先行実装はいくつかあります(そもそもtweetbot.shの核であるBashスクリプト製Twitterクライアント自体もUSP友の会のTwitter bot用スクリプトの改造に端を発しています)。じゃあなんで自作したのかという話なんですが、端的に言うと技術力のデモです。
というのも、自分が執筆しているシス管系女子はLinuxのコマンド操作やシェルスクリプトの事をマンガ形式で解説しているのですが、「ロクに技術的な知識も無い絵描きが適当なマンガ描きちらかしてんだろwwwww」と思われてはシャクだったので、「Twitter botをスクラッチで書けるくらいの技術力はあるぞゴラァ!!」と示しておきたかったのです。
……というのは半分くらい冗談です。元々、Twitter上で最新情報を時間を変えてツイートしたり言及を拾ったりという地道な広報活動をbotに任せられれば原稿制作の作業に集中できるかな?と思ったのがきっかけだったのですが、既製のbotを使えばいいのにを何をトチ狂ったのか「どうせやるならbotも自作した方が面白いんじゃね?」などと考えてしまい、軽い気持ちで始めたというのが正味の話なのでした。
とはいえ作って放置というわけではなく、実際にここ1年ほどTwitterの宣伝用アカウントの運用に活用しています。 時々動作が怪しくなったりもしますが、概ね安定して動作しているのでそこそこ実用的と言えるのではないでしょうか。
このbotはこんなことができます。
Twitterという公開の場に置く以上、イタズラで変な言葉を覚えさせられると困るので、いわゆる学習は行いません(単に、筆者に機械学習とその結果を活用するだけの知識が無いからという理由もありますが……)。
tweetbot.shは以下のコマンドに依存しています。
curl
jq
nkf
openssl
git
(インストールに使用)まずapt
やyum
などで各々のパッケージをインストールし、これらのコマンドを使える状態にしておいてください。
次に、データ類を置くためのディレクトリを用意します。ここでは仮に、~/tweetbot/
を使うとしましょう。
$ mkdir ~/tweetbot
$ cd tweetbot
█
ディレクトリができたら、そこにtweetbot.shのリポジトリをcloneします。
$ git clone --recursive https://github.com/piroor/tweetbot.sh.git
█
--recursive
オプションを付けて再帰的にcloneするか、cloneした後でgit submodule update --init
してサブモジュールもすべてダウンロードしておいて下さい。
次は、認証用にapps.twitter.comでアプリケーションを作成します。Webサービスではないので、URLやコールバックは特に指定しなくても大丈夫です。アプリケーションには投稿のための書き込み権限を与えておいてください。また、DMを扱いたい場合はDMの読み書きの権限も必要です。
アプリケーションができたら、認証情報を定義します。以下の内容で~/tweetbot/tweet.client.key
の位置にファイルを作成し、作成したアプリケーションのコンシューマキーとシークレット、アクセストークンとシークレットを記入して下さい。
MY_SCREEN_NAME=投稿に使うアカウントのスクリーンネーム
MY_LANGUAGE=投稿に使うアカウントの言語
CONSUMER_KEY=xxxxxxxxxxxxxxxxxxx
CONSUMER_SECRET=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
ACCESS_TOKEN=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
ACCESS_TOKEN_SECRET=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
例えば、スクリーンネームがexample
で言語が日本語なら以下のようになります。
MY_SCREEN_NAME=example
MY_LANGUAGE=ja
...
スクリーンネームと言語は、ストリーミングの受信やツイートの検索、取得したツイートの種類の判別のために使われます。これを忘れると、自分でやったRTを延々RTし続けるみたいなことになってしまうので注意して下さい。
認証情報のファイルを設置したら、今度はbotの挙動を定義するファイルを~/tweetbot/personality.txt
の位置に作成します。これはsource
で読み込む前提のファイルで、Bashスクリプトの記法で環境変数として各種の設定を記述します。以下は基本の設定例です。
~/tweetbot/personality.txt:
# 管理者として扱うユーザ。
# これらのユーザからのDMはコマンドとして受け付ける。
ADMINISTRATORS="piro_or, sysadgirl_mint"
# 反応キーワード。
# これらのキーワードへの言及を検出したら反応する。
WATCH_KEYWORDS='sysadgirl, system-admin-girl, シス管系女子, #シス管系女子'
# 独り言の自動投稿の間隔。
MONOLOGUE_INTERVAL_MINUTES=90
# メンションに連続して返事をする最大の回数。
# 延々と同じ返事ばかりを返さないようにする安全装置。
MAX_MENTIONS_IN_PERIOD=10
# 連続して返事をする最大の回数の制限を反映する時間。
# この指定の場合、120分の間に最大で10回までしか返事をしないということ。
MENTION_LIMIT_PERIOD_MIN=120
# フォローする基準:フォローされたらフォローし返す、言及されたらフォローする(RTは除く)。
FOLLOW_ON_FOLLOWED=true
FOLLOW_ON_MENTIONED=true
FOLLOW_ON_QUOTED=true
FOLLOW_ON_RETWEETED=false
# ファボる基準:言及されたらファボる。
FAVORITE_MENTIONS=true
FAVORITE_QUOTATIONS=true
FAVORITE_SEARCH_RESULTS=true
# RT基準:メンション以外はRTする。
RETWEET_MENTIONS=false
RETWEET_QUOTATIONS=true
RETWEET_SEARCH_RESULTS=true
# 言及への言及:リプライかメンションには応答、それ以外は言及しない
RESPOND_TO_MENTIONS=true
RESPOND_TO_QUOTATIONS=false
RESPOND_TO_SEARCH_RESULTS=false
他にも色々と細かい設定が可能です。詳しくはREADMEをご覧下さい。
設定ファイルを置いた~/tweetbot
をカレントディレクトリにした状態で、~/tweetbot/tweetbot.sh/watch.sh
を実行して下さい。
$ cd ~/tweetbot
$ tweetbot.sh/watch.sh
█
これでスクリプトが起動し、その後はCtrl-C
などで停止するまでスクリプトが動き続ける状態になります。
ただ、この方法だと自分がログアウトできません(botを動かしたままログアウトするには、screen
やtmux
といったいわゆるターミナルマルチプレクサを使う必要があります)。また、サーバーを再起動したらbotは停止したままになってしまいます。init.dを使うなりsystemdを使うなりして、サービスとして自動起動するようにしておくと便利でしょう。
これだけでもファボやRTはできるのですが、話しかけても全く応答しないbotになってしまうので、できれば応答や発言を用意しておきたい所です。
独り言用の発言は、~/tweetbot/monologues
ディレクトリの下に置かれたテキストファイル(エンコーディングはUTF-8、改行コードはLF)の1行を1発言としてランダムに選択します。特に重複の除外処理は行っていないため、十分な数の発言データが無いと同じ発言ばかり繰り返してしまいますので、なるべくたくさん用意してあげましょう。例えばこんな感じです。
~/tweetbot/monologues/バレンタイン.txt:
# バレンタイン
# date: *.02.01-*.02.14
バレンタインってなんかワクワクしません?✨おいしいチョコがいっぱい食べれる季節~!😁
この時期、スーパーとかコンビニとか行くとバレンタインソングがすっごいかかってますよねー💦聞きすぎたせいで歌詞覚えちゃいましたよ……😖
見ての通り、Unicode絵文字も使えます。行頭に#
がある行は発言にはならずコメントのように扱われますが、DMで発言を追加する際の目印として使う事ができます。
また、特定の範囲の日においてのみ投稿して欲しい独り言のデータも定義できます。日付の範囲はファイルごとに# date: YYYY.MM.DD-YYYY.MM.DD
の書式で指定し、ワイルドカードも受け付けます。この例であれば、毎年2月の上旬にだけ流れる発言となります。
メンションやリプライに対する応答の発言は、~/tweetbot/responses
ディレクトリの下に置かれたテキストファイル(エンコーディングはUTF-8、改行コードはLF)の1行を1発言として選択します。このとき、どの発言ファイルを使うかは受け取ったメンションの本文に対するパターンマッチングで判断されます。
~/tweetbot/responses/おはよう.txt:
# good morning
# おはよう
# お早う
# ぐっど? *もーにん
# グッド? *モーニン
おはようございまーす!🙌
おはようございまーす!🙌 今日も一日がんばりましょう🎵
#
で始まる行はコメントのように扱われますが、同時に正規表現によるマッチングルールとしても解釈されます。いずれかのルールにマッチすると、そのファイルの中に書かれた発言の中の1つが返事として投稿されます。
マッチングルールだけを定義して発言を定義しない場合、それらのルールにマッチしたメンションには無反応になります。いわゆるNGワードとして使えます。
~/tweetbot/responses/NG.txt:
# 嫌い
# 黙れ
返事の定義ファイルは名前順にマッチングが試行されるため、NGワードを定義するファイルは000NG.txt
のように名前順で先頭に来るようにしておくと良いでしょう。
メンションの本文がいずれのマッチングルールにもマッチしなかった場合、
_pong.txt
(相槌)_connectors.txt
(接続)_developments.txt
(会話の継続)_topics.txt
(新しい話題)の4つの特別な返事定義ファイルの内容に基づいて、それっぽい応答がランダムに生成されて返されます。これは、以下のような内容で用意します。
~/tweetbot/responses/_pong.txt:
# 相槌
おおっ!
へえー!
ふ~ん!
~/tweetbot/responses/_connectors.txt:
# 接続
そういえば
ところで
それはそうと
~/tweetbot/responses/_developments.txt:
# 会話の継続
いいですね!
すごい!
~/tweetbot/responses/_topics.txt:
# 新しい話題
何かオススメの本ってあります??📖
今日は何をしてるんですか?😃
返事の元になったメンションの内容は考慮されないので、空気を読まない・人の話を聞かない・適当に相槌を打つ、という相当アホの子な返事しか返せません。まあ会話してるっぽい雰囲気だけの古式ゆかしい人工無脳ということで、あしからずご了承ください。
発言ファイルには他にもいくつか機能があります。詳しくはREADMEをご覧下さい。
設定ファイルで管理者として登録したアカウントからのDMは、リモート操作用のコマンドとして解釈され、コマンドの実行結果がDMの返事として返却されます。以下は代表的なコマンドです。
echo 任意のテキスト
: 指定されたテキストをそのままDMで返却します。死活確認に使えます。+res キー 返事の内容
: 返事のパターンをキー.txt
のファイルに追加します。その名前のファイルがない場合は、# キー
というコメントがあるファイルに追加します。どちらの場合でもファイルが見つからない場合、新しくファイルを作成します。最初の+
を-
にすると、返事のパターンが削除されます。+キー 独り言の内容
: 独り言のパターンをキー.txt
のファイルに追加します。その名前のファイルがない場合は、# キー
というコメントがあるファイルに追加します。どちらの場合でもファイルが見つからない場合、新しくファイルを作成します。最初の+
を-
にすると、独り言のパターンが削除されます。del ツイートのID
: IDで指定したツイートを削除します。各コマンドの詳細やこれら以外のコマンドについては、READMEをご覧下さい。
……というのがtweetbot.shの大まかな使い方です。
では、ここからは実装の解説に入ります。
ただ、全部を解説するとキリが無いので、長時間動き続けるBashスクリプトという形でbotを作る時のキモになりそうな部分だけ解説することにします。
TwitterのストリーミングAPIのうちUser streamsは完全性が保証されておらず、見落としが発生することが度々あります。なので見落としを防ぐために普通のツイート検索も並行して実行しておきたくなるのですが、普通にやるとストリーミングAPIのレスポンスを待ち受けている間は他のことができません。
また、複雑な処理や外部(Twitter API)と通信する処理は、予期せぬエラーが起こって全体が停止してしまうリスクがあります。
これらの問題を回避するために、tweetbot.shはwatch.sh
を実行するプロセスをマスタープロセスとして、その配下にストリーミングの待ち受け用やポーリング用などのサブプロセスを従える設計としています。これにより、複数の処理を並行して実行できる上に、サブプロセス側でエラーが発生してプロセスが終了してしまっても、マスタープロセスが生きている間は自動的に処理を復帰できるようになっています。
これは、以下の要領で実装しています。
watch.sh:
#!/usr/bin/env bash
watch_mentions() {
while true
do
# メンション待ち受け処理。正常に動いていればこの行で処理がブロックするため、この先に進むことはない。
# 異常終了した場合、10秒待ってからまた同じ処理を実行し直す。
sleep 10
done
}
watch_mentions &
periodical_search() {
# 検索APIのポーリング用の処理。
}
periodical_search &
...
wait
通常、関数やコマンドを実行すると実行が終わるまで待ってから次の行に処理が進みますが、コマンド(関数)名の後に&
を付けると、子プロセスが作られて関数が非同期に実行されるようになります。この仕組みを使って、並行して動かしたいそれぞれの処理を関数にした上で子プロセスで実行しています。
最後の行にwait
と書いておくのがポイントで、これを書かないと、子プロセスがまだ動いているのにマスタープロセスの方だけ先に最終行に到達してそのまま終了してしまいます。wait
を実行すると、子プロセスがすべて終了するまでマスタープロセスの処理がそこで一時停止します。
マルチプロセス化とセットでやっておきたいのが、親プロセスの終了と同時に子孫プロセスを自動的に終了させるという事です。これを忘れると、マスタープロセスをCtrl-C
で終了させた後も子孫プロセスが動き続けてしまいます。
これを防ぐには、マスタープロセスが停止した時(正確には、停止要求のシグナルを受け取った時)の処理をtrap
コマンドで定義してやります。具体的には以下のようにしています。
watch.sh:
...
kill_descendants() {
local target_pid=$1
local children=$(ps --no-heading --ppid $target_pid -o pid)
for child in $children
do
kill_descendants $child
done
if [ $target_pid != $$ ]
then
kill $target_pid 2>&1 > /dev/null
fi
}
self_pid=$$
trap 'kill_descendants $self_pid; clear_all_lock; exit 0' HUP INT QUIT KILL TERM
...
コールバックに指定する関数kill_descendants
は「自分の直接の子プロセスを強制停止する処理」を再帰的に実行するようになっていて、これによってマスタープロセスの終了時には末端の子孫プロセスから順番に終了されていきます(プロセスツリーの末端から終了するのは、祖先側から終了すると、万が一親プロセスだけ終了してしまったという場合に子孫プロセスがトラッキング不能な形で取り残されてしまうと思ったのでこうしてみました)。
前述したようにストリーミングAPIの監視だけでは監視漏れが発生するため、tweetbot.shではそれとは別にツイートの検索やDMの取得のためのポーリング(定期的なAPI呼び出し)も行っています。
ちなみに、ツイート検索専用のストリーミングAPIという物もあり、tweet.shもそれに対応しているのですが、同一IPから複数のストリーミングAPIを同時に使用することは禁止されているため、tweetbot.shでは通常のREST APIを使ってポーリングしています。
watch.sh:
periodical_search() {
...
local last_id_file="$status_dir/last_search_result"
local last_id=''
[ -f "$last_id_file" ] && last_id="$(cat "$last_id_file")"
...
while true
do
debug "Processing results of REST search API (newer than $last_id)..."
while read -r tweet
do
[ "$tweet" = '' ] && continue
id="$(echo "$tweet" | jq -r .id_str)"
...
if [ $id -gt $last_id ]
then
last_id="$id"
echo "$last_id" > "$last_id_file"
fi
...
# 検索結果として得たツイートをここで処理する。
...
done < <("$tools_dir/tweet.sh/tweet.sh" search \
-q "$query" \
-c "$count" \
-s "$last_id" |
jq -c '.statuses[]' |
tac)
if [ "$last_id" != '' ]
then
# increment "since id" to bypass cached search results
last_id="$(($last_id + 1))"
echo "$last_id" > "$last_id_file"
fi
sleep 3m
done
}
...
if [ "$query" != '' ]
then
log "Tracking search results with the query \"$query\"..."
periodical_search &
periodical_process_queue &
else
log "No search queriy."
fi
この時大事なのは、前回取得した結果よりも新しい結果だけを取得するようにリクエストするということです。さもないと、同じツイートや同じDMを何度も処理してしまうことになります。
ここではwhile
ループを二重にしていて、外側が「前回の検索終了時から3分待って次の検索を行う」というポーリングのためのループ、内側が「検索結果として得られたツイート1つ1つを処理する」ためのループです。検索結果の中で最も新しいツイート(=次の検索を行う際に「これより新しい結果のみを返す」という条件に使用するツイート)のIDを保持する変数last_id
の値を外側のループと共有したかったので、内側はプロセス置換を使ったwhile
ループとしています。
tweet.shのsearch
コマンドはREST APIで得た検索結果を出力しますが、検索結果は新しいツイートの方が先に返ってきます。しかしストリーミングAPIの補完として使うには古いツイートから処理したいので、そのJSON文字列からjq -c '.statuses[]'
でツイートの配列を取り出してtac
で逆順に並べ替えています。その出力結果を<(コマンド列)
という書き方(Bashのプロセス置換機能)で擬似的なファイルとして扱って、リダイレクトの<
で内側のwhile
の標準入力に流し込んでいます。これにより、内側のwhile
ループが外側のwhile
ループと同じプロセスで実行され、変数last_id
の値が共有されるようになります。
内側のwhile
ループ内で一時ファイルに値を書き出して、それを外側のwhile
ループで読み込む、という風にすればべつにプロセス置換を使うまでもないのですが、既に値を保持した変数があるのにそれを使えないのは何となく気持ち悪かったので、このようにしました。
内側のループが終了したら、次の検索で「これより新しい結果のみを返す」という条件に使用するツイートのIDをlast_id="$(($last_id + 1))"
でインクリメントしています。これは、検索結果が1件も見つからなかった場合にlast_id
が変化しないと、次の検索リクエストの内容が前回と同じになってしまってキャッシュされたレスポンスが返ってきてしまう可能性があるためです。
発言を種類毎に管理できるよう、発言のデータは細かくファイルを分けられるようになっていますし、コメント行でマッチングルールの定義や投稿可能日時の範囲の指定もできるようになっています。しかし、実際に発言をする時になってこれらを一気に適切に取り扱うのは結構大変です。
そこで、tweetbot.shではwatch.sh
の起動時(および、DMのコマンドで動的に発言データが編集されたとき)に発言データファイルを全スキャンし、発言の選択ロジックそのものをシェルスクリプトとして組み立てて、以後はそれを使うようにしています。具体的には、独り言用はgenerate_monologue_selector.shが~/tweetbot/monologue_selector.sh
というファイルを生成し、返事用はgenerate_responder.shが~/tweetbot/responder.sh
というファイルを生成します。
自動生成された~/tweetbot/responder.sh
は、例えば以下のような内容になります。
~/tweetbot/responder.sh:
...
if echo "$input" | egrep -i "good morning|おはよう|お早う|ぐっど? *もーにん|グッド? *モーニン" > /dev/null
then
[ "$DEBUG" != '' ] && echo "Matched to \"good morning|おはよう|お早う|ぐっど? *もーにん|グッド? *モーニン\", from \"$base_dir/./responses/おはよう.txt\"" 1>&2
extract_response "$base_dir/./responses/おはよう.txt"
exit $?
fi
if echo "$input" | egrep -i "^(hello|hey|yo|hi)[ \.,!]|good afternoon|こんに?ちは|ハロー|はろー|ヘイ|やあ|よ[うお]" > /dev/null
then
[ "$DEBUG" != '' ] && echo "Matched to \"^(hello|hey|yo|hi)[ \.,!]|good afternoon|こんに?ちは|ハロー|はろー|ヘイ|やあ|よ[うお]\", from \"$base_dir/./responses/こんにちは.txt\"" 1>&2
extract_response "$base_dir/./responses/こんにちは.txt"
exit $?
fi
...
標準入力で与えられたメンションの本文に対してどのようにマッチングを行いどのファイルから発言を抽出するのか、スクリプトを見れば一目で分かりますし、動作試験も以下のようにスクリプトを実行するだけなので簡単です。常駐プロセスの側をいちいち止めたり再起動したりする必要はありません。
$ cd ~/tweetbot
$ echo "こんにちは" | ./responder.sh
こんにちは~!
$ █
また、今回は実装を見送りましたが、話しかけられた内容を学習したり文脈に応じて会話をしたりといった高度な応答に対応したくなった場合でも、この設計であればresponder.sh
やmonologue_selector.sh
を差し替えるだけで済むはずです。
tweetbot.shはTwitter用botですが、入出力の部分を入れ替えれば、同様のやり方で他の様々なサービス用のbotを作る事もできます。
nc
コマンドを使ってシェルスクリプトでHTTPサーバーを立てる例を参考にすると、GitHubのService Hookを受け取って何か応答を返しつつSlackのチャンネルやTwitterのタイムラインに通知を流す、というbotを作れそうです。いずれの場合も、ここで解説したような常駐型スクリプトを作る時の技術が役に立つでしょう。
シェルスクリプトの良い所は、どんなコマンドラインツールも容易に部品として組み込めるという点です。自分の得意な言語用にライブラリが提供されていない場合でも、コマンドラインツールがあればシェルスクリプトで利用できます。また、自分が何かツールを作る時も、コマンドラインツールとして標準入力・標準出力でデータをやりとりできるように設計しておけば、それもまた部品として再利用できます。
様々なコマンドを連携させるグルー(糊)としてシェルスクリプトを活用し、皆さんも普段のちょっとした不満や面倒を解消してみて下さい!
最後にまたまたシス管系女子の宣伝です。Qiitaではなくこちらを読まれている方はもう飽き飽きしてますよね……
「シス管系女子」は2011年から日経Linux誌上で連載させて頂いているケーススタディ形式の解説マンガ記事です。
本稿は普通のプログラミングげな事をBashでガッツリやる事例ですが、連載の内容は全くカラーが違っていて、「コマンド操作怖い……」レベルの人が自力でコマンド列を組み立てられるようになるくらいを目指してコマンドやオプションの動作を絵解きで説明する、いたって平易な入門記事となっています。
コマンド一覧を丸暗記しようとして挫折してしまった人、先輩にGUI禁止令を出されて途方に暮れてしまった新人さん、サーバーのトラブルでSSH越しに操作しないといけないのにお手上げになってしまったGUI派の人など、コマンド操作の勘所が分からずお悩みのすべての方にオススメの内容です!
現在は、連載に加筆修正した単行本が以下の2冊リリース済みです。
それ以外にも、Twitterのみんとちゃんbotアカウントでイラストや本編に入りきらなかった小ネタを流したり、Webサイトの方にも連載や本では扱わなかったもっと基礎的な話の特別編を置いていたりします。あと、「シス管系女子」をテーマにしたAdvent Calendarも公開中です。
ということで、最後は宣伝で〆てしまいましたがチャットボットAdvent Calendarの5日目でした。6日目はkashira2339さんによる、会社でリアルに良い反響のチャットボットの機能(hubot + GitHub webhook)のお話です。お楽しみに!
の末尾に2020年11月30日時点の日本の首相のファミリーネーム(ローマ字で回答)を繋げて下さい。例えば「noda」なら、「2016-12-05_twitter-bot.trackbacknoda」です。これは機械的なトラックバックスパムを防止するための措置です。
writeback message: Ready to post a comment.