Google App Engine/JRubyでTwitterの自動フォロー返し

先日Google App EngineSDKが1.2.6にバージョンアップし、アプリケーションがメールを受信できるようになりました。

Twitterの自動フォロー返しを実現するには、おおまかに分けて

  1. フォロー通知のメールを受信したタイミングでプログラムを呼び出す
  2. 定期的にメールサーバにフォロー通知メールを確認しに行く
  3. 定期的にfollowingとfollowersの差分をチェックする

という3パターンがあると思います。

GAEのメール受信機能を使えば、この中の1番が手軽に実現できそうです。

ということで、ごく簡単な自動フォロー返しの仕組みをGAE/JRubyで作ってみました。

下準備

GAE/JRubyの環境を整えるところまでは以下の記事を参照して下さい。

gemでインストールできるApp Engine SDKは2009/10/18現在まだ1.2.5ですが、特に問題なく動くようです。開発サーバでのテストメール送信機能が使えないのは痛いですが…。

環境が整ったら、ディレクトリを作って必要なgemのインストールをしておきます。

$ mkdir sample-twitter-follow-back
$ cd sample-twitter-follow-back
$ appcfg.rb gem install sinatra appengine-apis

設定ファイルの作成

rackupの設定ファイルを書きます。

$ vim config.ru
require 'appengine-rack'

AppEngine::Rack.configure_app(
  :application => 'your-application-id',
  :version => 1,
  # 1. メール受信サービスを有効化する
  :inbound_services => [ :mail ]
)

# 2. 管理者アカウント以外からのアクセスを禁止する
use AppEngine::Rack::AdminRequired

require 'main'

run Sinatra::Application

ソース中のコメント部分を少し補足しておきます。

1. メール受信サービスを有効化する

AppEngine::Rack.configure_appメソッドは:applicationと:version以外にもいくつかオプション引数を取ります。*1

各種サービスに関しては、上のように:inbound_servicesオプションに使いたいサービス名を指定することでWEB-INF/appengine-web.xmlにその内容が反映され、サービスが使えるようになります。

2. 管理者アカウント以外からのアクセスを禁止する

今回はメール受信サービスから呼び出される以外の使い方を想定していませんので、全体に“管理者のみ許可”のアクセス制限をかけてしまいます。

他にもAppEngine::Rack::LoginRequiredやAppEngine::Rack::SSLRequiredなどのセキュリティオプションがあり、Rack::Builder#mapメソッドと組み合わせることで適用範囲を指定することもできます。

アプリケーションの作成

アプリケーション本体のソースを書きます。

$ vim main.rb
require 'sinatra'
require 'yaml'
require 'appengine-apis/logger'
require 'appengine-apis/urlfetch'

# 1. メールが届くと、メールアドレスを含んだURLが呼び出される
post '/_ah/mail/:account@:domain' do |account, domain|
  logger = AppEngine::Logger.new

  # 2. Twitterアカウント情報を読み込む
  accounts = YAML.load_file('accounts.yaml')
  unless accounts[account]
    # 3. 不明なアカウントの場合はログに記録しておく
    logger.warn("Received a mail for an unkonwn account.")
    logger.warn("Account Name: #{account}")
    logger.warn("Domain: #{domain}")
    halt 'unkonwn account'
  end
  username = accounts[account]['username']
  password = accounts[account]['password']

  # 4. POSTデータとして送られくるメールを読み込む
  mail_data = env['rack.input'].read

  # 5. メールを解析する
  props = java.util.Properties.new
  session = javax.mail.Session.get_default_instance(props)
  stream = java.io.ByteArrayInputStream.new(mail_data.to_java_bytes)
  message = javax.mail.internet.MimeMessage.new(session, stream)

  # 6. Twitter通知メールの固有ヘッダから必要な情報を得る
  mail_type = message.get_header('X-TwitterEmailType').to_a.first
  sender_id = message.get_header('X-TwitterSenderID').to_a.first

  if mail_type == 'is_following' && sender_id
    # 7. フォロー通知メールなのでフォロー返しをする
    url = "https://twitter.com/friendships/create/#{sender_id}.json"
    request = Net::HTTP::Post.new('/')
    request.basic_auth(username, password)
    options = {
      :method => request.method,
      :payload => request.body,
      :headers => { 'Authorization' => request['Authorization'] }
    }
    response = AppEngine::URLFetch.fetch(url, options)
    unless response.code.to_i == 200
      # 8. リクエスト失敗時はログに記録して500エラーを返す
      logger.error("Reguest failed of following back.")
      logger.error("Status: #{response.code} #{response.message}")
      logger.error("User ID: #{sender_id}")
      halt 500, 'request failed'
    end
  else
    # 9. それ以外のメールはログに記録しておく
    subject = message.subject
    from = message.from.to_a.join(', ')
    logger.warn("Received a mail which is not 'is_following'.")
    logger.warn("Subject: #{subject}")
    logger.warn("From: #{from}")
  end

  'ok'
