Hatena::ブログ(Diary)

しばそんノート

2009-06-28

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を作成する場合に必要となる機能を考えると、

  1. Basic認証付きのGETリクエストでタイムラインを取得できること。
  2. 取得したデータ(XML or JSON)を解析できること。
  3. 取得した発言の内容によって処理を分岐できること。
  4. Basic認証付きのPOSTリクエストで発言を投稿できること。
  5. 最後に取得したステータスIDを保存及び取得できること。
  6. 一定間隔で自動実行できること。

といったところでしょうか。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の完成です。

次のステップへ

後はこのスクリプトのメッセージ分岐の部分を追加修正していけば、それなりにしっかりとしたbotにできる…のですが、試行錯誤しながら継ぎ足し継ぎ足し書いてきた関係で、だいぶごちゃごちゃしたソースになってしまっています。

Twitter APIを呼び出す部分なんかも、少し整理すれば別ファイルに切り出してライブラリにまとめられそうです。

ということで、次回*6は、ソースを整理しつつ、botとしてもう少しまともなスクリプトに発展させていきます。

ある程度整理が付いたら、また前回のように「GAE上のTwitter botのテンプレート」になるパッケージでも用意してみようと思います。

*1:現在はさくらのレンタルサーバ上でいくつか稼動させています

*2:読まなくても作れますが。

*3:rb-gae-supportを使えば巷のTwitterライブラリがそのまま使えるかもしれませんが、今回は独自で実装してみます。

*4:memcacheはいつデータが消えるかわかりませんし

*5:最初に200件ステータスを取得してしまうために30秒制限にひっかかるのかもしれません。最初の取得だけ件数を制限すれば問題ないかも。

*6:もしくは近い未来…あるいは遠い未来