Posted at

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

More than 3 years have passed since last update.

これは Hubot Advent Calendar 2014 の 23 日目の記事です。

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


前回まで、そして今回は

前回は Hubot スクリプトのテストやその環境を知ろう ということで、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

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

もちろん、記載されていないアダプターもたくさんあると思います。たとえば、ぼくのアダプター 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 に渡されて呼び出されます。


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


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 って名前どおりだけど、ハードコーディングされた定数ですからね。

HUBOT_DEFAULT_ADAPTERS = [

'campfire'
'shell'
]

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

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

  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


src/adapters/shell.coffee

exports.use = (robot) ->

new Shell robot

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


Shell のコンストラクター

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

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


src/adapters/shell.coffee

class Shell extends Adapter


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


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


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


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 ですね。(いや、shellcampfire の特別扱いは個人的には全然 OK じゃないですけど)


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

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

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


src/adapters/shell.coffee

{TextMessage} = require '../message'



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'

TextMessagesrc/message.coffee に含まれています。こんな感じ。

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


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.respondrobot.hear で受け取る TextMessage 以外にも robot.enterrobot.leaverobot.topic で使う EnterMessage / LeaveMessage / TopicMessage などがあります。

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


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

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


Response#send

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

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


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 スクリプトの紹介でもしますかねー。再編成したい気持ちが多々あります。漏れとか順番とか良くなかったなーって。

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