├── .gitignore ├── ReadMe.md ├── bin └── kohai ├── config.json ├── lib ├── channel.js ├── comments.js ├── kohai.js ├── listeners.js ├── listeners │ ├── beer.js │ └── twitter.js ├── plugins │ ├── beer.js │ ├── config.js │ ├── gh.js │ ├── help.js │ ├── support.js │ └── twitter.js └── triggers.js └── package.json /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | -------------------------------------------------------------------------------- /ReadMe.md: -------------------------------------------------------------------------------- 1 | # kohai - pluggable irc bot for managing real-time data events 2 | 3 | *I am Kohai, semi-useful communications-facilitating pseudointelligence!* 4 | 5 | 6 | [http://twitter.com/NodeKohai](http://twitter.com/NodeKohai) 7 | 8 | ##v0.1.0 - Overview 9 | 10 | Kohai is a communications-facilitating pseudointelligence (sometimes called a 'bot') used for managing real-time data events. Kohai makes use of the [Hook.io](http://github.com/hookio/hook.io) framework to separate its I/O concerns and provide for greater extensibility and interoperability with new and different sources of real-time data. 11 | 12 | Kohai, out of the box, is a set of four hooks: 13 | 14 | - Hook.io-IRC, for IRC connectivity 15 | - Hook.io-Twitter, for Twitter API access 16 | - Hook.io-Mailer, for sending support emails 17 | - Kohai itself, containing the bot's core, IRC command logic, and hook.io event bindings 18 | 19 | ## Installation 20 | 21 | git clone git://github.com/nodejitsu/kohai.git 22 | cd kohai 23 | npm install 24 | node bin/kohai 25 | 26 | 27 | `kohai` will now start up and connect to its default channels on irc.freenode.net. 28 | 29 | ## Got Ideas? Got Issues? 30 | 31 | Check out our [Issue Tracker](https://github.com/nodejitsu/kohai/issues), we have a lot of open issues being worked on. Feel free to add feature requests as well. 32 | 33 | ## Configuration 34 | 35 | All `kohai` configuration settings are stored in a `config.json` file. `kohai` ships with a `config.json` file that contains the proper structure and default values for `kohai`'s configuration. The IRC connection data provided by default is valid, but the Twitter and Bit.ly credentials provided are not. If these services are desired, valid credentials will need to be obtained before they are used. HTTP 401 errors being returned from the Twitter API are an indication of bad credentials. 36 | 37 | While kohai is running, users with administrative access may send private messages to kohai to alter configuration data on the fly. This is described in more detail [below](#config) 38 | 39 | Please note that unlike most `kohai` commands, the !config command is only available when messaging `kohai` directly, rather than when sending a message to a channel `kohai` is in. 40 | 41 | ## Setting up Twitter 42 | 43 | By default, the `config.json` will not contain any Twitter API keys. You'll need to setup: 44 | 45 | `auth.twitter.consumer_key` 46 | 47 | `auth.twitter.consumer_secret` 48 | 49 | `auth.twitter.access_token_key` 50 | 51 | `auth.twitter.access_token_secret` 52 | 53 | [Here is a link with further information on getting these keys from Twitter](https://dev.twitter.com/apps/new) 54 | 55 | ### Tracking keywords on Twitter 56 | 57 | `track` - array of keywords to search for on Twitter 58 | 59 | "track" : [ 60 | "#nodejs", "node.js", "@nodejitsu", "@nodekohai", "nodejitsu", "#nodejitsu", "#nodeconf", "#jsconf", "dnode" 61 | ] 62 | 63 | The Twitter Streaming API can take a variety of parameters, including specific user IDs to follow and bounded location boxes for tracking tweets from specific geographic areas. Please see Twitter's extensive API documentation for more information. 64 | 65 | ###Dynamic Twitter Rate-Limiting 66 | 67 | `kohai` has been designed to get its Twitter feed out of the way when an active conversation starts up in a joined channel, and keeps a rolling average of messages per second to achieve this. 68 | 69 | In addition to the rate-limiting, `kohai` also implements a Levenshtein-based similar tweet filter - each new incoming tweet is checked against recent tweets, and any tweet closer than the filter distance will be suppressed. This means a big reduction in spam from retweets and bots! The exact filtering level can be adjusted: 70 | 71 | !config set twitFilter 72 | 73 | The default is 25; values between 10 and 40 are recommended for ordinary spam filtering purposes. 74 | 75 | ## Simple role-based access control 76 | 77 | Kohai's configuration file contains an object called `access` with three arrays: `admin`, `employee`, and `friend`. These are the three possible levels of access, in descending order. On an incoming IRC message, the user's nickname (and, optionally, their ident status with nickserv) is checked against these lists - without matching a name from the proper access level, the trigger command will not be executed. 78 | 79 | ### Adding Users to the whitelist by IRC handle 80 | 81 | While `kohai` is running, an administrator can add any desired user to any level of the whitelist. 82 | 83 | !config add access:admin AvianFlu 84 | !config add access:employee leetcoder5 85 | 86 | 87 | ### Notable Administrator IRC Triggers 88 | 89 | /msg kohai !config 90 | 91 | Allows for alteration of `kohai`'s configuration data on the fly. Due to the verbosity of many responses, configuration is conducted via private messages to `kohai`. Options will take effect immediately, but `config save` is required to persist `kohai`'s settings to disk. For example: 92 | 93 | !config add access:friend someguy 94 | !config get access:friend 95 | !config save 96 | 97 | Would add "someguy" to the admin whitelist, show the whitelist to the user the command came from, and save the new config to disk. 98 | 99 | !op/!deop/!voice/!devoice/!ban/!unban 100 | 101 | Perform the associated IRC action - again, `kohai` must be an op. Please note that `!ban` also kicks the banned user. 102 | 103 | !join/!part 104 | 105 | Joins or leaves the specified IRC channel. 106 | 107 | !gtfo 108 | 109 | Tells `kohai` to shutdown 110 | 111 | ### Employee-level commands 112 | 113 | !kick 114 | 115 | Kicks the specified user from the current channel. 116 | 117 | !stfu 118 | 119 | Temporarily mutes a user (IRC mode +q). Requires `kohai` to be an op in the specified channel. 120 | 121 | ### Friend-level commands 122 | 123 | !tweet 124 | 125 | Tweets message from configured Twitter account 126 | 127 | !insult 128 | 129 | Insults a user with one of several random insults 130 | 131 | ### Commands with no access control 132 | 133 | !help 134 | 135 | Displays a list of available help topics. 136 | 137 | ## Extending Kohai 138 | 139 | ### Adding commands 140 | 141 | Every time `kohai` starts, `lib/plugins` will be read, and `require()` will be called on every `.js` file found there. To add a command that only exports one function: 142 | 143 | var mycommand = module.exports = function (data, command) { 144 | if (!data.friend) { 145 | // Check if the message came from a user with a proper access level. 146 | return false; 147 | } 148 | // From here, all properties of the kohai object can be accessed. 149 | this.emit('sendMsg', { dest: data.to, msg: 'Hi everybody!' }); 150 | } 151 | 152 | If you'd rather export multiple commands from the same file, this is supported as well - but note that the command will be `!methodName`, rather than the name of the file/object. 153 | 154 | If multiple methods of the same name are attempted to be loaded as commands, only the first will be loaded. If the file in question throws when it is required, it will similarly be skipped. 155 | 156 | ### Adding event listeners for other Hook.io hooks 157 | 158 | Kohai will also read all the `.js` files in `lib/listeners` on startup - rather than loading the functions to be run in the future, however, they will just be run once on startup to add the event listeners in question. 159 | 160 | var init = module.exports = function () { 161 | var self = this; 162 | self.on('*::eventName', function (data, callback) { 163 | // Do something with data 164 | return callback(null); // This is an over-the-wire callback. 165 | }); 166 | } 167 | 168 | Once again, access to the main `kohai` object is preserved, and if multiple methods are exported, each method will be run. 169 | 170 | ##Contributors: 171 | 172 | ### Charles McConnell, Marak Squires, Bradley Meck, Jacob Chapel, samsonjs, slickplaid, micrypt, and others -------------------------------------------------------------------------------- /bin/kohai: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | var Kohai = require('../lib/kohai').Kohai; 4 | 5 | var kohai = new Kohai({ 6 | name: 'kohai' 7 | }); 8 | 9 | kohai.start(); 10 | -------------------------------------------------------------------------------- /config.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "v0.1.3", 3 | "children": [ 4 | "irc", 5 | "twitter", 6 | "mailer" 7 | ], 8 | "access": { 9 | "admin": [ 10 | "AvianFlu", 11 | "Marak", 12 | "indexzero", 13 | "hij1nx" 14 | ], 15 | "employee": [ 16 | "SubStack", 17 | "bradleymeck", 18 | "jameson", 19 | "jesusabdullah", 20 | "slickplaid" 21 | ], 22 | "friend": [ 23 | "isaacs", 24 | "indutny", 25 | "ryah", 26 | "TheJH" 27 | ] 28 | }, 29 | "ranks": [ 30 | "admin", 31 | "employee", 32 | "friend" 33 | ], 34 | "insults": [ 35 | "%% is a wombat-loving heifer-puncher!", 36 | "%% is an unsightly trouser stain!", 37 | "%% is a venomous lily-licker!", 38 | "%% smells of elderberry wine!", 39 | "%% is like one of those callbacks that just won't fire.", 40 | "I slap thee verily with a trout quite large, %%!", 41 | "%% actually looks rather nice today." 42 | ], 43 | "help": { 44 | "password": "To reset your password, type `jitsu users forgot [username]`. To change it, type `jitsu users changepassword`.", 45 | "handbook": "The complete Nodejitsu handbook can be read at http://github.com/nodejitsu/handbook", 46 | "support": "If no one is here to help you, type `!support [msg]` to send an email to support@nodejitsu.com.", 47 | "blog": "Our blog can be viewed at http://blog.nodejitsu.com - a number of tutorials can be found there.", 48 | "forever": "Run and monitor your node.js processes forever - http://github.com/indexzero/forever", 49 | "http-proxy": "A robust, full-featured HTTP proxy for Node.js with full websocket support - http://github.com/nodejitsu/node-http-proxy", 50 | "jitsu": "A CLI tool for deploying your apps to the Nodejitsu hosting platform - http://github.com/nodejitsu/jitsu", 51 | "hook.io": "A distributed cross-platform namespaced event system using dnode and EventEmitter2 - http://github.com/hookio/hook.io", 52 | "node-prompt": "An easy-to-use library for getting input from users while your program is running - http://github.com/nodejitsu/node-prompt", 53 | "gh": "Link to GitHub repositories, issues and commits: `!gh [user]`, `!gh [user]/[project]`, `!gh [user]/[project]@[sha]`, `!gh [user]/[project]#[issue]` are supported", 54 | "like": "Type !like [drink] to select drink you get when you receive karma" 55 | }, 56 | "irc-server": "irc.freenode.net", 57 | "port": 6667, 58 | "nick": "kohai-default", 59 | "channels": ["#nodebombrange", "#kohai"], 60 | "showErrors": "true", 61 | "userName": "kohai", 62 | "realName": "the lovable IRC bot", 63 | "idCheck": true, 64 | "channelDefaults": { 65 | "commandString": "!", 66 | "messageCount": 0, 67 | "currentTweetCount": 0, 68 | "twitPeriod": 480, 69 | "volume": 10, 70 | "lastVolume": 10, 71 | "rate": 0, 72 | "autoVolume": true, 73 | "active": true, 74 | "wantsTweets": true 75 | }, 76 | "auth": { 77 | "twitter": { 78 | "consumer_key": "Public key for Streaming API access", 79 | "consumer_secret": "Secret key for Streaming API access", 80 | "access_token_key": "Public key for REST API access", 81 | "access_token_secret": "Secret key for REST API access" 82 | }, 83 | "bitly": { 84 | "user": "nodejitsu", 85 | "key": "R_067ef6cdcfc2b223ea26a986395b420c" 86 | } 87 | }, 88 | "track": [ 89 | "Node.js", 90 | "Nodejitsu", 91 | "#nodejs", 92 | "NodeKohai" 93 | ], 94 | "follow": [], 95 | "recentTweets": [] 96 | } 97 | -------------------------------------------------------------------------------- /lib/channel.js: -------------------------------------------------------------------------------- 1 | /* 2 | * 3 | * channel.js - an object to hold various channel data 4 | * 5 | * (c) 2011 Nodejitsu Inc. 6 | * 7 | */ 8 | 9 | var Channel = exports.Channel = function (options) { 10 | for (var o in options) { 11 | this[o] = options[o]; 12 | } 13 | } 14 | 15 | Channel.prototype.part = function () { 16 | this.active = false; 17 | } 18 | 19 | Channel.prototype.join = function () { 20 | this.active = true; 21 | } 22 | 23 | Channel.prototype.startVolume = function () { 24 | this._ircRate(); 25 | this._twitRate(); 26 | } 27 | 28 | Channel.prototype.stopVolume = function () { 29 | clearInterval(this.ircInterval); 30 | clearInterval(this.twitInterval); 31 | } 32 | 33 | Channel.prototype.config = function (key, value) { 34 | if (typeof value === 'undefined') { 35 | return this[key]; 36 | } 37 | if (typeof key === 'string') { 38 | this[key] = value; 39 | return key + ' has been set to ' + value; 40 | } 41 | else { 42 | return false; 43 | } 44 | } 45 | 46 | Channel.prototype._ircRate = function () { 47 | var self = this, 48 | timespan = 60; 49 | self.rateValues = []; 50 | self.ircInterval = setInterval(function () { 51 | var sum = 0; 52 | if (self.rateValues.length > timespan) { 53 | self.rateValues.shift(); 54 | } 55 | 56 | self.rateValues.push(self.messageCount); 57 | self.messageCount = 0; 58 | 59 | self.rateValues.forEach(function (value) { 60 | sum += Number(value); 61 | }); 62 | self.rate = sum; 63 | self._volumetrics(); 64 | }, 1000); 65 | 66 | } 67 | 68 | Channel.prototype._volumetrics = function () { 69 | var self = this; 70 | 71 | if (self.autoVolume) { 72 | if (self.rate > 10) { 73 | self.rate = 10; 74 | } 75 | if ((self.volume < 0) || (typeof self.volume === 'undefined')) { 76 | self.volume = 0; 77 | } 78 | if ((10 - self.rate) <= self.volume) { 79 | self.volume = 10 - self.rate; 80 | self.lastVolume = self.volume; 81 | } 82 | else if ((10 - self.rate) > self.volume) { 83 | self.lastVolume += self.lastVolume +0.05; 84 | self.volume = Math.round(self.lastVolume); 85 | } 86 | } 87 | } 88 | 89 | Channel.prototype._twitRate = function () { 90 | var self = this; 91 | 92 | self.twitInterval = setInterval(function () { 93 | if (self.currentTweetCount > 0) { 94 | self.currentTweetCount--; 95 | } 96 | }, self.twitPeriod * 1000); 97 | } 98 | 99 | 100 | -------------------------------------------------------------------------------- /lib/comments.js: -------------------------------------------------------------------------------- 1 | /* 2 | * 3 | * comments.js - Kohai's IRC auto-responses. 4 | * 5 | * (c) 2011 Nodejitsu Inc. 6 | * 7 | */ 8 | 9 | var comments = module.exports = function (data) { 10 | 11 | switch(true) { 12 | case /\bkohai\b.*\bbot\b.*/i.test(data.text): 13 | this.emit('sendMsg', { 14 | dest: data.to, 15 | msg: '\'Bot\' is a derogatory term, and I\'m offended.' 16 | }); 17 | break; 18 | case /.*\bkohai:(?:\s|$).*/i.test(data.text): 19 | this.emit('sendMsg', { 20 | dest: data.to, 21 | msg: 'I am Kohai, semi-useful communications-facilitating pseudointelligence!' 22 | }); 23 | break; 24 | default: 25 | // This is the no-match case, do nothing. 26 | break; 27 | } 28 | 29 | } -------------------------------------------------------------------------------- /lib/kohai.js: -------------------------------------------------------------------------------- 1 | /* 2 | * 3 | * kohai.js - The Kohai core. 4 | * 5 | * (c) 2011 Nodejitsu Inc. 6 | * 7 | */ 8 | 9 | var Hook = require('hook.io').Hook, 10 | Channel = require('./channel').Channel, 11 | util = require('util'), 12 | triggers = require('./triggers'), 13 | listeners = require('./listeners'), 14 | comments = require('./comments'); 15 | 16 | var Kohai = exports.Kohai = function (options) { 17 | var self = this; 18 | for (var o in options) { 19 | this[o] = options[o]; 20 | } 21 | Hook.call(this); 22 | self.hooks = self.config.get('children'); 23 | self.on('hook::ready', function () { 24 | if ((process.getuid() === 0) && self.uid) { 25 | process.setuid(self.uid); 26 | } 27 | self.channels.forEach(function (channel) { 28 | self.joinChannel(channel); 29 | }); 30 | listeners.init.call(self, null); 31 | self.insults = self.config.get('insults'); 32 | self.ranks = self.config.get('ranks'); 33 | }); 34 | } 35 | util.inherits(Kohai, Hook); 36 | 37 | Kohai.prototype.joinChannel = function (channel) { 38 | var self = this; 39 | if (!self.channels) { 40 | self.channels = {}; 41 | } 42 | if (typeof self.channels[channel] === 'undefined') { 43 | self.channels[channel] = new Channel(self.channelDefaults); 44 | } 45 | else { 46 | self.channels[channel].join(); 47 | } 48 | if (self.channels[channel].autoVolume) { 49 | self.channels[channel].startVolume(); 50 | } 51 | } 52 | 53 | Kohai.prototype.gotMessage = function (data) { 54 | var idCheck = this.idCheck ? '\\+' : '', 55 | trigger = new RegExp('^' 56 | + idCheck 57 | + this.channels[data.to].commandString 58 | + '\\w+\\s?\\w*.*'); 59 | this.channels[data.to].messageCount++; 60 | if (trigger.test(data.text)) { 61 | this.checkAuth(data); 62 | } 63 | else if (/^-!?(help|support)/.test(data.text)) { 64 | var command = data.text.replace(/-!/, '').split(' '); 65 | triggers[command[0]].call(this, data, command); 66 | } 67 | else { 68 | this.checkComment(data); 69 | } 70 | } 71 | 72 | Kohai.prototype.checkAuth = function (data) { 73 | var self = this, 74 | access = self.config.get('access'), 75 | inherit = false; 76 | self.ranks.forEach(function (rank) { 77 | // No message should have any rank fields prior to this function. 78 | if (typeof data[rank] !== 'undefined') { 79 | return false; 80 | } 81 | }); 82 | self.ranks.forEach(function (level) { 83 | if (inherit) { 84 | data[level] = true; 85 | return; 86 | } 87 | access[level].forEach(function (name) { 88 | if (name === data.nick) { 89 | data[level] = true; 90 | inherit = true; 91 | } 92 | }); 93 | }); 94 | if (data.to === self.nick) { 95 | return self._dispatchPM(data); 96 | } 97 | return self._dispatcher(data); 98 | } 99 | 100 | Kohai.prototype._dispatcher = function (data) { 101 | var self = this, 102 | replace = self.idCheck 103 | ? '+' + self.channels[data.to].commandString 104 | : self.channels[data.to].commandString, 105 | command = data.text.replace(replace, '').split(' '); 106 | if ((command[0] !== 'config')&&(typeof triggers[command[0]] !== 'undefined')) { 107 | triggers[command[0]].call(self, data, command); 108 | } 109 | } 110 | 111 | Kohai.prototype._dispatchPM = function (data) { 112 | var config = /^config\s(add|rm|set|get|save)\s?([\w\d#:._-]*)\s?(.*)$/, 113 | text = data.text.replace(/^\+!?/, ''), 114 | command; 115 | if (!config.test(text)) { 116 | command = text.split(' '); 117 | if (/-!(help|support)/.test(command[0])) { 118 | triggers[command[0].slice(1)].call(this, data, command); 119 | } 120 | else if (typeof triggers[command[0]] !== 'undefined') { 121 | data.to = data.nick; 122 | triggers[command[0]].call(this, data, command); 123 | } 124 | } 125 | else if (data.admin) { 126 | command = text.match(config); 127 | triggers.config.call(this, data.nick, command[1], command[2], command[3]); 128 | } 129 | } 130 | 131 | Kohai.prototype.checkComment = function (data) { 132 | comments.call(this, data); 133 | } 134 | 135 | Kohai.prototype.sayTweet = function (data) { 136 | var self = this; 137 | Object.getOwnPropertyNames(self.channels).forEach(function (channel) { 138 | if (self.channels[channel].wantsTweets === 'true') { 139 | if ((self.channels[channel].volume / 2) > self.channels[channel].currentTweetCount) { 140 | self.emit('sendMsg', {dest: channel, msg: data}); 141 | self.channels[channel].currentTweetCount++; 142 | } 143 | } 144 | }); 145 | } 146 | 147 | -------------------------------------------------------------------------------- /lib/listeners.js: -------------------------------------------------------------------------------- 1 | 2 | 3 | var fs = require('fs'); 4 | 5 | var listeners = exports; 6 | 7 | listeners.init = function () { 8 | var self = this; 9 | 10 | self.on('*::idCheck', function (data) { 11 | self.idCheck = data.check ? true : false; 12 | }); 13 | 14 | self.on('*::gotMessage', function (data) { 15 | if (data.to === self.nick) { 16 | return self.checkAuth(data); 17 | } 18 | return self.gotMessage(data); 19 | }); 20 | 21 | self.on('*::Isaid', function (data) { 22 | if (data.to[0] === '#') { 23 | self.channels[data.to].messageCount++; 24 | } 25 | }); 26 | 27 | self.on('*::Ijoined', function (data) { 28 | self.joinChannel(data.channel); 29 | }); 30 | 31 | self.on('*::userJoined', function (data) { 32 | var access = self.config.get('access'); 33 | access.employee.forEach(function (name) { 34 | if (data.nick === name) { 35 | self.emit('command', 'mode ' + data.channel + ' +v ' + data.nick); 36 | return; 37 | } 38 | }); 39 | }); 40 | 41 | self.on('*::Iparted', function (data) { 42 | if (typeof self.channels[data] !== 'undefined') { 43 | self.channels[data].part(); 44 | self.channels = self.channels.filter( function (item) { 45 | return item !== data; 46 | }); 47 | } 48 | }); 49 | 50 | self.on('*::error::*', function (data) { 51 | console.log(data); 52 | }); 53 | 54 | _lazyLoad.call(self); 55 | } 56 | 57 | function _lazyLoad() { 58 | var self = this; 59 | fs.readdirSync(__dirname + '/listeners').forEach(function (listener) { 60 | var mod; 61 | if (/^.*\.js$/.test(listener)) { 62 | listener = listener.replace('.js', ''); 63 | try { 64 | var mod = require(__dirname + '/listeners/' + listener); 65 | switch(typeof mod) { 66 | case 'function': 67 | mod.call(self); 68 | break; 69 | case 'object': 70 | Object.getOwnPropertyNames(mod).forEach(function (item) { 71 | if (typeof mod[item] === 'function') { 72 | mod[item].call(self); 73 | } 74 | else { 75 | console.log('Cannot load non-function as event listener: %s.%s', listener, item); 76 | } 77 | }); 78 | break; 79 | default: 80 | console.log('Cannot load non-functions as event listeners.'); 81 | break; 82 | } 83 | } 84 | catch (err) { 85 | console.log('Listener plugin \'%s\' was not loaded due to error: %s', listener, err.message); 86 | } 87 | } 88 | }); 89 | } 90 | -------------------------------------------------------------------------------- /lib/listeners/beer.js: -------------------------------------------------------------------------------- 1 | /* 2 | * 3 | * listeners/beer.js - Event listener for IRC events, providing you with cold 4 | * beer 5 | * 6 | * (c) 2011 Nodejitsu Inc. 7 | * 8 | */ 9 | 10 | var beer = module.exports = function () { 11 | var self = this; 12 | 13 | self.karma = self.config.get('karma:values') || {}; 14 | self.preferences = self.config.get('karma:preferences') || {}; 15 | 16 | self.on('*::gotMessage', function (data) { 17 | var re = /^[\+\-]?(`\w+)([:, ]){0,3}(\+\+|--)/, 18 | res; 19 | 20 | if (res = re.exec(data.text)) { 21 | if (res[1] === data.nick) { 22 | return self.emit('sendMsg', { 23 | dest: data.to, 24 | msg: "You can't give karma to yourself!" 25 | }); 26 | } 27 | self.karma[res[1]] || (self.karma[res[1]] = 0); 28 | if (res[3] === '++') { 29 | ++self.karma[res[1]]; 30 | } 31 | else if (res[3] === '--') { 32 | --self.karma[res[1]]; 33 | } 34 | 35 | self.config.set('karma:values', self.karma); 36 | self.config.save(); 37 | 38 | return self.emit('sendMsg', { 39 | dest: data.to, 40 | msg: res[1] + ' has ' + self.karma[res[1]] + ' ' + 41 | (self.preferences[res[1]] || 'beer') + 42 | ((Math.abs(self.karma[res[1]]) > 1) ? 's' : '') 43 | }); 44 | } 45 | }); 46 | 47 | self.on('karmaPreferenceSet', function (data) { 48 | self.preferences[data.nick] = data.preference; 49 | self.config.set('karma:preferences', self.preferences); 50 | self.config.save(); 51 | }); 52 | }; 53 | 54 | -------------------------------------------------------------------------------- /lib/listeners/twitter.js: -------------------------------------------------------------------------------- 1 | /* 2 | * 3 | * listeners/twitter.js - Event listeners for Hook.io-Twitter. 4 | * 5 | * (c) 2011 Nodejitsu Inc. 6 | * 7 | */ 8 | 9 | var twitter = module.exports = function () { 10 | var self = this; 11 | 12 | self.on('*::keptTweet', function (data) { 13 | self.sayTweet(data); 14 | }); 15 | 16 | self.on('*::reported', function (data) { 17 | self.emit('sendMsg', {dest: data.to, msg: 'I have reported ' + data.name + ' as a spammer.'}); 18 | }); 19 | 20 | self.on('*::blocked', function (data) { 21 | self.emit('sendMsg', {dest: data.to, msg: 'I have blocked ' + data.name + '.'}); 22 | }); 23 | }; 24 | -------------------------------------------------------------------------------- /lib/plugins/beer.js: -------------------------------------------------------------------------------- 1 | /* 2 | * 3 | * plugins/beer.js - IRC commands for beer listener 4 | * 5 | * (c) 2011 Nodejitsu Inc. 6 | * 7 | */ 8 | 9 | var beer = exports; 10 | 11 | beer.like = function (data, command) { 12 | if (!command[1]) { 13 | return; 14 | } 15 | 16 | var drink = command.splice(1).join(' '); 17 | 18 | this.emit('karmaPreferenceSet', { 19 | nick: data.nick, 20 | preference: drink 21 | }); 22 | 23 | this.emit('sendMsg', { 24 | dest: data.to, 25 | msg: data.nick + ' likes ' + drink + '.' 26 | }); 27 | }; 28 | 29 | -------------------------------------------------------------------------------- /lib/plugins/config.js: -------------------------------------------------------------------------------- 1 | /* 2 | * 3 | * config.js - commands to configure Kohai on the fly 4 | * 5 | * (c) 2011 samsonjs & AvianFlu 6 | * 7 | */ 8 | 9 | var config = module.exports = function (name, operation, key, val) { 10 | // Permissions already checked, so no need here. 11 | // Thanks again to samsonjs for the config code. 12 | 13 | var self = this, 14 | repr, 15 | a, b; 16 | 17 | // get key 18 | if (operation === 'get') { 19 | if (!key) { 20 | self.emit('sendMsg', {dest: name, msg: 'Get what?'}); 21 | } 22 | else if (!key.match(/^auth[:\w\d\s]*/)) { 23 | val = self.config.get(key); 24 | if (val && typeof val.join === 'function') { 25 | repr = '[' + val.join(', ') + ']'; 26 | } 27 | else { 28 | repr = JSON.stringify(val); 29 | } 30 | self.emit('sendMsg', {dest: name, msg: key + ' is ' + repr}); 31 | } 32 | else { 33 | self.emit('sendMsg', {dest: name, msg: 'Retrieval of authorization info not permitted.'}); 34 | } 35 | } 36 | 37 | // set key json 38 | else if (operation === 'set') { 39 | try { 40 | self.config.set(key, JSON.parse(val)); 41 | self.emit('sendMsg', {dest: name, msg: key + ' has been set to: ' + val + '.'}); 42 | } 43 | catch (e) { 44 | self.emit('sendMsg', {dest: name, msg: 'Sorry, invalid JSON'}); 45 | } 46 | } 47 | 48 | // add list-key value 49 | else if (operation === 'add') { 50 | a = self.config.get(key); 51 | if (!(a && typeof a.push === 'function')) { 52 | self.emit('sendMsg', {dest: name, msg: 'Sorry, cannot add to ' + key}); 53 | } 54 | else if (a.indexOf(val) !== -1) { 55 | self.emit('sendMsg', {dest: name, msg: val + ' is already in ' + key}); 56 | } 57 | else { 58 | a.push(val); 59 | self.config.set(key, a); 60 | self.emit('sendMsg', {dest: name, msg: val + ' was added to ' + key + '.'}); 61 | } 62 | } 63 | 64 | // rm list-key value 65 | else if (operation === 'rm') { 66 | a = self.config.get(key); 67 | if (!(a && typeof a.filter === 'function')) { 68 | self.emit('sendMsg', {dest: name, msg: 'Sorry, cannot remove from ' + key}); 69 | return; 70 | } 71 | b = a.filter(function (x) { 72 | return x !== val; 73 | }); 74 | if (b.length < a.length) { 75 | self.config.set(key, b); 76 | self.emit('sendMsg', {dest: name, msg: val + ' was removed from ' + key + '.'}); 77 | } 78 | else { 79 | self.emit('sendMsg', {dest: name, msg: val + ' was not found in ' + key + '.'}); 80 | } 81 | } 82 | 83 | // save 84 | else if (operation === 'save') { 85 | self.config.save(function (err) { 86 | if (err) { 87 | self.emit('sendMsg', {dest: name, msg: err}); 88 | } 89 | self.emit('sendMsg', {dest: name, msg: 'Config saved.'}); 90 | self.emit('save'); 91 | }); 92 | } 93 | 94 | else { 95 | self.emit('sendMsg', {dest: name, msg: 'Sorry, ' + name + ', invalid operation for config.'}); 96 | } 97 | }; 98 | -------------------------------------------------------------------------------- /lib/plugins/gh.js: -------------------------------------------------------------------------------- 1 | /* 2 | * 3 | * plugins/gh.js - IRC commands for GitHub interaction. 4 | * 5 | * (c) 2011 Nodejitsu Inc. 6 | * 7 | */ 8 | 9 | var gh = exports; 10 | 11 | gh.gh = function (data, command) { 12 | if (!command[1]) { 13 | return; 14 | } 15 | var user = /([\w\.\-]+)/, 16 | project = /([\w\.\-]+)\/([\w\.\-]+)/, 17 | issue = /([\w\.\-]+)\/([\w\.\-]+)#(\d+)/, 18 | SHA = /([\w\.\-]+)\/([\w\.\-]+)@([a-fA-F0-9]+)/, 19 | result; 20 | 21 | if (result = issue.exec(command[1])) { 22 | return this.emit('sendMsg', { 23 | dest: data.to, 24 | msg: data.nick + ', https://github.com/' + result[1] + '/' + result[2] + 25 | '/issues/' + result[3] 26 | }); 27 | } 28 | 29 | if (result = SHA.exec(command[1])) { 30 | return this.emit('sendMsg', { 31 | dest: data.to, 32 | msg: data.nick + ', https://github.com/' + result[1] + '/' + result[2] + 33 | '/commit/' + result[3] 34 | }); 35 | } 36 | 37 | if (result = project.exec(command[1])) { 38 | return this.emit('sendMsg', { 39 | dest: data.to, 40 | msg: data.nick + ', https://github.com/' + result[1] + '/' + result[2] 41 | }); 42 | } 43 | 44 | if (result = user.exec(command[1])) { 45 | return this.emit('sendMsg', { 46 | dest: data.to, 47 | msg: data.nick + ', https://github.com/' + result[1] 48 | }); 49 | } 50 | }; 51 | 52 | -------------------------------------------------------------------------------- /lib/plugins/help.js: -------------------------------------------------------------------------------- 1 | /* 2 | * 3 | * help.js - kohai's interactive help system. 4 | * 5 | * (c) 2011 Nodejitsu Inc. 6 | * 7 | */ 8 | 9 | function say(dest, msg) { 10 | this.emit('sendMsg', { dest: dest, msg: msg }); 11 | } 12 | 13 | var help = module.exports = function (data, command) { 14 | var helpInfo = this.config.get('help'); 15 | if (command.length === 1) { 16 | this.emit('sendMsg', { 17 | dest: data.nick, 18 | msg: 'Hi! I\'m ' + this.nick + '. For help topics, type `help list`.' 19 | }); 20 | } 21 | else if (command[1] in helpInfo) { 22 | say.call(this, data.to, helpInfo[command[1]]); 23 | } 24 | else if (command[1] === 'list') { 25 | say.call(this, data.nick, 'Available help topics: ' + Object.keys(helpInfo).join(' ')); 26 | } 27 | }; 28 | 29 | 30 | -------------------------------------------------------------------------------- /lib/plugins/support.js: -------------------------------------------------------------------------------- 1 | /* 2 | * 3 | * support.js - send support emails from the IRC. 4 | * 5 | * (c) 2011 Nodejitsu Inc. 6 | * 7 | */ 8 | 9 | var support = module.exports = function (data, command) { 10 | var self = this, 11 | emailOptions, 12 | msg = command.slice(1).join(' '), 13 | source = ' [' + data.nick + ']'; 14 | 15 | emailOptions = { 16 | to : 'support@nodejitsu.com', 17 | from : 'kohai@nodejitsu.com', 18 | subject : '[IRC]' + source, 19 | body : new Date() + ' ' + msg 20 | }; 21 | 22 | self.emit('sendEmail', emailOptions); 23 | 24 | self.once('*::emailSent', function (result) { 25 | self.emit('sendMsg', { 26 | dest: data.to, 27 | msg: 'Support email sent successfully. Someone will try to find you ASAP!' 28 | }); 29 | }); 30 | }; 31 | -------------------------------------------------------------------------------- /lib/plugins/twitter.js: -------------------------------------------------------------------------------- 1 | /* 2 | * 3 | * plugins/twitter.js - IRC commands for Twitter interaction. 4 | * 5 | * (c) 2011 Nodejitsu Inc. 6 | * 7 | */ 8 | 9 | var twitter = exports; 10 | 11 | twitter.tweet = function (data, command) { 12 | if (!data[this.ranks[2]]) { 13 | return false; 14 | } 15 | var text = command.slice(1).join(' '); 16 | if (text.length > 140) { 17 | this.emit('sendMsg', { 18 | dest: data.to, 19 | msg: 'Sorry ' + data.nick + ', that tweet message exceeds 140 characters (' + text.length + ').' 20 | }); 21 | } 22 | this.emit('tweet', text); 23 | }; 24 | 25 | twitter.stoptweets = function (data, command) { 26 | if (!data[this.ranks[1]]) { 27 | return false; 28 | } 29 | this.emit('stopTweets', null); 30 | }; 31 | 32 | twitter.starttweets = function (data, command) { 33 | if (!data[this.ranks[1]]) { 34 | return false; 35 | } 36 | this.emit('startTweets', null); 37 | }; 38 | 39 | twitter.report = function (data, command) { 40 | if (!data[this.ranks[1]]) { 41 | return false; 42 | } 43 | this.emit('report', {name: command[1], to: data.to}); 44 | }; 45 | 46 | twitter.block = function (data, command) { 47 | if (!data[this.ranks[1]]) { 48 | return false; 49 | } 50 | this.emit('block', {name: command[1], to: data.to}); 51 | }; 52 | 53 | 54 | 55 | -------------------------------------------------------------------------------- /lib/triggers.js: -------------------------------------------------------------------------------- 1 | /* 2 | * 3 | * triggers.js - The commands available to IRC users. 4 | * 5 | * (c) 2011 Nodejitsu Inc. 6 | * 7 | */ 8 | 9 | var fs = require('fs'); 10 | 11 | var triggers = module.exports = { 12 | 13 | 'version' : function (data, command) { 14 | if (!data[this.ranks[2]]) { return false; } 15 | this.emit('sendMsg', { 16 | dest: data.to, 17 | msg: 'Kohai ' + this.config.get('version') 18 | + ' running on Node.JS ' 19 | + process.version 20 | }); 21 | }, 22 | 23 | 'channel': function (data, command) { 24 | if (!data[this.ranks[0]]) { return false; } 25 | if (!this.channels[command[1]]) { 26 | return this.emit('sendMsg', { 27 | dest: data.to, 28 | msg: 'Sorry, cannot alter channel configuration for ' + command[1] 29 | }); 30 | } 31 | var result = this.channels[command[1]].config(command[2], command[3]); 32 | if (result) { 33 | this.emit('sendMsg', { dest: data.to, msg: result }); 34 | } 35 | else { 36 | this.emit('sendMsg', { dest: data.to, msg: 'Sorry, channel does not have property ' + command[2] }); 37 | } 38 | }, 39 | 40 | 'join' : function (data, command) { 41 | if (!data[this.ranks[0]]) { return false; } 42 | this.emit('join', command[1]); 43 | }, 44 | 45 | 'part' : function (data, command) { 46 | if (!data[this.ranks[0]]) { return false; } 47 | var channel = command[1] || data.to; 48 | this.emit('part', channel); 49 | }, 50 | 51 | 'insult' : function (data, command) { 52 | if (!data[this.ranks[2]]) { return false; } 53 | var n = Math.floor(Math.random() * this.insults.length), 54 | target = command.slice(1).join(' ').replace(/[^\w\d\s]/g, ''); 55 | this.emit('sendMsg', { dest: data.to, msg: this.insults[n].replace('%%', target) }) 56 | }, 57 | 58 | 'voice' : function (data, command) { 59 | if (!data[this.ranks[0]]) { return false } 60 | var dest = command[2] || data.to; 61 | this.emit('sendMsg', { dest: dest, msg: 'I declare you cool, ' + command[1] + '!' }); 62 | this.emit('command', 'mode ' + dest + ' +v ' + command[1]); 63 | }, 64 | 65 | 'devoice' : function (data, command) { 66 | if (!data[this.ranks[0]]) { return false } 67 | var dest = command[2] || data.to; 68 | this.emit('sendMsg', { dest: dest, msg: 'No more voice for you, ' + command[1] + '!' }); 69 | this.emit('command', 'mode ' + dest + ' -v ' + command[1]); 70 | }, 71 | 72 | 'op' : function (data, command) { 73 | if (!data[this.ranks[0]]) { return false } 74 | var dest = command[2] || data.to; 75 | this.emit('command', 'mode ' + dest + ' +o ' + command[1]); 76 | }, 77 | 78 | 'deop' : function (data, command) { 79 | if (!data[this.ranks[0]]) { return false } 80 | var dest = command[2] || data.to; 81 | this.emit('command', 'mode ' + dest + ' -o ' + command[1]); 82 | }, 83 | 84 | 'kick' : function (data, command) { 85 | if (!data[this.ranks[1]]) { return false; } 86 | var dest = command[2] || data.to; 87 | console.log(command[1], ' has been kicked from ', dest); 88 | this.emit('sendMsg', { dest: dest, msg: 'kohai says GTFO!' }); 89 | this.emit('command', 'kick ' + dest + ' ' + command[1]); 90 | }, 91 | 92 | 'ban' : function (data, command) { 93 | if (!data[this.ranks[0]]) { return false; } 94 | var dest = command[2] || data.to; 95 | console.log(command[1], ' has been banned from ', dest, ' at the request of ', data.nick); 96 | this.emit('sendMsg', { dest: dest, msg: 'BEHOLD THE MIGHT OF THE BANHAMMER!!!' }); 97 | this.emit('command', 'mode ' + dest + ' +b ' + command[1]); 98 | this.emit('command', 'kick ' + dest + ' ' + command[1]); 99 | }, 100 | 101 | 'unban' : function (data, command) { 102 | if (!data[this.ranks[0]]) { return false; } 103 | var dest = command[2] || data.to; 104 | console.log(command[1], ' has been unbanned from ', dest, ' at the request of ', data.nick); 105 | this.emit('sendMsg', { dest: dest, msg: 'Mercy has been bestowed upon ' + command[1] }); 106 | this.emit('command', 'mode ' + dest + ' -b ' + command[1]); 107 | }, 108 | 109 | 'stfu' : function (data, command) { 110 | if (!data[this.ranks[1]]) { return false; } 111 | var self = this, 112 | dest = (isNaN(parseInt(command[2]))) ? command[2] : data.to, 113 | timer = (command[3] && !isNaN(parseInt(command[3]))) ? // If command[3] is a number, 114 | parseInt(command[3]) : // parse it as an int and use it. 115 | !isNaN(parseInt(command[2])) ? // Otherwise, if command[2] is a number, 116 | parseInt(command[2]) : // try to parse command[2] as an int; 117 | 60; // if that fails as well, the default timer is 60 seconds. 118 | self.emit('sendMsg', { 119 | dest: dest, 120 | msg: 'Gross Adjusted Noobosity of ' + command[1] 121 | + ' has exceeded specified telemetry parameters. Irrevocable ' 122 | + timer + ' second mute has been initiated.' 123 | }); 124 | self.emit('command', 'mode ' + dest + ' +q ' + command[1]); 125 | setTimeout(function () { 126 | self.emit('command', 'mode ' + dest + ' -q ' + command[1]); 127 | self.emit('sendMsg', { 128 | dest: dest, 129 | msg: 'Noobosity telemetry data now below thresholds. Removing mute for ' + command[1] + '.' 130 | }); 131 | }, timer * 1000); 132 | }, 133 | 134 | 'gtfo': function (data, command) { 135 | if (!data[this.ranks[0]]) { return false; } 136 | this.emit('hook::exit', null); 137 | } 138 | } 139 | 140 | fs.readdirSync(__dirname + '/plugins').forEach(function (plugin) { 141 | var mod; 142 | if (/^.*\.js$/.test(plugin)) { 143 | plugin = plugin.replace('.js', ''); 144 | try { 145 | var mod = require(__dirname + '/plugins/' + plugin); 146 | switch(typeof mod) { 147 | case 'function': 148 | if (!(plugin in triggers)) { 149 | triggers[plugin] = mod; 150 | } 151 | else { 152 | console.log('Duplicate trigger not loaded: %s', plugin); 153 | } 154 | break; 155 | case 'object': 156 | Object.getOwnPropertyNames(mod).forEach(function (item) { 157 | if (item in triggers) { 158 | console.log('Duplicate trigger not loaded: %s.%s', plugin, item); 159 | } 160 | else if (typeof mod[item] === 'function') { 161 | triggers[item] = mod[item]; 162 | } 163 | else { 164 | console.log('Unable to load trigger: %s.%s', plugin, item); 165 | } 166 | }); 167 | break; 168 | default: 169 | console.log('Cannot load non-functions at this time.'); 170 | break; 171 | } 172 | } 173 | catch (err) { 174 | console.log('Plugin \'%s\' was not loaded due to error: %s', plugin, err.message); 175 | } 176 | } 177 | }); 178 | 179 | 180 | 181 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "kohai", 3 | "description": "IRC bot for managing real-time data events.", 4 | "version": "0.1.3-1", 5 | "author": "Nodejitsu Inc. ", 6 | "maintainers": [ 7 | "AvianFlu ", 8 | "Marak " 9 | ], 10 | "repository": { 11 | "type": "git", 12 | "url": "http://github.com/nodejitsu/kohai.git" 13 | }, 14 | "bin": { 15 | "kohai": "bin/kohai" 16 | }, 17 | "main": "./lib/kohai", 18 | "dependencies": { 19 | "hook.io": "0.7.x", 20 | "colors": "0.5.x", 21 | "hook.io-irc": "0.4.x", 22 | "hook.io-twitter": "0.2.x", 23 | "hook.io-mailer": "0.3.x" 24 | }, 25 | "engines": { 26 | "node": ">= 0.4.x < 0.7.x" 27 | } 28 | } 29 | 30 | --------------------------------------------------------------------------------