Google App Engineで作るTwitter bot 〜 JRuby編
前置き
続きです。前回、Google App Engine(以下GAE)上でRubyスクリプトを動作させるところまでできました。次のサンプルとして、簡単なTwitterのbotを作成してみようと思います。
…どうして普通のWebアプリケーションじゃないのかって?それはまぁ…色々ありますが、元々私がGAEに触ってみようと思ったきっかけが「これを自作botの動作プラットフォームにできないかなぁ*1」と思ったから、ということもありまして。
それに、丁度URLフェッチやらGAEデータストア、タスクのスケジューリングなど、GAE独自の機能がいくつか必要となってきますので、手軽なサンプルとしても悪くはないかな、と。
サンプルコードだらけで少々長い記事となってしまいましたが、どうぞお付き合い下さい。
作成するbotのタイプ
一言でTwitterのbotと言っても、様々なタイプが存在します。今回はシンプルな挨拶系botを作ってみます。タイムラインから他者の発言を取得し、「起きた」「おはよう」などのキーワードが含まれていたら「おはようございます!」とリプライを飛ばす…といった感じのものです。
この系統のbotを作成する場合に必要となる機能を考えると、
- Basic認証付きのGETリクエストでタイムラインを取得できること。
- 取得したデータ(XML or JSON)を解析できること。
- 取得した発言の内容によって処理を分岐できること。
- Basic認証付きのPOSTリクエストで発言を投稿できること。
- 最後に取得したステータスIDを保存及び取得できること。
- 一定間隔で自動実行できること。
といったところでしょうか。3番はRubyのcase式と正規表現でなんとでもなりそうですので、それ以外の機能が実現可能かどうかがカギとなります。
なお、基本的にこの記事ではTwitterのAPIについては一通りご存知であるという前提で話を進めていきます。ご存知でない方は、以下のドキュメントにざっと目を通しておくと良いかもしれません。*2
それでは、一つずつ条件をクリアしていきます。
雛形の準備
前回作成した雛形をそのまま流用します。
ダウンロードして展開し、一応わかりやすくディレクトリ名を変更します。
$ wget http://shiba.rdy.jp/souko/sample-jruby-on-gaej.tgz $ tar xvzf sample-jruby-on-gaej.tgz $ mv sample-jruby-on-gaej sample-twitterbot-for-jruby-on-gaej
アプリケーション本体のファイル名(hello.rb)も変更しても良いのですが、今回は面倒なのでそのまま使っています。
ちなみに、GAEでは現状最大10個のアプリケーションしかデプロイできず、しかも削除することができません。なので、今回のようなサンプルは、枠を一つだけ確保し、そこをどんどん上書きする形で使っていった方が得策です。バージョン履歴は全部残りますので、いつでも昔のサンプルを動かすことができます。
friends_timelineを取得してみる
friends_timelineを取得するには、Basic認証付きのGETメソッドが使える必要があります。以下のページを眺めてみると、どうやらurlfetch-gaeというライブラリがこの要件を満たしてくれそうです。*3
簡単な使い方の説明などはこちら。
では、早速ダウンロードしてサンプルアプリに組み込みます。ライブラリの実体は一つのrbファイルですので、それをWEB-INFディレクトリに放り込むだけでOKです。gitをインストールしていない場合は、上のサイトの"download"ボタンからダウンロードしてください。
$ git clone git://github.com/Basaah/urlfetch-gae.git $ cp urlfetch-gae/lib/urlfetch.rb sample-twitterbot-for-jruby-on-gaej/WEB-INF
ちなみにこのurlfetch.rb、わずか63行のシンプルなRubyスクリプトとなっていますので、RubyからどうやってJavaのクラスを利用するのかを学ぶのに良い題材となるかもしれません。
ではfriends_timelineを取得してみます。
$ cd sample-twitterbot-for-jruby-on-gaej $ vim WEB-INF/hello.rb
require 'rubygems' require 'rack' require 'urlfetch' class HelloWorld include Rack::Utils def call(env) res = Rack::Response.new res.write '<html><head>' res.write '<title>Sample Twitter bot for JRuby on GAEJ</title>' res.write '</head><body>' url = 'https://twitter.com/statuses/friends_timeline.xml' username = 'username' # ここをbotのユーザ名に書き換える password = 'password' # ここをbotのパスワードに書き換える auth_token = [ "#{username}:#{password}" ].pack('m').chomp request_header = { 'Authorization' => "Basic #{auth_token}" } response = URLFetch.get(url, :header => request_header) res.write '<h2>Response code</h2>' res.write "<p>#{response.code}</p>" res.write '<h2>Response headers</h2>' res.write '<dl>' response.header_hash.each_pair do |key, value| res.write "<dt>#{escape_html(key)}</dt><dd>#{escape_html(value)}</dd>" end res.write '</dl>' res.write '<h2>Response body</h2>' res.write '<p>' + escape_html(response.content) + '</p>' res.write '</body></html>' res.finish end end
今回は取得したレスポンスボディは特に解析せずそのまま出力します。また、Basic認証の仕組みは用意されていないようですので、自力でリクエストヘッダにセットしています。
開発サーバを起動して8080番にアクセスし、レスポンスヘッダや取得したXMLが表示されれば成功です。
$ ../appengine-java-sdk-1.2.1/bin/dev_appserver.sh --address=0.0.0.0 .
取得したXMLを解析してみる
次はこのXMLを解析して必要な情報だけを取り出してみます。
JRubyではNative extensionが必要なgemは使えないようですので、JSONライブラリは使えません。Javaのクラスを呼び出して使ってもいいのですが、幸いRuby標準添付のREXMLがJRubyでも使えるようですので、こちらを利用します。つまり、フォーマットは常にXMLでリクエストすることになります。
では先ほどのソースを少々改造して、取得したメッセージをいくつかの情報に分け、テーブルで表示するようにしてみます。
$ vim WEB-INF/hello.rb
require 'rubygems' require 'rack' require 'urlfetch' require 'rexml/document' require 'time' class HelloWorld include Rack::Utils def call(env) res = Rack::Response.new res.write '<html><head>' res.write '<title>Sample Twitter bot for JRuby on GAEJ</title>' res.write '</head><body>' url = 'https://twitter.com/statuses/friends_timeline.xml' username = 'username' # ここをbotのユーザ名に書き換える password = 'password' # ここをbotのパスワードに書き換える auth_token = [ "#{username}:#{password}" ].pack('m').chomp request_header = { 'Authorization' => "Basic #{auth_token}" } response = URLFetch.get(url, :header => request_header) res.write <<-HTML <table> <tr> <th>ID</th> <th>text</th> <th>from</th> <th>posted at</th> </tr> HTML doc = REXML::Document.new(response.content) doc.each_element('/statuses/status') do |status| id = status.elements['id'].text created_at = Time.parse(status.elements['created_at'].text) text = status.elements['text'].text user = status.elements['user'] screen_name = user.elements['screen_name'].text res.write <<-HTML <tr> <td>#{id}</td> <td>#{escape_html(text)}</td> <td>#{screen_name}</td> <td>#{created_at}</td> </tr> HTML end res.write '</table>' res.write '</body></html>' res.finish end end
$ ../appengine-java-sdk-1.2.1/bin/dev_appserver.sh --address=0.0.0.0 .
ID、発言内容、ユーザ名、投稿時間が表組みで表示されれば成功です。
発言を投稿してみる
取得は十分いけそうですので、次は発言の投稿を試してみます。urlfetch-gaeにはPOSTメソッドもサポートされており、ほぼ同じ感覚で使えるようですので、これも問題無さそうです。
とりあえずサンプルとして、アクセスする度に現在時刻をTwitterに投稿するスクリプトを書いてみます。
$ vim WEB-INF/hello.rb
require 'rubygems' require 'rack' require 'urlfetch' class HelloWorld include Rack::Utils def call(env) res = Rack::Response.new res.write '<html><head>' res.write '<title>Sample Twitter bot for JRuby on GAEJ</title>' res.write '</head><body>' url = 'https://twitter.com/statuses/update.xml' username = 'username' # ここをbotのユーザ名に書き換える password = 'password' # ここをbotのパスワードに書き換える auth_token = [ "#{username}:#{password}" ].pack('m').chomp request_header = { 'Authorization' => "Basic #{auth_token}" } now = Time.now.strftime("%Y/%m/%d %H:%M:%S") message = "現在時刻は #{now} です。" query_string = 'status=' + escape(message) response = URLFetch.post(url, query_string, :header => request_header) if response.code == 200 res.write '<p>Success!</p>' else res.write '<p>Failure</p>' end res.write '</body></html>' res.finish end end
ソース中にマルチバイト文字が出てきましたが、基本的に全てUTF-8で保存しています。TwitterのAPIがUTF-8なので、そこを揃えないと文字化けしてしまいます。
$ ../appengine-java-sdk-1.2.1/bin/dev_appserver.sh --address=0.0.0.0 .
今回はアクセスしてみても'Success!'か'Failure'しか表示されませんので、Twitterのほうで実際に発言が投稿されているか確認する必要があります。
最後に取得したステータスIDを保存してみる
ここまででタイムラインの取得と発言まではできましたが、定期的に稼動させるbotとするためにはもう一つ重要な要素があります。
それは「前回取得したタイムラインの最後のステータスIDを保存できること」です。これがないと、次のタイムライン取得時に「どこまでが前回処理済なのか」を判別できず、同じ発言に対して2回リプライを飛ばしてしまう状態に陥ってしまいます。
前の記事で言及した通り、GAEではローカルファイルへの書き込みアクセスが禁止されていますので、他の手段をとる必要があります。
再度先ほどのページを眺めてみると、どうやらbumbleというライブラリがこの目的に使えそうです。
簡単な使い方の説明などは以下のページの真ん中少し下あたりが参考になります。
たった一つのデータを保存するためだけに使うには少々大げさですが、他に手段もないようですし*4、これを使うことにします。
インストールは先ほどのurlfetch-gaeと同じで、ダウンロード後、ライブラリの実体である一つのrbファイルをWEB-INFディレクトリに放り込むだけです。
$ cd .. $ git clone git://github.com/olabini/bumble.git $ cp bumble/bumble/bumble.rb sample-twitterbot-for-jruby-on-gaej/WEB-INF
もう一つ上の階層にもbumble.rbがありますが、これは無くても構いません。
では、先ほどのfriends_timelineをテーブルで出力するスクリプトにこれを組み込んで、前回実行時以降の発言だけ表示するようにしてみます。
$ cd sample-twitterbot-for-jruby-on-gaej $ vim WEB-INF/hello.rb
require 'rubygems' require 'rack' require 'urlfetch' require 'rexml/document' require 'time' require 'bumble' class HelloWorld include Rack::Utils class StoredData include Bumble ds :last_id end def call(env) res = Rack::Response.new res.write '<html><head>' res.write '<title>Sample Twitter bot for JRuby on GAEJ</title>' res.write '</head><body>' data_set = StoredData.all({}, :limit => 1) if data_set.empty? data = StoredData.create else data = data_set.first end last_id = data.last_id || 1 url = 'https://twitter.com/statuses/friends_timeline.xml' query_params = { :since_id => last_id, :count => 200 } query_string = query_params.map do |key, value| escape(key.to_s) + '=' + escape(value.to_s) end.join('&') url += '?' + query_string username = 'username' # ここをbotのユーザ名に書き換える password = 'password' # ここをbotのパスワードに書き換える auth_token = [ "#{username}:#{password}" ].pack('m').chomp request_header = { 'Authorization' => "Basic #{auth_token}" } response = URLFetch.get(url, :header => request_header) res.write <<-HTML <table> <tr> <th>ID</th> <th>text</th> <th>from</th> <th>posted at</th> </tr> HTML doc = REXML::Document.new(response.content) doc.elements.to_a('/statuses/status').reverse_each do |status| last_id = id = status.elements['id'].text.to_i created_at = Time.parse(status.elements['created_at'].text) text = status.elements['text'].text user = status.elements['user'] screen_name = user.elements['screen_name'].text res.write <<-HTML <tr> <td>#{id}</td> <td>#{escape_html(text)}</td> <td>#{screen_name}</td> <td>#{created_at}</td> </tr> HTML end res.write '</table>' data.last_id = last_id data.save! res.write '</body></html>' res.finish end end
GETリクエストにsince_idとcountパラメータを含めていること、また最後に処理する発言を最新のものにするために、ステータスの配列をreverse_eachで処理していることあたりがポイントです。
$ ../appengine-java-sdk-1.2.1/bin/dev_appserver.sh --address=0.0.0.0 .
間を空けつつ何度かアクセスしてみると、確かに新着メッセージしか表示されないことがわかります。
ちなみに、開発サーバでGAEデータストアを利用すると、WEB-INFディレクトリの中にappengine-generatedというディレクトリが作成されます。この中に書き込んだデータが保存されるため、開発サーバを立ち上げなおしても引き続きデータを利用することができます。逆にこのディレクトリを消してしまえばデータを初期化できます。
このディレクトリはGAEにアップロードする際は無視されますので、気にする必要はありません。
挨拶botを作ってみる
ここまでの材料で、手動で動かす挨拶botは作れるようになりました。ではhello.rbを整理しつつ少々改造して、簡単な挨拶botに仕立て上げてみます。
$ vim WEB-INF/hello.rb
require 'rubygems' require 'rack' require 'urlfetch' require 'rexml/document' require 'time' require 'bumble' class Hash def to_query_string map do |key, value| Rack::Utils.escape(key.to_s) + '=' + Rack::Utils.escape(value.to_s) end.join('&') end end class HelloWorld include Rack::Utils class StoredData include Bumble ds :last_id end URLS = { :friends_timeline => 'https://twitter.com/statuses/friends_timeline.xml', :status_update => 'https://twitter.com/statuses/update.xml', } USERNAME = 'username' # ここをbotのユーザ名に書き換える PASSWORD = 'password' # ここをbotのパスワードに書き換える def request_header auth_token = [ "#{USERNAME}:#{PASSWORD}" ].pack('m').chomp { 'Authorization' => "Basic #{auth_token}" } end def get_timeline(options = {}) url = URLS[:friends_timeline] + '?' + options.to_query_string response = URLFetch.get(url, :header => request_header) raise "Get timeline failed: #{response.code}" unless response.code == 200 doc = REXML::Document.new(response.content) doc.elements.to_a('/statuses/status').reverse end def post(text, options = {}) options[:status] = text response = URLFetch.post(URLS[:status_update], options.to_query_string, :header => request_header) raise "Post status failed: #{response.code}" unless response.code == 200 doc = REXML::Document.new(response.content) doc.elements['/status'] end def call(env) res = Rack::Response.new res.write '<html><head>' res.write '<title>Sample Twitter bot for JRuby on GAEJ</title>' res.write '</head><body>' data_set = StoredData.all({}, :limit => 1) if data_set.empty? data = StoredData.create else data = data_set.first end last_id = data.last_id || 1 statuses = get_timeline( :since_id => last_id, :count => 200 ) res.write <<-HTML <table> <tr> <th>ID</th> <th>text</th> <th>from</th> <th>posted at</th> <th>status</th> </tr> HTML statuses.each do |status| last_id = id = status.elements['id'].text.to_i created_at = Time.parse(status.elements['created_at'].text) text = status.elements['text'].text screen_name = status.elements['user/screen_name'].text next if screen_name == USERNAME # 自分の発言はスキップ reply_message = nil unless text.include?('@') # 誰かへの返信ではない場合に限り # ここの条件分岐を発展させていけば、リプライのパターンを充実させられる case text when /(?:起きた|おきた|おはよ|オハヨ)/ reply_message = 'おはようございます!いい朝ですね。' when /(?:こんにち(?:は|わ)|コンニチ(?:ハ|ワ))/ reply_message = 'こんにちは。ご機嫌はいかがですか?' when /(?:こんばん(?:は|わ)|コンバン(?:は|ワ))/ reply_message = 'こんばんは。今日も一日お疲れ様でした。' end end if reply_message post("@#{screen_name} #{reply_message}", :in_reply_to_status_id => id ) end res.write <<-HTML <tr> <td>#{id}</td> <td>#{escape_html(text)}</td> <td>#{screen_name}</td> <td>#{created_at}</td> <td>#{reply_message ? 'replied' : 'ignored'}</td> </tr> HTML end res.write '</table>' data.last_id = last_id data.save! res.write '</body></html>' res.finish end end
少々長くなりました。細部がかなり適当な実装となっていますが、最低限の挨拶botとしては稼動できる状態です。
$ ../appengine-java-sdk-1.2.1/bin/dev_appserver.sh --address=0.0.0.0 .
Twitter上で他のアカウントから発言してみるなどして動作を確認します。
自動的に定期実行させてみる
さて、ここまででbot自体の機能実装は一通り完了しましたが、botならばやはり自動的に定期実行させておきたいものです。
幸い、GAEにはcronの仕組みが用意されており、しかも最短で1分毎に指定したURLを呼び出すことができます。
上のページを読んで、cronの実行に必要な設定ファイルを記述します。
$ vim WEB-INF/cron.xml
<?xml version="1.0" encoding="UTF-8"?> <cronentries> <cron> <url>/</url> <description>Auto-reply of sample twitter bot every 3 minutes</description> <schedule>every 3 minutes</schedule> </cron> </cronentries>
そんなに急ぐようなbotでもありませんので、3分間隔であれば十分でしょう。
このままですとURLを手動で呼び出した場合もbotが起動してしまい、下手をすればTwitterのAPI制限に引っかかってしまいますので、このURLを管理者以外アクセスできないような設定にし、cron以外では実行できないようにしてしまいます。
…と思いましたが、どうも現状その辺りにバグがあるらしく、アクセス制限をかけるとcronが機能しなくなる模様。仕方ないので今回はあくまでもサンプルということでスルーしておきます。
では開発サーバで動作確認…といきたいところですが、あいにく開発サーバはcronの実行まではサポートしていませんので、実際にGAEにアップロードして確認する必要があります。
上のページにはアプリケーション本体はアップロードせず、cronの設定だけを書き換える方法も書いてありますので、何度か試行錯誤してみても良いでしょう。
アップロードする前にWEB-INF/appengine-web.xmlのapplicationとversionの値を適切に書き換えておいてください。
$ ../appengine-java-sdk-1.2.1/bin/appcfg.sh --enable_jar_splitting update .
versionを上げた場合、アップロードが完了したらGAEのダッシュボードからアプリケーションのデフォルトのバージョンを変更するのを忘れないようにしてください。古いバージョンがデフォルトのままになっています。
最初の数回は上手く動かない場合もあるかもしれませんが、しばらく様子を見ていると安定してくるはずです。*5
他のアカウントからキーワードにかかるような発言をしてみて、手動でURLにアクセスすることなく正しくリプライが返ってくれば、めでたくbotの完成です。