mikutterの Service.primary.post :message => "Hello, mikutter!" を旅してみる

ことはじめ

「mikutterでアイコンを設定したい」「mikutterから名前を変更したい」そう思ったことはありませんか?mikutterにはプロフィール周りを設定する機能がないような気がするので,mikutterからTwitterAPIを叩いて実現しようと考えました.mikutter/core/mikutwitter/ あたりにTwitterAPIを叩くときに関係するソースが置いてあるので,その辺を読めば使い方が分かります.ちなみに,TwitterREST API v1.1 に関する情報は https://dev.twitter.com/docs/api/1.1 にまとめられています.

Service.primary.post :message => "Hello, mikutter!"

mikutterでツイートするときは,タイトルにもあるように次のように入力します.

Service.primary.post :message => "Hello, mikutter!"

さて,Service.primary.postは何をしているのでしょうか? mikutter/core/service.rb をのぞいてみましょう!

class Service
  include ConfigLoader

  # MikuTwitter のインスタンス
  attr_reader :twitter

  # 現在ログイン中のアカウント
  @@services = Set.new

  def self.services_refresh
    Service.new if(@@services.empty?) end

  # 存在するServiceオブジェクトをSetで返す。
  # つまり、投稿権限のある「自分」のアカウントを全て返す。
  def self.all
    Service.services_refresh
    @@services.dup end
  class << self; alias services all end

  def self.primary
    services.first end
  class << self; alias primary_service primary end

Serviceクラスには@@servicesというクラス変数があり,ここにログイン中のアカウント情報を格納しているようです. Service.primary はこの中の最初のインスタンスを指定しています.@@servicesに複数のServiceインスタンスを格納すれば,複垢対応も簡単にできそうですね.そして,上の方にコメントがある MikuTwitter が気になります.

さて,次は Service.primary.post に進みます.Serviceクラスの中にpostメソッドが定義されています.同じく mikutter/core/service.rb の中をのぞいてみます.

  # なんかコールバック機能つける
  # Deferred返すから無くてもいいんだけどねー
  def self.define_postal(method, twitter_method = method, &wrap)
    function = lambda{ |api, options, &callback|
      if(callback)
        callback.call(:start, options)
        callback.call(:try, options)
        api.call(options).next{ |res|
          callback.call(:success, res)
          res
        }.trap{ |exception|
          callback.call(:err, exception)
          callback.call(:fail, exception)
          callback.call(:exit, nil)
          Deferred.fail(exception)
        }.next{ |val|
          callback.call(:exit, nil)
          val }
      else
        api.call(options) end }
    if block_given?
      define_method(method){ |*args, &callback|
        wrap.call(lambda{ |options|
               function.call(twitter.method(twitter_method), options, &callback) }, self, *args) }
    else
      define_method(method){ |options, &callback| function.call(twitter.method(twitter_method), options, &callback) } end
  end

  define_postal(:update){ |parent, service, options|
    parent.call(options).next{ |message|
      notice 'event fire :posted and :update by statuses/update'
      Plugin.call(:posted, service, [message])
      Plugin.call(:update, service, [message])
      message } }

このあたりでupdateメソッドを定義しています.読みにくいですが,

class Foo
  define_method(:method_name) { |args..| ... }
end

の形でFooクラスにmethod_nameメソッドを定義することができます.ここではこの形でupdateメソッドを定義しています. define_postal(:update){...} からがメソッド定義開始になります.主要部分だけを抜き出すと,だいたい次のような形に展開できます.

def update(options, &callback)

  callback.call(:start, options)      #APIを叩くよー
  callback.call(:try, options)        #試してみる.Javaで言うtryブロック的なやつ

  deferred = twitter.update(options)  #APIを叩くとDeferredが戻ってくる

  deferred.next { |res|               #成功したときのブロックを記述
    callback.call(:success, res)
    res
  }.trap{ |exception|                 #失敗したとき.Javaで言うcatchブロック的なやつ
    callback.call(:fail, exception)
    Deferred.fail(exception)
  }

end

