hubot adapterの作り方

はじめに

最近chatworkやtypetalkといったコミュニケーションツールのhubot adapterを作ったりしてます。

どちらもまだ開発途中ですが、これまでに得た知見を残していこうと思います。

間違った情報があったらコメント等でご指摘いただければ嬉しいです。

hubotとは

hubotはgithub社が開発しているchat用のbotフレームワークです。

通常のbotとどう違うのかというと、botのロジックとchatとの処理部分を分離して、様々なサービスへ応用できるようにしているところです。

前者のbotのロジック部分をhubot scriptと呼び、後者のchatとの処理部分をhubot adapterと呼びます。

hubot scriptには例えば天気を教えてくれるものからjenkinsにbuildを行わせるものまであります。 もちろん、自分で開発して追加することも簡単にできます。scriptは開発例なども豊富でわかりやすいです。

対してhubot adapterは各種サービスとのやりとりを行うものです。IRC用はもちろん、twitter用やyammer用などさまざまなadapterが公開されています。今回説明するのはこのadapterの開発方法です。

hubotについてもっと詳しく知りたい方や、hubotの導入方法などは、こちらの資料が非常にわかりやすいです。

hubotのアーキテクチャ

矢印の説明とかなんかいろいろ怪しいですが、だいたいこんなイメージです。

f:id:akiomik:20131216050155p:plain

この図のうち、真ん中の青いところがhubotのコア部分となっており、scriptとadapterとの橋渡しや便利機能の提供をしています。

adapterの役割は、サービスからデータを受け取り(run)、そのデータをrobotというモジュールに渡し(receive)、hubot scriptから受け取ったメッセージをサービスに渡すこと(send/reply)です。

公式ドキュメント

adapterの実装方法を知るには公式ドキュメントが一番です。 この記事を書いている時点で、ドキュメントには以下の説明があります。

The best place to start is src/adapter.coffee, and inheriting from Adapter. There is not as much documentation as could exist (yet!), so it is worth reviewing existing adapters as well as how hubot internally uses an adapter.

(意訳: まずsrc/adapter.coffeeを読め。あ、まだドキュメントはねーから。あとはコード読んで頑張れよ!)

rtsl

というわけでコードを読みましょう!!

というだけなのもアレなので、すこし補足したいと思います。

adapterのコード

実際にhubotのsrc/adapter.coffeeを見てみると、Adapterというクラスがあります。

これがadapterのベースとなります。 実装時にはAdapterを継承したクラスを作り、必要なメソッドをオーバーライドしていくことになります。

その中でも重要なメソッドはsendrunです。

send

これはhubot scriptがなんかの処理を行ったあと、チャット側になにかを通知するときに利用するメソッドで、scriptから呼ばれるメソッドの中では一番利用頻度の高いものです。

その他のメソッドとしてはemotereplytopicplayなど色々ありますが、これらはsendと同様にチャット側に通知するのが目的です。これらは必要に応じて実装すればよいと思います。

例えばplayは定義のみの空メソッドですが、音の再生が可能なチャットサービスは限られているので、他のhubot adapterを見ても実装していないことが多いです。

run

hubotのコード上ではチャットとやり取りをするもののことをbotと呼んでいます(hubotだのrobotだの色々出てきてややこしいですね)。

runはこのbotの初期化と、チャットから継続的にメッセージを取得し発言者情報とともに@receiveにデータを渡すための処理を実装します。

Hoge adapterの解説

実際のコードに近いものを、Hogeという架空のチャットサービスのためのadapterという形で解説したいと思います。

以下はhubot公式のadapterであるhubot-campfireから重要な部分を抜粋して書き直したものです。

{Adapter, TextMessage} = require 'hubot'
{EventEmitter} = require 'events'

