HaskellでTwitterクライアント開発blog(仮)

今すぐに挫折するかもしれない程度のモチベーションによるTwitterクライアント開発記

Network.CurlでHTTPS通信に対応しようとしてみたけど……

今までHTTP通信でOAuthのトークンリクエスト・API呼び出しをやっていたのをHTTPS通信に変更することを試みてみました。

今回、HTTPS通信に切り替えるにあたって、少々関数のインタフェースを変更……
変更したのはOAuthモジュールのoauthRequest,apiRequest関数(つまり、HTTPリクエストを生成する関数)で、返り値を、IO Response_String型ではなくIO Stringにし、レスポンスボディだけ受け取るようにしました。
結局レスポンスボディだけしか使ってなかったので。

これにより、Mainモジュールではレスポンスボディの処理だけ考えれば良くなったので、HTTP通信周りは完全にOAuthモジュール内に隠蔽されることになり、HTTPS通信を行うにあたって変更するのはOAuthモジュールの以下の関数(と、URL)だけになります。

で、HaskellHTTPS通信を行う方法ですが、Network.Curlモジュールを使うことにしました。
http-enumeratorとか使う方法もあるようですが、よくわからなかったので……

で、cURLの使い方は、まず

Network.Curl.Easy.initialize :: IO Curl 

を呼んでから、

Network.Curl.setopts :: Curl -> [CurlOption] -> IO ()

でオプションを設定し、

Network.Curl.do_curl_ ::
(CurlHeader headerTy, CurlBuffer bodyTy) =>
Curl
-> URLString
-> [CurlOption]
-> IO (CurlResponse_ headerTy bodyTy)

を呼んでレスポンスを処理すればいいと。

 そこでまず、HTTPで通信する例をNetwork.Curlモジュールを利用したものに書き換えてみました。

 

いろいろ雑ですが、まぁこれで通りました。トークンリクエストもAPI呼び出しも問題無さそうです。
実際は、Curlオプションをどう設定すればいいのか分からずかなり躓いたのですが……
ちなみにOAuth側でPOSTリクエストボディ周りをサボっても通りましたが、その場合もしっかりCurlPostFieldSize=0を指定しないとエラーになってしまいました。

 

で、HTTPS
HTTPSプロトコル自体、「サーバ・クライアント間の通信を暗号化してセキュリティを向上させる」以上の知識がなく、具体的にどんな手続きをすればいいのかよくわかんなかったので、HTTPS通信でTwitter APIを利用している例を調べました。

http://d.hatena.ne.jp/aquarla/20101005/1286274634

http://d.hatena.ne.jp/unageanu/20070504

それにしても、Twitter APIについて調べるとRubyでやってる人が多いですね……
つまり、PEMファイルを落としてそれをCA_Fileに設定して通信、という手順っぽい。
そこでPEMファイルを落としてきていろいろ設定を頑張ってみたのですが。

上で提示したHTTP用のコードで、アドレスだけhttpsに変えると通ってしまう……

というよくわからない事態になりました。PEMファイル関係ありません。
CA_Fileに対応していそうなCurlCAInfo,CurlCAPath, CurlCRLFileなんてオプションも見つけたのですが、
設定しても動くし、そもそも全然違うファイル(PEM形式ですら無い、空のファイル)を指定しても動く。
さっきから「動く」といってるのは200 OKステータスが返って問題なく通信できているという意味です。

よくわからなくなってきました。わかっていることは

(1)simpleHTTPだとhttps://~アドレスへのアクセスは403 Unauthorizedで弾かれる。
→初期にハマったミス

(2)du_curl_にはURLStringというフィールドがあり、ここにhttp://api~を入れても、https://api~を入れても通る。
→つまり、simpleHTTPとは明らかに挙動が違う。https通信がちゃんとできているのか、実は内部で「証明書がちゃんとできてないからHTTPプロトコルで送るか」みたいな挙動になっているのか?

(3)du_curl_にはCurlSSLCertというオプションもあり、ここにPEMファイルや他の適当なファイルを指定すると通らない。
→このオプションは怪しいけど、今回の認証にはあんまり関係ないっぽい?

