スニペットの実行結果を投稿するSlackBot

2017-10-03



はじめに

bot.gif

みんなー、雑にプログラミング問題を解いて遊んでるー!? (ゝω・)v
今日は私用チームに作ったSlackボットについて解説させてくれ。

その名もRubyEvalBot、名前の通りSlackへrubyのスニペットが投稿された際にそれを(ほぼ)安全に評価して標準出力を答えてくれる便利な奴だ。

ちょっとしたコードに言及したい時や仲間が投げつけて来たプログラミング問題へ解答を叩き付けたい時に、コードを投稿したらそれがどういう結果になるのかを出してくれれば便利だよね? それをやってくれるんだ。

はい、GitHubはこっちだよー。
https://github.com/owlworks/slack-ruby-eval

全体の解説

難しいことはそれほどやっていない。必要なことは…

などだ。

投稿の監視

    def run
      response = HTTP.post('https://slack.com/api/rtm.start', params: {token: CONFIG.slack.token})
      url = JSON.parse(response.body)['url']
      EM.run do
        @ws = Faye::WebSocket::Client.new(url)

        @ws.on :open do
          @logger.info '=== Open Bot'
        end

        @ws.on :message do |event|
          data = JSON.parse(event.data)
          process_event(data)
        end

        @ws.on :close do |event|
          @logger.info "===Close Bot - CODE: #{event.code}"
          @ws = nil
          EM.stop
        end
      end
    end

    def process_event(data)
      # To override this method.
      @logger.info data.inspect
    end

早速だがここらへんの処理のより詳しい解説について本件の主題ではないので別記事へお任せします。
http://studio-andy.hatenablog.com/entry/ruby-bot

ま、とりあえず ws.on :messageのブロックで受け取れるeventはメッセージの投稿だけではなく、ユーザーがタイピング中であるとか、ログイン状態が変わったとか多種多様な情報を含む。

ここは様々なボットで利用することを想定した基底クラスなのでフィルタリングもせずにproccess_eventへJSONパースした結果を渡している。実際の処理はこのproccess_eventへ書いて行くというワケ。

rubyスニペットを受け取った時の処理

    def process_event(data)
      return unless is_ruby_snippet?(data)
      url = data['file']['url_private_download'] # スニペットのダウンロードurl
      file_path = download_snippet(url)
      channel = data['channel']
      eval_and_post_stdot(file_path, channel)
    end

    def is_ruby_snippet?(data)
      data['subtype'] == 'file_share' && data['file']['filetype'] == 'ruby' # スニペットの共有はファイル共有(file_share)扱いで、例えば画像ファイルをアップされるのと同じ。
    end

    def eval_and_post_stdot(code_file_path, channel)
      begin
        output = get_eval_result(code_file_path)
        post_text = fetch_result_message(output)
        return 'Too long output.' if post_text.length > MAX_POST_LENGTH
        params = { type: 'message', text: post_text, channel: channel }.to_json
        @ws.send(params)
        @logger.info "Posted message =>"
        @logger.info params
      rescue => e
        @logger.error e.inspect
      end
    end

    def get_eval_result(code_file_path)
      SafeEvaler.safe_eval(code_file_path)
    end

受け取ったメッセージがrubyのスニペットか判定し、そのスニペットをファイルとしてダウンロードする。そしてそれを評価し結果をスニペットが投稿されたチャットへ投稿し返す。それがこのprocess_eventの概要だ。

スニペットのダウンロード

    def download_snippet(url)
      uri = URI.parse(url)
      response = nil
      request = Net::HTTP::Get.new(uri.request_uri)
      request['Authorization'] = 'Bearer ' + CONFIG.slack.token
      http = Net::HTTP.new(uri.host, uri.port)
      http.use_ssl = true
      http.verify_mode = OpenSSL::SSL::VERIFY_NONE
      http.start { |h| response = h.request(request) }
      code = response.body.force_encoding('UTF-8')
      file_path = File.join('/tmp/', File.basename(url) )
      File.open(file_path, "w") { |f| f.puts(code) }
      file_path
    end

ちょっとコードが汚いなぁ…。 Slackから受け取ったjson形式のメッセージ(data)に含まれているスニペットのurl(data['file']['url_private_download'])に対してNet::HTTPでSSL接続してアクセスし、そのレスポンスをローカルへファイルとして書き出す。

少し注意したいのは以下の部分で、ヘッダーのAuthorizationにトークンを付与してあげないとアクセス拒否される。

