mikutterの Service.primary.post :message => "Hello, mikutter!" を旅してみる
ことはじめ
「mikutterでアイコンを設定したい」「mikutterから名前を変更したい」そう思ったことはありませんか?mikutterにはプロフィール周りを設定する機能がないような気がするので,mikutterからTwitterAPIを叩いて実現しようと考えました.mikutter/core/mikutwitter/ あたりにTwitterAPIを叩くときに関係するソースが置いてあるので,その辺を読めば使い方が分かります.ちなみに,Twitterの REST 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を叩く方法
TwitterのAPIリファレンスを見れば,実際にAPIを叩くことができるようになります.ここでは例としてプロフィールアイコンと名前を設定してみます.APIは https://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だけなので,名前変更芸やアイコン芸が簡単にできそうですね!