end

先ほどと同じように、コメント部分について少し補足しておきます。

1. メールが届くと、メールアドレスを含んだURLが呼び出される

メール受信サービスのドキュメントを読むと、メールを受信する毎に"/_ah/mail/<address>"というURLに対してPOSTリクエストが発行されるようです。

URLに含まれるメールアドレスからアカウント名を取得しておきます。

2. Twitterアカウント情報を読み込む

Twitterのアカウント情報は別ファイルにまとめておくことにしました。こうすれば、複数のアカウントの自動フォローを1つのアプリケーションで担当することができます。

アカウント情報ファイルの詳細は後述します。

3. 不明なアカウントの場合はログに記録しておく

メールは誰でも送信できますので、想定していないアカウントに対してメールが送られてくることもあるかもしれません。どうもGAEのメール受信サービスはリクエストが失敗するとメールの再送をかけるようになっているようなので、ここはログに書き残しておくだけで正常なレスポンスを返しておきます。そうしないと延々と再送をかけられてしまいます。

4. POSTデータとして送られくるメールを読み込む

先ほどのドキュメントにもありましたが、メールソースはPOSTデータとして送られてきますので、一旦全てメモリ上に読み込んでしまいます。

5. メールを解析する

読み込んだメールソースをJavaのMimeMessageクラスを使って解析します。この辺は詳しい説明は省きます。こういう流れなのだと思って下さい。あるいはJavaのリファレンスを参照して下さい。

6. Twitter通知メールの固有ヘッダから必要な情報を得る

Twitterからのフォロー通知メールには、プログラムがメールを処理しやすいように、いくつか固有のヘッダが付加されています。

ここから情報を取得すれば、わざわざ面倒なメール本文の解析を行う必要はありません。

7. フォロー通知メールなのでフォロー返しをする

フォロー返しはAppEngine::URLFetch.fetchメソッドを使って行っています。Basic認証なのでHTTPSでリクエストします。

Net::HTTP::PostクラスはBasic認証に使うAuthorizationヘッダのエンコードと、POSTパラメータ*2エンコードのために使っているだけで、直接リクエストには使用しません。自力でエンコードするならNet::HTTP::Postクラスを使う必要はありません。

8. リクエスト失敗時はログに記録して500エラーを返す

前述の通り、リクエスト失敗時はメールの再送をかけてくれるようなので、フォローに失敗した場合はわざと500エラーを返しておきます。

ただ、例えば「パスワードが間違っている」などの「再送しても成功しないケース」だった場合、再送のループに陥ってしまいます。きちんと作るならその辺りの配慮も必要になります。

9. それ以外のメールはログに記録しておく

これも今まで出てきたパターンと同じように、ログに記録した上で再送を防ぐために正常終了しています。

アカウント情報ファイルの作成

これでアプリケーションは完成ですので、最後にアカウント情報ファイルを同じディレクトリに保存しておきます。

