mikutterをいじりたおす 第4回

画像を投稿します。第1回はこちら

画像投稿実装(前半)

mikutterはもともと画像投稿機能がない。プラグインもあるが、twitterのgemを使っていてちょっと悲しい。それに複数画像投稿にも対応していない。そんなわけで、画像投稿を実装しよう。

実装フロウ

  1. mikutterコンソールから画像投稿をしてみる
  2. 画像投稿用のGUIを作る
  3. 全体を組み合わせて画像投稿を行えるようにする

とまあ簡単に書いたが、実際には投稿ボックスの仕様を解説するのに1億年かかるので今回も前半後半に分けた。

mikutterコンソールを使ってみる

mikutterには開発用のコンソールが付いている。知らない人もいるかと思うので、これを見ながらとりあえず小一時間いじってみてほしい。できること、できないこと、いろいろわかるだろう。

mikutterコンソールからAPIを叩く

さて、いきなり大きな課題だ。まずは一体どうやってAPIを叩くのか。

さきほどの投稿のテストの際は、Service.primary.postを叩いていた。ということは、Service.primaryに関数postが定義されているはずだ。Service.primaryをmikutterコンソールで見てみると、

>>> Service.primary
#<Service Ishotihadus>

つまり、これもまたServiceのインスタンスだということだ。

Serviceはcore/service.rbで定義されている。ここでpostの定義を探すと、alias post updateという記述が234行目にある。しかしupdateを探してもそれっぽい定義が出てこない……。というわけでここで手詰まり。

全ファイルに「def update」でgrepをかけてみよう。すると、core/lib/mikutwitter/api_shortcuts.rbにそれっぽい定義がある。どうやらmessageとかいう関数を呼び出しているらしい。

さらに「def message」でgrepをかけると、api_call_support.rbというファイルに定義されていることがわかる。これの上の方を見ると、APIのリクエストを発行する関数jsonという関数がある。さらにこれの中を見てみると、apiという関数を呼び出していることがわかる。

「def api」でgrepをかけると、query.rbというファイルが見つかる。この中で関数apiのコメントを見ると「query!」を実行する、と書いてある。だから、query!を実行すれば、ナマのAPIを叩ける可能性が高い。

そこで、とりあえずService.primary.query!とコンソールに打ち込んでみると、wrong number of argumentsと言われる。そう、ちゃんとこの関数が使えるのだ。

ここまで来たらお手の物、次のように入力してコンソールで実行してみよう。

Service.primary.query!('statuses/update', {'status' => 'ぽ'})

「ぽ」というツイートが投稿されるとともに、#<Net::HTTPOK 200 OK readbody=true>が返ってきていることがわかる。なおこの最後に.bodyをつけると当然送られてきたJSONのデータを取得することができる。

補足

当然service.rbのなかでupdateは定義されている。define_postalという関数によってメソッドが定義されているのだ。define_postalは180行目付近に定義があり、第1引数のmethodが「twitter.method」の形で呼ばれるようになっている。つまり、service.updateを呼び出すと、service.twitter.updateが内部で呼び出される。service.twitterはMikuTwitterのインスタンスだ。これ自体はただMikuTwitter::APICallSupportをincludeしているだけだが、APICallSupportのなかでMikuTwitter::Queryをincludeしている。うえで書いたように、MikuTwitter::Queryの中にはquery!やapiなどの関数が定義されている。

戻ってきて、APICallSupportのうえでAPIがそのまま直だたきできるようになっていることがわかるだろう。これを実行するとDeferredが返ってくるのだが、肝心の叩いた時のコールバックが呼ばれない。これのコールバックを提供しているのが、define_postalだ。

define_postalの定義の中にdefine_methodがあることもわかるだろう。すなわち、define_postalを適宜適用してあげるだけで、コールバックを含めたポスト用の関数が作れる、という仕組みだったのだ。

