diff --git a/README.md b/README.md index 69cf92b..0f1852c 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,11 @@ # Slack IRC Plugin -IRC integration with [slack](http://slack.com). +Bidirectional IRC integration with [slack](http://slack.com), with simple functionality like avoiding higlighting yourself on IRC, etc. ## Usage ```javascript -git clone https://github.com/jimmyhillis/slack-irc-plugin.git +git clone https://github.com/jaykul/slack-irc-plugin.git cd slack-irc-plugin npm install ``` @@ -15,11 +15,9 @@ Write your own configuration file (`config-example.js`) is a good starting point ```javascript var config = { // required - server: 'irc.freenode.com', - port: 6667, - secure: false, - password: '(optional)', + server: 'irc.freenode.net', nick: 'slackbot', + password: '(optional)', username: 'slackbot-username', token: 'XXXX-XXXXXXXXXX-XXXXXXXXXX-XXXXXXXXXX-XXXXXX', channels: { @@ -28,9 +26,6 @@ var config = { users: { '~irclogin': 'slackuser' }, - // optionals - silent: false // default - // node-irc options floodProtection: true } ``` @@ -49,5 +44,10 @@ This will launch the bot in your terminal based on provided configuration. - `token`: Your Slack API token, get your token at https://api.slack.com/ - `channels`: Map of IRC channel to Slack channel names, with optional password - `users`: Map of IRC nick to Slack username -- `silent`: Set to true to stop IRC bot from speaking into the channel +- `highlight:` Set to true to turn off unicode zero-width character insertion in nicks for IRC +- `slackmark:` Any text value will be appended to usernames from IRC when sent to slack + +Note that additionally, some nodejs irc settings are allowed: + +`floodProtection`, `port`, `debug`, `showErrors`, `autoRejoin`, `autoConnect`, `secure`, `selfSigned`, `certExpired`, `floodProtection`, `floodProtectionDelay`, `sasl`, `stripColors`, `channelPrefixes`, `messageSplit`, `password` diff --git a/config-example.js b/config-example.js index 677ec25..fdf2b58 100644 --- a/config-example.js +++ b/config-example.js @@ -1,12 +1,13 @@ var slackbot = require('./lib/bot'); var config = { - server: 'irc.freenode.com', + server: 'irc.freenode.net', nick: 'slackbot', username: 'slackbot-username', token: 'XXXX-XXXXXXXXXX-XXXXXXXXXX-XXXXXXXXXX-XXXXXX', + // use all lowercase. use # on IRC channel, but not on slack channel channels: { - '#irc-channel password(optional)': '#slack-channel' + '#irc-channel password(optional)': 'slack-channel' }, users: { '~irclogin': 'slackuser' diff --git a/lib/bot.js b/lib/bot.js index 5703244..6c16392 100755 --- a/lib/bot.js +++ b/lib/bot.js @@ -1,6 +1,9 @@ var _ = require('underscore'); var IRC = require('irc'); -var slack = require('./slacker'); +var SlackClient = require('slack-client'); +var Log = require('log'); +var Entities = require('html-entities').XmlEntities; + /** * IRC Bot for syncing messages from IRC to Slack @@ -16,8 +19,14 @@ var Bot = function (config) { this.config = _.defaults(config, { silent: false, nick: 'slckbt', - username: 'slckbt' + username: 'slckbt', + highlight: false, + slackmark: " (irc)", + users: false }); + + this.logger = new Log(process.env.SLACK_LOG_LEVEL || 'info'); + this.entities = new Entities(); // default node-irc options // (@see https://github.com/martynsmith/node-irc/blob/0.3.x/lib/irc.js) this.irc = { @@ -33,21 +42,33 @@ var Bot = function (config) { self.irc[opt] = self.config[opt]; } }); - // ensure tilde is present if not provided by the user - Object.keys(this.config.users).forEach(function (username) { - if (username[0] !== "~") { - self.config.users["~" + username] = self.config.users[username]; - delete self.config.users[username]; - } - }); - this._usermap = { - users: this.config.users || {}, - nicks: {} - }; + if(this.config.users) { + this.logger.info("TRACKING USERS = ON") + + // ensure tilde is present if not provided by the user + Object.keys(this.config.users).forEach(function (username) { + if (username[0] !== "~") { + self.config.users["~" + username] = self.config.users[username]; + delete self.config.users[username]; + } + }); + + this._usermap = { + users: this.config.users || {}, + nicks: {} + }; + } + // start with a stupidly long fake hostmask + this.hostmask = this.config.nick + "@cpe-123-123-123-123.rochester.res.rr.com" + this.client = new IRC.Client(this.config.server, this.config.nick, this.irc); - this.slacker = new slack.Slacker({ token: this.config.token }); - this._trackUsers(); + if(this.config.users) { + this._trackUsers(); + } this._handleErrors(); + + this.slacker = new SlackClient(this.config.token); + this._slackOn(); return this; }; @@ -59,8 +80,10 @@ Bot.prototype._handleErrors = function () { this.client.addListener('error', function (message) { var channel = message.args[1]; var error_message = mapPronouns(message.args[2]); - self.speak(channel, 'I don\'t feel so well because ' + error_message); + self.ircSpeak(channel, 'I don\'t feel so well because ' + error_message); }); + + // TODO: deal with slack-side errors }; /** @@ -74,12 +97,12 @@ Bot.prototype._trackUsers = function () { Object.keys(nicks).forEach(function (nick) { self.client.whois(nick, function (whois) { if (whois.user === myusername) { + self.hostmask = whois.user + "@" + whois.host return; } self._usermap.nicks[nick] = self._usermap.users[whois.user]; }); }); - self.speak(channel, 'I\'m all over you slackers'); }); // New user has joined, match him up this.client.addListener('join', function (channel, nick, whois) { @@ -88,9 +111,6 @@ Bot.prototype._trackUsers = function () { } else { self._usermap.nicks[nick] = self._usermap.users[whois.user]; - self.giveOps(channel, nick); - self.speak(channel, 'i\'m watching you slacker @' + - self._usermap.nicks[nick]); } }); // Existing user has changed nickname @@ -100,13 +120,22 @@ Bot.prototype._trackUsers = function () { } self._usermap.nicks[new_nick] = self._usermap.nicks[old_nick]; delete self._usermap.nicks[old_nick]; - channels.forEach(function (channel) { - self.speak(channel, 'don\'t think you can hide slacker @' + - self._usermap.nicks[new_nick]); - }); }); }; +Bot.prototype._slackOn = function () { + var self = this; + this.slacker.addListener('loggedIn', function (user, team){ + self.logger.info('Slack client now logged in (' + user.id + ') as ' + user.name + ' to ' + team.name); + }); + + this.slacker.addListener('open', function (){ + self.logger.info('Slack client now connected'); + }); + + self.slacker.login(); +}; + /** * Attempt to give a user op controls * @param {string} channel IRC channel name @@ -121,47 +150,191 @@ Bot.prototype.giveOps = function (channel, nick) { */ Bot.prototype.listen = function () { var self = this; - // Handle public user post + + // Handle slack messages + this.slacker.addListener('message', function (message) { + self.logger.info('Message: ' + message); + if (message.hidden) { + return; + } + if (!message.text && !message.attachments) { + return; + } + if (message.subtype === 'bot_message') { + return; + } + if (!message.user) { + return; + } + if (message.user === self.config.username) { + return; + } + if(message.getChannelType() === 'DM') { + return; + } + channel = self.slacker.getChannelGroupOrDMByID(message.channel); + + if (message.subtype === 'channel_join' || message.subtype === 'group_join') { + } else if (message.subtype === 'channel_leave' || message.subtype === 'group_leave') { + } else if (message.subtype === 'channel_topic' || message.subtype === 'group_topic') { + } else { + var username = message.username || self.slacker.getUserByID(message.user).name + self.logger.info('SLACK_MSG> ' + channel.name + ": " + username + ': ' + message.getBody()); + + for(to in self.config.channels) { + if(self.config.channels[to] === channel.name) { + self.ircSpeak(to, message.getBody(), username); + } + } + } + }); + + // Handle irc user post this.client.addListener('message', function (from, to, message) { - self.slacker.send('chat.postMessage', { - channel: self.config.channels[to], - text: self.prepareMessage(message, self._usermap.nicks), - username: self._usermap.nicks[from] || from, - parse: 'full', - link_names: 1, - unfurl_links: 1 - }); + self.logger.info('IRC_MSG> ' + to + ": " + from + ': ' + message); + + if(self.config.users){ + from = self._usermap.nicks[from] || from + } + to = self.config.channels[to.toLowerCase()] + self.slackSpeak(to, message, from) }); + }; /** * Push a message to a channel * @param {string} channel IRC channel name - * @param {string} message Message to push to channel + * @param {string} message Text to push to channel + * @param {string} username Who sent the message */ -Bot.prototype.speak = function (channel, message) { - if (!this.config.silent) { - this.client.say(channel, message); +Bot.prototype.ircSpeak = function (channel, message, username) { + var self = this + + // insert a zero-width non-joiner character to prevent name-highlighting on IRC + if(!self.config.highlight) { + username = username.replace(/^(.)/, '$1\u200C') } + username = "<" + username + "> " + message = self.entities.decode(message) + message = self.prepareMessage(message) + + // IRC max length is 512, minus the CR LF and other headers ... + // In practice, it looks like this: + // :Nick!Ident@Host PRIVMSG #Powershell :Your Message Here + // + // So the real max message lengthis 510 + // But there are parts of the header that are mandatory: + // (":" + "!" + " PRIVMSG " + " :").length; + // 497 = 510 - 13 + + maxLineLength = 479 - (self.hostmask.length + self.client.nick.length + self.client.hostMask.length) + maxLineLength -= (channel.length + username.length + 3) + + message.split(/\s*[\r\n]+\s*/).forEach(function(msg) { + while (msg.length > maxLineLength) { + length = msg.slice(0, maxLineLength).split(" ").slice(0,-1).join(" ").length + + line = msg.slice(0, length) + msg = msg.slice(length).trim() + + self.logger.debug('IRC(' + line.length + ')> #' + channel + " " + username + line); + self.client.say(channel, username + line); + } + self.logger.debug('IRC> #' + channel + " " + username + msg); + self.client.say(channel, username + msg); + }); }; + /** - * Map users with whois to get ~loginname for stability - * @param {string} message Message to replace IRC user with slack @user - * @param {array} users User mapping - * @return {string} Message with slack @users + * Push a message to a channel + * @param {string} channel slack channel name + * @param {string} message Text to push to channel + * @param {string} username Who sent the message */ -Bot.prototype.prepareMessage = function (message, users) { - Object.keys(users).forEach(function (name) { - if (message.indexOf(name) >= 0) { - if (users[name] !== undefined) { - message = message.replace(new RegExp(name, 'g'), '@' + users[name]); +Bot.prototype.slackSpeak = function (channel, message, username) { + var self = this + // append a "(irc)" or other marker when sending to slack to make users distinct + username += self.config.slackmark + + self.logger.info('SLACK> ' + channel + ": " + username + ': ' + message); + + if(typeof message == 'string' || message instanceof String) { + message = self.prepareMessage(message) + message = { text: message } + } else { + message = message || {}; + } + + if(username) { + message.username = username + } + + var channel = self.slacker.getChannelByName(channel); + + message.parse = 'full' + message.link_names = 1 + message.unfurl_links = 1 + + channel.postMessage(message) +}; + + +Bot.prototype.prepareMessage = function(txt) { + + if(this.config.users) { + var users = this._usermap.nicks + Object.keys(users).forEach(function (name) { + if (txt.indexOf(name) >= 0) { + if (users[name] !== undefined) { + txt = txt.replace(new RegExp(name, 'g'), '@' + users[name]); + } } - } - }); - return message; + }); + } + + txt = txt.replace(/<([\@\#\!])(\w+)(?:\|([^>]+))?>/g, (function(self) { + return function(m, type, id, label) { + var channel, user; + if (label) { + return label; + } + switch (type) { + case '@': + user = self.slacker.getUserByID(id); + if (user) { + return "@" + user.name; + } + break; + case '#': + channel = self.slacker.getChannelByID(id); + if (channel) { + return "\#" + channel.name; + } + break; + case '!': + if (id === 'channel' || id === 'group' || id === 'everyone') { + return "@" + id; + } + break; + } + return "" + type + id; + }; + })(this)); + txt = txt.replace(/<([^>\|]+)(?:\|([^>]+))?>/g, (function(self) { + return function(m, link, label) { + if (label) { + return label + " " + link; + } else { + return link; + } + }; + })(this)); + return txt; }; + /** * Try and map error commands (in third person) to first person * so the bot is more personal. diff --git a/lib/slacker.js b/lib/slacker.js deleted file mode 100644 index 8038b76..0000000 --- a/lib/slacker.js +++ /dev/null @@ -1,32 +0,0 @@ -var request = require('request'); - -/** - * Slack API wrapper - * @param {object} config Slacker configuration - * - token: Slack API token - */ -var Slacker = function (config) { - this.token = config.token; - return this; -}; - -/** - * Send slack API request - * @param {string} method Slack API method - * @param {object} args POST arguments to send to Slack - */ -Slacker.prototype.send = function (method, args) { - args = args || {} ; - args.token = this.token; - request.post({ - url: 'https://slack.com/api/' + method, - json: true, - form: args - }, function (error, response, body) { - if (error || !body.ok) { - console.log('Error:', error || body.error); - } - }); -}; - -exports.Slacker = Slacker; diff --git a/package.json b/package.json index 9af7442..af81794 100644 --- a/package.json +++ b/package.json @@ -25,9 +25,11 @@ "node": "*" }, "dependencies": { - "request": "~2.33.0", + "underscore": "^1.7.0", "irc": "~0.3.6", - "underscore": "^1.7.0" + "slack-client": "~1.3.1", + "log": "~1.4.0", + "html-entities": "~1.1.2" }, "devDependencies": {}, "bin": {