$ vim accounts.yaml
user1:
  username: Sample_User1
  password: 1234ABCD
user2:
  username: testuser2
  password: foobarbaz

上の例で言うと"user1", "user2"の部分が「アカウント名」になります。「アカウント名」とTwitterへのログインに使う「ユーザ名」は同じでも異なっていても構いません。

言うまでもありませんが、これらのアカウント情報をクラウド上にアップロードすることはそれなりのリスクを伴います。その辺りは自己責任で行って下さい。

メールアドレスの登録

自動フォローしたいTwitterアカウントの「設定」ページでメールアドレスを登録します。メールアドレスは

<アカウント名>@<アプリケーションID>.appspotmail.com

になります。ドメインが"appspotmail.com"なのに注意して下さい。

アプリケーションのアップロード

本来ならここで開発サーバでのテストを行いたいところですが、前述の通り、2009/10/18現在はまだgoogle-appengine gemの各モジュールがSDK 1.2.6の機能に対応していないため、さっさと実環境にアップロードして、そちらで動作確認してしまいます。*3

config.ruの:applicationと:versionが正しいことを確認しておいて下さい。

$ appcfg.rb update .

アップロードが完了したら、試しに先ほど登録したメールアドレスに対して適当なメールを送信してみたり、実際に他のアカウントからフォローしてみたりして動作を確認しておくと良いと思います。

問題点

これでとりあえず自動フォローの仕組みが完成しました。しかし、このアプリケーションにはいくつかの重大な欠陥が存在します。

届いたメールがそのまま失われてしまうのが最たる問題でしょうか。フォロー通知メールなら別に消えたって構わないかもしれませんが、ダイレクトメッセージのメールやその他のメールまで消えてしまうのは問題です。転送するなり、データストアやログに記録しておくなりしたほうが良いでしょう。

パスワードを平文でアップロードしなければいけないのも気になる点です。この点はOAuthの仕組みを実装することで解決できそうです。

最もどうにもならない問題は、「GAEの1リクエスト30秒制限」と「JRubyインスタンスのロードの遅さ」の組み合わせによる「タイムアウトの頻発」でしょうか。

ログを監視しているとわかりますが、ぶっちゃけた話、JRuby環境をロードするだけで普通に30秒制限を突破してDeadlineExceededException例外が発生していたりします。幸い何度も再送をかけてくれるおかげで、そのうち成功してくれますが…。

今後GAE/JRubyの最適化が進めば解決するかもしれませんが、やはりGAEのRubyネイティブ対応が欲しいところです。

更なるフォロワー管理

さて、これで「フォロー通知メール反応型の自動フォロー」は実現できましたが、フォロワーの完全自動管理を行うためには、これだけでは全然足りません。

皆さんもご経験があるかもしれませんが、フォロー通知メールは100%信頼のおけるものではなく、実際には「フォローされたのにメールが送られてこない」というパターンもよくあります。

それに、bot等のプログラムではフォローを解除された場合の「自動フォロー外し」機能も必要なことがあるかもしれません。

これらはGAEのCronサービスと、Twitterの"friends/ids", "followers/ids" APIを組み合わせることで解決可能ですが、それはまた別の機会に。

簡単にやり方だけ書いておきますと、Cronで一日一回タスクを起動し、friends/idsとfollowers/idsをチェックして、差分IDに対してフォロー/アンフォローを実行していくだけです。

実際には前述の30秒制限もあり、確実に動作させるにはTask Queueサービスを利用するなど、色々工夫が必要になりそうな気がします。

…まぁ、レンタルでも自前でも、自由に使えるサーバ環境を用意できるならば、GAEではなくそちらを使ったほうが簡単だったりもしますが…。

*1:具体的に何をとるかは、ドキュメントが見当たらなかったのでソースを読むしかないのかもしれませんが…。

*2:今回はPOSTパラメータは存在しませんが。

*3:実際はコードのほうを変更して開発サーバでも動作確認をしています。