このコールバックを提供する環境は他になく、現状ではPOST関係の関数はすべてdefine_postalで定義するのがよいと考えられる。successが発行されるときにはレスポンスも得られる(しかも整形された状態で)。

mikutterコンソールから画像の投稿をしてみる

まずは画像の投稿のAPIの説明から。といってもみなさんなら公式ドキュメントを見ればウィークボソンの半減期くらいの時間でわかるだろう。簡単に説明すれば、media/uploadを叩く→アップロードした画像のidを返ってきたJSONから取得する→statuses/updateをmedia_ids付きで叩く、と言った流れだ。

まずはquery!を使ってmedia/uploadを叩きたいところだが、これだけURLがhttps://upload.twitter.com/1.1/から始まるため、現状の実装では対応できない(ほかはhttps://api.twitter.com/1.1/から始まる)。下記のコードを見ると、optionsに:hostが定義されていれば自由なhostにアクセスできるが、httpsにはできない。そこで、httpsでも叩ける実装が必要になる。

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

なお気づきにくいが、メソッドも定義されていないとPOSTではなくGETとなるようである。これも実装の問題となってしまうので、こちらも修正しなければいけない。

まずはhttpsが叩ける用にしよう。optionsに:httpsが含まれていれば(正確にはfalseもしくはnilでなければ)httpsを叩くようにする。といっても以下のようにすればいいだけだろう。

(options[:https] ? "https" : "http") + "://#{options[:host]}/#{api}.json"

次にmethod_of_apiを修正して、media/uploadでPOSTとなるように設定する。こちらも周りの例にならえばよい。最後の要素に追加するときは、直前のカンマを忘れないようにしよう。

'media' => {
  'upload' => :post }

これで、query!(‘media/upload’, {:host => ‘upload.twitter.com’, :https => true})などと叩けば動くようになる(ただしまだ投稿画像を置いていないのでダメ)。

じゃあ画像を投稿しよう。バイナリでそのまま投稿することもできるのだが、おそらく上の実装はapplication/x-www-form-urlencodedで送っていると思うので、バイナリそのままで送ることはできない。実はTwitterのAPIはBase64で送ることにも対応している。これを使うことにしよう。

Rubyでバイナリファイルを読み込んでBase64に変換するには、多少ググれば即出てくる。まあこのへんのを参考とすることにしよう。

さて、これで準備はでそろった。コンソールにまずはbase64のライブラリを読み込む。

>>> require 'base64'
false

この場合はfalseで読み込まれなかったが、読み込まれることもあるんじゃないんですかね(ヘラヘラ)

次にBase64を読み込む。変数名に@をつけるのを忘れずに。

>>> @data = Base64.encode64(File.binread("/home/ishotihadus/Desktop/chino.png"))
"iVBORw0KGgoAAAANSUhEUgAAA+gAAAIzCAYAAACTLTO8AAAABHNCSVQICAgI..."

そして、media/uploadを叩く!

>>> Service.primary.query!('media/upload', {:host => 'upload.twitter.com/1.1', :https => true, :media_data => @data}).body
"{"media_id":661157935330951168,"media_id_string":"661157935330951168","size":457227,"expires_after_secs":86400,"image":{"image_type":"image\/png","w":1000,"h":563}}"

そんでもってstatuses/updateを叩く!

>>> Service.primary.query!('statuses/update', {:status => 'チノちゃんかわいい', :media_ids => '661157935330951168'}).body
"{"created_at":"Mon Nov 02 12:29:35 +0000 2015","id":661158208392704000,..."

できたー!

これで画像をAPIを通してアップロードすることができた。これをモチベーションにして、次はGUIを作ろう。

GUIをつくる

イメージとしてはこんな感じ。投稿欄の下に画像投稿欄を用意して、画像を追加するとそこに画像のプレビューをもったボタンが追加される。ボタンを押すと画像を削除することができる。みたいなものだ。

Screenshot from 2015-11-02 21&%34&%42

