これは 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 もそうです。
アダプター一覧
どんなアダプターがあるのでしょうか。アダプターの一覧はドキュメントに記載されています。
- Flowdock
- HipChat
- IRC
- Partychat
- Talker
- Twilio
- XMPP
- Gtalk
- Yammer
- Skype
- Jabbr
- iMessage
- Hall
- ChatWork
- AIM
- Slack
- Lingr
- Gitter
- Proxy
- Visual Studio Online
- Typetalk
こんな感じで色々あります。 Twitter やら Twilio なんてのもありますね。「それはチャットなのか?」というツッコミは置いておきましょう。
もちろん、記載されていないアダプターもたくさんあると思います。たとえば、ぼくのアダプター bouzuya/hubot-b は記載されていません (そもそも b というチャットは存在しません) 。
アダプターは簡単につくれるのか?
答えは「チャット次第」です。 API がきちんと提供されているなら簡単につくれると思います。
たとえば hubot-lingr はチャットから BOT へのメッセージの受け渡しは、URL を指定しておき、それを呼び出してもらう形です。以前も紹介したとおり Hubot は HTTP の待ち受けも標準で備えているので、これくらいなら対応できますね。
たまに API が足りていなくて、まともにメッセージを取れないなどがあると、簡単に、というかそもそもつくるのが難しい場合もあります。hubot-skype なども動作環境がすこし厳しくなったりしていたはずです (Skype をインストールしないといけなかったはず……)
Shell アダプターを読んでみよう
今回は日頃お世話になっている Shell アダプターのコードリーディングをしてみましょう。
アダプターの初期化処理
さっそくですが、ぼくは特別扱いされている Campfire アダプターと Shell アダプターとが嫌いです。
アダプター自体の良し悪しはともかく、これらの特別扱いの仕方が悪く、テストダブルとしてのアダプターを作りづらくなっています。これは最初のアダプターの初期化処理を読めば分かります。
Robot#loadAdapter
以前、Hubot スクリプトの読み込み処理を読んだときにも触れたかもしれませんが、Robot
のコンストラクターで、コンストラクターの第一引数 adapterPath と第二引数 adapter がそのまま Robot#loadAdapter
に渡されて呼び出されます。
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
で、呼び出し先がこう。
# 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'
アダプター扱いで初期化していると。
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
しておくべきプロパティ (関数) です。
exports.use = (robot) ->
new Shell robot
処理はインスタンスをつくっているだけ。Shell アダプターなら new Shell(robot)
して返しているだけ。
Shell のコンストラクター
Shell
の constructor
は定義されておらず、Adapter
を継承しているのでそちらを見る。
class Shell extends Adapter
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()
が呼ばれます。
# Public: Kick off the event loop for the adapter
#
# Returns nothing.
run: ->
@emit "running"
@adapter.run()
で、 Adapter#run()
は言うなればアダプターの本体ですね。
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
のインスタンスを渡します。
{TextMessage} = require '../message'
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
に含まれています。こんな感じ。
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 スクリプトだけを書いていたら、この薄さには驚くと思います。
# 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
を見るしかないです。ドキュメントにはソースコードを見ろって書いてあるので……。
そのドキュメント (前々回に触れましたね、これ) 。
まとめ
Shell アダプターの動きを追いかけながら、アダプターの理解を深めてみました。
たぶん、ぼくの説明だと読みづらいので、自分で読んだ方が早い、ってなると思います。が、それで、それが良いと思います。Hubot のソースコードはここまで読むと、ホントあと少しだけなので、一気に読んでしまうのが吉です。
最後に
あとは実際にアダプターをつくったり、紹介できなかった部分に触れたり、1 日 1 Hubot スクリプトの紹介でもしますかねー。再編成したい気持ちが多々あります。漏れとか順番とか良くなかったなーって。
いや、まだ終わってないんですけどね。