hubot-ircではmsg.replyのリプライ先が変わるので注意

hubot-ircを使い、こういうコードで一定時間後に後からユーザに通知しようとしてたところ、
replyしてるのに発言元とは別のチャットに送信してしまうという問題が起きました。

robot.respond /進捗 start/i, (msg) ->
  setTimeout(->
    msg.reply "進捗どうですか?"
    return
  , 30 * 60 * 1000)

  msg.send "進捗 start"

何度か意図的に起こしてみたところ、どうやらmsg.replyの送信先は、
そのユーザがreplyする時に最後に発言したチャットに対して行われるらしく、
メッセージが作られた後に別のチャットに発言した場合、そちらに送られてしまうようです。

そのため、以下のように送信先を待避することで回避できます。

robot.respond /進捗 start/i, (msg) ->
  user = msg.message.user.name
  room = msg.message.user.room

  setTimeout(->
    robot.send {room: room}, "#{user} 進捗どうですか?"
    return
  , 30 * 60 * 1000)

  msg.send "進捗 start"

原因となるコードを捜す

というのは振る舞いから推測したものなので、実際にコードを追ってみます。

hubot-irc.reply

まずはhubot-ircのmsg.replyの中を見ます。
https://github.com/nandub/hubot-irc/blob/master/src/irc.coffee#L78

reply: (envelope, strings...) ->
  for str in strings
    @send envelope.user, "#{envelope.user.name}: #{str}"

メッセージをユーザ宛に変換し、sendメソッドにenvelope.userと一緒に渡しています。

hubot-irc.send

次にsendメソッドの中身を見ます。
https://github.com/nandub/hubot-irc/blob/522c50166f15e57ada0c10f181d4de26b4349717/src/irc.coffee#L16

send: (envelope, strings...) ->
  # Use @notice if SEND_NOTICE_MODE is set
  return @notice envelope, strings if process.env.HUBOT_IRC_SEND_NOTICE_MODE?

  target = @_getTargetFromEnvelope envelope

  unless target
    return logger.error "ERROR: Not sure who to send to. envelope=", envelope

  for str in strings
    @bot.say target, str

envelope(replyメソッドのenvelope.user)から_getTargetFromEnvelopeでターゲットを取り出し、
@bot.sayで発言をしています。

このとき、@botはnodeのircパッケージのオブジェクトで、
sayメソッドはtargetに対してメッセージを送るようになっています。
そのため、このtargetに設定される返信先が変更される可能性が高そうです。

hubt-irc._getTargetFromEnvelope

https://github.com/nandub/hubot-irc/blob/master/src/irc.coffee#L336

_getTargetFromEnvelope: (envelope) ->
  user = null
  room = null
  target = null

  # as of hubot 2.4.2, the first param to send() is an object with 'user'
  # and 'room' data inside. detect the old style here.
  if envelope.reply_to
    user = envelope
  else
    # expand envelope
    user = envelope.user
    room = envelope.room

  if user
    # most common case - we're replying to a user in a room
    if user.room
      target = user.room
    # reply directly
    else if user.name
      target = user.name
    # replying to pm
    else if user.reply_to
      target = user.reply_to
    # allows user to be an id string
    else if user.search?(/@/) != -1
      target = user
  else if room
    # this will happen if someone uses robot.messageRoom(jid, ...)
    target = room

  target

ユーザの情報を使ってリプライ先を決定しているようです。
この中で、envelope.roomが存在すれば、それをtargetとして設定するようになっています。
なお、このメソッド内のenvelopeは、replyメソッドのenvelope.userを差しています。

そのため、次はreplyメソッドのenvelope.user.roomが書き換わるかどうかを調べていきます。

replyメソッドの呼ばれ方

コールスタックをさかのぼっていくと、以下のメソッドが順に呼ばれていました。
https://github.com/github/hubot/blob/39681ea35de6375154748418e11f533ef51c3340/src/listener.coffee#L22
https://github.com/github/hubot/blob/39681ea35de6375154748418e11f533ef51c3340/src/robot.coffee#L192
https://github.com/github/hubot/blob/39681ea35de6375154748418e11f533ef51c3340/src/adapter.coffee#L65
https://github.com/nandub/hubot-irc/blob/522c50166f15e57ada0c10f181d4de26b4349717/src/irc.coffee#L237

この中で、最後のhubt-ircの以下の部分で、nodeのircがメッセージを受信した場合に、
メッセージから特定されたuserオブジェクトが、replyメソッドのenvelope.userになっていました。

bot.addListener 'message', (from, to, message) ->
  if options.nick.toLowerCase() == to.toLowerCase()
    # this is a private message, let the 'pm' listener handle it
    return

  if from in options.ignoreUsers
    logger.info('Ignoring user: %s', from)
    # we'll ignore this message if it's from someone we want to ignore
    return

  logger.debug "From #{from} to #{to}: #{message}"

  user = self.createUser to, from
  if user.room
    logger.info "#{to} <#{from}> #{message}"
  else
    unless message.indexOf(to) == 0
      message = "#{to}: #{message}"
    logger.debug "msg <#{from}> #{message}"

  self.receive new TextMessage(user, message)

userオブジェクトは、受け取ったメッセージからcreateUserを使って求めているようです

hubot-irc.createUser

https://github.com/nandub/hubot-irc/blob/522c50166f15e57ada0c10f181d4de26b4349717/src/irc.coffee#L111

  createUser: (channel, from) ->
    user = @getUserFromId from
    user.name = from

    if channel.match(/^[&#]/)
      user.room = channel
    else
      user.room = null
    user

ここで、user.roomに最新のメッセージが送られたchannelをセットしています。
そのため、getUserFromIdが返すuserオブジェクトがメッセージごとに共通なら、
replyメソッドに渡された後に別のメッセージによって書き換わる可能性があると言えます。

hubot.getUserFromId

getUserFromIdはhubot.getUserFromIdを呼び出しているだけでした。
(HubotのAPIが変わったため、一手間挟んでいる)

https://github.com/github/hubot/blob/39681ea35de6375154748418e11f533ef51c3340/src/brain.coffee#L103

 userForId: (id, options) ->
    user = @data.users[id]
    unless user
      user = new User id, options
      @data.users[id] = user

    if options and options.room and (!user.room or user.room isnt options.room)
      user = new User id, options
      @data.users[id] = user

    user

@data内に保存されているユーザを帰しています。
そのため、各メッセージ毎に共通のuserオブジェクトが返されます。

hubot-irc.createUser内でこのオブジェクトのroomを書き換えているため、
ユーザが発言を行うと、robot.replyの時に使用するuserオブジェクトが変更され、
慈顔をおいて発言しようとすると別のチャットに発言していたようです。