class Hoge extends Adapter
  send: (envelope, strings...) ->
    @bot.send str for str in strings

  run: ->
    options =
      token:   process.env.HUBOT_HOGE_TOKEN
      rooms:   process.env.HUBOT_HOGE_ROOMS
      account: process.env.HUBOT_HOGE_ACCOUNT

    bot = new HogeStreaming options, @robot

    bot.on 'message', (userId, userData, message) ->
      user = @robot.brain.userForId userId, userData
      @receive new TextMessage user, message

    bot.listen()

exports.use = (robot) ->
  new Hoge robot

class HogeStreaming extends EventEmitter
  constructor: (options, @robot) ->
    @token         = options.token
    @rooms         = options.rooms.split(",")
    @account       = options.account

  send: (message) ->
    # チャットにメッセージを送信する処理...

  listen: ->
    # チャットから継続的にメッセージを取得する処理
    # メッセージを取得したら...
    # @emit 'message', user, message
Hogeクラス

HogeAdapterを継承しているクラスで、メインの処理を記述しています。 前述のsendrunというメソッドをオーバーライドしています。

send

sendは単にHogeStreaming#sendを呼び出しているだけです。 ここでenvelopeにはユーザ情報やルーム情報が格納されています。

run

runはまず環境変数の読み取りを行っています。 hubotは設定を環境変数で行う設計になっているので、慣習的にアクセストークンや参加するルーム情報などを環境変数から受け取ります。

次にHogeStreamingの初期化とチャットサービスメッセージ受け取りを行っています。 メッセージを受け取ったら@robot.brain.userForIdでユーザインスタンスを作り、ユーザインスタンスと取得したメッセージからTextMessageを作成し、それを@receiveに渡しています。 @receiveにデータを渡すことで、各種hubot scriptが発火する仕組みになっています。

TextMessage以外にもいくつかメッセージの種類が定義されているので、使い分けると良さそうです。

HogeStreamingクラス

HogeStreamingはhubotが呼ぶところのbotにあたるもので、チャットのAPIを叩くなどをしてメッセージの取得や送信を行うクラスです。

メッセージの取得時にはユーザ情報を付与してHogeに渡しています。

Hoge adapterの利用

実際に利用する際には、hubot-hogeという名前のnpmパッケージをnpm installするか、src/robot.coffeeHUBOT_DEFAULT_ADAPTERS'hoge'を追加し、以下のコマンドでhubotを立ち上げます。

bin/hubot -a hoge

その他参考になるコード

shell & campfire

どちらも公式のadapterです。基本的な流れをつかむには一番わかりやすいと思います。

twitter

hubot-twitterは一番参考にされているadapterだと思います。 基本的な実装以外でも、OAuthやストリーミングAPIを利用するときに参考になりそうです。

その他

yammerはcometを、hipchatはXMPP(jabbar)を、idobataはpusherを使っているため、それぞれ似たようなサービスの実装時に参考になると思います。

まとめ

書く気力が尽きました…。

実装の雰囲気が伝わったかどうかわかりませんが、hubotならこんな感じで簡単にbotが作れます。 ぜひお試しください!

google spreadsheetの変更をhubotに通知する

スプレッドシートの値が変更されたら、その変更された内容をhubotに渡して投稿する仕組みを考えてみます。

hubot側

hubot scriptでは簡単にhttpリクエストを処理できるので、それを使って通知を受け取るようにします。

google docsやspreadsheet用のscriptはさすがになさそうだったので、以下のscriptを参考に書いてみました。

# Dependencies:
#   "url": ""
#   "querystring": ""
#
# Commands:
#   None
#
# URLS:
#   POST /hubot/google-spreadsheet?room=<room>&name=<name>&value=<value>
#
url = require 'url'
querystring = require 'querystring'

module.exports = (robot) ->
  robot.router.post "/hubot/google-spreadsheet", (req, res) ->
    query = querystring.parse (url.parse req.url).query
    res.end()

    return unless query.room

    user =
      room: query.room

    name = query.name or "未設定"
    value = query.value or "未設定"

    try
      message = "【#{name}】の値が#{value}に変更されました。"
      robot.send user, message
      console.log message
    catch error
      console.log "google spreadsheet notifier error: #{error}. Request: #{req.body}"

