Hubot 標準同梱の Shell アダプターを読んでみよう

これは [Hubot Advent Calendar 2014][hubot-adventar-2014] の 23 日目の記事です。

また、今回は @bouzuya の Hubot 連載の第 15 回です。[目次は、第 1 回の記事にある][hubot-adventar-2014-bouzuya-index]ので、そちらをどうぞ。

# 前回まで、そして今回は

前回は [Hubot スクリプトのテストやその環境を知ろう][hubot-adventar-2014-22] ということで、Hubot のテストについて軽く紹介しました。

今回は Hubot アダプターについての紹介と Shell アダプターのコードリーディングをしましょう。

# Hubot アダプターとは

そういえば Hubot の全体構成を話していなかった気がしますが、第 1 回でも紹介したとおり、 Hubot は特定のチャットに依存しないためにチャット別の「アダプター」を持っています。

例えば Slack なら hubot-slack 、 HipChat なら hubot-hipchat 、IRC なら hubot-irc 、本体同梱の Campfire 向けのものや、普段デバッグに使っている shell もそうです。

# アダプター一覧

どんなアダプターがあるのでしょうか。アダプターの一覧はドキュメントに記載されています。

https://github.com/github/hubot/blob/v2.11.0/docs/adapters.md

* [Flowdock](https://github.com/flowdock/hubot-flowdock)
* [HipChat](https://github.com/hipchat/hubot-hipchat)
* [IRC](https://github.com/nandub/hubot-irc)
* [Partychat](https://github.com/iangreenleaf/hubot-partychat-hooks)
* [Talker](https://github.com/unixcharles/hubot-talker)
* [Twilio](https://github.com/jkarmel/hubot-twilio)
* [Twitter](https://github.com/MathildeLemee/hubot-twitter)
* [XMPP](https://github.com/markstory/hubot-xmpp)
* [Gtalk](https://github.com/atmos/hubot-gtalk)
* [Yammer](https://github.com/athieriot/hubot-yammer)
* [Skype](https://github.com/netpro2k/hubot-skype)
* [Jabbr](https://github.com/smoak/hubot-jabbr)
* [iMessage](https://github.com/lazerwalker/hubot-imessage)
* [Hall](https://github.com/Hall/hubot-hall)
* [ChatWork](https://github.com/akiomik/hubot-chatwork)
* [QQ](https://github.com/xhan/qqbot)
* [AIM](https://github.com/shaundubuque/hubot-aim)
* [Slack](https://github.com/tinyspeck/hubot-slack)
* [Lingr](https://github.com/miyagawa/hubot-lingr)
* [Gitter](https://github.com/huafu/hubot-gitter2)
* [Proxy](https://github.com/Hammertime38/hubot-proxy)
* [Visual Studio Online](https://github.com/scrumdod/hubot-VSOnline)
* [Typetalk](https://github.com/nulab/hubot-typetalk)

こんな感じで色々あります。 Twitter やら Twilio なんてのもありますね。「それはチャットなのか？」というツッコミは置いておきましょう。

もちろん、記載されていないアダプターもたくさんあると思います。たとえば、ぼくのアダプター [bouzuya/hubot-b](https://github.com/bouzuya/hubot-b) は記載されていません (そもそも b というチャットは存在しません) 。

# アダプターは簡単につくれるのか？

答えは「チャット次第」です。 API がきちんと提供されているなら簡単につくれると思います。

たとえば hubot-lingr はチャットから BOT へのメッセージの受け渡しは、URL を指定しておき、それを呼び出してもらう形です。以前も紹介したとおり Hubot は HTTP の待ち受けも標準で備えているので、これくらいなら対応できますね。

たまに API が足りていなくて、まともにメッセージを取れないなどがあると、簡単に、というかそもそもつくるのが難しい場合もあります。hubot-skype なども動作環境がすこし厳しくなったりしていたはずです (Skype をインストールしないといけなかったはず……)

# Shell アダプターを読んでみよう

今回は日頃お世話になっている Shell アダプターのコードリーディングをしてみましょう。

## アダプターの初期化処理

さっそくですが、ぼくは特別扱いされている Campfire アダプターと Shell アダプターとが嫌いです。

アダプター自体の良し悪しはともかく、これらの特別扱いの仕方が悪く、テストダブルとしてのアダプターを作りづらくなっています。これは最初のアダプターの初期化処理を読めば分かります。

## `Robot#loadAdapter`

https://github.com/github/hubot/blob/v2.11.0/src/robot.coffee#L31-L67

以前、Hubot スクリプトの読み込み処理を読んだときにも触れたかもしれませんが、`Robot` のコンストラクターで、コンストラクターの第一引数 adapterPath と第二引数 adapter がそのまま `Robot#loadAdapter` に渡されて呼び出されます。

```coffeescript:src/robot.coffee
class Robot
  # Robots receive messages from a chat source (Campfire, irc, etc), and
  # dispatch them to matching listeners.
  #
  # adapterPath - A String of the path to local adapters.
  # adapter - A String of the adapter name.
  # httpd - A Boolean whether to enable the HTTP daemon.
  # name - A String of the robot name, defaults to Hubot.
  #
  # Returns nothing.
  constructor: (adapterPath, adapter, httpd, name = 'Hubot') ->

  ...

    @loadAdapter adapterPath, adapter
```

で、呼び出し先がこう。

https://github.com/github/hubot/blob/v2.11.0/src/robot.coffee#L320-L338

```coffeescript:src/robot.coffee
  # Load the adapter Hubot is going to use.
  #
  # path    - A String of the path to adapter if local.
  # adapter - A String of the adapter name to use.
  #
  # Returns nothing.
  loadAdapter: (path, adapter) ->
    @logger.debug "Loading adapter #{adapter}"

    try
      path = if adapter in HUBOT_DEFAULT_ADAPTERS
        "#{path}/#{adapter}"
      else
        "hubot-#{adapter}"

      @adapter = require(path).use @
    catch err
      @logger.error "Cannot load adapter #{adapter} - #{err}"
      process.exit(1)
```

何がおかしいって、`HUBOT_DEFAULT_ADAPTERS` に含まれていてはじめて `path` が使われているってのが笑える。`HUBOT_DEFAULT_ADAPTERS` って名前どおりだけど、ハードコーディングされた定数ですからね。

```coffeescript
HUBOT_DEFAULT_ADAPTERS = [
  'campfire'
  'shell'
]
```

で、それをそのまま `require("hubot-#{adapter}")` 。これ結構ハードな仕組みなんです。だから、[bouzuya/hubot-script-boilerplate](https://github.com/bouzuya/hubot-script-boilerplate/) ではモックアダプターのファイル名が `shell.coffee` だし、 `'shell'` アダプター扱いで初期化していると。

https://github.com/bouzuya/hubot-script-boilerplate/blob/master/test/scripts/hello.coffee#L7-L16

```coffeescript
  beforeEach (done) ->
    @sinon = sinon.sandbox.create()
    # for warning: possible EventEmitter memory leak detected.
    # process.on 'uncaughtException'
    @sinon.stub process, 'on', -> null
    @robot = new Robot(path.resolve(__dirname, '..'), 'shell', false, 'hubot')
    @robot.adapter.on 'connected', =>
      @robot.load path.resolve(__dirname, '../../src/scripts')
      done()
    @robot.run()
```

このへんは昨日 `generator-hubot` の生成したものに合わせて書いたみたいな意味のないテストを書いてる人には分からないかもしれないけど、普通にテストを書きたい人は余裕でハマります。

怒りを抑えて解説すると、`@adapter = require( ... ).use(@)` (`@` は Robot、) 相当のコードが動くわけです。

## アダプターの `use`

次はアダプターの `use` ですね。`use` はアダプターのモジュールが `exports` しておくべきプロパティ (関数) です。

https://github.com/github/hubot/blob/v2.11.0/src/adapters/shell.coffee#L65-L66

```coffeescript:src/adapters/shell.coffee
exports.use = (robot) ->
  new Shell robot
```

処理はインスタンスをつくっているだけ。Shell アダプターなら `new Shell(robot)` して返しているだけ。

## Shell のコンストラクター

`Shell` の `constructor` は定義されておらず、`Adapter` を継承しているのでそちらを見る。

https://github.com/github/hubot/blob/v2.11.0/src/adapters/shell.coffee#L13

```coffeescript:src/adapters/shell.coffee
class Shell extends Adapter
```

https://github.com/github/hubot/blob/v2.11.0/src/adapter.coffee#L3-L7

```coffeescript:src/adapter.coffee
class Adapter extends EventEmitter
  # An adapter is a specific interface to a chat source for robots.
  #
  # robot - A Robot instance.
  constructor: (@robot) ->
```

アダプター自身の `@robot` に渡された `robot` を代入しているだけ。

これで終わりなので、アダプターは `new Robot( ... )` した時点では何も動きません。実際に動くのは `run()` のタイミングです。

## `Shell#run()`

`Robot#run()` すると `Adapter#run()` が呼ばれます。

https://github.com/github/hubot/blob/v2.11.0/src/robot.coffee#L438-L443

```coffeescript:src/robot.coffee
  # Public: Kick off the event loop for the adapter
  #
  # Returns nothing.
  run: ->
    @emit "running"
    @adapter.run()
```

で、 `Adapter#run()` は言うなればアダプターの本体ですね。

https://github.com/github/hubot/blob/v2.11.0/src/adapters/shell.coffee#L28-L62

```coffeescript:src/adapters/shell.coffee
  run: ->
    stdin = process.openStdin()
    stdout = process.stdout

    @repl = null
    Readline.createInterface
      path: ".hubot_history",
      input: stdin,
      output: stdout,
      maxLength: historySize, # number of entries
      next: (rl) =>
        @repl = rl
        @repl.on 'close', =>
          stdin.destroy()
          @robot.shutdown()
          process.exit 0

        @repl.on 'line', (buffer) =>

          switch buffer.toLowerCase()
            when "exit"
              @repl.close()
            when "history"
              stdout.write "#{line}\n" for line in @repl.history
            else
              user_id = parseInt(process.env.HUBOT_SHELL_USER_ID or '1')
              user_name = process.env.HUBOT_SHELL_USER_NAME or 'Shell'
              user = @robot.brain.userForId user_id, name: user_name, room: 'Shell'
              @receive new TextMessage user, buffer, 'messageId'
          @repl.prompt(true)

        @emit 'connected'

        @repl.setPrompt "#{@robot.name}> "
        @repl.prompt(true)
```

Shell アダプター固有処理が並んでいます。Readline を使って、その終了に Hubot の終了処理を付けたり、行入力完了時に `@receive` を呼び出したりしています。

`Adapter` の仕様的には、準備ができたら `'connected'` イベントを発生させることだけが決まっています。イベント名からも分かるとおり、一般的なアダプターなら、チャットシステムとの「接続」を行って、それを完了したら、通知する、と。

Shell アダプターにおける接続は REPL の起動と、終了や入力に対するイベント設定、というわけです。

Hubot スクリプトの読み込み処理の回でも見たとおり、`robot.adapter.on('connected', -> ... )` をトリガーに Hubot スクリプトの読み込みははじまります。

これでアダプターの読み込みは OK ですね。(いや、`shell` と `campfire` の特別扱いは個人的には全然 OK じゃないですけど) 

# チャット→ Hubot のメッセージ

Shell アダプターの場合、Shell (本来はチャットシステム) から受け取ったメッセージをどう Hubot に伝えるのでしょう。

上記のソースコードでも出てきていますが、`@receive` に `TextMessage` のインスタンスを渡します。

```coffeescript:src/adapters/shell.coffee
{TextMessage} = require '../message'
```

```coffeescript:src/adapters/shell.coffee
              user_id = parseInt(process.env.HUBOT_SHELL_USER_ID or '1')
              user_name = process.env.HUBOT_SHELL_USER_NAME or 'Shell'
              user = @robot.brain.userForId user_id, name: user_name, room: 'Shell'
              @receive new TextMessage user, buffer, 'messageId'
```

`TextMessage` は `src/message.coffee` に含まれています。こんな感じ。

https://github.com/github/hubot/blob/v2.11.0/src/message.coffee#L14-L35

```coffeescript:src/message.coffee
class TextMessage extends Message
  # Represents an incoming message from the chat.
  #
  # user - A User instance that sent the message.
  # text - A String message.
  # id   - A String of the message ID.
  constructor: (@user, @text, @id) ->
    super @user

  # Determines if the message matches the given regex.
  #
  # regex - A Regex to check.
  #
  # Returns a Match object or null.
  match: (regex) ->
    @text.match regex
  
  # String representation of a TextMessage
  #
  # Returns the message text
  toString: () ->
    @text
```

ここから追いかけていくと `Robot#listeners` のループなどに発展していくのですが、真面目においかけるとキリがないので、今回は割愛します。

`message.coffee` には何種類かのメッセージがあります。それは `robot.respond` や `robot.hear` で受け取る `TextMessage` 以外にも `robot.enter` や `robot.leave` や `robot.topic` で使う `EnterMessage` / `LeaveMessage` / `TopicMessage` などがあります。

実はこの連載では `respond` と `hear` しか取り上げていないので、紹介しても微妙な感じはしています。

# Hubot →チャットのメッセージ

今度は逆方向のメッセージを考えます。Hubot からチャットへとメッセージを返す場合です。具体的には `msg.send` などの場合ですね。

## `Response#send`

たぶん、Hubot スクリプトだけを書いていたら、この薄さには驚くと思います。

https://github.com/github/hubot/blob/v2.11.0/src/response.coffee#L15-L22

```coffeescript:src/response.coffee
  # Public: Posts a message back to the chat source
  #
  # strings - One or more strings to be posted. The order of these strings
  #           should be kept intact.
  #
  # Returns nothing.
  send: (strings...) ->
    @robot.adapter.send @envelope, strings...
```

渡しているだけ……　ちなみに `envelope` の設定は `Response` のコンストラクタを見ると良いです (割愛) 。

要するに `Adapter#send` におまかせってことです。

`send` 以外に何を生やすべきかは `adapter.coffee` を見るしかないです。ドキュメントにはソースコードを見ろって書いてあるので……。

https://github.com/github/hubot/blob/v2.11.0/src/adapter.coffee

そのドキュメント (前々回に触れましたね、これ) 。

https://github.com/github/hubot/blob/v2.11.0/docs/adapters.md#writing-your-own-adapter

# まとめ

Shell アダプターの動きを追いかけながら、アダプターの理解を深めてみました。

たぶん、ぼくの説明だと読みづらいので、自分で読んだ方が早い、ってなると思います。が、それで、それが良いと思います。Hubot のソースコードはここまで読むと、ホントあと少しだけなので、一気に読んでしまうのが吉です。

# 最後に

あとは実際にアダプターをつくったり、紹介できなかった部分に触れたり、1 日 1 Hubot スクリプトの紹介でもしますかねー。再編成したい気持ちが多々あります。漏れとか順番とか良くなかったなーって。

いや、まだ終わってないんですけどね。

[getting-started-with-hubot]: https://github.com/github/hubot/tree/v2.9.3/docs#getting-started-with-hubot
[bouzuya/hubot-hello-world]: https://github.com/bouzuya/hubot-hello-world/
[bouzuya/hubot-choice]: https://github.com/bouzuya/hubot-choice/
[bouzuya/hubot-adventar]: https://github.com/bouzuya/hubot-adventar/
[bouzuya/hubot-homepage]: https://github.com/bouzuya/hubot-homepage/
[bouzuya/hubot-script-gist]: https://github.com/bouzuya/hubot-script-gist/
[bouzuya/hubot-brainfxxk]: https://github.com/bouzuya/hubot-brainfxxk/
[bouzuya/hubot-7days-later]: https://github.com/bouzuya/hubot-7days-later/
[bouzuya/hubot-file-brain]: https://github.com/bouzuya/hubot-file-brain/
[hubot-scripts/hubot-redis-brain]: https://github.com/hubot-scripts/hubot-redis-brain
[hubot-script-per-day-61]: http://bouzuya.hatenablog.com/entry/2014/09/12/224215
[github/hubot]: https://github.com/github/hubot/
[github/generator-hubot]: https://github.com/github/generator-hubot/
[ops-for-everyone]: https://speakerdeck.com/johndbritton/ops-for-everyone
[adventar]: http://www.adventar.org/
[express-reference]: http://expressjs.com/3x/api.html
[hubot.github.com]: https://hubot.github.com/
[hubot-adventar-2014]: http://www.adventar.org/calendars/384
[hubot-adventar-2014-bouzuya-index]: http://qiita.com/bouzuya/items/c7d0ad80c357aab6b696
[hubot-adventar-2014-1]: http://qiita.com/bouzuya/items/c7d0ad80c357aab6b696
[hubot-adventar-2014-2]: http://qiita.com/bouzuya/items/11c0c6da2b3ad54b827f
[hubot-adventar-2014-3]: http://qiita.com/bouzuya/items/2a200c9e8a45e2478bc2
[hubot-adventar-2014-4]: http://qiita.com/bouzuya/items/4c0206d72ff22ade9339
[hubot-adventar-2014-5]: http://pasero.net/~mako/blog/s/685
[hubot-adventar-2014-6]: http://qiita.com/bouzuya/items/4e051f68f8d68a2944b0
[hubot-adventar-2014-7]: http://qiita.com/bouzuya/items/2e935c99647b93d8ee1d
[hubot-adventar-2014-8]: http://udzura.hatenablog.jp/entry/2014/12/08/182744
[hubot-adventar-2014-9]: http://qiita.com/bouzuya/items/587c4e920151342e6f1d
[hubot-adventar-2014-10]: http://qiita.com/bouzuya/items/8e8ee8e2e8f83513ad35
[hubot-adventar-2014-11]: http://qiita.com/bouzuya/items/3e239bc530e7c2c3a26c
[hubot-adventar-2014-12]: http://qiita.com/hiconyan/items/3e5481a61657890624dc
[hubot-adventar-2014-13]: http://pasero.net/~mako/blog/s/689
[hubot-adventar-2014-16]: http://qiita.com/bouzuya/items/d65a394cac9e76b56d3d
[hubot-adventar-2014-17]: http://qiita.com/hiconyan/items/baf6ac56129a26d8ac0c
[hubot-adventar-2014-18]: http://qiita.com/bouzuya/items/d5fd0ef9a8cb100933f4
[hubot-adventar-2014-19]: http://qiita.com/bouzuya/items/ae1cb5610a64794bc522
[hubot-adventar-2014-20]: http://bochibochibocchi0095.hatenablog.com/entry/2014/12/21/000710
[hubot-adventar-2014-21]: http://qiita.com/bouzuya/items/ca69548c813e87c2845a
[hubot-adventar-2014-22]: 
[hubot-adventar-2014-23]: 
[hubot-adventar-2014-24]: 
[hubot-adventar-2014-25]: