hubot-hipchat-dbeard
Version:
A HipChat adapter for Hubot
535 lines (473 loc) • 19 kB
text/coffeescript
# Modified from [Wobot](https://github.com/cjoudrey/wobot).
#
# Copyright (C) 2011 by Christian Joudrey
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
# THE SOFTWARE.
{EventEmitter} = require "events"
fs = require "fs"
{bind, isString, isRegExp} = require "underscore"
xmpp = require 'node-xmpp-client'
# Parse and cache the node package.json file when this module is loaded
pkg = do ->
data = fs.readFileSync __dirname + "/../package.json", "utf8"
JSON.parse(data)
# ##Public Connector API
module.exports = class Connector extends EventEmitter
# This is the `Connector` constructor.
#
# `options` object:
#
# - `jid`: Connector's Jabber ID
# - `password`: Connector's HipChat password
# - `host`: Force host to make XMPP connection to. Will look up DNS SRV
# record on JID's host otherwise.
# - `caps_ver`: Name and version of connector. Override if Connector is being used
# to power another connector framework (e.g. Hubot).
# - `logger`: A logger instance.
constructor: (options={}) ->
@once "connect", (->) # listener bug in Node 0.4.2
@setMaxListeners 0
@jabber = null
@keepalive = null
@name = null
@plugins = {}
@iq_count = 1 # current IQ id to use
@logger = options.logger
# add a JID resource if none was provided
jid = new xmpp.JID options.jid
jid.resource = "hubot-hipchat" if not jid.resource
@jid = jid.toString()
@password = options.password
@host = options.host
@caps_ver = options.caps_ver or "hubot-hipchat:#{pkg.version}"
@xmppDomain = options.xmppDomain
@bosh = options.bosh
# Multi-User-Conference (rooms) service host. Use when directing stanzas
# to the MUC service.
@mucDomain = "conf.#{if @xmppDomain then @xmppDomain else 'hipchat.com'}"
@disconnecting = false
@onError @disconnect
# Connects the connector to HipChat and sets the XMPP event listeners.
connect: ->
@jabber = new xmpp.Client
jid: @jid,
password: @password,
host: @host
bosh: @bosh
@jabber.on "error", bind(onStreamError, @)
@jabber.on "online", bind(onOnline, @)
@jabber.on "stanza", bind(onStanza, @)
@jabber.on "offline", bind(onOffline, @)
@jabber.on "close", bind(onClose, @)
# debug network traffic
do =>
@jabber.on "data", (buffer) =>
@logger.debug " IN > %s", buffer.toString()
_send = @jabber.send
@jabber.send = (stanza) =>
@logger.debug " OUT > %s", stanza
_send.call @jabber, stanza
# Disconnect the connector from HipChat, remove the anti-idle and emit the
# `disconnect` event.
disconnect: =>
# since we're going to emit "disconnect" event in the end, we should prevent ourself from handling it here
if ! @disconnecting
@disconnecting = true
@logger.debug 'Disconnecting here'
if @keepalive
clearInterval @keepalive
delete @keepalive
@jabber.end()
@emit "disconnect"
@disconnecting = false
# Fetches our profile info
#
# - `callback`: Function to be triggered: `function (err, data, stanza)`
# - `err`: Error condition (string) if any
# - `data`: Object containing fields returned (fn, title, photo, etc)
# - `stanza`: Full response stanza, an `xmpp.Element`
getProfile: (callback) ->
stanza = new xmpp.Element("iq", type: "get")
.c("vCard", xmlns: "vcard-temp")
@sendIq stanza, (err, res) ->
data = {}
if not err
for field in res.getChild("vCard").children
data[field.name.toLowerCase()] = field.getText()
callback err, data, res
# Fetches the rooms available to the connector user. This is equivalent to what
# would show up in the HipChat lobby.
#
# - `callback`: Function to be triggered: `function (err, items, stanza)`
# - `err`: Error condition (string) if any
# - `rooms`: Array of objects containing room data
# - `stanza`: Full response stanza, an `xmpp.Element`
getRooms: (callback) ->
iq = new xmpp.Element("iq", to: this.mucDomain, type: "get")
.c("query", xmlns: "http://jabber.org/protocol/disco#items");
@sendIq iq, (err, stanza) ->
rooms = if err then [] else
# Parse response into objects
stanza.getChild("query").getChildren("item").map (el) ->
x = el.getChild "x", "http://hipchat.com/protocol/muc#room"
# A room
jid: el.attrs.jid.trim()
name: el.attrs.name
id: getInt(x, "id")
topic: getText(x, "topic")
privacy: getText(x, "privacy")
owner: getText(x, "owner")
guest_url: getText(x, "guest_url")
is_archived: !!getChild(x, "is_archived")
callback err, (rooms or []), stanza
# Fetches the roster (buddy list)
#
# - `callback`: Function to be triggered: `function (err, items, stanza)`
# - `err`: Error condition (string) if any
# - `items`: Array of objects containing user data
# - `stanza`: Full response stanza, an `xmpp.Element`
getRoster: (callback) ->
iq = new xmpp.Element("iq", type: "get")
.c("query", xmlns: "jabber:iq:roster")
@sendIq iq, (err, stanza) ->
items = if err then [] else usersFromStanza(stanza)
callback err, (items or []), stanza
# Updates the connector's availability and status.
#
# - `availability`: Jabber availability codes
# - `away`
# - `chat` (Free for chat)
# - `dnd` (Do not disturb)
# - `status`: Status message to display
setAvailability: (availability, status) ->
packet = new xmpp.Element "presence", type: "available"
packet.c("show").t(availability)
packet.c("status").t(status) if (status)
# Providing capabilities info (XEP-0115) in presence tells HipChat
# what type of client is connecting. The rest of the spec is not actually
# used at this time.
packet.c "c",
xmlns: "http://jabber.org/protocol/caps"
node: "http://hipchat.com/client/bot" # tell HipChat we're a bot
ver: @caps_ver
@jabber.send packet
# Join the specified room.
#
# - `roomJid`: Target room, in the form of `????_????@conf.hipchat.com`
# - `historyStanzas`: Max number of history entries to request
join: (roomJid, historyStanzas) ->
historyStanzas = 0 if not historyStanzas
packet = new xmpp.Element "presence", to: "#{roomJid}/#{@name}"
packet.c "x", xmlns: "http://jabber.org/protocol/muc"
packet.c "history",
xmlns: "http://jabber.org/protocol/muc"
maxstanzas: String(historyStanzas)
@jabber.send packet
# Part the specified room.
#
# - `roomJid`: Target room, in the form of `????_????@conf.hipchat.com`
part: (roomJid) ->
packet = new xmpp.Element 'presence',
type: 'unavailable'
to: "#{roomJid}/#{@name}"
packet.c 'x', xmlns: 'http://jabber.org/protocol/muc'
packet.c('status').t('hc-leave')
@jabber.send packet
# Send a message to a room or a user.
#
# - `targetJid`: Target
# - Message to a room: `????_????@conf.hipchat.com`
# - Private message to a user: `????_????@chat.hipchat.com`
# - `message`: Message to be sent to the room
message: (targetJid, message) ->
parsedJid = new xmpp.JID targetJid
if parsedJid.domain is @mucDomain
packet = new xmpp.Element "message",
to: "#{targetJid}/#{@name}"
type: "groupchat"
else
packet = new xmpp.Element "message",
to: targetJid
type: "chat"
from: @jid
packet.c "inactive", xmlns: "http://jabber/protocol/chatstates"
# we should make sure that the message is properly escaped
# based on http://unix.stackexchange.com/questions/111899/how-to-strip-color-codes-out-of-stdout-and-pipe-to-file-and-stdout
message = message.replace(/\x1B\[([0-9]{1,2}(;[0-9]{1,2})?)?[mGK]/g, "") # remove bash color codes
@logger.debug 'building message'
@logger.debug message
packet.c("body").t(message)
@jabber.send packet
# Send a topic change message to a room
#
# - `targetJid`: Target
# - Message to a room: `????_????@conf.hipchat.com`
# - `message`: Text string that the topic should be set to
topic: (targetJid, message) ->
parsedJid = new xmpp.JID targetJid
packet = new xmpp.Element "message",
to: "#{targetJid}/#{@name}"
type: "groupchat"
packet.c("subject").t(message)
@jabber.send packet
# Sends an IQ stanza and stores a callback to be called when its response
# is received.
#
# - `stanza`: `xmpp.Element` to send
# - `callback`: Function to be triggered: `function (err, stanza)`
# - `err`: Error condition (string) if any
# - `stanza`: Full response stanza, an `xmpp.Element`
sendIq: (stanza, callback) ->
stanza = stanza.root() # work with base element
id = @iq_count++
stanza.attrs.id = id;
@once "iq:#{id}", callback
@jabber.send stanza
loadPlugin: (identifier, plugin, options) ->
if typeof plugin isnt "object"
throw new Error "Plugin argument must be an object"
if typeof plugin.load isnt "function"
throw new Error "Plugin object must have a load function"
@plugins[identifier] = plugin
plugin.load @, options
true
# ##Events API
# Emitted whenever the connector connects to the server.
#
# - `callback`: Function to be triggered: `function ()`
onConnect: (callback) -> @on "connect", callback
# Emitted whenever the connector disconnects from the server.
#
# - `callback`: Function to be triggered: `function ()`
onDisconnect: (callback) -> @on "disconnect", callback
# Emitted whenever the connector is invited to a room.
#
# `onInvite(callback)`
#
# - `callback`: Function to be triggered:
# `function (roomJid, fromJid, reason, matches)`
# - `roomJid`: JID of the room being invited to.
# - `fromJid`: JID of the person who sent the invite.
# - `reason`: Reason for invite (text)
onInvite: (callback) -> @on "invite", callback
# Makes an onMessage impl for the named message event
onMessageFor = (name) ->
(condition, callback) ->
if not callback
callback = condition
condition = null
@on name, ->
message = arguments[arguments.length - 1]
if not condition or message is condition
callback.apply @, arguments
else if isRegExp condition
match = message.match condition
return if not match
args = [].slice.call arguments
args.push match
callback.apply @, args
# Emitted whenever a message is sent to a channel the connector is in.
#
# `onMessage(condition, callback)`
#
# `onMessage(callback)`
#
# - `condition`: String or RegExp the message must match.
# - `callback`: Function to be triggered: `function (roomJid, from, message, matches)`
# - `roomJid`: Jabber Id of the room in which the message occured.
# - `from`: The name of the person who said the message.
# - `message`: The message
# - `matches`: The matches returned by the condition when it is a RegExp
onMessage: onMessageFor "message"
# Emitted whenever a message is sent privately to the connector.
#
# `onPrivateMessage(condition, callback)`
#
# `onPrivateMessage(callback)`
#
# - `condition`: String or RegExp the message must match.
# - `callback`: Function to be triggered: `function (fromJid, message)`
onPrivateMessage: onMessageFor "privateMessage"
# Emitted whenever the connector receives a topic change in a room
#
# - `callback`: Function to be triggered: `function ()`
onTopic: (callback) -> @on "topic", callback
onEnter: (callback) -> @on "enter", callback
onLeave: (callback) -> @on "leave", callback
onRosterChange: (callback) -> @on "rosterChange", callback
# Emitted whenever the connector pings the server (roughly every 30 seconds).
#
# - `callback`: Function to be triggered: `function ()`
onPing: (callback) -> @on "ping", callback
# Emitted whenever an XMPP stream error occurs. The `disconnect` event will
# always be emitted afterwards.
#
# Conditions are defined in the XMPP spec:
# http://xmpp.org/rfcs/rfc6120.html#streams-error-conditions
#
# - `callback`: Function to be triggered: `function(condition, text, stanza)`
# - `condition`: XMPP stream error condition (string)
# - `text`: Human-readable error message (string)
# - `stanza`: The raw `xmpp.Element` error stanza
onError: (callback) -> @on "error", callback
# ##Private functions
# Whenever an XMPP stream error occurs, this function is responsible for
# triggering the `error` event with the details and disconnecting the connector
# from the server.
#
# Stream errors (http://xmpp.org/rfcs/rfc6120.html#streams-error) look like:
# <stream:error>
# <system-shutdown xmlns='urn:ietf:params:xml:ns:xmpp-streams'/>
# </stream:error>
onStreamError = (err) ->
if err instanceof xmpp.Element
condition = err.children[0].name
text = err.getChildText "text"
if not text
text = "No error text sent by HipChat, see
http://xmpp.org/rfcs/rfc6120.html#streams-error-conditions
for error condition descriptions."
@emit "error", condition, text, err
else
@emit "error", null, null, err
# Whenever an XMPP connection is made, this function is responsible for
# triggering the `connect` event and starting the 30s anti-idle. It will
# also set the availability of the connector to `chat`.
onOnline = ->
@setAvailability "chat"
ping = =>
@jabber.send new xmpp.Element('r')
@emit "ping"
@keepalive = setInterval ping, 30000
# Load our profile to get name
@getProfile (err, data) =>
if err
# This isn't technically a stream error which is what the `error`
# event usually represents, but we want to treat a profile fetch
# error as a fatal error and disconnect the connector.
@emit "error", null, "Unable to get profile info: #{err}", null
else
# Now that we have our name we can let rooms be joined
@name = data.fn;
# This is the name used to @mention us
@mention_name = data.nickname
@emit "connect"
# This function is responsible for handling incoming XMPP messages. The
# `data` event will be triggered with the message for custom XMPP
# handling.
#
# The connector will parse the message and trigger the `message`
# event when it is a group chat message or the `privateMessage` event when
# it is a private message.
onStanza = (stanza) ->
@emit "data", stanza
if stanza.is "message"
if stanza.attrs.type is "groupchat"
return if stanza.getChild "delay"
fromJid = new xmpp.JID stanza.attrs.from
fromChannel = fromJid.bare().toString()
fromNick = fromJid.resource
# Ignore our own messages
return if fromNick is @name
# Look for body msg
body = stanza.getChildText "body"
# look for Subject: http://xmpp.org/extensions/xep-0045.html#subject-mod
subject = stanza.getChildText "subject"
if body
# message stanza
@emit "message", fromChannel, fromNick, body
else if subject
# subject stanza
@emit "topic", fromChannel, fromNick, subject
else
# Skip parsing other types and return
return
else if stanza.attrs.type is "chat"
# Message without body is probably a typing notification
body = stanza.getChildText "body"
return if not body
# Ignore chat history
return if stanza.getChild "delay"
fromJid = new xmpp.JID stanza.attrs.from
@emit "privateMessage", fromJid.bare().toString(), body
else if not stanza.attrs.type
x = stanza.getChild "x", "http://jabber.org/protocol/muc#user"
return if not x
invite = x.getChild "invite"
return if not invite
reason = invite.getChildText "reason"
inviteRoom = new xmpp.JID stanza.attrs.from
inviteSender = new xmpp.JID invite.attrs.from
@emit "invite", inviteRoom.bare(), inviteSender.bare(), reason
else if stanza.is "iq"
# Handle a response to an IQ request
event_id = "iq:#{stanza.attrs.id}"
if stanza.attrs.type is "result"
@emit event_id, null, stanza
else if stanza.attrs.type is "set"
# Check for roster push
if stanza.getChild("query").attrs.xmlns is "jabber:iq:roster"
users = usersFromStanza(stanza)
@emit "rosterChange", users, stanza
else
# IQ error response
# ex: http://xmpp.org/rfcs/rfc6121.html#roster-syntax-actions-result
condition = "unknown"
error_elem = stanza.getChild "error"
condition = error_elem.children[0].name if error_elem
@emit event_id, condition, stanza
else if stanza.is "presence"
jid = new xmpp.JID stanza.attrs.from
room = jid.bare().toString()
return if not room
name = stanza.attrs.from.split("/")[1]
# Fall back to empty string if name isn't reported in presence
name ?= ""
type = stanza.attrs.type or "available"
x = stanza.getChild "x", "http://jabber.org/protocol/muc#user"
return if not x
entity = x.getChild "item"
return if not entity
from = entity.attrs?.jid
return if not from
if type is "unavailable"
@emit "leave", from, room, name
else if type is "available" and entity.attrs.role is "participant"
@emit "enter", from, room, name
onOffline = ->
@logger.info 'Connection went offline'
onClose = ->
@logger.info 'Connection was closed'
@disconnect()
usersFromStanza = (stanza) ->
# Parse response into objects
stanza.getChild("query").getChildren("item").map (el) ->
jid: el.attrs.jid
name: el.attrs.name
# Name used to @mention this user
mention_name: el.attrs.mention_name
# Email address
email_address: el.attrs.email
# DOM helpers
getChild = (el, name) ->
el.getChild name
getText = (el, name) ->
getChild(el, name).getText()
getInt = (el, name) ->
parseInt getText(el, name), 10