ここで,Deferredは mikutter/core/lib/deferred/ に定義されているクラスです.ネットワークを介しているので,リクエストの結果が戻ってくるまで時間がかかりますが,Deferredを使えばリクエストを別スレッドで実行して,あとから結果を取り出すことができます.
twitter.update(options) でTwitterAPIを叩いているように見えますね.twitterはMikuTwitterのインスタンスです.MikuTwitterはTwitterAPIにアクセスするための機能を提供しています. mikutter/core/mikutwitter/api_shortcuts.rb を見ると,APIを叩いてツイートしている部分が見つかります.

  def update(message)
    text = message[:message]
    replyto = message[:replyto]
    receiver = message[:receiver]
    data = {:status => text }
    data[:in_reply_to_user_id] = User.generate(receiver)[:id].to_s if receiver
    data[:in_reply_to_status_id] = Message.generate(replyto)[:id].to_s if replyto
    (self/'statuses/update').message(data) end
  alias post update

引数で受け取っているmessageはMessageオブジェクトですね.text = message[:message]はツイート本文です.textを追っていくと,最終的に (self/'statuses/update').message(data) のdataに入っていくことが分かります. self/'statuses/update' は不思議な形をしていますが, mikutter/core/mikutwitter/api_call_support.rb を見ると,MikuTwitterには / メソッドがあり,リクエストを作成していることが分かります.

  # APIのパスを指定する。
  # 例えば、 statuses/show/1234567890.json?include_entities=true を叩きたい場合は、以下のように書く。
  # (twitter/:statuses/:show/1234567890).json include_entities: true
  def /(api)
    Request.new(api, self) end

messageメソッドでdataを引数に渡していますが,ツイートするだけなら data は最小限で構わないようです.つまり,ここまでの結果を見ると,

(Service.primary.twitter/'statuses/update').message({:status => "Hello, mikutter!"})

でツイートできるようです. https://api.twitter.com/1.1/statuses/update.json を見ると,確かにstatusだけがrequiredになっています.ここのmessageメソッドは mikutter/core/lib/mikutwitter/api_call_support.rb 中に定義されています.

    defparser :user, :users, Users
    defparser :message, :messages, Messages
    defparser :list
    defparser :id
    defparser :direct_message

defparser メソッドを呼び出していますが,先ほどの define_postal と同様に,defparserメソッド内でdefine_methodを呼んでいます.

    def self.defparser(uni, multi = :"#{uni}s", container = Array, defaults = {})
      parser = lazy{ MikuTwitter::ApiCallSupport::Request::Parser.method(uni) }
      define_method(multi){ |options = {}|
        type_strict options => Hash
        json(defaults.merge(options)).next{ |node|
          Thread.new{ container.new(node.map(&parser)).freeze } } }

      define_method(uni){ |options = {}|
        type_strict options => Hash
        json(defaults.merge(options)).next{ |node|
          Thread.new{ parser.call(node) } } }

      define_method(:"paged_#{multi}"){ |options|
        type_strict options => Hash
        json(defaults.merge(options)).next{ |node = {}|
          Thread.new {
            node[multi] = node[multi].map(&parser)
            node } } } end

さて,jsonメソッドでは twitter.api(api, options, force_oauth) を呼んでいます.

    # APIリクエストを実際に発行する
    # ==== Args
    # [options] API引数(Hash)
    # ==== Return
    # Deferredのインスタンス
    def json(options)
      type_strict options => Hash
      twitter.api(api, options, force_oauth).next{ |res|
        Thread.new{ JSON.parse(res.body).symbolize } } end

twitter.api は mikutter/core/lib/mikutwitter/query.rb に定義されています.

  # 別のThreadで MikuTwitter::Query#query! を実行する。
  # ==== Args
  # MikuTwitter::Query#query! と同じ
  # ==== Return
  # Deferredのインスタンス
  def api(api, options = {}, force_oauth = false)
    type_strict options => Hash
    promise = Thread.new do
      query!(api, options, force_oauth) end
    promise.abort_on_exception = false
    promise end