google spreadsheet側

まず、変更を検知したいspeadsheetを開き、メニューの「ツール > スクリプト エディタ」を選択します。

変更を処理するスクリプトgoogle apps script(GAS)というjs拡張言語で記述していきます。

以下の実装ではC列が変更されたとき、F列とC列の値をそれぞれnamevalueとしてhubotに送っています。

このスクリプトを使うときにはroomの値やhubotのホスト名を適切な値に変更してください。

function postToHubot(event) {
  if (!event) {
    return;
  }
  
  var range = event.source.getActiveRange();
  var col = range.getColumn();
  var row = range.getRow();

  // C列が変更されたとき
  if (col == 3.0) {
    try {
      var ss = SpreadsheetApp.getActiveSpreadsheet();
      var sheet = ss.getSheets()[0];
      var room = 1234;
      var value = event.value;
      var name = sheet.getRange(row, 6.0).getValue();
      var params = '?room=' + room + '&name=' + name + '&value=' + value;

      var res = UrlFetchApp.fetch(
        'http://hubot.example.com:8080/hubot/google-spreadsheet' + params, {
        'method': 'POST'
      });
    } catch(err) {
      Logger.log(err);
    }
  }
}

後はスプレッドシートの値が変更されたときにスクリプトの関数が実行されるようにスクリプトエディタのメニューで「リソース > 現在のプロジェクトのトリガー」を選択し以下のように設定します。

これで編集するたびにこの関数が呼ばれるようになります。

ハマったところ

gasを使うのは初めてだったのですが、以下の2点でハマりました。

onEdit()のパーミッション

最初はonEdit()内に通知の処理を書いていたのですが、UrlFetchApp#fetchで例外が出て動きませんでした。

原因はonEdit()のパーミッションで、自作関数をトリガーに登録する場合はユーザ権限で実行になるため問題ないのですが、onEdit()はシステムの権限で実行されるようで例外が出ていました。

例外

そもそも最初はこの例外が出ていることに気がつかず(例外が出てもログになにも出ないため)、気づくのに時間がかかりました…。

感想とか

やっぱりhubot scriptは簡単に書けて良いですね。

gasはちょっと書くのがめんどくさいですが(coffeescriptじゃないしドキュメントもわかりにくい)、spreadsheet以外でも色々できそうなので通知書くと便利な場面は多そうだなと思いました。

mocha+chai+blanketを使ってcoffeescriptでBDD

最近空いた時間にhubotのadapterを開発してるのですが、coffeescriptでテストを書きたくなったときにちょっと試行錯誤したので備忘録として残しておきます。

目標

mochaとchaiでBDD

セットアップ

mochaはjs用のテストフレームワークで、C/Sのどちらでも動作します。他のアサーションライブラリと組み合わせて使う必要があるため、今回はBDDアサーションライブラリであるchaiを使います。

まずpackage.jsonに以下を追記します。

{
  "scripts": {
    "test": "mocha test"
  }
}

次に必要なmoduleをインストールします。

npm install coffee-script -g
npm install chai --save-dev
npm install mocha --save-dev

次にtest/mocha.optsというファイルを作ります。 これはmochaに渡すコマンドライン引数をまとめたものです。 直接引数として渡しても問題ないのですが、長くなりがちなのでこっちの方がのちのち便利かな思います。

--compilers coffee:coffee-script

テストコード

chaiにはTDDスタイルのassertを使う方法と、BDDスタイルのshould/expectを使う方法があります。 個人的にはshouldスタイルが好きなので以下はshoudスタイルの例。

# test/sample.test.coffee
should = (require 'chai').should()

describe 'String', ->
  describe '#concat()', ->
    it 'should return "John Doe"', ->
      # assert values
      'foo'.concat('bar').should.equal 'foobar'

  describe '#split()', ->
    it 'should return [1,2,3]', ->
      # assert arrays and objects
      'foo,bar,baz'.split(',').should.deep.equal ['foo', 'bar', 'baz']

