UNPKG

hubot-hipchat-dbeard

Version:
535 lines (473 loc) 19 kB
# 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