ということ。怖いのはhttpsを指定しても暗号化なんて全然されてませんでした!ってオチなのですが。
一応、httpsアドレスを指定してちゃんとリクエストが通ってるので、当面コレで行きます。
明らかに上のRubyでやってる人たちとは違うことしてますが、いいんでしょうか……

ユーザ認証の機構を再考・エラー処理

一応前回(http://hshstter.hatenablog.com/entry/2012/03/27/025504)まででクライアントとしての基本的な機能は揃ったのですが、Access Tokenを持たない(つまり初回ログイン時)にはCUIに表示されるアドレスをコピーし、CUIの標準入力にPINを打って認証……なんてカッコ悪いことをしていたので、ここまで含めて全てGUIで処理できるようにしました。

具体的には、

(1)保存済みAccess Tokenがある→そのAccess Tokenでログインする。

(2)保存済みAccess Tokenがない→メインウインドウは出さずに認証用ダイアログを表示し、認証用URLを表示。
PINをText entryに入力後、認証ボタンを押すと、その認証でAccess Tokenを取得・保存し、ログインする。

という流れを踏みたい。

このためには、ツイートする関数・タイムライン更新関数などはAccess Token取得後に設定する必要があるため、Access Tokenを持たない場合はまずメインループを開始させ、Access Tokenを取得してからonClickedイベントの登録・タイマの追加などを行うような関数を書く必要があります。

流れだけ書くとこんな感じです。具体的な処理は全部消去して骨組みだけにするとこうなります。

mainRoutine関数で、メインウインドウが起動しクライアントとして使えるようになる感じです。
mainRoutine関数を呼ぶのは、restoreAccessTokenでファイル読み込みをした後か、getNewAccessTokenで新規にAccess Tokenを取得したあとなので、mainRoutine実行時には必ずOAuth値にはAccess Tokenの値が設定されています。

最後に、そういえば作成したGUIを公開してなかったのでここで公開。あくまで形を整えただけ。
特にタイムラインは早めになんとかせんとこれじゃ何もできん……

メインウインドウ。上のテキストエントリにツイート内容を書き、tweetボタンで投稿。
空白部分はテキストエリア。タイムラインが表示される。

f:id:xenophobia:20120327211258p:plain

認証用ウインドウ。英語は適当。

f:id:xenophobia:20120327211313p:plain

そう言えばいい忘れましたが、新たに定義したOAuth/GUI/Tweet型の各フィールドを正格な値にしました。
特にTweetなんかLazyなままだったら今後遅すぎてやばくなりそうな気がしたので。
そのあたりに関する変更点はほぼ無いので説明はなし。

gtk2hs+GladeでGUI対応:タイムライン表示+ツイート

前回GLUTを使いましたが、やっぱりこのままこれでGUI開発を続けるのは不可能な気がしたので、
なんとかしてgtk2hsをインストールしました。

インストール時に出るエラーが"gtk2hsC2hsのバージョンが違います"だったので、cabalをアップデートしてからもう一度gtk2hs-buildtoolsをインストールしてみたのですが、うまく行かない。
なんでだ?と思ってよく読むと、gtk2hsC2hsのファイルがあるのは~/.cabal/binのはずなのに、"/usr/local/binにあるgtk2hsC2hsのバージョンが……"と怒られている。
どうやら、~/.cabal/bin以外にも/usr/local/binにgtk2hsC2hsがあったらしく、そのファイルはcabalでアップデートされていなかったと。
そこで、/usr/local/binにあるファイルを~/.cabal/binにあるものに代えたらインストールに成功しました。

で、まぁ苦労しましたがこれでめでたく使えるようになったんで、Gladeも入れてシンプルなGUIを作ってみました。
gtk2hsとGladeを対応させる方法はReal World Haskellに方法+コードが載っていたので、基本的な部分はそれの丸写しです。
Gladeで用意したhshstterGUI.gladeと、HshstterLocalMain.hs、HshstterMain.hsを用意して以下のようにGladeと結合。
GUIにはツイート書込欄・ツイートボタン・タイムライン表示領域の3つをセット。

HshstterLocalMain.hs

HshstterMain.hs

これでOK、コンパイル→実行……しようとしても読み込まれない。
調べてみると、同じようなエラーに悩まされている人のブログ記事を発見。

http://yasutech.blogspot.jp/2011/12/gtk2hs-glade.html

どうやら、Glade3.10系のXMLの形式にgtk2hsがまだ対応してない模様。
そこで、出力されたXMLファイルを3.8版っぽく書きなおした所、なんとか読み込んでくれました。
以下のようなところを書き換えました。

・<interface>タグを<glade-interface>タグに書き換え

・<requires ……>タグを消去

・<object>〜</object>タグを<widget>〜</widget>タグに置き換え

こんな機械的な書き換えで通ったので、スクリプト書いて置き換えたほうがよさそうですね……

ともあれこれでウインドウが表示されたので、あとはイベントを登録するだけなのですが、
ここはかなり直感的に書くことができて、使いやすかったです。

 ・タイムラインを表示

この部分の実装は、ひとまず書き込み不可なテキストエリアに表示する、という実装にしました。
(これだと画像の表示ができない気がするので、あくまで暫定的に。)

TextView Widgetにテキストを表示させるには、

TextBufferを取得→TextBufferにテキストをセット

という段階を踏めばとりあえず表示できます。

 TextBufferの取得は

Graphics.UI.Gtk.Multiline.TextView.textViewGetBuffer :: TextViewClass self => self -> IO TextBuffer

TextBufferへのテキストのセットは

Graphics.UI.Gtk.Multiline.TextBuffer.textBufferSetText :: TextBufferClass self => self -> String -> IO ()

 

で行えます。

・タイムラインの更新

上のshowTimeline関数はIO Bool型にしていますが、これは次の関数の引数にするためでした。

 

Graphics.UI.Gtk.General.General.timeoutAdd :: IO Bool -> Int -> IO HandlerId

 

第二引数に指定した秒数ごとに、第一引数に指定したアクションを実行します。
Bool型の引数がFalseになるとそこでタイマ繰り返しが終了し、Trueが返される限りインターバルごとに実行され続けます。

ここではshowTimelineはTrueをreturnし続けるので、更新され続けます。

・入力欄にツイート内容を書き込み、tweetボタンでツイートする

ツイート入力欄はEntry Widget、ボタンはButton Widgetを使用。

Entry Widgetの内容の取得は

Graphics.UI.Gtk.Entry.Entry.entryGetText :: EntryClass self => self -> IO String

 逆にEntry Widgetのセットは

Graphics.UI.Gtk.Entry.Entry.entrySetText :: EntryClass self => self -> String -> IO ()

で行えます。

ボタンがクリックされた時のアクションの設定は

Graphics.UI.Gtk.Buttons.Button.onClicked :: ButtonClass b => b -> IO () -> IO (ConnectId b)

を使い、第一引数にButton Widgetを、第一引数にクリック時のアクションを指定します。

・xボタンでプロセスを終了させる

実はこのままだと、デフォルトではxボタンを押してもプロセスは死なず、ウインドウだけ消えます。
そこで、xボタンをクリックした時のアクション

Graphics.UI.Gtk.Abstract.Widget.onDestroy :: WidgetClass w => w -> IO () -> IO (ConnectId w)

 

で指定して、プロセス終了関数

Graphics.UI.Gtk.General.General.mainQuit :: IO ()

 

を呼ぶ必要があります。

 

とりあえず、これでクライアントとして最低限の機能は実装できました。
エラー処理やらいろいろなところが雑ですが……
ツイートもByteStringで処理するべきでしょう。

HaskellでGLUT使ってみた: ウインドウ表示・塗りつぶし

ちょっと気が早いかもですが、GUIの勉強をはじめました。
gtk2hsを使おうと考えていたのですが、どうもうまくインストールできず断念。
とりあえずGLUTを使ってみることに。こちらはcabal install GLUTで入りました。

しかし、GLUTそのものの資料はわりと見かけるのですが、Haskellで扱ってる人があまりいないようで調べづらかったです。
ここにプログラムの例がいくつか載ってたので参考になりました。

http://www.f13g.com/%A5%D7%A5%ED%A5%B0%A5%E9%A5%DF%A5%F3%A5%B0/Haskell/GLUT/

GLUTのCによる入門はよさげなページがありました。和歌山大学の講義ページのようですが……

http://www.wakayama-u.ac.jp/~tokoi/opengl/libglut.html

 

とりあえず、ウインドウを出して中身を白く塗りつぶすHaskellプログラムはこうなります。上のページのCのプログラムをなるべく写経してみました。

各関数の詳細をわかるところまで書きます。正直GLUTOpenGLも触るの初めてなんでよくわかってないところも多いですが……

 

Graphics.UI.GLUT.Initialization.getArgsAndInitialize :: IO (String, [String])

環境の初期化・オプション引数の処理を行い、余った(オプションなどではなく、使わなかった)引数及びプログラム名を返す関数です。
これか、またはinitialize関数などを呼んでおかないと、実行時に「GLUTOpenGLの環境初期化がされてない」と怒られます。
とりあえずGLUT使うときには呼んでおくべき関数っぽいです。

 

Graphics.UI.GLUT.Window.createWindow :: String -> IO Window

ウインドウ名を受け取ってウインドウを生成し、ウインドウを識別するための値(Window型)を返す関数。
これを呼んだだけ(また、flushで実行しただけ)ではウインドウは表示されませんでした。

 

class HasSetter s where
($=) :: s a -> a -> IO ()

これはOpenGLGLUT関連モジュールのものではなく、Data.StateVarモジュールの型クラス・および関数です。
書き込み可能な変数を表す型で、($=)メソッドにより状態の更新ができます。
GLUTでは、コールバック関数の設定や各状態変数のセットなどがこの関数を用いて行われているようです。
「(a型を書き込み可能な変数) $= a型」という形で登録します。どことなく手続き型っぽいインタフェースですね。

 

Graphics.UI.GLUT.Callbacks.Window.displayCallback :: SettableStateVar DisplayCallback

SettableStateVarはWrite-onlyな状態変数の型で、DisplayCallbackはIO ()型の型シノニムです。
current displayに対するコールバック(一連の処理みたいなもの?という認識。よくわかってないです)を登録するための変数です。
登録は上の$=演算子を使って行われます。

 

Graphics.UI.GLUT.Begin.mainLoop :: IO ()

GLUTのメインループに入り、GUIをイベント待ち受け状態にします。
一連の設定がすんだら最後に呼び出す関数です。


Graphics.Rendering.OpenGL.GL.Framebuffer.clearColor :: StateVar (Color4 GLclampf)
Graphics.Rendering.OpenGL.GL.Framebuffer.clear :: [ClearBuffer] -> IO ()

OpenGLはウインドウに対していくつかのバッファ(これもよくわからない。一つの画面に関する色などの情報を保持するメモリのようなもの?)を管理しており、HaskellではこれらをclearColor, clearIndex, clearDepth, clearStencilなどで設定することができ、設定後、clearを呼ぶことでcurrent windowに反映させることができるそうです。

clearColorに設定するColor4 GLclampf型は、

data Color4 a = Color4 !a !a !a !a
newtype GLclampf = GLclampf CFloat

のようになっており、指定する実数値の意味はそれぞれ "Red, Green, Blue, Alpha" で、値は0.0が最低・1.0が最大値らしいです。

 

Graphics.Rendering.OpenGL.GL.FlushFinish.flush :: IO ()

OpenGLは、命令をいくつか溜めこんでおき、一定量溜まった時点で実行するのですが、flushを実行すると、現在実行が保留されている命令が全て実行されます。

 

こんな感じでしょうか。ここまでやるだけでだいぶ調べました……
タイムラインを順次表示させることを当面の目標にしてますが、とりあえず今回はここまで。

タイムラインのJSONデータを解析してみる(Text.JSON)

API利用は前回できましたので、次はタイムラインの取得を。
とは言ったものの、CUIなので見栄えはしませんが……
自動更新もつけてません。

自分のアカウントのタイムラインを取得するには、home_timelineを使えばOK。
パラメータはいりませんが、いろいろとオプションで付けることができます。
何もパラメータを付けずにリクエストすると、最新20件のツイートを取得します。

このとき、データの形式は色々と選ぶことができるのですが、
今回はJSON形式を使ってみました。

HaskellにはJSON解析ライブラリがあります。
cabal install JSONでText.JSONモジュールを使います。

 decode :: JSON a => String -> Result a 

でJSONデータの文字列をパースしてくれます。

取得データの形式は次のようになってました。

・(home_timelineで取得できるデータ) = JSArray [(tweet)] (各tweetが最新のものから順に入っている)

・(tweet) = JSObject JSValue (各ツイートのデータ)

・ツイートのデータにはさらに"user"フィールドというJSObject型の値が存在し、ここにツイートしたアカウントの詳細なデータが入っている

ツイートのデータはかなり多いのでここに書くのは省略……
JSObject型は、fromJSObject関数で連想リストの形に変換できるので、
変換して目的のデータをデータ名で指定して取得する関数、及び取り出したJSValue型から目的の型にあったデータを取得してくる関数を作ることで、
各フィールドの値にアクセスするようにしました。

-- ツイート
data Tweet = Tweet {
      name :: String, -- ユーザ名
      screen_name :: String, -- ユーザID
      retweeted :: Bool, -- リツイートされたか
      created_at :: String, -- ツイートされた時刻
      profile_image_url :: String, -- プロフィール画像
      text :: String -- ツイート内容
}

-- オブジェクト名指定で対応するJSONオブジェクトの値を取得
findJSObject :: Monad m => JSObject a -> String -> m a
findJSObject jsobject objectName =
case dropWhile ( (/= objectName) . fst) (fromJSObject jsobject) of
[] -> fail $ "findJSObject: Not_found(\"" ++ objectName ++ "\")"
object:_ -> return . snd $ object

ofJSBool :: Monad m => JSValue -> m Bool
ofJSBool (JSBool b) = return b
ofJSBool _ = fail "ofJSBool: Not JSBool"

ofJSString :: Monad m => JSValue -> m String
ofJSString (JSString str) = return $ fromJSString str
ofJSString _ = fail "ofJSBool: Not JSString"

ofJSObject :: Monad m => JSValue -> m (JSObject JSValue)
ofJSObject (JSObject jsobject) = return jsobject
ofJSObject _ = fail "ofJSBool: Not JSObject"

-- ツイートを取得
getTweet :: Monad m => JSObject JSValue -> m Tweet
getTweet jsobject = do
user <- ofJSObject =<< findJSObject jsobject "user"
name <- ofJSString =<< findJSObject user "name"
screen_name <- ofJSString =<< findJSObject user "screen_name"
retweeted <- ofJSBool =<< findJSObject jsobject "retweeted"
created_at <- ofJSString =<< findJSObject jsobject "created_at"
profile_image_url <- ofJSString =<< findJSObject user "profile_image_url"
text <- ofJSString =<< findJSObject jsobject "text"
return $ Tweet name screen_name retweeted created_at profile_image_url text

それなりに使いやすいインタフェースに出来たかなと思います。
各関数において、不正なデータ型が来たらエラーにするようにしてあります。(ofJSBool, ofJSString, ofJSObject)

で、コードですが、ようやくgithubに上げました。

リポジトリ
https://github.com/xenophobia/hshstter

Haskell で OAuth API利用Tweetしてみた

いよいよOAuth APIを使ってtweetしてみました。
が、そのまえに前記事・前々記事には気づかなかった署名アルゴリズムに関する私の誤解について。

署名対象文字列生成の際、パラメータ部分を接合する時

パラメータを' & ' , ' = 'で接合→URLエンコード

という変換だと思っていましたが、これは誤解で

パラメータを' & ' , ' = 'で接合してURLエンコード→URLエンコード

でした。

この問題は、URLエンコードによって変更される文字がパラメータ・パラメータ名には無かったので、今まで表面化しなかったようです。
ここで随分ハマってしまいました。

さて本題です。tweetしてみた話。
status="ツイート内容"という、APIに渡す引数が加わっただけですが、コレのせいでいろいろなバグを踏みました。

(1)署名にはstatus="ツイート内容"を含めて、Authorizationヘッダのパラメタには含めない
→statusはAuthorizationヘッダの内容ではないが、署名対象にはなるということ。

(2)POSTリクエストボディにstatus="ツイート内容"を込めて送信する場合、Content-Lengthヘッダを付ける。
→これは単に私がHTTP通信に慣れてなかったせいで見落としました(たぶん)。ただ、この場合も返ってくるエラーは"401 Unauthorized"なんですよね……

とりあえずこれで無事自作クライアントでtweetすることができました。

コードも載せますが、一応ツイートが成功するHTTPリクエストの内容を載せます。
自分が作っていて一番欲しい情報だったので……

POST http://api.twitter.com/1/statuses/update.json HTTP/1.1
Authorization: OAuth oauth_token="********************", oauth_consumer_key="***************", oauth_nonce="804621766734007680", oauth_timestamp="1332346240", oauth_signature_method="HMAC-SHA1", oauth_version="1.0", oauth_signature="*********************"
Content-Length: 11


Request Body:

"status=TEST"

リクエストボディにstatus="ツイート内容"を入れ、Content-Lengthヘッダに「status=」も含めた長さを指定。

このときの署名は

POST&「"http://api.twitter.com/1/statuses/update.json"をURLエンコード」&(「上のAuthorizationヘッダのパラメタ+status="ツイート内容"」を'&', '='で接合してURLエンコード

全体をさらにURLエンコードした文字列です。

ちなみに、日本語ツイートをする場合、ツイート内容はUTF8エンコードしておく必要があります。

コードはこちら → http://ideone.com/tJe1I

gistが文字数多すぎて使えないんですよね……早めにgithubでコード公開するようにしたいです。

ユーザ認証・アクセストークン取得

追記)署名アルゴリズムに誤りがあったので訂正

ユーザ認証をどうすべきか悩んだのですが、あんまりいいのが思いつかなかったんで保留。
とりあえず認証用URL表示→PINを手入力でアクセストークン取得まで書いた。

いろいろと型を再考しなきゃならないような気がしていますが、ひとまず思いつくままに書いています。
たとえば以下の関数2つ。

 

-- パラメータのリストから特定のパラメータを取得
getParameter :: Monad m => [Parameter] -> String -> m Parameter
getParameter parameters parameterName =
    case find ( (== parameterName) . fst) parameters of
      Just parameter -> return parameter
      Nothing -> fail "Parameter not found."

なんでモナドにしたのかというとIOモナド内で使いたい&失敗の可能性があるからなんですが、別にモナドである必要がない?
実はエラーを定義(あるいはConnErrorあたりを借用)してFailure型で一般化しようかとも思ったのですが、そこまでする必要があるのか決めかねて結果こんな中途半端な感じに抽象化する結果になりました。
抽象化とかやめてもう完全にIO型にしちゃったほうがすっきりしたかも。

 

-- simpleHTTP のIO版
simpleHTTPIO :: HStream a => Request a -> IO (Response a)
simpleHTTPIO req = do
  res <- simpleHTTP req
  case res of
    Right res' -> return res'
    Left err -> fail.show $ err

simpleHTTPがResult a = Either ConnError a型で返ってきて使いづらかったので、失敗時にはIO例外を投げるIO版simpleHTTP関数を作った。
これはそもそも元のIO (Result (Response a))型がちょっと冗長な感がありますね……IOで包まれてるのにわざわざEither aでエラー通知する必要があんまりない気もします。
どのような事情があってこんな型になっているのでしょう?
(ていうか、これがIOなんだからgetParameterもおとなしくIOにすべきですね……)

一応書いたコードをideoneに置いときます。
表示されたアドレスにアクセスして認証→PINを入力してEnter、でアクセストークンの値を表示します。

http://ideone.com/5EdMg