これをブロック的に表すとこんな感じになる。枠の名前は変数名、カッコ内はクラス名だ。

スクリーンショット 2015-11-02 21.47.10

picturebox_addbuttonを押すと、ファイル選択ダイアログを開き、それでOKが押されると、picturebox_piclistにButtonのインスタンスをぶち込む、という流れで動くつもりである。

正直そろそろ全部が全部解説するのはつらくなってきたので、全体的にサラッと解説するだけとしたい。ここまでしっかり読んできた読者であれば、自分で実装をできるまでは言わないが、それでもコードを見て何をしているか理解することは容易いであろう。

まずはこの投稿欄を定義しているクラスを見つけよう。といっても第1回のパクツイ実装のときにcore/mui/gtk_postbox.rbを扱ったのを覚えているだろうか。あれが投稿ボックスの実体なので、ここに追加してしまえばよい。簡単な話だ。

gtk_postbox.rbの中ではgenerate_box関数のなかでGUIを作っていて、その中でwidget_toolやwidget_postなどの関数を呼び出している。これらの関数は、そのウィジェットが作られていればそのウィジェットをそのまま、作られていなければウィジェットを作ってそれを返す関数になっている。ここでは、これと同じようにwidget_pictureboxという関数を定義して、これが画像の一覧画面となるようにしよう。

まずはHBoxとaddbuttonを追加する。なおここではpack_startという関数を用いている。Boxに追加する系の関数なのでcloseupとあまり変わらないのだがまあとりあえずpack_startとしておいた。

def widget_picturebox
  return @picturebox if defined?(@picturebox)
  @picturebox = Gtk::HBox.new(false, 4)

  @picturebox_piclist = Gtk::HBox.new(false, 5)
  @picturebox.pack_start(@picturebox_piclist, true)

  @picturebox_addbutton = Gtk::Button.new
  @picturebox_addbutton.label = '画像の追加'
  @picturebox.pack_start(@picturebox_addbutton, false)

  @picturebox
end

次はaddbuttonのイベントの定義。画像のリストを保持しておく配列@picturesはあらかじめinitializeの中で定義しておく。ファイル選択ダイアログは最初に紹介したmikutterプラグインの実装が参考になる。なおここではプレビューにかかわる部分は削除した。またadd_pictureという関数は画像を追加する関数とした。これは別途定義しておこう。引数は1つで、ファイル名の文字列。

@picturebox_addbutton.signal_connect('clicked') do |button|
  if @pictures.count >= 4
  else
    dialog = Gtk::FileChooserDialog.new('アップロードする画像を選択...', nil,
                                        Gtk::FileChooser::ACTION_OPEN,
                                        nil,
                                        [Gtk::Stock::CANCEL,	Gtk::Dialog::RESPONSE_CANCEL],
                                        [Gtk::Stock::OPEN, Gtk::Dialog::RESPONSE_ACCEPT])
    filter = Gtk::FileFilter.new
    filter.name = '画像ファイル'
    filter.add_pattern('*.png')
    filter.add_pattern('*.PNG')
    filter.add_pattern('*.jpg')
    filter.add_pattern('*.JPG')
    filter.add_pattern('*.jpeg')
    filter.add_pattern('*.JPEG')
    filter.add_pattern('*.gif')
    filter.add_pattern('*.GIF')
    dialog.add_filter(filter)

    if dialog.run == Gtk::Dialog::RESPONSE_ACCEPT
      filename = dialog.filename.to_s
      add_picture(filename)
     end
    dialog.destroy
  end
end

これでとりあえず追加ボタンを押すとファイル選択ダイアログが開いてファイルを選べるところまではいったはずだ。

補足

僕は実験中にgtk_postbox.rbに投稿欄が実装されていることに気づかずすごく時間を使いました。疲れました。

画像追加関数の定義

