Repository: hipchat/hubot-hipchat Branch: master Commit: 19199efee358 Files: 9 Total size: 38.1 KB Directory structure: gitextract_dlevzx9j/ ├── .gitignore ├── CHANGELOG.md ├── LICENSE ├── README.md ├── package.json └── src/ ├── connector.coffee ├── hipchat.coffee ├── promises.coffee └── test.coffee ================================================ FILE CONTENTS ================================================ ================================================ FILE: .gitignore ================================================ node_modules .DS_Store* ================================================ FILE: CHANGELOG.md ================================================ # Changelog All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). ## [Unreleased] ## [2.13.0] - 2017-11-21 ### Changed - `message.user` returns an object from the brain. - Changed by [Andrew Widdersheim](https://github.com/awiddersheim) in Pull Request [#277](https://github.com/hipchat/hubot-hipchat/pull/277). - `user.room` always returns a JID. - Changed by [Andrew Widdersheim](https://github.com/awiddersheim) in Pull Request [#277](https://github.com/hipchat/hubot-hipchat/pull/277). ### Removed - `reply_to` from `envelope.user` and `message.user`. - Removed by [Andrew Widdersheim](https://github.com/awiddersheim) in Pull Request [#277](https://github.com/hipchat/hubot-hipchat/pull/277). ### Security - Updated `node-xmpp-client` to 3.2.0. - Updated by [Sam](https://github.com/samcday) in Pull Request [#272](https://github.com/hipchat/hubot-hipchat/pull/272). ================================================ FILE: LICENSE ================================================ Copyright (c) HipChat, Inc. 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. ================================================ FILE: README.md ================================================ # hubot-hipchat ## Quickstart: Hubot for HipChat on Heroku ### The Easy Way Try deploying the "[Triatomic](https://github.com/hipchat/triatomic)" starter HipChat Hubot project to Heroku. Once you have it running, simply clone it and customize its scripts as you please. [![Deploy](https://www.herokucdn.com/deploy/button.png)](https://heroku.com/deploy?template=https://github.com/hipchat/triatomic) ### The "I do it myself!" Way This is a HipChat-specific version of the more general [instructions in the Hubot wiki](https://github.com/github/hubot/wiki/Deploying-Hubot-onto-Heroku). Some of this guide is derived from Hubot's [general set up instructions](https://hubot.github.com/docs). You may wish to see that guide for more information about the general use and configuration of Hubot, in addition to details for deploying it to environments other than Heroku. 1. From your existing HipChat account add your bot as a [new user](http://help.hipchat.com/knowledgebase/articles/64413-how-do-i-add-invite-new-users-). Stay signed in to the account - we'll need to access its account settings later. 1. If you are using Linux, make sure libexpat is installed: % apt-get install libexpat1-dev 1. You will need [node.js](https://nodejs.org). Joyent has an [excellent blog post on how to get those installed](https://www.joyent.com/blog/installing-node-and-npm), so we'll omit those details here. You'll want node.js 0.12.x or later. 1. Once node and npm are ready, we can install the hubot generator: % npm install -g yo generator-hubot 1. This will give us the hubot yeoman generator. Now we can make a new directory, and generate a new instance of hubot in it, using this Hubot HipChat adapter. For example, if we wanted to make a bot called myhubot: % mkdir myhubot % cd myhubot % yo hubot --adapter hipchat 1. At this point, you'll be asked a few questions about the bot you are creating. When you finish answering, yeoman will download and install the necessary dependencies. (If the generator hangs, a workaround is to re-run without the `--adapter hipchat` argument, accept the default `campfire` value when prompted, and then re-run yet again, again with the hipchat adapter argument, accepting the prompts to overwrite existing files. This appears to be an issue with the generator itself.) 1. Turn your `hubot` directory into a git repository: % git init % git add . % git commit -m "Initial commit" 1. Install the [Heroku command line tools](http://devcenter.heroku.com/articles/heroku-command) if you don't have them installed yet. 1. Create a new Heroku application and (optionally) rename it: % heroku create our-company-hubot 1. Add [Redis To Go](http://devcenter.heroku.com/articles/redistogo) to your Heroku app: % heroku addons:create redistogo:nano --app our-company-hubot 1. Configure it: You will need to set a configuration variable if you are hosting on the free Heroku plan. % heroku config:add HEROKU_URL=http://our-company-hubot.herokuapp.com Where the URL is your Heroku app's URL (shown after running `heroku create`, or `heroku rename`). Set the JID to the "Jabber ID" shown on your bot's [XMPP/Jabber account settings](https://www.hipchat.com/account/xmpp): % heroku config:add HUBOT_HIPCHAT_JID="..." Set the password to the password chosen when you created the bot's account. % heroku config:add HUBOT_HIPCHAT_PASSWORD="..." If using HipChat Server Beta, you need to set xmppDomain to btf.hipchat.com. % heroku config:add HUBOT_HIPCHAT_XMPP_DOMAIN="btf.hipchat.com" 1. Deploy and start the bot: % git push heroku master % heroku ps:scale web=1 This will tell Heroku to run 1 of the `web` process type which is described in the `Procfile`. 1. You should see the bot join all rooms it has access to (or are specified in HUBOT\_HIPCHAT\_ROOMS, see below). If not, check the output of `heroku logs`. You can also use `heroku config` to check the config vars and `heroku restart` to restart the bot. `heroku ps` will show you its current process state. 1. Assuming your bot's name is "Hubot", the bot will respond to commands like "@hubot help". It will also respond in 1-1 chat ("@hubot" must be omitted there, so just use "help" for example). 1. To configure the commands the bot responds to, you'll need to edit the `hubot-scripts.json` file ([valid script names here](https://github.com/github/hubot-scripts/tree/master/src/scripts)) or add scripts to the `scripts/` directory. 1. To deploy an updated version of the bot, simply commit your changes and run `git push heroku master` again. Bonus: Add a notification hook to Heroku so a notification is sent to a room whenever the bot is updated: https://marketplace.atlassian.com/plugins/com.heroku.hipchat/cloud/overview ## Scripting Gotchas `robot.messageRoom` syntax is as follows ``` robot.messageRoom("1234_room@conf.hipchat.com", "message"); ``` ## Adapter configuration This adapter uses the following environment variables: ### HUBOT\_HIPCHAT\_JID This is your bot's Jabber ID which can be found in your [XMPP/Jabber account settings](https://www.hipchat.com/account/xmpp). It will look something like `123_456@chat.hipchat.com` ### HUBOT\_HIPCHAT\_PASSWORD This is the password for your bot's HipChat account. ### HUBOT\_HIPCHAT\_ROOMS Optional. This is a comma separated list of room JIDs that you want your bot to join. You can leave this blank or set it to "All" to have your bot join every room. Room JIDs look like "123_development@conf.hipchat.com" and can be found in the [XMPP/Jabber account settings](https://www.hipchat.com/account/xmpp) - just add "@conf.hipchat.com" to the end of the room's "XMPP/Jabber Name". ### HUBOT\_HIPCHAT\_ROOMS\_BLACKLIST Optional. This is a comma separated list of room JIDs that should not be joined. ### HUBOT\_HIPCHAT\_JOIN\_ROOMS\_ON\_INVITE Optional. Setting to `false` will prevent the HipChat adapter from auto-joining rooms when invited. ### HUBOT\_HIPCHAT\_JOIN\_PUBLIC\_ROOMS Optional. Setting to `false` will prevent the HipChat adapter from auto-joining rooms that are publicly available (i.e. guest-accessible). ### HUBOT\_HIPCHAT\_HOST Optional. Use to force the host to open the XMPP connection to. ### HUBOT\_HIPCHAT\_XMPP\_DOMAIN Optional. Set to btf.hipchat.com if using HipChat Server. ### HUBOT\_LOG\_LEVEL Optional. Set to `debug` to enable detailed debug logging. ### HUBOT\_HIPCHAT\_RECONNECT Optional. Seting to `false` will prevent the HipChat adapter from auto-reconnecting if it detects a server error or disconnection. ## Running locally To run locally on OSX or Linux you'll need to set the required environment variables and run the `bin/hubot` script. An example script to run the bot might look like: #!/bin/bash export HUBOT_HIPCHAT_JID="..." export HUBOT_HIPCHAT_PASSWORD="..." bin/hubot --adapter hipchat But be aware that credentials normally shouldn't be checked into your vcs. ================================================ FILE: package.json ================================================ { "name": "hubot-hipchat", "version": "2.13.0", "author": "HipChat ", "description": "A HipChat adapter for Hubot", "keywords": [ "hipchat", "hubot", "xmpp", "chat", "bot" ], "repository": { "type": "git", "url": "http://github.com/hipchat/hubot-hipchat.git" }, "license": "MIT", "main": "./src/hipchat", "engines": { "node": "~0.12.2" }, "dependencies": { "node-xmpp-client": "^3.2.0", "rsvp": "~1.2.0", "underscore": "~1.4.4" } } ================================================ FILE: src/connector.coffee ================================================ # 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: # # # 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 ================================================ FILE: src/hipchat.coffee ================================================ {Adapter, TextMessage, EnterMessage, LeaveMessage, TopicMessage, User} = require "hubot" HTTPS = require "https" {inspect} = require "util" Connector = require "./connector" promise = require "./promises" class HipChat extends Adapter constructor: (robot) -> super robot @logger = robot.logger reconnectTimer = null emote: (envelope, strings...) -> @send envelope, strings.map((str) -> "/me #{str}")... send: (envelope, strings...) -> for str in strings @connector.message envelope.room, str topic: (envelope, message) -> {user, room} = envelope user = envelope if not user # pre-2.4.2 style target_jid = # most common case - we're replying to a user in a room or 1-1 user?.reply_to or # allows user objects to be passed in user?.jid or if user?.search?(/@/) >= 0 user # allows user to be a jid string else room # this will happen if someone uses robot.messageRoom(jid, ...) if not target_jid return @logger.error "ERROR: Not sure who to send to: envelope=#{inspect envelope}" @connector.topic target_jid, message reply: (envelope, strings...) -> user = if envelope.user then envelope.user else envelope @send envelope, "@#{user.mention_name} #{str}" for str in strings waitAndReconnect: -> if !@reconnectTimer delay = Math.round(Math.random() * (20 - 5) + 5) @logger.info "Waiting #{delay}s and then retrying..." @reconnectTimer = setTimeout () => @logger.info "Attempting to reconnect..." delete @reconnectTimer @connector.connect() , delay * 1000 run: -> botjid = process.env.HUBOT_HIPCHAT_JID if not botjid throw new Error("Environment variable HUBOT_HIPCHAT_JID is required to contain your bot's user JID.") botpw = process.env.HUBOT_HIPCHAT_PASSWORD if not botpw throw new Error("Environment variable HUBOT_HIPCHAT_PASSWORD is required to contain your bot's user password.") @options = jid: botjid password: botpw token: process.env.HUBOT_HIPCHAT_TOKEN or null rooms: process.env.HUBOT_HIPCHAT_ROOMS or "All" rooms_blacklist: process.env.HUBOT_HIPCHAT_ROOMS_BLACKLIST or "" rooms_join_public: process.env.HUBOT_HIPCHAT_JOIN_PUBLIC_ROOMS isnt "false" host: process.env.HUBOT_HIPCHAT_HOST or null bosh: { url: process.env.HUBOT_HIPCHAT_BOSH_URL or null } autojoin: process.env.HUBOT_HIPCHAT_JOIN_ROOMS_ON_INVITE isnt "false" xmppDomain: process.env.HUBOT_HIPCHAT_XMPP_DOMAIN or null reconnect: process.env.HUBOT_HIPCHAT_RECONNECT isnt "false" @logger.debug "HipChat adapter options: #{JSON.stringify @options}" # create Connector object connector = new Connector jid: @options.jid password: @options.password host: @options.host logger: @logger xmppDomain: @options.xmppDomain bosh: @options.bosh host = if @options.host then @options.host else "hipchat.com" @logger.info "Connecting HipChat adapter..." init = promise() connector.onTopic (channel, from, message) => @logger.info "Topic change: " + message author = getAuthor: => @robot.brain.userForName(from) or new User(from) author.room = channel @receive new TopicMessage(author, message, 'id') connector.onDisconnect => @logger.info "Disconnected from #{host}" if @options.reconnect @waitAndReconnect() connector.onError => @logger.error [].slice.call(arguments).map(inspect).join(", ") if @options.reconnect @waitAndReconnect() firstTime = true connector.onConnect => @logger.info "Connected to #{host} as @#{connector.mention_name}" # Provide our name to Hubot @robot.name = connector.mention_name # Tell Hubot we're connected so it can load scripts if firstTime @emit "connected" @logger.debug "Sending connected event" saveUsers = (users) => # Save users to brain for user in users user.id = @userIdFromJid user.jid # userForId will not merge to an existing user if user.id of @robot.brain.data.users oldUser = @robot.brain.data.users[user.id] for key, value of oldUser unless key of user user[key] = value delete @robot.brain.data.users[user.id] @robot.brain.userForId user.id, user joinRoom = (jid) => if jid and typeof jid is "object" jid = "#{jid.local}@#{jid.domain}" if jid in @options.rooms_blacklist.split(",") @logger.info "Not joining #{jid} because it is blacklisted" return @logger.info "Joining #{jid}" connector.join jid # Fetch user info connector.getRoster (err, users, stanza) => return init.reject err if err init.resolve users init .done (users) => saveUsers(users) # Join requested rooms if @options.rooms is "All" or @options.rooms is "@All" connector.getRooms (err, rooms, stanza) => if rooms for room in rooms if !@options.rooms_join_public && room.guest_url != '' @logger.info "Not joining #{room.jid} because it is a public room" else joinRoom(room.jid) else @logger.error "Can't list rooms: #{errmsg err}" # Join all rooms else for room_jid in @options.rooms.split "," joinRoom(room_jid) .fail (err) => @logger.error "Can't list users: #{errmsg err}" if err connector.onRosterChange (users) => saveUsers(users) handleMessage = (opts) => # buffer message events until the roster fetch completes # to ensure user data is properly loaded init.done => {getAuthor, message, room} = opts author = getAuthor() or {} author.room = room @receive new TextMessage(author, message) if firstTime connector.onMessage (channel, from, message) => # reformat leading @mention name to be like "name: message" which is # what hubot expects mention_name = connector.mention_name regex = new RegExp "^@#{mention_name}\\b", "i" message = message.replace regex, "#{mention_name}: " handleMessage getAuthor: => @robot.brain.userForName(from) or new User(from) message: message room: channel connector.onPrivateMessage (from, message) => # remove leading @mention name if present and format the message like # "name: message" which is what hubot expects mention_name = connector.mention_name regex = new RegExp "^@?#{mention_name}\\b", "i" message = "#{mention_name}: #{message.replace regex, ""}" handleMessage getAuthor: => @robot.brain.userForId(@userIdFromJid from) message: message room: from changePresence = (PresenceMessage, user_jid, room_jid, currentName) => # buffer presence events until the roster fetch completes # to ensure user data is properly loaded init.done => user = @robot.brain.userForId(@userIdFromJid(user_jid)) or {} if user user.room = room_jid # If an updated name was sent as part of a presence, update it now user.name = currentName if currentName.length @receive new PresenceMessage(user) if firstTime connector.onEnter (user_jid, room_jid, currentName) => changePresence EnterMessage, user_jid, room_jid, currentName connector.onLeave (user_jid, room_jid) -> changePresence LeaveMessage, user_jid, room_jid connector.onInvite (room_jid, from_jid, message) => action = if @options.autojoin then "joining" else "ignoring" @logger.info "Got invite to #{room_jid} from #{from_jid} - #{action}" joinRoom(room_jid) if @options.autojoin firstTime = false connector.connect() @connector = connector userIdFromJid: (jid) -> try jid.match(/^\d+_(\d+)@chat\./)[1] catch e @logger.error "Bad user JID: #{jid}" # Convenience HTTP Methods for posting on behalf of the token'd user get: (path, callback) -> @request "GET", path, null, callback post: (path, body, callback) -> @request "POST", path, body, callback request: (method, path, body, callback) -> @logger.debug "Request:", method, path, body host = @options.host or "api.hipchat.com" headers = "Host": host unless @options.token return callback "No API token provided to Hubot", null options = agent : false host : host port : 443 path : path += "?auth_token=#{@options.token}" method : method headers: headers if method is "POST" headers["Content-Type"] = "application/x-www-form-urlencoded" options.headers["Content-Length"] = body.length request = HTTPS.request options, (response) => data = "" response.on "data", (chunk) -> data += chunk response.on "end", => if response.statusCode >= 400 @logger.error "HipChat API error: #{response.statusCode}" try callback null, JSON.parse(data) catch err callback null, data or { } response.on "error", (err) -> callback err, null if method is "POST" request.end(body, "binary") else request.end() request.on "error", (err) => @logger.error err @logger.error err.stack if err.stack callback err errmsg = (err) -> err + (if err.stack then '\n' + err.stack else '') exports.use = (robot) -> new HipChat robot ================================================ FILE: src/promises.coffee ================================================ {Promise} = require "rsvp" Promise::done = (done) -> @then done Promise::fail = (fail) -> @then null, fail module.exports = -> new Promise() ================================================ FILE: src/test.coffee ================================================ user = id: 1, name: "yannick" oldUser = role: "lol", name: "bob" for key, value of oldUser unless key of user user[key] = value console.log user, oldUser