query!メソッドを呼んでいますね.

  # APIを叩く
  # ==== Args
  # [method] メソッド。:get, :post, :put, :delete の何れか
  # [api] APIの種類(文字列)
  # [options]
  #   API引数。ただし、以下のキーは特別扱いされ、API引数からは除外される
  #   :head :: HTTPリクエストヘッダ(Hash)
  # [force_oauth] 互換性のため
  # ==== Return
  # API戻り値(HTTPResponse)
  # ==== Exceptions
  # TimeoutError, MikuTwitter::Error
  def query!(api, options = {}, force_oauth = false)
    type_strict options => Hash
    resource = ratelimit(api.to_s)
    if resource and resource.limit?
      raise MikuTwitter::RateLimitError.new("Rate limit #{resource.endpoint}", nil) end
    method = get_api_property(api, options, method_of_api) || :get
    url = if options[:host]
            "http://#{options[:host]}/#{api}.json"
          else
            "#{@base_path}/#{api}.json" end
    res = _query!(api, options, method, url)
    if('2' == res.code[0])
      res
    else
      raise MikuTwitter::Error.new("#{res.code} #{res.to_s}", res) end
  rescue MikuTwitter::RateLimitError => e
    # 変数 resource の情報は振るい可能性がある(他のTwitterクライアントが同じエンドポイントを使用した時等)
    Plugin.call(:mikutwitter_ratelimit, self, ratelimit(api.to_s))
    raise e end

内部でさらに_query!メソッドを呼んでいますね.

  # query! の本質的な部分。単純に query_with_oauth! を呼び出す
  def _query!(api, options, method, url)
    query_uri = (url + get_args(options)).freeze
    MikuTwitter::Query.api_lock(query_uri) {
      cache(api, url, options, method) {
        retry_if_fail(method, query_uri){
          fire_request_event(api, url, options, method) {
            query_with_oauth!(method, url, options) } } } }
  end

さらにさらにquery_with_oauth!を呼んでいます. mikutter/core/lib/mikutwitter/connect.rb の中に定義部分があります.

  def query_with_oauth!(method, url, options = {})
    if [:get, :delete].include? method
      path = url + get_args(options)
      res = access_token.__send__(method, path, options[:head])
    else
      path = url
      query_args = options.melt
      head = options[:head]
      query_args.delete(:head)
      res = access_token.__send__(method, path, query_args, head) end
    if res.is_a? Net::HTTPResponse
      case res.code
      when '200'
      when '401'
        notice "#{res.code} Authorization failed."
        notice res.body
        notice "trigger request: #{path}"
        begin
          errors = (JSON.parse(res.body)["errors"] rescue nil)
          errors.each { |error|
            notice error
            if [INVALID_OR_EXPIRED_TOKEN].include? error["code"]
              atoken = authentication_failed_action(method, url, options, res)
              notice atoken
              return query_with_oauth!(method, url, options) if atoken end }
        rescue Exception => e
          notice e end
      when '429'
        raise MikuTwitter::RateLimitError.new("Rate limit #{url}", nil)
      end
    end
    res
  end

ツイートするにはaccess tokenが必要ですね.

  attr_accessor :consumer_key, :consumer_secret, :a_token, :a_secret, :oauth_url

  def initialize(*a, &b)
    @oauth_url = 'https://twitter.com'
    super(*a, &b)
  end

  def consumer(url=oauth_url)
    OAuth::Consumer.new(consumer_key, consumer_secret,
                        :site => url) end

  def access_token(url=@oauth_url)
    OAuth::AccessToken.new(consumer(url), a_token, a_secret) end

やっとURLを叩けるようになりました.ここまできてやっとツイートができるんですね.

mikutterで適当なAPIを叩く方法

TwitterAPIリファレンスを見れば,実際にAPIを叩くことができるようになります.ここでは例としてプロフィールアイコンと名前を設定してみます.APIhttps://dev.twitter.com/docs/api/1.1/post/account/update_profile_image にまとめられています.これを見ると,Base64エンコードされた700kB以下のGIF,JPG,PNG画像であればアップロードできるようです.基本的にRequestオブジェクトを作成してjsonメソッドを呼べばよいので,割と簡単に実装できます.

require 'base64'
(Service.primary.twitter/'account/update_profile_image').json(:image => Base64.encode64(open('path/to/icon.png').read))

こんな感じでアイコンが変更できます.名前の変更も同様に叩けます.

(Service.primary.twitter/'account/update_profile').json(:name => "ぺんぎんさんだよー")

TwitterAPIが手で叩けるTwitterクライアントはたぶんmikutterだけなので,名前変更芸やアイコン芸が簡単にできそうですね!