describe 'Tweet', ->
  # async test
  it 'should post', (done) ->
    tweet = new Tweet 'Hello!'
    tweet.post 'Hello!', (err, res) ->
      done()

その他いろいろな使い方はAPIリファレンスを参照。

sinonと組み合わせて使うのも簡単です。

ちなみに上に書いたコードの中でdescribeitといった枠組みを提供しているのがmochaです。beforeafterなどもあります。

テストの実行

npm test

blanketを使ったカバレッジ測定

もともとcoverallsでカバレッジ測定したかったので、以下の要件でライブラリを探してました。

そこで試したのがibrik(coffeescript用istanbul)とblanketだったのですが、ibrikの方はパーサーがエラーで動かず。 結局blanketを使うことにしました。

セットアップ

package.jsonに以下を追記します。

{
  "config": {
    "blanket": {
      "pattern": "src",
      "loader": "./node-loaders/coffee-script",
      "data-cover-never": "node_modules"
    }
  }
}

上記のpatternにカバレッジ計測対象のファイルにマッチするパターンを書きます。 このpatternなんですが、上の例だとフルパスにsrcが含まれてる場合(/Users/akiomi/src/hoge-project/など)はプロジェクト直下のファイルがすべて含まれてしまったので"pattern": "hoge-project/src"とするなどちょっと工夫が必要です。

次はblanketのインストール。

npm install blanket@">=1.1.5" --save-dev

blanketの古いバージョン(1.1.4以前)ではpackage.jsonのscriptsの中に設定を書かなければいけなかったのですが、新しいnpmを使っているとpublish時にscriptsに文字列以外が含まれているエラーとなってしまいます。 blanketの1.1.5からはconfigに設定を書くことが推奨となりこの問題が解決されたので、npm publishを行う場合には1.1.5以上を使用した方がよさそうです。 また原因がわからないのですが、travis-ciでビルドする場合には1.1.5を指定してもうまく動かなかったため、1.1.6が出るまではgitのコミットハッシュを指定するなどして対応が必要です。

そしてmochaからblanketを呼び出すために以下をtest/mocha.optsに追記。

--require blanket

ここで、カバレッジ測定しやすくするためにMakefileを作ります。 本当はgruntを使いたかったんですが、travis-ciでgrunt-mocha-covを使って動かすことができず断念…(ローカルの実行ではうまくいくんですが)。

MOCHA = ./node_modules/.bin/mocha
.PHONY: test

test:
    $(MOCHA) test

test-coverage:
    $(MOCHA) -R html-cov test > coverage.html

カバレッジ計測

以下のコマンドでテストとカバレッジ測定が出来ます。

make test # テスト
make test-coverage # カバレッジ計測

あとpackage.jsonの方のscripts.testも変更しておきます。

{
  "scripts": {
    "test": "make test"
  }
}

travis-ciとcoveralls

githubリポジトリを公開している場合はtravis-ciによるビルドとcoverallsによるカバレッジ計測が簡単にできます。 travis-ciとcoverallsの設定は省略。

まずcoverallsでのカバレッジ計測用のmoduleをインストール。

npm install mocha-lcov-reporter --save-dev
npm install coveralls --save-dev

次にMakefiletravis-ci用のビルドタスクを追加。

test-coveralls:
    mocha test --reporter mocha-lcov-reporter | coveralls

あとはプロジェクトルートに.travis.ymlを作るだけ。

language: node_js
node_js:
  - "0.10"
script: make test-coveralls

これでリポジトリにpushした段階で勝手にtravis-ciがビルドしてくれて、ビルド結果をcoverallsに連携してくれます。キーやトークンの設定とかも特に必要ありません。

感想とか

今回試したいろいろなライブラリは成熟してないものが多くてハマりポイントが多かったです。 全体的にまだいいやり方があると思うので、もうちょっと試行錯誤してみようと思います。

参考になるかはわかりませんが、以下のリポジトリで実践しています。