request['Authorization'] = 'Bearer ' + CONFIG.slack.token

だいたい多分、おそらくは安全な評価

rubyにはセキュリティ機構としてセーフモデルという仕組みがあり、これを設定することによって危険な処理を実行してしまうことを避けることが出来る…のだが現在は利用が実質的に推奨されておらず利用できなくなった。

具体的には元々あったセーフレベル0から4(高いほど安全)のうち、レベル2-4が廃止されてしまった。これによって残っているのはデフォルト値であり何も制限しないレベル0と、少しだけ制限するレベル1のみである。

セーフレベル1

セーフレベルを1に設定することで以下の処理が禁止される。

  • 汚染された文字列を引数としたDir, IO, File, FileTest のメソッド呼び出し
  • ファイルテスト演算子の使用、ファイルの更新時刻比較
  • 外部コマンド実行 (Kernel.#system, Kernel.#exec, Kernel.#`, Kernel.#spawn など)
  • Kernel.#eval
  • トップレベルへの Kernel.#load (第二引数を指定してラップすれば実行可能)
  • Kernel.#require
  • Kernel.#trap

https://docs.ruby-lang.org/ja/latest/doc/spec=2fsafelevel.html

引数が汚染されていなければDir, IO, File, FileTestのメソッドを使うことができることに注意。機能が制限されたとはいえ、このような機能においてはセーフレベルを高めておくに越したことはないし外部コマンドの実行などは避けたい。投稿されたコードはセーフレベル1で実行することにしよう。

安全…なの?

ぶっちゃけNoだ。タチの悪いスクリプトを実行する方法はいくらでもある。クローズなチーム以外で運用すべきではない。より根本的には実行するunixユーザーを別に作ってパーミッションを絞り込むとか対策が別途必要だろう。このbotをroot権限で動かすだって?

処理時間の制限

さて、無限ループや非常に長く掛かる処理のコードが投稿された場合にも備えなくてはならないだろう。

rubyではTimeout.timeoutを使うことで処理時間を制限出来る。これはブロック内の処理が一定時間が過ぎても終わらなければTimeout::Errorを発生させる。

Timeout.timeout(3) do
  code_to_eval = "$SAFE = 1;" + code
  result = capture(:stdout) { eval(code_to_eval) }
end

このスクリプトでは3秒間待っても処理が終わらなければ例外エラーを発生させるというわけ。

標準出力を受け取る

このコードについては以下の記事で紹介しているものを利用している。コード元はwycatsさん。孫引きで申し訳ない。
http://pochi.hatenablog.jp/entry/20100324/1269413263

def capture(stream)
  begin
    stream = stream.to_s
    eval "$#{stream} = StringIO.new"
    yield
    result = eval("$#{stream}").string
  ensure
    eval("$#{stream} = #{stream.upcase}")
  end
  result
end

# usage
result = capture(:stdout) { eval(code_to_eval) }

ここでは何をしているのか? ちょっとわかりにくいかな
。汎用性をなくして愚直に書き直してみよう。

def capture
  begin
    eval '$stdout = StringIO.new' # 標準出力先を画面からStringIOに変える
    yield # 渡されたブロック(評価したいコード)の実行
    result = eval('$stdout').string # 上記で作成して標準出力を受け取ったStringIOを文字列化してresultへ入れる
  ensure
    eval('$stdout = STDOUT') # エラーが出ようが標準出力先は元に戻す
  end
  result
end

オーケー。では解説だ。まず$stdoutが謎だろう。これは標準出力先を指定している。初期値はObject::STDOUT、つまりこのオブジェクトに指定されている場合には一般的な標準出力の振る舞いをするというワケ。

そして、これは他のオブジェクトへ指定しなおすことが出来る。例えばStringIOやFileなんかにね。指定先のオブジェクトはwriteメソッドを持つ必要があるからただのStringとかは無理だよ。

# 標準出力の出力先を /tmp/foo に変更
$stdout = File.open("/tmp/foo", "w")
puts "foo"         # 出力する
$stdout = STDOUT   # 元に戻す

参照: Ruby 2.4.0 リファレンスマニュアル variable $>

といったところで今回のスクリプトで解説すべき点はおしまい。
現場から以上です。╭( ・ㅂ・)و


 このエントリーをはてなブックマークに追加


   初心者向け本格的なrubyバッチの書き方 >>

[12]