まずは条件弾きをしておく。画像が4つ以上ある場合、指定されたファイルがディレクトリであった場合、MIMEタイプが画像でない場合、ファイルが3MB以上である場合。これらの条件のいずれかでも当てはまる場合はすべて追加動作を行わない。

MIMEのタイプを判別するgemはいくつかあるのだが、今回はmime-typesを使った。詳しい使い方はググるなり公式READMEを読むなりしてほしい。なおこれは新しいgemなので、Gemfileを書き換えるのを忘れずに。

さらに、ボタンを追加する定義も同時にしてしまおう。GtkのButtonにはadd関数で任意のウィジェットを追加できる(参考)。画像は縮小したいので、Pixbufを一回介してから追加するが、これもググれば資料が得られるだろう。あとはボタンをクリックした時に、@picturebox_piclistと@picturesからそれぞれ対象の画像が削除されるようにイベントを定義する。ボタンのインスタンスがわからないと使いづらいので、@picturesはハッシュの配列として定義した。

def add_picture(filename)
  return if @pictures.count >= 4
  return if FileTest.directory?(filename)

  type = MIME::Types.type_for(filename)
  return if type.length == 0
  return unless type[0].content_type.index('image')
  return if File::stat(filename).size >= 3*1024*1024

  pixbuf = Gdk::Pixbuf.new(filename, 80, 80)
  preview = Gtk::Image.new
  preview.set_pixbuf(pixbuf)
  preview_button = Gtk::Button.new
  preview_button.add(preview)

  @pictures << {:path => filename, :instance => preview_button}
  @picturebox_piclist.pack_start(preview_button, false)
  @picturebox_piclist.show_all

  preview_button.signal_connect('clicked') do |button|
    @pictures.delete_if{|h| h[:instance] == button}
    @picturebox_piclist.remove(button)
  end
end

かなり駆け足だったが、これで画像が追加できるようになり、さらにそのプレビューが表示できるようになった。

ドラッグ・アンド・ドロップの実装

実験ではすべて実装した後におまけとして実装したが、順番的にこちらのほうがまとまりがよいのでここで紹介しよう。おまけとしては調べるのに時間がかかった難所でもある。

Gtkのドラッグ・アンド・ドロップに関しては調べても使い方がよくわからなかった。最終的にはこの資料が見つかり、これのp.257付近に詳しく紹介されていることが判明。これを利用して、@picturebox_piclistに画像をドラッグ・アンド・ドロップするとadd_pictureが呼ばれるようにしよう。

signal_connectするのはdrag-data-received。ここでデータが得られるので、このイベントをリッスンする。

selection.dataにはURLが改行区切りで入っているので、これはsplitで適当に分裂させてURLデコードを行い、最後にfile://の部分だけ削除する。これで得られたファイル名の配列をadd_pictureにそれぞれ渡す。

@picturebox_piclist.signal_connect('drag-data-received') do |widget, datacontext, x, y, selection, info, time|
  datas = selection.data.split.select{|f| f.start_with? 'file://'}
    .collect{|f| URI.decode(f['file://'.length...f.length])}
  datas.each do |f| add_picture(f) end
  Gtk::Drag.finish(datacontext, true, false, time)
end
Gtk::Drag.dest_set(@picturebox_piclist,
                   Gtk::Drag::DEST_DEFAULT_ALL,
                   [["text/plain", Gtk::Drag::TARGET_OTHER_APP, 12345]],
                   Gdk::DragContext::ACTION_LINK)

なお12345という謎のマジックナンバーが登場するが、とくに意味はない。0にしたら動かなかったので適当に書いただけ。

また最後のfinish関数は、これを呼び出してあげることがマナーのよう(参考)。

次回予告

次回は、gtk_postbox.rbの投稿時の動きを追って、画像の配列をあっちこっちにぶん回し、最終的に投稿までこぎつけます。


コメントを残す

メールアドレスが公開されることはありません。 * が付いている欄は必須項目です