diff --git a/.gitmodules b/.gitmodules index 0d17ed4..365ed83 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,3 +1,9 @@ [submodule "jsbot"] path = jsbot url = git://github.com/reality/jsbot.git +[submodule "modules/stats"] + path = modules/stats + url = git://github.com/SamStudio8/stats.git +[submodule "modules/github"] + path = modules/github + url = git://github.com/zuzak/dbot-github.git diff --git a/LICENCE b/LICENCE new file mode 100644 index 0000000..627898c --- /dev/null +++ b/LICENCE @@ -0,0 +1,18 @@ +Copyright (c) 2012-2013 Luke Slater (tinmachin3@gmail.com) + +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. diff --git a/README.md b/README.md index f9adc1c..3b3264a 100644 --- a/README.md +++ b/README.md @@ -3,8 +3,8 @@ ## Introduction Depressionbot is an IRC bot which aims to be the fanciest IRC bot around - On -the general standard of software fanciness, dbot is rated as being '81% the same -as bathing in fine, fine grape juice.' +the general standard of software fanciness, dbot is statistically rated as being +'82% the same as bathing in fine, fine grape juice.' Please note that this documentation is not complete and is a work in progress, given I started it rather a long time after I began development of the project. @@ -15,111 +15,14 @@ Requirements: - Node JS - [JSBot](http://github.com/reality/JSBot "JSBot"), a Javascript library which - handles the IRC protocol. + handles the IRC protocol +- Underscore JS library - Various modules have their own requirements also. -### JSBot +### External Modules -JSBot can be imported by running the following commands in the cloned repository: +JSBot and externally developed modules can be imported by running the following +commands in the cloned repository: git submodule init git submodule update - -## Modules: - -### Command - -This handles the command execution logic for DBot. - -1. Does the input match a command key in *dbot.commands* ? -2. Is there a quote category which matches the first part of the input - (*~category*)? -3. Is there a command name similar to to the first part of the input (*~name*) - in *dbot.commands*? - -This is the only module which is force loaded, even if it's not in the -configuration. - -### Quotes - -This is the original reason that DBot was created, stores and displays quotes. - -Commands: - -- _~q category_ - Display a random quote from a given category. -- _~qadd category=newquote_ - Add a new quote to the database. -- _~qstats_ - Show a list of the biggest quote categories. -- _~qsearch category needle_ - Search for a quote in a given category. -- _~rmlast [category]_ - Remove the last quote added to a given category, or the - last quote added. -- _~rm category quote_ - Remove a given quote from the given category. -- _~qcount category_ - Show the number of quotes stored in the given category. -- _~rq_ - Show a random quote from a random category. -- _~d_ - Show a quote from the category which matches the bot's name. -- _~link category_ - Create a link to the page on the web interface which displays the - given category's quotes. -- _~qprune_ - Delete empty quote categories. - -Unfortunately, this module is fairly highly coupled with certain other areas of -the program. I am working on this, but note, for example, that one can still -access quotes with the *~category* syntax even if the quotes module isn't -loaded. - -### Admin - -Various administration functionality such as banning users, hot-reloading the -code and ordering him to talk. Note that commands added here are handled with -their own listener, rather than being part of the command logic which is handled -by the Command module. Functionality in this module can be slightly unsafe as -not much error checking on the input is performed. - -TODO: Add summaries for each command in this module. - -### Spelling - -Will attempt to correct a users' spelling by using the levenshtein distance -algorithm. One corrects the spelling of their previous message by simply posting -a message with their correction and an asterisk: - - > user: I am a tutrle. - > user: *turtle - user meant: I am a turtle. - -The regular expression for this module also accepts two asterisks at the -beginning of the correction, or at the end; it also accepts several words as the -correction and deals with these fairly intelligently. Users may also attempt -to correct another users like so: - - > userone: I am a tutrle. - > usertwo: userone: *turtle - > usertwo thinks userone meant: I am a turtle. - -### JS - -This module provides two commands which allow the execution of Javascript code. -For regular users, there is the *~js* command, which is completely sandboxed, -but can still be used for calculation and the like. - - > ~js Array(16).join('wat'-1) + " Batman!"; - 'NaNNaNNaNNaNNaNNaNNaNNaNNaNNaNNaNNaNNaNNaNNaN Batman!' - -This feature is fairly safe as the user doesn't have access to anything -dangerous, and is safe from infinite loops or locking DBot up because the code -which is run is killed if it does not finish within a short amount of time. - -For administrators, the incredibly useful *~ajs* command is also available. The -input for this command is simply 'eval'-ed and therefore has full access to -DBot's memory. Of course, this is incredibly unsafe, but I find it rather fun; -remember to only give extremely trusted friends administrator access to DBot, as -there's nothing to stop them wiping the database or something similar - if -you're worried about that kind of thing, do not load this module. - -However, it's useful for many things, such as administrative activity for -which there isn't a command in the admin module. For example, you could hot-add -a new administrator like this: - - > ~ajs dbot.admin.push('batman'); - 2 - -You can also use this for debugging, or even adding new commands while DBot is -running. diff --git a/VERSION b/VERSION new file mode 100644 index 0000000..f593bdf --- /dev/null +++ b/VERSION @@ -0,0 +1,5 @@ +depressionbot version 0.4-dev + +"the database is a grilled cheese" +_.each(dbot.modules, function(module) { "RESCORE EVERYTHING" }); +"He called his bot depressionbot, and that's when he was happy." diff --git a/config.json.sample b/config.json.sample index 31c11f6..05c37a4 100644 --- a/config.json.sample +++ b/config.json.sample @@ -12,6 +12,8 @@ } }, "admins": [ "batman" ], - "moduleNames": [ "ignore", "admin", "command", "dice", "js", "kick", "puns", "quotes", "spelling", "youare" ], - "language": "english" + "moderators": [ "whatever" ], + "moduleNames": [ "ignore", "admin", "command", "dice", "js", "kick", "quotes", "spelling", "youare", "stats", "users" ], + "language": "english", + "debugMode": true } diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..8a8d85d --- /dev/null +++ b/install.sh @@ -0,0 +1,35 @@ +#!/bin/bash +cat LICENCE +git submodule init +git submodule update + +npm install underscore request sandbox express moment jade@0.25 + +cd public/ +wget http://twitter.github.com/bootstrap/assets/bootstrap.zip +unzip bootstrap.zip +rm bootstrap.zip + +mkdir d3 +cd d3 +wget http://d3js.org/d3.v3.zip +unzip d3.v3.zip +rm d3.v3.zip + +cd ../.. + +if [ ! -f config.json ]; +then + echo 'Creating configuration file...' + cp config.json.sample config.json + vim config.json +fi + +read -p "Setup complete. Run depressionbot now? [y/N]" +if [[ ! $REPLY =~ ^[Yy]$ ]] +then + echo 'Okay. To run the bot, use "node run.js"' + exit +fi +node run.js + diff --git a/jsbot b/jsbot index dbd9875..35910d9 160000 --- a/jsbot +++ b/jsbot @@ -1 +1 @@ -Subproject commit dbd987551bd8ce68655bbedb6bc5e98e90557708 +Subproject commit 35910d9025fa3af15b24cecc3f6e7ee897aee4dc diff --git a/modules/admin/README.md b/modules/admin/README.md new file mode 100644 index 0000000..0a0583c --- /dev/null +++ b/modules/admin/README.md @@ -0,0 +1,57 @@ +## Admin + +Administrator functionality. + +### Description + +Various administration functionality such as banning users, hot-reloading the +code and ordering him to talk. Note that commands added here are handled with +their own listener, rather than being part of the command logic which is handled +by the Command module. Functionality in this module can be slightly unsafe as +not everything is thoroughly sanity checked. + +### Commands + +#### join [#channel] +Join the given channel. + +#### part [#channel] +Leave the given channel. + +#### opme [#channel] +Gives the caller ops in a given channel if possible. If called without a +channel, it will attempt to give the caller ops in the current channel. + +#### greload +Perform a git pull, and then execute the 'reload' command. Saves a lot of time +updating! + +#### reload +Reload all of the modules currently in use by DBot. By using this, all module +functionality should be reloadable and replaceable without having to restart the +bot or interrupt the connection to the server. + +#### say [#channel] [message] +Have DBot post the given message in the given channel (uses the server from +which you are sending the message). You may replace channel with '@' to have him +post the message in the current channel. Channel may also be replaced with a +nick on the server. + +#### load [module] +Load a new module. This works by adding a module name to the roster and then +triggering a reload of all modules, at which point the new module is actually +loaded by the standard DBot process. + +#### unload [module] +Unload a currently loaded module. This removes the module, and then triggers a +reload of all modules. + +#### ban [user] [command] +Ban a user from using a command. Command may be replaced with '\*,' which will +ban a user from use of all commands. Users banned from all commands will still +be subject to module listeners. + +#### unban [user] [command] +Unban a user from using a given command. If a user was previously banned using +the '\*' wildcard, they may also be unbanned from such by replacing command with +an asterisk here as well. diff --git a/modules/admin/admin.js b/modules/admin/admin.js index d51474b..f1edaf3 100644 --- a/modules/admin/admin.js +++ b/modules/admin/admin.js @@ -3,147 +3,12 @@ * Description: Set of commands which only one who is a DepressionBot * administrator can run - as such, it has its own command execution listener. */ -var fs = require('fs'); -var sys = require('sys') -var exec = require('child_process').exec; +var fs = require('fs'), + _ = require('underscore')._; var admin = function(dbot) { - var commands = { - // Join a channel - 'join': function(event) { - var channel = event.params[1]; - if(event.allChannels.hasOwnProperty(channel)) { - event.reply("I'm already in that channel."); - } else { - dbot.instance.join(event, channel); - event.reply(dbot.t('join', {'channel': channel})); - } - }, - - // Leave a channel - 'part': function(event) { - var channel = event.params[1]; - if(!event.allChannels.hasOwnProperty(channel)) { - event.reply("I'm not in that channel."); - } else { - event.instance.part(event, channel); - event.reply(dbot.t('part', {'channel': channel})); - } - }, - - // Op admin caller in given channel - 'opme': function(event) { - var channel = event.params[1]; - - // If given channel isn't valid just op in current one. - if(!event.allChannels.hasOwnProperty(channel)) { - channel = event.channel.name; - } - dbot.instance.mode(event, channel, ' +o ' + event.user); - }, - - // Do a git pull and reload - 'greload': function(event) { - var child = exec("git pull", function (error, stdout, stderr) { - event.reply(dbot.t('gpull')); - commands.reload(event); - }.bind(this)); - }, - - // Reload DB, translations and modules. - 'reload': function(event) { - dbot.db = JSON.parse(fs.readFileSync('db.json', 'utf-8')); - dbot.reloadModules(); - event.reply(dbot.t('reload')); - }, - - // Say something in a channel (TODO probably doesn't work.) - 'say': function(event) { - var channel = event.params[1]; - if(event.params[1] === "@") { - var channel = event.channel.name; - } - var message = event.params.slice(2).join(' '); - dbot.say(event.server, channel, message); - }, - - // Load new module - 'load': function(event) { - var moduleName = event.params[1]; - dbot.config.moduleNames.push(moduleName); - dbot.reloadModules(); - event.reply(dbot.t('load_module', {'moduleName': moduleName})); - }, - - // Unload a loaded module - 'unload': function(event) { - var moduleNames = dbot.config.moduleNames; - var moduleName = event.params[1]; - if(moduleNames.include(moduleName)) { - var moduleDir = '../' + moduleName + '/'; - var cacheKey = require.resolve(moduleDir + moduleName); - delete require.cache[cacheKey]; - - var moduleIndex = moduleNames.indexOf(moduleName); - moduleNames.splice(moduleIndex, 1); - dbot.reloadModules(); - - event.reply(dbot.t('unload_module', {'moduleName': moduleName})); - } else { - event.reply(dbot.t('unload_error', {'moduleName': moduleName})); - } - }, - - // Ban user from command or * - 'ban': function(event) { - var username = event.params[1]; - var command = event.params[2]; - - if(!dbot.db.bans.hasOwnProperty(command)) { - dbot.db.bans[command] = [ ]; - } - dbot.db.bans[command].push(username); - event.reply(dbot.t('banned', {'user': username, 'command': command})); - }, - - // Unban a user from command or * - 'unban': function(event) { - var username = event.params[1]; - var command = event.params[2]; - if(dbot.db.bans.hasOwnProperty(command) && dbot.db.bans[command].include(username)) { - dbot.db.bans[command].splice(dbot.db.bans[command].indexOf(username), 1); - event.reply(dbot.t('unbanned', {'user': username, 'command': command})); - } else { - event.reply(dbot.t('unban_error', {'user': username})); - } - }, - - // Lock quote category so quotes can't be removed - 'lock': function(event) { - var category = event.params[1]; - dbot.db.locks.push(category); - event.reply(dbot.t('qlock', {'category': category})); - } - }; - - return { - 'name': 'admin', - 'ignorable': false, - - /** - * Run the appropriate admin command given the input (and user). - */ - 'listener': function(event) { - var commandName = event.params[0]; - if(commands.hasOwnProperty(commandName) && dbot.config.admins.include(event.user)) { - commands[commandName](event); - dbot.save(); - } - }, - 'on': 'PRIVMSG' - }; }; exports.fetch = function(dbot) { - return admin(dbot); + return new admin(dbot); }; diff --git a/modules/admin/commands.js b/modules/admin/commands.js new file mode 100644 index 0000000..a5d3719 --- /dev/null +++ b/modules/admin/commands.js @@ -0,0 +1,312 @@ +var fs = require('fs'), + _ = require('underscore')._, + sys = require('sys'), + exec = require('child_process').exec; + +var commands = function(dbot) { + var noChangeConfig = [ 'servers', 'name', 'moduleNames' ]; + + var getCurrentConfig = function(configKey) { + var defaultConfigPath = dbot.config; + var userConfigPath = dbot.db.config; + + if(configKey) { + var configKey = configKey.split('.'); + for(var i=0;i 0){ + event.reply(stdout); + } + else{ + event.reply("No version information or queried module not loaded"); + } + }.bind(this)); + }, + + + 'status': function(event) { + var moduleName = event.params[1]; + if(_.has(dbot.status, moduleName)) { + var status = dbot.status[moduleName]; + if(status === true) { + event.reply(moduleName + ' status: Shit looks good.'); + } else { + event.reply(moduleName + ' status: Failed to load: ' + status); + } + } else { + event.reply('Either that module wasn\'t on the roster or shit is totally fucked.'); + } + }, + + // Reload DB, translations and modules. + 'reload': function(event) { + dbot.db = JSON.parse(fs.readFileSync('db.json', 'utf-8')); + dbot.reloadModules(); + event.reply(dbot.t('reload')); + }, + + // Say something in a channel + 'say': function(event) { + var channel = event.params[1]; + if(event.params[1] === "@") { + var channel = event.channel.name; + } + var message = event.params.slice(2).join(' '); + dbot.say(event.server, channel, message); + }, + + // Load new module + 'load': function(event) { + var moduleName = event.params[1]; + if(!_.include(dbot.config.moduleNames, moduleName)) { + dbot.config.moduleNames.push(moduleName); + dbot.reloadModules(); + if(dbot.status[moduleName] === true) { + event.reply(dbot.t('load_module', {'moduleName': moduleName})); + } else { + event.reply('Failed to load ' + moduleName + '. See \'status ' + moduleName + '\'.'); + } + } else { + if(moduleName == 'web') { + event.reply(dbot.t('already_loaded_web')); + } else { + event.reply(dbot.t('already_loaded', {'moduleName': moduleName})); + } + } + }, + + // Unload a loaded module + 'unload': function(event) { + var moduleNames = dbot.config.moduleNames; + var moduleName = event.params[1]; + if(_.include(moduleNames, moduleName)) { + var moduleDir = '../' + moduleName + '/'; + var cacheKey = require.resolve(moduleDir + moduleName); + delete require.cache[cacheKey]; + dbot.config.moduleNames = _.without(dbot.config.moduleNames, moduleName); + dbot.reloadModules(); + + event.reply(dbot.t('unload_module', {'moduleName': moduleName})); + } else { + event.reply(dbot.t('unload_error', {'moduleName': moduleName})); + } + }, + + // Ban user from command or * + 'ban': function(event) { + var username = event.params[1]; + var command = event.params[2]; + + if(!_.has(dbot.db.bans, command)) { + dbot.db.bans[command] = [ ]; + } + dbot.db.bans[command].push(username); + event.reply(dbot.t('banned', {'user': username, 'command': command})); + }, + + // Unban a user from command or * + 'unban': function(event) { + var username = event.params[1]; + var command = event.params[2]; + if(_.has(dbot.db.bans, command) && _.include(dbot.db.bans[command], username)) { + _.reject(dbot.db.bans[command], function(bans) { + return bans == username; + }, this); + event.reply(dbot.t('unbanned', {'user': username, 'command': command})); + } else { + event.reply(dbot.t('unban_error', {'user': username})); + } + }, + + /*** Config options ***/ + + 'setconfig': function(event) { + var configPathString = event.params[1], + configKey = _.last(configPathString.split('.')), + newOption = event.params[2]; + + if(!_.include(noChangeConfig, configKey)) { + var configPath = getCurrentConfig(configPathString); + + if(configPath == false || _.isUndefined(configPath.value)) { + event.reply("Config key doesn't exist bro"); + return; + } + var currentOption = configPath.value; + + // Convert to boolean type if config item boolean + if(_.isBoolean(currentOption)) { + newOption = (newOption == "true"); + } + + if(_.isArray(currentOption)) { + event.reply("Config option is an array. Try 'pushconfig'."); + } + + event.reply(configPathString + ": " + currentOption + " -> " + newOption); + configPath['user'][configKey] = newOption; + dbot.reloadModules(); + } else { + event.reply("This config option cannot be altered while the bot is running."); + } + }, + + 'pushconfig': function(event) { + var configPathString = event.params[1], + configKey = _.last(configPathString.split('.')), + newOption = event.params[2]; + + if(!_.include(noChangeConfig, configKey)) { + var configPath = getCurrentConfig(configPathString); + if(configPath == false || _.isUndefined(configPath.value)) { + event.reply("Config key doesn't exist bro"); + return; + } + var currentArray = configPath.value; + + if(!_.isArray(currentArray)) { + event.reply("Config option is not an array. Try 'setconfig'."); + return + } + + event.reply(configPathString + ": " + currentArray + " << " + newOption); + currentArray.push(newOption); + dbot.reloadModules(); + } + }, + + 'showconfig': function(event) { + var configPathString = event.params[1]; + var configPath = getCurrentConfig(configPathString); + + if(configPathString) { + var configKey = _.last(configPathString.split('.')); + if(configKey == false) { + event.reply("Config path doesn't exist"); + return; + } + + if(_.isArray(configPath.value)) { + event.reply(configKey + ': ' + configPath.value); + } else if(_.isObject(configPath.value)) { + event.reply('Config keys in ' + configPathString + ': ' + Object.keys(configPath.value)); + } else { + event.reply(configKey + ': ' + configPath.value); + } + } else { + event.reply('Config keys in root: ' + Object.keys(configPath['default'])); + } + } + }; + + commands['greload'].access = 'admin'; + commands['reload'].access = 'admin'; + commands['unload'].access = 'admin'; + commands['load'].access = 'admin'; + commands['setconfig'].access = 'admin'; + commands['showconfig'].access = 'moderator'; + commands['join'].access = 'moderator'; + commands['part'].access = 'moderator'; + commands['opme'].access = 'moderator'; + commands['say'].access = 'moderator'; + commands['ban'].access = 'moderator'; + commands['unban'].access = 'moderator'; + + return commands; +}; + +exports.fetch = function(dbot) { + return commands(dbot); +} diff --git a/modules/admin/config.json b/modules/admin/config.json index e1a07bb..009fb85 100644 --- a/modules/admin/config.json +++ b/modules/admin/config.json @@ -1,3 +1,6 @@ { - "dbKeys": [ "bans", "locks" ] + "ignorable": false, + "dbKeys": [ "bans" ], + "dependencies": [ "command" ], + "help": "http://github.com/reality/depressionbot/blob/master/modules/admin/README.md" } diff --git a/modules/admin/strings.json b/modules/admin/strings.json index 8ecacd0..272b72a 100644 --- a/modules/admin/strings.json +++ b/modules/admin/strings.json @@ -64,5 +64,21 @@ "spanish": "Cerrado la categoría: {category}", "na'vi": "{category}ìri oel 'upxareti fmoli", "welsh": "Categori wedi cloi: {category}" + }, + "already_in_channel": { + "english": "I'm already in {channel}", + "na'vi": "Oel {channel}it tok li" + }, + "not_in_channel": { + "english": "I'm not in {channel}", + "na'vi": "Oel {channel}it ke tok" + }, + "already_loaded_web": { + "english": "WHY CAN'T I LOAD ALL THIS WEB? (web already loaded)", + "na'vi": "PELUN OEL KE TSUN OMUM FÌWETIT NÌWOTX (wetìri oe omum li)" + }, + "already_loaded": { + "english": "{moduleName} is already loaded.", + "na'vi": "Oel omum teri {moduleName}it li." } } diff --git a/modules/command/README.md b/modules/command/README.md new file mode 100644 index 0000000..619fa04 --- /dev/null +++ b/modules/command/README.md @@ -0,0 +1,67 @@ +## Command + +Handles the command execution logic for DBot. + +### Description + +Command flow: + +1. Does the input match a command key in the loaded commands? + * If command not found and quotes is loaded, attempt to print quote of given + command name +2. Is the user banned from running the given command? +3. Is the user ignoring the command? +4. Is the channel ignoring the command? +5. Does the use have the access level to run the command? +6. Is the command set as disabled? +7. Apply regex to the command, pass into event object. + * If regex does not apply, show usage info. +8. Run the command. + +This is the only module which is force loaded, even if it's not specified in +the configuration file. + +### Commands + +#### ~usage [command] +Show usage information for a given command. + +#### ~help [command|module] +Link module help for a module given either the module name or the name of a +command belonging to a module. + +### API + +#### isBanned(user, command) +Return whether a user is currently banned from a given commands. + +#### hasAccess(user, command) +Return whether a user has the access level (moderator, admin) to run a given +command. + +#### isIgnoring(user, command) +Return whether a user is currently marked as ignoring a given command. + +#### addHook(command, callback) +This API function allows you to hook functions into DBot commands. For example, +you may add a hook to post on Identica when a new quote is added to the database +with the ~qadd command. As a less useful example, here is how you might add a +hook to log to the console every time someone uses the reload command: + + dbot.api.command.addHook('reload', function() { + console.log('Reload run!'); + }); + +Hook arguments are populated by the return values of the functions they are +hooked into, and command hooks are not run if the command explicitly returns +'false.' For example, the ~qadd command returns *[ key, quote ]*, and the hook +function will be called with these variables given in the order they were +returned, so you would retrieve the key and the quote from a hook to ~qadd like +this: + + dbot.api.command.addHook('~qadd', function(key, quote) { ... + +The best place to add hooks to commands is in the 'onLoad' function of your +module, as this ensures it will be run while all other modules are loaded. If +the target command does not exist (for example if its module was not loaded), +the hook will not be added and no errors will be thrown. diff --git a/modules/command/api.js b/modules/command/api.js new file mode 100644 index 0000000..51202e9 --- /dev/null +++ b/modules/command/api.js @@ -0,0 +1,78 @@ +var _ = require('underscore')._; + +var api = function(dbot) { + return { + 'isBanned': function(user, command) { + var banned = false; + if(_.has(dbot.db.bans, command)) { + if(_.include(dbot.db.bans[command], user) || _.include(dbot.db.bans['*'], user)) { + banned = true; + } + } + return banned; + }, + + /** + * Does the user have the correct access level to use the command? + */ + 'hasAccess': function(user, command) { + var access = true; + var accessNeeded = dbot.commands[command].access; + + if(accessNeeded == 'admin') { + if(!_.include(dbot.config.admins, user)) { + access = false; + } + } else if(accessNeeded == 'moderator') { + if(!_.include(dbot.config.moderators, user) && + !_.include(dbot.config.admins, user)) { + access = false; + } + } + + return access; + }, + + /** + * Is item (user or channel) ignoring command? + */ + 'isIgnoring': function(item, command) { + var module = dbot.commands[command].module; + return (_.has(dbot.db.ignores, item) && + _.include(dbot.db.ignores[item], module)); + }, + + /** + * Apply Regex to event message, store result. Return false if it doesn't + * apply. + */ + 'applyRegex': function(commandName, event) { + var applies = false; + if(_.has(dbot.commands[commandName], 'regex')) { + var cRegex = dbot.commands[commandName].regex; + var q = event.message.valMatch(cRegex[0], cRegex[1]); + if(q) { + applies = true; + event.input = q; + } + } else { + applies = true; + } + return applies; + }, + + 'addHook': function(command, callback) { + console.log('adding hook'); + if(_.has(dbot.commands, command)) { + if(!_.has(dbot.commands[command], 'hooks')) { + dbot.commands[command].hooks = []; + } + dbot.commands[command].hooks.push(callback); + } + } + }; +}; + +exports.fetch = function(dbot) { + return api(dbot); +}; diff --git a/modules/command/command.js b/modules/command/command.js index 6342da2..935f6e0 100644 --- a/modules/command/command.js +++ b/modules/command/command.js @@ -4,100 +4,62 @@ * command and then runs that command, given the user isn't banned from or * ignoring that command. */ +var _ = require('underscore')._; var command = function(dbot) { + this.dbot = dbot; + /** - * Is user banned from using command? + * Run the appropriate command given the input. */ - var isBanned = function(user, command) { - var banned = false; - if(dbot.db.bans.hasOwnProperty(command)) { - if(dbot.db.bans[command].include(user) || dbot.db.bans['*'].include(user)) { - banned = true; - } - } - return banned; - }; - - /** - * Is user ignoring command? - */ - var isIgnoring = function(user, command) { - var module = dbot.commandMap[command]; - var ignoring = false; - if(dbot.db.ignores.hasOwnProperty(user) && dbot.db.ignores[user].include(module)) { - ignoring = true; - } - return ignoring; - }; - - /** - * Apply Regex to event message, store result. Return false if it doesn't - * apply. - */ - var applyRegex = function(commandName, event) { - var applies = false; - if(dbot.commands[commandName].hasOwnProperty('regex')) { - var cRegex = dbot.commands[commandName].regex; - var q = event.message.valMatch(cRegex[0], cRegex[1]); - if(q) { - applies = true; - event.input = q; - } - } else { - applies = true; - } - return applies; - }; - - return { - 'name': 'command', - 'ignorable': false, - - 'commands': { - '~usage': function(event) { - var commandName = event.params[1]; - if(dbot.usage.hasOwnProperty(commandName)) { - event.reply('Usage for ' + commandName + ': ' + - dbot.usage[commandName]); - } else { - event.reply('No usage information for ' + commandName); - } - } - }, - - /** - * Run the appropriate command given the input. - */ - 'listener': function(event) { - var commandName = event.params[0]; - if(!dbot.commands.hasOwnProperty(commandName)) { + this.listener = function(event) { + var commandName = event.params[0]; + if(!_.has(dbot.commands, commandName)) { + if(_.has(dbot.modules, 'quotes')) { commandName = '~'; - } - - if(isBanned(event.user, commandName)) { - event.reply(dbot.t('command_ban', {'user': event.user})); } else { - if(!isIgnoring(event.user, commandName)) { - if(applyRegex(commandName, event)) { - dbot.commands[commandName](event); - dbot.save(); - } else { - if(commandName !== '~') { - if(dbot.usage.hasOwnProperty(commandName)){ - event.reply('Usage: ' + dbot.usage[commandName]); - } else { - event.reply(dbot.t('syntax_error')); - } + return; + } + } + + if(this.api.isBanned(event.user, commandName)) { + event.reply(dbot.t('command_ban', {'user': event.user})); + } else { + if(!this.api.isIgnoring(event.user, commandName) && + !this.api.isIgnoring(event.channel, commandName) && + this.api.hasAccess(event.user, commandName) && + dbot.commands[commandName].disabled !== true) { + if(this.api.applyRegex(commandName, event)) { + try { + var command = dbot.commands[commandName]; + var results = command.apply(dbot.modules[command.module], [event]); + if(_.has(command, 'hooks') && results !== false) { + _.each(command['hooks'], function(hook) { + hook.apply(hook.module, _.values(results)); + }, this); + } + } catch(err) { + if(dbot.config.debugMode == true) { + event.reply('- Error in ' + commandName + ':'); + event.reply('- Message: ' + err); + event.reply('- Top of stack: ' + err.stack.split('\n')[1].trim()); + } + } + dbot.save(); + } else { + if(commandName !== '~') { + if(_.has(dbot.usage, commandName)) { + event.reply('Usage: ' + dbot.usage[commandName]); + } else { + event.reply(dbot.t('syntax_error')); } } } } - }, - 'on': 'PRIVMSG' - }; + } + }.bind(this); + this.on = 'PRIVMSG'; }; exports.fetch = function(dbot) { - return command(dbot); + return new command(dbot); }; - diff --git a/modules/command/commands.js b/modules/command/commands.js new file mode 100644 index 0000000..96ac77a --- /dev/null +++ b/modules/command/commands.js @@ -0,0 +1,43 @@ +var _ = require('underscore')._; + +var commands = function(dbot) { + return { + '~usage': function(event) { + var commandName = event.params[1]; + if(_.has(dbot.usage, commandName)) { + event.reply(dbot.t('usage', { + 'command': commandName, + 'usage': dbot.usage[commandName] + })); + } else { + event.reply(dbot.t('no_usage_info', { + 'command': commandName + })); + } + }, + + '~help': function(event) { + var moduleName = event.params[1]; + if(!_.has(dbot.modules, moduleName)) { + var moduleName = dbot.commands[moduleName].module; + } + + if(moduleName && _.has(dbot.config[moduleName], 'help')) { + var help = dbot.config[moduleName].help; + event.reply(dbot.t('help_link', { + 'module': moduleName, + 'link': help + })); + } else { + if(!moduleName) { + moduleName = event.params[1]; + } + event.reply(dbot.t('no_help', { 'module': moduleName })) + } + } + }; +}; + +exports.fetch = function(dbot) { + return commands(dbot); +}; diff --git a/modules/command/config.json b/modules/command/config.json new file mode 100644 index 0000000..f7de9b5 --- /dev/null +++ b/modules/command/config.json @@ -0,0 +1,5 @@ +{ + "ignorable": false, + "help": "http://github.com/reality/depressionbot/blob/master/modules/command/README.md", + "dbKeys": [ "ignores" ] +} diff --git a/modules/command/strings.json b/modules/command/strings.json index 47e1b11..511c51e 100644 --- a/modules/command/strings.json +++ b/modules/command/strings.json @@ -10,5 +10,21 @@ "spanish": "Sintaxis no válida. Iniciar incineración.", "na'vi": "Ngeyä pamrel keyawr lu. Nga skxawng lu.", "welsh": "Cystrawen annilys. Cychwyn orfflosgiad" + }, + "usage": { + "english": "Usage for {command}: {usage}.", + "na'vi": "Nga tsun sivar ìlä {command}: {usage}." + }, + "no_usage_info": { + "english": "No usage information found for {command}.", + "na'vi": "Oel ke tsun sivar {comamnd}it" + }, + "help_link": { + "english": "Help for {module}: {link}", + "na'vi": "{module}ä srungìl {link} it tok" + }, + "no_help": { + "english": "No help found for {module}.", + "na'vi": "Fì{module}ìri oel ke tsun run srungit" } } diff --git a/modules/dent/README.md b/modules/dent/README.md new file mode 100644 index 0000000..a13464a --- /dev/null +++ b/modules/dent/README.md @@ -0,0 +1,31 @@ +## Dent + +Post dents. + +### Description + +Allows the posting of dents to Identica. Easily abused for posting status +messages to Twitter by linking the Identica account. + +### Commands + +#### ~dent [text] +Post the given text to Identica. + +### Configuration + +#### username +Identica username to post with. + +#### password +Identica password to post with. + +### API + +#### post(content) +Post the given content to Identica. + +### Hooks + +#### ~qadd +Posts new quote additions. diff --git a/modules/dent/config.json b/modules/dent/config.json new file mode 100644 index 0000000..4a48ea8 --- /dev/null +++ b/modules/dent/config.json @@ -0,0 +1,7 @@ +{ + "username": "youruserhere", + "password": "yourpasswordhere", + "dependencies": [ "command" ], + "ignorable": true, + "dentQuotes": false +} diff --git a/modules/dent/dent.js b/modules/dent/dent.js new file mode 100644 index 0000000..8ee57e0 --- /dev/null +++ b/modules/dent/dent.js @@ -0,0 +1,47 @@ +var request = require('request'); + _ = require('underscore')._; + +var dent = function(dbot) { + this.dbot = dbot; + + this.api = { + 'post': function(content) { + var username = dbot.config.dent.username, + password = dbot.config.dent.password, + info, + auth = "Basic " + + new Buffer(username + ":" + password).toString("base64"); + + request.post({ + 'url': 'http://identi.ca/api/statuses/update.json?status=' + + content, + 'headers': { + 'Authorization': auth + } + }, + function(error, response, body) { + console.log(body); + }.bind(this)); + } + }; + + this.commands = { + '~dent': function(event) { + this.api.post(event.input[1]); + event.reply('Dent posted (probably).'); + } + }; + this.commands['~dent'].regex = [/^~dent (.+)$/, 2]; + + this.onLoad = function() { + if(dbot.config.dent.dentQuotes === true && _.has(dbot.modules, 'quotes')) { + dbot.api.command.addHook('~qadd', function(key, text) { + this.api.post(key + ': ' + text); + }.bind(this)); + } + }.bind(this); +}; + +exports.fetch = function(dbot) { + return new dent(dbot); +}; diff --git a/modules/dice/README.md b/modules/dice/README.md new file mode 100644 index 0000000..049d695 --- /dev/null +++ b/modules/dice/README.md @@ -0,0 +1,9 @@ +## Dice +Rolls virtual dice. + +### Description +Rolls a virtual die and outputs the result to the channel. +### Commands + +#### ~roll +Rolls a die. 1d6 will be rolled by default. diff --git a/modules/dice/config.json b/modules/dice/config.json new file mode 100644 index 0000000..3f82d9c --- /dev/null +++ b/modules/dice/config.json @@ -0,0 +1,4 @@ +{ + "ignorable": true, + "dependencies": [ "command" ] +} diff --git a/modules/dice/dice.js b/modules/dice/dice.js index 172822b..82459b3 100644 --- a/modules/dice/dice.js +++ b/modules/dice/dice.js @@ -92,14 +92,9 @@ var dice = function(dbot) { } } }; - - return { - 'name': 'dice', - 'commands': commands, - 'ignorable': true - }; + this.commands = commands; } exports.fetch = function(dbot) { - return dice(dbot); + return new dice(dbot); }; diff --git a/modules/event/README.md b/modules/event/README.md new file mode 100644 index 0000000..7474288 --- /dev/null +++ b/modules/event/README.md @@ -0,0 +1,30 @@ +## Event + +Emit events for whatever you want man idk. + +### Description + +This is a library module designed for other modules to use to emit various +events at any point, and also to attach functions to said events. These are +similar to command hooks, however their advantage is that they may be called +anywhere in your code; they are particularly useful when you want to attach a +callback to a listener. + +### API + +#### addHook(eventName, callback) +This function will set a given callback to be executed every time the +emit API function is executed with the given event name. The arguments of your +callback are defined as an array in the emit call. + +The best place to add hooks to commands is in the 'onLoad' function of your +module, as this ensures it will be run while all other modules are loaded so +nothing will be missed. + +#### emit(eventName, [ arguments ]) +This function executes all of the functions associated with the given eventName, +passing your given array of arguments. + +For example, to emit an event when you detect a nick change: + + dbot.api.event.emit('nick_changed', [ event.server, newNick ]); diff --git a/modules/event/event.js b/modules/event/event.js new file mode 100644 index 0000000..83fb822 --- /dev/null +++ b/modules/event/event.js @@ -0,0 +1,28 @@ +/** + * Module Name: event + * Description: Allow other modules to emit events and that + */ +var _ = require('underscore')._; + +var event = function(dbot) { + this.dbot = dbot; + this.hooks = {}; + this.api = { + 'addHook': function(eventName, callback) { + if(!_.has(this.hooks, eventName)) this.hooks[eventName] = []; + this.hooks[eventName].push(callback); + }, + + 'emit': function(eventName, args) { + if(_.has(this.hooks, eventName)) { + _.each(this.hooks[eventName], function(callback) { + callback.apply(callback.module, args); + }); + } + } + }; +} + +exports.fetch = function(dbot) { + return new event(dbot); +}; diff --git a/modules/github b/modules/github new file mode 160000 index 0000000..ca25f8f --- /dev/null +++ b/modules/github @@ -0,0 +1 @@ +Subproject commit ca25f8f94e205e2a6e3119227a6255eb80cd26df diff --git a/modules/ignore/README.md b/modules/ignore/README.md new file mode 100644 index 0000000..816b059 --- /dev/null +++ b/modules/ignore/README.md @@ -0,0 +1,26 @@ +## Ignore + +Ignore modules. + +### Description + +Commands with which users can choose to ignore listeners and commands from +certain modules persistently, by storing their choices in the database. This is +an interface for the JSBot ignoreTag functionality which actually implements +the ignoration. + +### Configuration + +All modules may return with them an 'ignorable' property, which defines whether +or not their functionality may be ignored by users. + +### Commands + +#### ~ignore [module] +Ignore a given module. If the user does not specify a module, or provides an +invalid one a list of modules which are available to ignore will be given. + +#### ~unignore [module] +Unignore a previously ignored module. If the user does not specify a module, or +provides an invalid choice a list of modules which are currently ignored will be +given. diff --git a/modules/ignore/config.json b/modules/ignore/config.json index 7a4c116..fa1961a 100644 --- a/modules/ignore/config.json +++ b/modules/ignore/config.json @@ -1,3 +1,6 @@ { - "dbKeys": [ "ignores" ] + "ignorable": false, + "dependencies": [ "command" ], + "dbKeys": [ "ignores" ], + "help": "http://github.com/reality/depressionbot/blob/master/modules/ignore/README.md" } diff --git a/modules/ignore/ignore.js b/modules/ignore/ignore.js index b7330b7..9fa9eac 100644 --- a/modules/ignore/ignore.js +++ b/modules/ignore/ignore.js @@ -2,80 +2,137 @@ * Module Name: Ignore * Description: Handles commands in which users can choose to ignore listeners * and commands from certain modules. It also populates the JSBot instance with - * this information, since that actually performs the ignorance. + * this information, since that actually performs the ignorance. Also provides + * commands for moderators to choose the bot to ignore certain channels. */ +var _ = require('underscore')._; + var ignore = function(dbot) { var commands = { '~ignore': function(event) { - var ignorableModules = []; - for(var i=0;i ~js Array(16).join('wat'-1) + " Batman!"; + 'NaNNaNNaNNaNNaNNaNNaNNaNNaNNaNNaNNaNNaNNaNNaN Batman!' + +This feature is fairly safe as the user doesn't have access to anything +dangerous, and is safe from infinite loops or locking DBot up because the code +which is run is killed if it does not finish within a short amount of time. + +#### ~ajs [code] +For administrators, the incredibly useful *~ajs* command is also available. The +input for this command is simply 'eval'-ed and therefore has full access to +DBot's memory. Of course, this is incredibly unsafe, but I find it rather fun; +remember to only give extremely trusted friends administrator access to your +DBot instance, as there's nothing to stop them wiping the database or probably +even your hard drive - if you're worried about that kind of thing, do not load +this module. + +However, it's useful for many things, such as administrative activity for +which there isn't a command in the admin module. For example, you could hot-add +a new administrator like this: + + > ~ajs dbot.admin.push('batman'); + 2 + +You can also use it for debugging, or even adding new commands while DBot is +running. diff --git a/modules/js/config.json b/modules/js/config.json new file mode 100644 index 0000000..084df95 --- /dev/null +++ b/modules/js/config.json @@ -0,0 +1,10 @@ +{ + "commands": { + "~js": { + "disabled": true + } + }, + "dependencies": [ "command" ], + "ignorable": true, + "help": "http://github.com/reality/depressionbot/blob/master/modules/js/README.md" +} diff --git a/modules/js/js.js b/modules/js/js.js index d6f5dd1..430ba66 100644 --- a/modules/js/js.js +++ b/modules/js/js.js @@ -8,12 +8,11 @@ var vm = require('vm'); var sbox = require('sandbox'); var js = function(dbot) { - var s = new sbox(); - var commands = { // Run JS code sandboxed, return result to channel. '~js': function(event) { try { + var s = new sbox(); s.run(event.input[1], function(output) { event.reply(output.result); }.bind(this)); @@ -22,11 +21,9 @@ var js = function(dbot) { // Run JS code un-sandboxed, with access to DBot memory (admin-only). '~ajs': function(event) { - if(dbot.config.admins.include(event.user) ) { - var ret = eval(event.input[1]); - if(ret !== undefined) { - event.reply(ret); - } + var ret = eval(event.input[1]); + if(ret !== undefined) { + event.reply(ret); } }, 'jesus': function (event) { @@ -36,14 +33,13 @@ var js = function(dbot) { commands['~js'].regex = [/^~js (.*)/, 2]; commands['~ajs'].regex = [/^~ajs (.*)/, 2]; commands['jesus'].regex = [/^jesus$/, 2]; + commands['~ajs'].access = 'admin'; - return { - 'name': 'js', - 'ignorable': true, - 'commands': commands - }; + this.name = 'js'; + this.ignorable = true; + this.commands = commands; }; exports.fetch = function(dbot) { - return js(dbot); + return new js(dbot); }; diff --git a/modules/kick/README.md b/modules/kick/README.md new file mode 100644 index 0000000..b3aa217 --- /dev/null +++ b/modules/kick/README.md @@ -0,0 +1,17 @@ +## Kick + +Kicking and kicking-related accessories. + +### Description +This module counts the number of times people are kicked from and kick people +from channels, and provides commands for viewing this data. It also has the bot +attempt to rejoin the channel if it is kicked. + +### Commands + +#### ~kickcount [username] +Show the number of times a given user has been kicked and has kicked other +people. + +#### ~kickstats +Show a list of top kickers and kickees. diff --git a/modules/kick/config.json b/modules/kick/config.json index 10e1e76..558129d 100644 --- a/modules/kick/config.json +++ b/modules/kick/config.json @@ -1,3 +1,6 @@ { - "dbKeys": [ "kicks", "kickers" ] + "dbKeys": [ "kicks", "kickers" ], + "dependencies": [ "command" ], + "help": "http://github.com/reality/depressionbot/blob/master/modules/kick/README.md", + "ignorable": true } diff --git a/modules/kick/kick.js b/modules/kick/kick.js index 150fb55..b70bb61 100644 --- a/modules/kick/kick.js +++ b/modules/kick/kick.js @@ -1,3 +1,5 @@ +var _ = require('underscore')._; + var kick = function(dbot) { var commands = { // Give the number of times a given user has been kicked and has kicked @@ -5,27 +7,35 @@ var kick = function(dbot) { '~kickcount': function(event) { var username = event.params[1]; - if(!dbot.db.kicks.hasOwnProperty(username)) { + if(!_.has(dbot.db.kicks, username)) { var kicks = '0'; } else { var kicks = dbot.db.kicks[username]; } - if(!dbot.db.kickers.hasOwnProperty(username)) { + if(!_.has(dbot.db.kickers, username)) { var kicked = '0'; } else { var kicked = dbot.db.kickers[username]; } - event.reply(dbot.t('user_kicks', {'user': username, 'kicks': kicks, 'kicked': kicked})); + event.reply(dbot.t('user_kicks', { + 'user': username, + 'kicks': kicks, + 'kicked': kicked + })); }, // Output a list of the people who have been kicked the most and those // who have kicked other people the most. '~kickstats': function(event) { var orderedKickLeague = function(list, topWhat) { - var kickArr = Object.prototype.sort(list, function(key, obj) { return obj[key]; }); - kickArr = kickArr.slice(kickArr.length - 10).reverse(); + var kickArr = _.chain(list) + .pairs() + .sortBy(function(kick) { return kick[1] }) + .reverse() + .first(10) + .value(); var kickString = "Top " + topWhat + ": "; for(var i=0;i +Returns a link to the [xkcd](http://xkcd.com) comic specified, or the latest one if a comic is not given. diff --git a/modules/link/config.json b/modules/link/config.json new file mode 100644 index 0000000..9c3dc3e --- /dev/null +++ b/modules/link/config.json @@ -0,0 +1,6 @@ +{ + "autoTitle": false, + "dependencies": [ "command" ], + "ignorable": true, + "help": "http://github.com/reality/depressionbot/blob/master/modules/link/README.md" +} diff --git a/modules/link/link.js b/modules/link/link.js index b9c5eaf..d684cf3 100644 --- a/modules/link/link.js +++ b/modules/link/link.js @@ -3,50 +3,85 @@ * Description: Stores recent channel links, with commands to retrieve * information about links. */ -var request = require('request'); +var request = require('request'), + _ = require('underscore')._; + var link = function(dbot) { - var urlRegex = /(\b(https?|ftp|file):\/\/[-A-Z0-9+&@#\/%?=~_|!:,.;]*[-A-Z0-9+&@#\/%=~_|])/ig; - var links = {}; + this.urlRegex = /(\b(https?|ftp|file):\/\/[-A-Z0-9+&@#\/%?=~_|!:,.;]*[-A-Z0-9+&@#\/%=~_|])/ig; + this.links = {}; + this.fetchTitle = function(event, link) { + request(link, function(error, response, body) { + if(!error && response.statusCode == 200) { + body = body.replace(/(\r\n|\n\r|\n)/gm, " "); + var title = body.valMatch(/(.*)<\/title>/, 2); + if(title) { + event.reply(title[1]); + } + } + }); + }; var commands = { '~title': function(event) { - var link = links[event.channel.name]; - if(event.params[1] !== undefined) { - var urlMatches = event.params[1].match(urlRegex); + var link = this.links[event.channel.name]; + if(!_.isUndefined(event.params[1])) { + var urlMatches = event.params[1].match(this.urlRegex); if(urlMatches !== null) { link = urlMatches[0]; } } - - request(link, function (error, response, body) { - if(!error && response.statusCode == 200) { - body = body.replace(/(\r\n|\n\r|\n)/gm, " "); - var title = body.valMatch(/<title>(.*)<\/title>/, 2); - if(title) { - event.reply(title[1]); - } else { - event.reply('no title found'); - } + this.fetchTitle(event, link); + }, + + '~xkcd': function(event) { + var comicId = event.params[1]; + if(comicId){ + comicId = comicId + "/"; + } else { + comicId = ""; + } + var link = "http://xkcd.com/"+comicId+"info.0.json"; + request(link, function(error, response, body) { + if (response.statusCode == "200") { + data = JSON.parse(body); + event.reply(dbot.t("xkcd",data)); + } else { + event.reply(dbot.t("no-hits")); } }); + }, + + '~ud': function(event) { + var query = event.input[1]; + var reqUrl = 'http://api.urbandictionary.com/v0/define?term=' + encodeURI(query); + request(reqUrl, function(error, response, body) { + try { + var result = JSON.parse(body); + if(_.has(result, 'result_type') && result.result_type != 'no_results') { + event.reply(query + ': ' + result.list[0].definition.split('\n')[0]); + } else { + event.reply(event.user + ': No definition found.'); + } + } catch(err) { } + }); } }; + commands['~ud'].regex = [/~ud (.+)/, 2]; + this.commands = commands; - return { - 'name': 'link', - 'ignorable': true, - 'commands': commands, + this.listener = function(event) { + var urlMatches = event.message.match(this.urlRegex); + if(urlMatches !== null) { + this.links[event.channel.name] = urlMatches[0]; - 'listener': function(event) { - var urlMatches = event.message.match(urlRegex); - if(urlMatches !== null) { - links[event.channel.name] = urlMatches[0]; + if(dbot.config.link.autoTitle == true) { + this.fetchTitle(event, urlMatches[0]); } - }, - 'on': 'PRIVMSG' - }; + } + }.bind(this); + this.on = 'PRIVMSG'; }; exports.fetch = function(dbot) { - return link(dbot); + return new link(dbot); }; diff --git a/modules/link/strings.json b/modules/link/strings.json new file mode 100644 index 0000000..935e635 --- /dev/null +++ b/modules/link/strings.json @@ -0,0 +1,12 @@ +{ + "title_not_found": { + "english": "No page title found.", + "na'vi": "Oel ke tsun run 'upxare atxin." + }, + "xkcd": { + "english": "xkcd {num}: {title} https://xkcd.com/{num}" + }, + "no-hits": { + "english": "No hits." + } +} diff --git a/modules/poll/README.md b/modules/poll/README.md new file mode 100644 index 0000000..86d3faa --- /dev/null +++ b/modules/poll/README.md @@ -0,0 +1,35 @@ +## Poll + +Pollers gonna poll. + +### Description +This module allows creation of and voting in polls, with associated +functionality. + +Note that while in terms of the interface all votes are anonymous, users' +voting choices are stored in the database for the purpose of users being +able to change their votes. Therefore an admin can technically go delving in +the database to see users' voting choices. + +### Commands + +#### ~newpoll [pollname] options=[each,poll,option] [Poll Description] +Creates a new poll with the given name, options and descriptions. From this +point people will be able to use the ~vote command to cast their vote in the +poll. + +#### ~addoption [pollname] [newoption] +Using this command you can add a given option to a poll you are the creator of. + +#### ~rmoption [pollname] [optiontoremove] +Using this command you can remove a given option from a poll you are the creator +of. + +#### ~vote [pollname] [option] +Cast your vote for the given option in the given poll. If you have already cast +your vote in the given poll, your vote will be changed to the new option you +have provided. + +#### ~pdesc [pollname] +Show the full description for a given poll name along with its available voting +options. diff --git a/modules/poll/commands.js b/modules/poll/commands.js new file mode 100644 index 0000000..ac36252 --- /dev/null +++ b/modules/poll/commands.js @@ -0,0 +1,197 @@ +var _ = require('underscore')._; + +var commands = function(dbot) { + var polls = dbot.db.polls; + var commands = { + '~newpoll': function(event) { + var name = event.input[1].toLowerCase(), + options = event.input[2].toLowerCase().split(','), + description = event.input[3]; + + if(_.has(polls, name)) { + event.reply(dbot.t('poll_exists', { 'name': name })); + } else { + polls[name] = { + 'name': name, + 'description': description, + 'owner': dbot.api.users.resolveUser(event.server, event.user), + 'votes': {}, + 'votees': {} + }; + for(var i=0;i<options.length;i++) { + polls[name]['votes'][options[i]] = 0; + } + event.reply(dbot.t('poll_created', { + 'name': name, + 'description': description, + 'url': dbot.t('url', { + 'host': dbot.config.web.webHost, + 'port': dbot.config.web.webPort, + 'path': 'polls/' + name + }) + })); + } + }, + + '~addoption': function(event) { + var name = event.input[1].toLowerCase(), + option = event.input[2].toLowerCase(), + user = dbot.api.users.resolveUser(event.server, event.user); + + if(_.has(polls, name)) { + if(polls[name].owner === user) { + if(!_.has(polls[name].votes, option)) { + polls[name]['votes'][option] = 0; + event.reply(dbot.t('option_added', { + 'user': event.user, + 'name': name, + 'option': option + })); + } else { + event.reply(dbot.t('option_exists', { + 'option': option, + 'name': name, + 'user': event.user + })); + } + } else { + event.reply(dbot.t('not_poll_owner', { + 'user': event.user, + 'name': name + })); + } + } else { + event.reply(dbot.t('poll_unexistent', {'name': name})); + } + }, + + '~rmoption': function(event) { + var name = event.input[1].toLowerCase(), + option = event.input[2].toLowerCase(), + user = dbot.api.users.resolveUser(event.server, event.user); + + if(_.has(polls, name)) { + if(polls[name].owner === user) { + if(_.has(polls[name].votes, option)) { + delete polls[name]['votes'][option]; + event.reply(dbot.t('option_removed', { + 'user': event.user, + 'name': name, + 'option': option + })); + } else { + event.reply(dbot.t('invalid_vote', { 'vote': option })); + } + } else { + event.reply(dbot.t('not_poll_owner', { 'name': name })); + } + } else { + event.reply(dbot.t('poll_unexistent', { 'name': name })); + } + }, + + '~vote': function(event) { + var name = event.input[1].toLowerCase(), + vote = event.input[2].toLowerCase(), + user = dbot.api.users.resolveUser(event.server, event.user); + + if(_.has(polls, name)) { + if(_.has(polls[name].votes, vote)) { + if(_.has(polls[name].votees, user)) { + var oldVote = polls[name].votees[user]; + polls[name].votes[oldVote]--; + polls[name].votes[vote]++; + polls[name].votees[user] = vote; + + event.reply(dbot.t('changed_vote', { + 'vote': vote, + 'poll': name, + 'count': polls[name].votes[vote], + 'user': event.user + })); + } else { + polls[name].votes[vote]++; + polls[name].votees[user] = vote; + event.reply(dbot.t('voted', { + 'vote': vote, + 'poll': name, + 'count': polls[name].votes[vote], + 'user': event.user + })); + } + } else { + event.reply(dbot.t('invalid_vote', { 'vote': vote })); + } + } else { + event.reply(dbot.t('poll_unexistent', { 'name': name })); + } + }, + + '~pdesc': function(event) { + var name = event.input[1].toLowerCase(); + + if(_.has(polls, name)) { + var options = _.keys(polls[name].votes); + var optionString = " Choices: "; + for(var i=0;i<options.length;i++) { + optionString += options[i] + ', '; + } + optionString = optionString.slice(0, -2) + '.'; + + event.reply(dbot.t('poll_describe', { + 'name': name, + 'description': polls[name].description, + 'url': dbot.t('url', { + 'host': dbot.config.web.webHost, + 'port': dbot.config.web.webPort, + 'path': 'polls/' + name + }) + }) + optionString); + } else { + event.reply(dbot.t('poll_unexistent', { 'name': name })); + } + }, + + '~count': function(event) { + var name = event.input[1].toLowerCase(); + + if(_.has(polls, name)) { + var order; + var votesArr = []; + + var order = _.chain(polls[name].votes) + .pairs() + .sortBy(function(option) { return option[1] }) + .reverse() + .value(); + + var orderString = ""; + for(var i=0;i<order.length;i++) { + orderString += order[i][0] + + " (" + order[i][1] + "), "; + } + orderString = orderString.slice(0, -2); + + event.reply(dbot.t('count', { + 'poll': name, + 'description': polls[name].description, + 'places': orderString + })); + } else { + event.reply(dbot.t('poll_unexistent', {'name': name})); + } + } + }; + commands['~newpoll'].regex = [/~newpoll ([^ ]+) options=([^ ]+) (.+)/, 4]; + commands['~addoption'].regex = [/~addoption ([^ ]+) ([^ ]+)/, 3]; + commands['~rmoption'].regex = [/~rmoption ([^ ]+) ([^ ]+)/, 3]; + commands['~vote'].regex = [/~vote ([^ ]+) ([^ ]+)/, 3]; + commands['~pdesc'].regex = [/~pdesc ([^ ]+)/, 2]; + commands['~count'].regex = [/~count ([^ ]+)/, 2]; + + return commands; +}; + +exports.fetch = function(dbot) { + return commands(dbot); +} diff --git a/modules/poll/config.json b/modules/poll/config.json new file mode 100644 index 0000000..7b0f48b --- /dev/null +++ b/modules/poll/config.json @@ -0,0 +1,6 @@ +{ + "help": "http://github.com/reality/depressionbot/blob/master/modules/poll/README.md", + "dbKeys": [ "polls" ], + "ignorable": true, + "dependencies": [ "users", "command" ] +} diff --git a/modules/poll/pages.js b/modules/poll/pages.js new file mode 100644 index 0000000..0c8ea41 --- /dev/null +++ b/modules/poll/pages.js @@ -0,0 +1,45 @@ +var _ = require('underscore')._; + +var pages = function(dbot) { + var polls = dbot.db.polls; + var pages = { + // Shows the results of a poll + '/polls/:key': function(req, res) { + var key = req.params.key.toLowerCase(); + if(_.has(dbot.db.polls, key)) { + var totalVotes = _.reduce(dbot.db.polls[key].votes, + function(memo, option) { + return memo += option; + }, 0); + res.render('polls', { + 'name': dbot.config.name, + 'description': dbot.db.polls[key].description, + 'votees': Object.keys(dbot.db.polls[key].votees), + 'options': dbot.db.polls[key].votes, + locals: { + 'totalVotes': totalVotes, + 'url_regex': RegExp.prototype.url_regex() + } + }); + } else { + res.render('error', { + 'name': dbot.config.name, + 'message': 'No polls under that key.' + }); + } + }, + + // Lists all of the polls + '/polls': function(req, res) { + res.render('polllist', { + 'name': dbot.config.name, + 'polllist': Object.keys(dbot.db.polls) + }); + }, + }; + return pages; +}; + +exports.fetch = function(dbot) { + return pages(dbot); +}; diff --git a/modules/poll/poll.js b/modules/poll/poll.js index 8c79a02..1110faa 100644 --- a/modules/poll/poll.js +++ b/modules/poll/poll.js @@ -1,229 +1,25 @@ var poll = function(dbot) { - var polls = dbot.db.polls; - var commands = { - '~newpoll': function(event) { - var av = event.input[1] != undefined; - var name = event.input[2]; - var options = event.input[3].split(','); - var description = event.input[4]; - - if(name === undefined || name === 'help') { - event.reply(dbot.t('newpoll_usage')); - } else { - if(polls.hasOwnProperty(name)) { - event.reply(dbot.t('poll_exists', {'name': name})); - } else { - if(av) { - polls[name] = { - 'av': av, - 'name': name, - 'description': description, - 'owner': event.user, - 'votes': {}, - 'options': [] - }; - for(var i=0;i<options.length;i++) { - polls[name].options.push(options[i]); - } - } else { - polls[name] = { - 'av': av, - 'name': name, - 'description': description, - 'owner': event.user, - 'votes': {}, - 'votees': {} - }; - for(var i=0;i<options.length;i++) { - polls[name]['votes'][options[i]] = 0; - } - } - - event.reply(dbot.t('poll_created', {'name': name, 'description': description, - 'url': dbot.t('url', {'host': dbot.config.web.webHost, - 'port': dbot.config.web.webPort, 'path': 'polls/' + name})})); + this.internalAPI = { + 'updatePollNicks': function(server, oldNick) { + var newNick = dbot.api.users.resolveUser(server, oldNick); + _.each(dbot.db.polls, function(poll) { + if(poll.owner === oldNick) { + poll.owner = newNick; } - } - }, - - '~addoption': function(event) { - var name = event.input[1]; - var option = event.input[2]; - - if(polls.hasOwnProperty(name)) { - if(polls[name].owner === event.user) { - if(!polls[name].votes.hasOwnProperty(name)) { - polls[name]['votes'][option] = 0; - event.reply(dbot.t('option_added', {'user': event.user, - 'name': name, 'option': option})); - } else { - event.reply(dbot.t('option_exists', {'option': option, - 'name': name, 'user': event.user})); - } - } else { - event.reply(dbot.t('not_poll_owner', {'user': event.user, - 'name': name})); + if(_.has(poll.votees, oldNick)) { + poll.votees[newNick] = poll.votees[oldNick]; + delete poll.votees[oldNick]; } - } else { - event.reply(dbot.t('poll_unexistent', {'name': name})); - } - }, - - '~rmoption': function(event) { - var name = event.input[1]; - var option = event.input[2]; - - if(polls.hasOwnProperty(name)) { - if(polls[name].owner === event.user) { - if(polls[name].votes.hasOwnProperty(option)) { - delete polls[name]['votes'][option]; - event.reply(dbot.t('option_removed', {'user': event.user, - 'name': name, 'option': option})); - } else { - event.reply(dbot.t('invalid_vote', {'vote': option})); - } - } else { - event.reply(dbot.t('not_poll_owner', {'name': name})); - } - } else { - event.reply(dbot.t('poll_unexistent', {'name': name})); - } - }, - - '~vote': function(event) { - var name = event.input[1]; - var vote = event.input[2]; - - if(polls.hasOwnProperty(name)) { - if(polls[name].av) { - var prefs = vote.split(','); - prefs = prefs.uniq(); - var valid = true; - - prefs.each(function(pref) { - valid = valid && polls[name].options.indexOf(pref) != -1; - }); - if(valid){ - if(polls[name].votes.hasOwnProperty(event.user)) { - polls[name].votes[event.user] = prefs; - event.reply(dbot.t('av_changed_vote', {'vote': prefs.join(','), 'poll': name, 'user': event.user})); - } else { - polls[name].votes[event.user] = prefs; - event.reply(dbot.t('av_voted', {'vote': prefs.join(','), 'poll': name, 'user': event.user})); - } - } else { - event.reply(dbot.t('invalid_vote', {'vote': vote})); - } - } else { - if(polls[name].votes.hasOwnProperty(vote)) { - if(polls[name].votees.hasOwnProperty(event.user)) { - var oldVote = polls[name].votees[event.user]; - polls[name].votes[oldVote]--; - polls[name].votes[vote]++; - polls[name].votees[event.user] = vote; - event.reply(dbot.t('changed_vote', {'vote': vote, 'poll': name, - 'count': polls[name].votes[vote], 'user': event.user})); - } else { - polls[name].votes[vote]++; - polls[name].votees[event.user] = vote; - event.reply(dbot.t('voted', {'vote': vote, 'poll': name, - 'count': polls[name].votes[vote], 'user': event.user})); - } - } else { - event.reply(dbot.t('invalid_vote', {'vote': vote})); - } - } - } else { - event.reply(dbot.t('poll_unexistent', {'name': name})); - } - }, - - '~pdesc': function(event) { - var name = event.input[1]; - if(polls.hasOwnProperty(name)) { - event.reply(dbot.t('poll_describe', {'name': name, 'description': polls[name].description, - 'url': dbot.t('url', {'host': dbot.config.web.webHost, 'port': - dbot.config.web.webPort, 'path': 'polls/' + name})})); - } else { - event.reply(dbot.t('poll_unexistent', {'name': name})); - } - }, - - '~count': function(event) { - var name = event.input[1]; - - if(polls.hasOwnProperty(name)) { - var order; - if(polls[name].av) { - var finished = false; - var rounds = []; - var eliminated = []; - var voted; - - for(var roundn = 0; roundn < polls[name].options.length; roundn++) { - var roundLoser; - - // Populate candidates for this round - rounds[roundn] = {}; - polls[name].options.each(function (option) { - if(eliminated.indexOf(option) == -1) - rounds[roundn][option] = 0; - }); - - // Count votes - polls[name].votes.withAll(function (name, vote) { - voted = false; - vote.each(function (pref) { - if(!voted && rounds[roundn].hasOwnProperty(pref)) { - rounds[roundn][pref]++; - voted = true; - } - }); - }); - - // Find the loser - var min = polls[name].votes.length() + 1; - rounds[roundn].withAll(function (option, count) { - if(count < min) { - roundLoser = option; - min = count; - } - }); - - // Eliminate loser - eliminated.push(roundLoser); - } - order = eliminated.reverse().join(', ') - } else { - var votesArr = []; - polls[name].votes.withAll(function(option, count) { - votesArr.push([option, count]); - }); - - votesArr = votesArr.sort(function(a, b) { return b[1] - a[1]; }); - - order = votesArr.map(function(vote) { return vote[0]; }); - } - event.reply(dbot.t('count', {'poll': name, 'description': polls[name].description, 'places': order})); - } else { - event.reply(dbot.t('poll_unexistent', {'name': name})); - } + }, this); } }; - commands['~newpoll'].regex = [/~newpoll (av )?([^ ]+) options=([^ ]+) (.+)/, 5]; - commands['~addoption'].regex = [/~addoption ([^ ]+) ([^ ]+)/, 3]; - commands['~rmoption'].regex = [/~rmoption ([^ ]+) ([^ ]+)/, 3]; - commands['~vote'].regex = [/~vote ([^ ]+) ([^ ]+)/, 3]; - commands['~pdesc'].regex = [/~pdesc ([^ ]+)/, 2]; - commands['~count'].regex = [/~count ([^ ]+)/, 2]; - - return { - 'name': 'poll', - 'ignorable': true, - 'commands': commands - }; + + this.onLoad = function() { + dbot.api.command.addHook('~setaliasparent', this.internalAPI.updatePollNicks); + dbot.api.command.addHook('~mergeusers', this.internalAPI.updatePollNicks); + }.bind(this); }; exports.fetch = function(dbot) { - return poll(dbot); + return new poll(dbot); } diff --git a/modules/poll/strings.json b/modules/poll/strings.json index 459ea98..412c3ec 100644 --- a/modules/poll/strings.json +++ b/modules/poll/strings.json @@ -17,7 +17,7 @@ "na'vi": "sìpawm sna'o '{name}' ngìyop ({description}). Nga tìpe'unit Pe'eiun - {url}" }, "poll_describe": { - "english": "{name}: {description} - {url}" + "english": "{name}: {description} - {url}." }, "changed_vote": { "english": "{user} changed their vote in {poll} to '{vote}' ({count}).", diff --git a/modules/profile/api.js b/modules/profile/api.js new file mode 100644 index 0000000..b1c36d2 --- /dev/null +++ b/modules/profile/api.js @@ -0,0 +1,71 @@ +var _ = require('underscore')._; + +var api = function(dbot) { + return { + + /** + * Create a profile for a new primary user on a given server. + * If the server does not already exist, create it. + */ + "createProfile": function(server, primary){ + var primaryLower = primary.toLowerCase(); + + if(!_.has(this.profiles, server)){ + this.profiles[server] = {}; + } + if(!_.has(this.profiles[server], primaryLower)){ + this.profiles[server][primaryLower] = { + "profile": {}, + "preferences": {} + }; + this.profiles[server][primaryLower].profile.primary = primary; + } + + // Ensure all profiles have the keys specified by config.json + //TODO(samstudio8) Currently only handles "top-level" + _.defaults(this.profiles[server][primaryLower].profile, this.config.schema.profile); + _.defaults(this.profiles[server][primaryLower].preferences, this.config.schema.preferences); + }, + + /** + * Given a server and "new" alias, resolve this alias to the user's + * new primary name and move profile data pertaining to the alias to + * the new primary name. + */ + 'renameProfile': function(server, alias){ + if(!_.has(this.profiles, server)) return; + var profiles = dbot.db.profiles[server]; + + if(_.has(profiles, alias)){ + var primary = dbot.api.users.resolveUser(server, alias, true); + var primaryLower = primary.toLowerCase(); + alias = alias.trim().toLowerCase(); + + profiles[primaryLower] = profiles[alias]; + profiles[primaryLower].profile.primary = primary; + delete profiles[alias]; + } + }, + + /** + * Given a server and a primary username which has been converted to a + * secondary alias find and remove the profile for the alias. + */ + 'mergeProfile': function(server, mergeFromPrimary){ + if(!_.has(this.profiles, server)) return; + var profiles = dbot.db.profiles[server]; + + mergeFromPrimary = mergeFromPrimary.toLowerCase(); + var mergeToPrimary = dbot.api.users.resolveUser(server, mergeFromPrimary, true).toLowerCase(); + if(!_.has(profiles, mergeToPrimary) + || !_.has(profiles, mergeFromPrimary)) return; + + // Remove the profile of the alias + delete profiles[mergeFromPrimary]; + }, + } +}; + +exports.fetch = function(dbot) { + return api(dbot); +}; diff --git a/modules/profile/commands.js b/modules/profile/commands.js new file mode 100644 index 0000000..a402066 --- /dev/null +++ b/modules/profile/commands.js @@ -0,0 +1,57 @@ +var _ = require('underscore')._; + +var commands = function(dbot){ + var commands = { + + "~getprop": function(event){ + if(event.params[1]){ + var primary = dbot.api.users.resolveUser(event.server, event.user); + var res = dbot.db.profiles[event.server][primary.toLowerCase()].profile[event.params[1]]; + if(res){ + event.reply(res); + } + else{ + event.reply("Nope."); + } + } + }, + + "~setprop": function(event){ + if(event.input[1] && event.input[2]){ + if(_.has(this.config.schema.profile, event.input[1])){ + var primary = dbot.api.users.resolveUser(event.server, event.user); + dbot.db.profiles[event.server][primary.toLowerCase()].profile[event.input[1]] = event.input[2]; + event.reply("Property set, maybe?"); + } + else{ + event.reply("Invalid property. Go home."); + } + } + }, + + "~profile": function(event){ + if(event.params[1]){ + var primary = dbot.api.users.resolveUser(event.server, event.params[1]); + if(_.has(dbot.db.profiles[event.server], primary.toLowerCase())){ + event.reply("http://"+dbot.config.web.webHost+":"+dbot.config.web.webPort+"/profile/"+event.server+"/"+primary.toLowerCase()); + } + else{ + event.reply("No profile found for "+event.params[1]); + } + } + else{ + event.message = '~profile ' + event.user; + event.action = 'PRIVMSG'; + event.params = event.message.split(' '); + dbot.instance.emit(event); + } + } + }; + commands['~setprop'].regex = [/~setprop ([^ ]+) (.+)/, 3]; + + return commands; +}; + +exports.fetch = function(dbot){ + return commands(dbot); +}; diff --git a/modules/profile/config.json b/modules/profile/config.json new file mode 100644 index 0000000..24f5027 --- /dev/null +++ b/modules/profile/config.json @@ -0,0 +1,21 @@ +{ + "ignorable": false, + "dbKeys": [ "profiles" ], + "help": "https://github.com/reality/depressionbot/blob/master/modules/profile/README.md", + "schema": { + "profile": { + "primary": null, + "name": null, + "tagline": null, + "avatar": null, + "bio": null, + "favourites": { + "colour": null + } + }, + "preferences": { + "timezone": null + } + }, + "dependencies": [ "quotes", "users", "command" ] +} diff --git a/modules/profile/pages.js b/modules/profile/pages.js new file mode 100644 index 0000000..b01ed28 --- /dev/null +++ b/modules/profile/pages.js @@ -0,0 +1,67 @@ +var pages = function(dbot) { + var _ = require('underscore')._; + var connections = dbot.instance.connections; + + return { + '/profile/:connection/:user': function(req, res) { + var connection = req.params.connection; + var user = dbot.cleanNick(req.params.user); + + var primary = dbot.api.users.resolveUser(connection, user, true); + //var profile = dbot.api.profile.getProfile(primary); + var profile = dbot.db.profiles[connection][primary.toLowerCase()].profile; + var stats = dbot.api.stats.getUserChansStats(connection, primary.toLowerCase(), [ + "lines", "words", "lincent", "wpl", "in_mentions"] + ); + + res.render('profile', { + 'name': dbot.config.name, + 'connection': connection, + 'primary': primary, + 'profile': profile, + 'stats': stats.channels, + }); + }, + + '/profile/:connection': function(req, res) { + var connection = req.params.connection; + var profiles = dbot.db.profiles[connection]; + + // TODO: Clean up + _.each(profiles, function(profile) { + if(_.has(dbot.db.quoteArrs, profile.profile.primary) && !profile.profile.avatar) { + var category = dbot.db.quoteArrs[profile.profile.primary]; + var avatar = _.find(category, function(quote) { + return quote.match(/(\.jpg|\.png|\.jpeg)$/i); + }); + if(avatar) profile.profile.avatar = avatar; + } + }); + + var nicks = []; + for (var p in profiles) { + if (profiles.hasOwnProperty(p) && profiles[p].profile.avatar) { + nicks.push(p); + } + } + nicks.sort(function(a, b) { + var x = profiles[a].profile.primary.toLowerCase(); + var y = profiles[b].profile.primary.toLowerCase(); + if(x > y) return 1; + if(x < y) return -1; + return 0; + }); + + res.render('profile_grid', { + 'name': dbot.config.name, + 'connection': connection, + 'nicks': nicks, + 'profiles': profiles, + }); + } + } +}; + +exports.fetch = function(dbot) { + return pages(dbot); +}; diff --git a/modules/profile/profile.js b/modules/profile/profile.js new file mode 100644 index 0000000..740790c --- /dev/null +++ b/modules/profile/profile.js @@ -0,0 +1,32 @@ +var _ = require('underscore')._; + +var profile = function(dbot) { + + this.profiles = dbot.db.profiles; + + /** + * Iterate over known user profiles and ensure they contain all the + * required properties as defined in the configuation. + */ + this.onLoad = function(){ + var api = this.api; + var schema = this.config.schema; + + // Ensure all known users have a profile + _.each(dbot.api.users.getAllUsers(), function(server, serverName){ + _.each(server, function(primary, primaryi){ + api.createProfile(serverName, primary); + }); + }); + dbot.save(); + + // Add API Hooks + dbot.api.command.addHook('~setaliasparent', this.api.renameProfile); + dbot.api.command.addHook('~mergeusers', this.api.mergeProfile); + dbot.api.event.addHook('new_user', this.api.createProfile); + }; +}; + +exports.fetch = function(dbot) { + return new profile(dbot); +}; diff --git a/modules/puns/puns.js b/modules/puns/puns.js deleted file mode 100644 index 187a652..0000000 --- a/modules/puns/puns.js +++ /dev/null @@ -1,25 +0,0 @@ -var puns = function(dbot) { - var name = 'puns'; - var dbot = dbot; - - return { - 'name': name, - 'ignorable': true, - - 'listener': function(event) { - event.user = dbot.cleanNick(event.user); - if(dbot.config.moduleNames.include('quotes') && - dbot.db.quoteArrs.hasOwnProperty(event.user)) { - event.message = '~q ' + event.user; - event.action = 'PRIVMSG'; - event.params = event.message.split(' '); - dbot.instance.emit(event); - } - }, - 'on': 'JOIN' - }; -} - -exports.fetch = function(dbot) { - return puns(dbot); -}; diff --git a/modules/quotes/README.md b/modules/quotes/README.md new file mode 100644 index 0000000..c397a6c --- /dev/null +++ b/modules/quotes/README.md @@ -0,0 +1,73 @@ +## Quotes + +Stores and displays quotes. + +### Description + +This is the original reason that DBot was created, stores and displays quotes. + +### Configuration + +#### rmLimit: 10 +Amount of quotes which can be removed before admin approval is required. + +### Commands + +#### ~q [category] +Display a random quote from a given category. + +#### ~qadd [category] = [quote] +Add a new quote to the database. + +#### ~qstats +Show a list of the biggest quote categories. + +#### ~qsearch [category] = [needle] +Search a category for quotes including the given text. + +#### ~rmlast [category] +Remove the last quote added to a given category. + +#### ~rmstatus +Show how many quotes are currently in the removal cache, and whether they will +be randomly removed. + +#### ~rm [category] = [quote] +Remove a given quote from the given category. + +#### ~qcount [category] +Show the number of quotes stored in the given category, or if called without a +category it will show the total number of quotes in the database. + +#### ~rq +Show a random quote from the database. + +#### ~link [category] +Show a link to the page on the web interface which shows this category's quotes. + +### Admin-only Commands + +#### ~rmconfirm +Confirm that the quotes currently in the removal cache are okay to be removed, +and permanently delete them. + +#### ~rmdeny +Re-instate the quotes that are currently in the removal cache back into the main +quote database. + +### API + +#### getQuote(event, category) +Returns a random quote from the given category. + +### Removal Spam Protection + +When quotes are removed using either the ~rm or ~rmlast commands, the quotes are +removed from the main database, but are stored in a removal cache which is cleared +out ten minutes from the last time a quote was removed from the database. If the +number of quotes removed from the database reaches a certain limit (as per rmLimit +in config, default 10) then the counter is removed and the cache will not be deleted +automatically. In such a case, a DBot admin needs to either run the ~rmconfim command +to have the removal cache cleared, or ~rmdeny to re-instate all of the quotes in +the removal cache back into the main quote database. This is to stop mass +removal from the database without limiting the user interface. diff --git a/modules/quotes/commands.js b/modules/quotes/commands.js new file mode 100644 index 0000000..86cf45b --- /dev/null +++ b/modules/quotes/commands.js @@ -0,0 +1,226 @@ +var _ = require('underscore')._; + +var commands = function(dbot) { + var quotes = dbot.db.quoteArrs; + var commands = { + // Alternative syntax to ~q + '~': function(event) { + commands['~q'].bind(this)(event); + }, + + '~rmstatus': function(event) { + var rmCacheCount = this.rmCache.length; + if(rmCacheCount < dbot.config.quotes.rmLimit) { + event.reply(dbot.t('quote_cache_auto_remove', + { 'count': rmCacheCount })); + } else { + event.reply(dbot.t('quote_cache_manual_remove', + { 'count': rmCacheCount })); + } + }, + + '~rmconfirm': function(event) { + var rmCacheCount = this.rmCache.length; + this.rmCache.length = 0; + event.reply(dbot.t('quote_cache_cleared', + { 'count': rmCacheCount })); + }, + + '~rmdeny': function(event) { + var rmCache = this.rmCache; + var rmCacheCount = rmCache.length; + for(var i=0;i<rmCacheCount;i++) { + if(!_.has(quotes, rmCache[i].key)) { + quotes[rmCache[i].key] = []; + } + quotes[rmCache[i].key].push(rmCache[i].quote); + } + rmCache.length = 0; + + event.reply(dbot.t('quote_cache_reinstated', + { 'count': rmCacheCount })); + }, + + + // Retrieve quote from a category in the database. + '~q': function(event) { + var key = event.input[1].trim().toLowerCase(); + var quote = this.api.getQuote(event, event.input[1]); + if(quote) { + event.reply(key + ': ' + quote); + } else { + event.reply(dbot.t('category_not_found', {'category': key})); + } + }, + + // Shows a list of the biggest categories + '~qstats': function(event) { + var qSizes = _.chain(quotes) + .pairs() + .sortBy(function(category) { return category[1].length }) + .reverse() + .first(10) + .value(); + + var qString = dbot.t('large_categories'); + for(var i=0;i<qSizes.length;i++) { + qString += qSizes[i][0] + " (" + qSizes[i][1].length + "), "; + } + + event.reply(qString.slice(0, -2)); + }, + + // Search a given category for some text. + // TODO fix + '~qsearch': function(event) { + var haystack = event.input[1].trim().toLowerCase(); + var needle = event.input[2]; + if(_.has(quotes, haystack)) { + var matches = _.filter(quotes[haystack], function(quote) { + return _.indexOf(quote, needle) != -1; + }, this); + + if(matches.length == 0) { + event.reply(dbot.t('no_results')); + } else { + event.reply(dbot.t('search_results', { + 'category': haystack, + 'needle': needle, + 'quote': matches.random(), + 'matches': matches.length + })); + } + } else { + event.reply(dbot.t('empty_category')); + } + }, + + '~rmlast': function(event) { + if(this.rmAllowed === true || _.include(dbot.config.admins, event.user)) { + var key = event.input[1].trim().toLowerCase(); + if(_.has(quotes, key)) { + var quote = quotes[key].pop(); + if(quotes[key].length == 0) { + delete quotes[key]; + } + this.internalAPI.resetRemoveTimer(event, key, quote); + + event.reply(dbot.t('removed_from', { + 'quote': quote, + 'category': key + })); + } else { + event.reply(dbot.t('no_quotes', {'category': q[1]})); + } + } else { + event.reply(dbot.t('rmlast_spam')); + } + }, + + '~rm': function(event) { + if(this.rmAllowed == true || _.include(dbot.config.admins, event.user)) { + var key = event.input[1].trim().toLowerCase(); + var quote = event.input[2]; + + if(_.has(quotes, key)) { + var category = quotes[key]; + var index = category.indexOf(quote); + if(index !== -1) { + category.splice(index, 1); + if(category.length === 0) { + delete quotes[key]; + } + this.internalAPI.resetRemoveTimer(event, key, quote); + + event.reply(dbot.t('removed_from', {'category': key, 'quote': quote})); + } else { + event.reply(dbot.t('q_not_exist_under', {'category': key, 'quote': quote})); + } + } else { + event.reply(dbot.t('category_not_found', {'category': key})); + } + } else { + event.reply(dbot.t('rmlast_spam')); + } + }, + + '~qcount': function(event) { + var input = event.message.valMatch(/^~qcount ([\d\w\s-]*)/, 2); + if(input) { // Give quote count for named category + var key = input[1].trim().toLowerCase(); + if(_.has(quotes, key)) { + event.reply(dbot.t('quote_count', { + 'category': key, + 'count': quotes[key].length + })); + } else { + event.reply(dbot.t('no_quotes', { 'category': key })); + } + } else { // Give total quote count + var totalQuoteCount = _.reduce(quotes, function(memo, category) { + return memo + category.length; + }, 0); + event.reply(dbot.t('total_quotes', { 'count': totalQuoteCount })); + } + }, + + '~qadd': function(event) { + var key = event.input[1].toLowerCase(); + var text = event.input[2]; + if(!_.isArray(quotes[key])) { + quotes[key] = []; + } + + if(_.include(quotes[key], text)) { + event.reply(dbot.t('quote_exists')); + } else { + quotes[key].push(text); + this.rmAllowed = true; + event.reply(dbot.t('quote_saved', { + 'category': key, + 'count': quotes[key].length + })); + + return { 'key': key, 'text': text }; + } + return false; + }, + + '~rq': function(event) { + var category = _.keys(quotes)[_.random(0, _.size(quotes) -1)]; + event.reply(category + ': ' + this.internalAPI.interpolatedQuote(event.server, event.channel.name, category)); + }, + + '~link': function(event) { + var key = event.params[1].trim().toLowerCase(); + if(_.has(quotes, key)) { + event.reply(dbot.t('quote_link', { + 'category': key, + 'url': dbot.t('url', { + 'host': dbot.config.web.webHost, + 'port': dbot.config.web.webPort, + 'path': 'quotes/' + key + }) + })); + } else { + event.reply(dbot.t('category_not_found', { 'category': key })); + } + }, + }; + + commands['~'].regex = [/^~([\d\w\s-]*)/, 2]; + commands['~q'].regex = [/^~q ([\d\w\s-]*)/, 2]; + commands['~qsearch'].regex = [/^~qsearch ([\d\w\s-]+?)[ ]?=[ ]?(.+)$/, 3]; + commands['~rm'].regex = [/^~rm ([\d\w\s-]+?)[ ]?=[ ]?(.+)$/, 3]; + commands['~rmlast'].regex = [/^~rmlast ([\d\w\s-]*)/, 2]; + commands['~qadd'].regex = [/^~qadd ([\d\w\s-]+?)[ ]?=[ ]?(.+)$/, 3]; + + commands['~rmconfirm'].access = 'moderator'; + commands['~rmdeny'].access = 'moderator'; + + return commands; +}; + +exports.fetch = function(dbot) { + return commands(dbot); +}; diff --git a/modules/quotes/config.json b/modules/quotes/config.json index 32c3324..e8d0465 100644 --- a/modules/quotes/config.json +++ b/modules/quotes/config.json @@ -1,3 +1,7 @@ { - "dbKeys": [ "quoteArrs" ] + "dbKeys": [ "quoteArrs" ], + "dependencies": [ "command", "users" ], + "rmLimit": 10, + "ignorable": true, + "help": "http://github.com/reality/depressionbot/blob/master/modules/quotes/README.md" } diff --git a/modules/quotes/pages.js b/modules/quotes/pages.js new file mode 100644 index 0000000..7800cd0 --- /dev/null +++ b/modules/quotes/pages.js @@ -0,0 +1,29 @@ +var _ = require('underscore')._; +var pages = function(dbot) { + return { + // Lists quotes in a category + '/quotes/:key': function(req, res) { + var key = req.params.key.toLowerCase(); + if(_.has(dbot.db.quoteArrs, key)) { + res.render('quotes', { 'name': dbot.config.name, 'quotes': dbot.db.quoteArrs[key], locals: { 'url_regex': RegExp.prototype.url_regex() } }); + } else { + res.render('error', { 'name': dbot.config.name, 'message': 'No quotes under that key.' }); + } + }, + + // Show quote list. + '/quotes': function(req, res) { + res.render('quotelist', { 'name': dbot.config.name, 'quotelist': Object.keys(dbot.db.quoteArrs) }); + }, + + // Load random quote category page + '/rq': function(req, res) { + var rCategory = Object.keys(dbot.db.quoteArrs).random(); + res.render('quotes', { 'name': dbot.config.name, 'quotes': dbot.db.quoteArrs[rCategory], locals: { 'url_regex': RegExp.prototype.url_regex() } }); + } + } +}; + +exports.fetch = function(dbot) { + return pages(dbot); +}; diff --git a/modules/quotes/quotes.js b/modules/quotes/quotes.js index abcc073..269e252 100644 --- a/modules/quotes/quotes.js +++ b/modules/quotes/quotes.js @@ -1,256 +1,115 @@ +var _ = require('underscore')._; + var quotes = function(dbot) { - var name = 'quotes'; - var quotes = dbot.db.quoteArrs; - var addStack = []; - var rmAllowed = true; + dbot.sessionData.rmCache = []; + this.quotes = dbot.db.quoteArrs, + this.addStack = [], + this.rmAllowed = true, + this.rmCache = dbot.sessionData.rmCache, + this.rmTimer; - // Retrieve a random quote from a given category, interpolating any quote - // references (~~QUOTE CATEGORY~~) within it - var interpolatedQuote = function(key, quoteTree) { - if(quoteTree !== undefined && quoteTree.indexOf(key) != -1) { - return ''; - } else if(quoteTree === undefined) { - quoteTree = []; - } - - var quoteString = quotes[key].random(); - - // Parse quote interpolations - var quoteRefs = quoteString.match(/~~([\d\w\s-]*)~~/g); - var thisRef; - - while(quoteRefs && (thisRef = quoteRefs.shift()) !== undefined) { - var cleanRef = dbot.cleanNick(thisRef.replace(/^~~/,'').replace(/~~$/,'').trim()); - if (quotes.hasOwnProperty(cleanRef)) { - quoteTree.push(key); - quoteString = quoteString.replace("~~" + cleanRef + "~~", - interpolatedQuote(cleanRef, quoteTree.slice())); - quoteTree.pop(); + this.internalAPI = { + // Retrieve a random quote from a given category, interpolating any quote + // references (~~QUOTE CATEGORY~~) within it + 'interpolatedQuote': function(server, channel, key, quoteTree) { + if(!_.isUndefined(quoteTree) && quoteTree.indexOf(key) != -1) { + return ''; + } else if(_.isUndefined(quoteTree)) { + quoteTree = []; } - } - return quoteString; + var index = _.random(0, this.quotes[key].length - 1); + var quoteString = this.quotes[key][index]; + + // Parse quote interpolations + var quoteRefs = quoteString.match(/~~([\d\w\s-]*)~~/g); + var thisRef; + + while(quoteRefs && (thisRef = quoteRefs.shift()) !== undefined) { + var cleanRef = dbot.cleanNick(thisRef.replace(/^~~/,'').replace(/~~$/,'').trim()); + if(cleanRef === '-nicks-') { + var randomNick = dbot.api.users.getRandomChannelUser(server, channel); + quoteString = quoteString.replace("~~" + cleanRef + "~~", randomNick); + quoteTree.pop(); + } else if(_.has(this.quotes, cleanRef)) { + quoteTree.push(key); + quoteString = quoteString.replace("~~" + cleanRef + "~~", + this.internalAPI.interpolatedQuote(server, channel, cleanRef, quoteTree.slice())); + quoteTree.pop(); + } + } + + return quoteString; + }.bind(this), + + 'resetRemoveTimer': function(event, key, quote) { + this.rmAllowed = false; + setTimeout(function() { + this.rmAllowed = true; + }.bind(this), 5000); + + this.rmCache.push({ + 'key': key, + 'quote': quote + }); + + clearTimeout(this.rmTimer); + if(this.rmCache.length < dbot.config.quotes.rmLimit) { + this.rmTimer = setTimeout(function() { + this.rmCache.length = 0; // lol what + }.bind(this), 600000); + } else { + _.each(dbot.config.admins, function(admin) { + dbot.say(event.server, admin, dbot.t('rm_cache_limit')); + }); + } + }.bind(this) }; - var commands = { - // Alternative syntax to ~q - '~': function(event) { - commands['~q'](event); - }, - - // Retrieve quote from a category in the database. - '~q': function(event) { - var key = event.input[1].trim().toLowerCase(); + this.api = { + 'getQuote': function(event, category) { + var key = category.trim().toLowerCase(); var altKey; if(key.split(' ').length > 0) { altKey = key.replace(/ /g, '_'); } if(key.charAt(0) !== '_') { // lol - if(quotes.hasOwnProperty(key)) { - event.reply(key + ': ' + interpolatedQuote(key)); - } else if(quotes.hasOwnProperty(altKey)) { - event.reply(altKey + ': ' + interpolatedQuote(altKey)); + if(_.has(this.quotes, key)) { + return this.internalAPI.interpolatedQuote(event.server, event.channel.name, key); + } else if(_.has(this.quotes, altKey)) { + return this.internalAPI.interpolatedQuote(event.server, event.channel.name, altKey); } else { - event.reply(dbot.t('category_not_found', {'category': key})); + return false; } - } - }, - - // Shows a list of the biggest categories - '~qstats': function(event) { - var qSizes = Object.prototype.sort(quotes, function(key, obj) { return obj[key].length }); - qSizes = qSizes.slice(qSizes.length - 10).reverse(); - - var qString = dbot.t('large_categories'); - for(var i=0;i<qSizes.length;i++) { - qString += qSizes[i][0] + " (" + qSizes[i][1] + "), "; - } - - event.reply(qString.slice(0, -2)); - }, - - // Search a given category for some text. - '~qsearch': function(event) { - var haystack = event.input[1].trim().toLowerCase(); - var needle = event.input[2]; - if(quotes.hasOwnProperty(haystack)) { - var matches = []; - quotes[haystack].each(function(quote) { - if(quote.indexOf(needle) != -1) { - matches.push(quote); - } - }.bind(this)); - - if(matches.length == 0) { - event.reply(dbot.t('no_results')); - } else { - event.reply(dbot.t('search_results', {'category': haystack, 'needle': needle, - 'quote': matches.random(), 'matches': matches.length})); - } - } else { - event.reply(dbot.t('empty_category')); - } - }, - - '~rmlast': function(event) { - if(rmAllowed == true || dbot.config.admins.include(event.user)) { - var key = event.input[1].trim().toLowerCase(); - if(quotes.hasOwnProperty(key)) { - if(!dbot.db.locks.include(key) || dbot.config.admins.include(event.user)) { - var quote = quotes[key].pop(); - if(quotes[key].length === 0) { - delete quotes[key]; - } - rmAllowed = false; - event.reply(dbot.t('removed_from', {'quote': quote, 'category': key})); - } else { - event.reply(dbot.t('locked_category', {'category': q[1]})); - } - } else { - event.reply(dbot.t('no_quotes', {'category': q[1]})); - } - } else { - event.reply(dbot.t('rmlast_spam')); - } - }, - - '~rm': function(event) { - if(rmAllowed == true || dbot.config.admins.include(event.user)) { - var key = event.input[1].trim().toLowerCase(); - var quote = event.input[2]; - - if(quotes.hasOwnProperty(key)) { - if(!dbot.db.locks.include(key)) { - var category = quotes[key]; - var index = category.indexOf(quote); - if(index !== -1) { - category.splice(index, 1); - if(category.length === 0) { - delete quotes[key]; - } - event.reply(dbot.t('removed_from', {'category': key, 'quote': quote})); - } else { - event.reply(dbot.t('q_not_exist_under', {'category': key, 'quote': quote})); - } - } else { - event.reply(dbot.t('locked_category', {'category': key})); - } - } else { - event.reply(dbot.t('category_not_found', {'category': key})); - } - } else { - event.reply(dbot.t('rmlast_spam')); - } - }, - - '~qcount': function(event) { - var input = event.message.valMatch(/^~qcount ([\d\w\s-]*)/, 2); - if(input) { // Give quote count for named category - var key = input[1].trim().toLowerCase(); - if(quotes.hasOwnProperty(key)) { - event.reply(dbot.t('quote_count', {'category': key, 'count': quotes[key].length})); - } else { - event.reply(dbot.t('no_quotes', {'category': key})); - } - } else { // Give total quote count - var totalQuoteCount = 0; - for(var category in quotes) { - if(quotes.hasOwnProperty(category)) { - totalQuoteCount += quotes[category].length; - } - } - event.reply(dbot.t('total_quotes', {'count': totalQuoteCount})); - } - }, - - '~qadd': function(event) { - var key = event.input[1].toLowerCase(); - var text = event.input[2]; - if(!Object.isArray(quotes[key])) { - quotes[key] = []; } - - if(quotes[key].include(text)) { - event.reply(dbot.t('quote_exists')); - } else { - quotes[key].push(text); - rmAllowed = true; - event.reply(dbot.t('quote_saved', {'category': key, 'count': quotes[key].length})); - } - }, - - '~rq': function(event) { - var rQuote = Object.keys(quotes).random(); - event.reply(rQuote + ': ' + interpolatedQuote(rQuote)); - }, - - '~link': function(event) { - var key = event.params[1].trim().toLowerCase(); - if(quotes.hasOwnProperty(key)) { - event.reply(dbot.t('quote_link', {'category': key, - 'url': dbot.t('url', {'host': dbot.config.web.webHost, - 'port': dbot.config.web.webPort, 'path': 'quotes/' + key})})); - } else { - event.reply(dbot.t('category_not_found', {'category': key})); - } - }, + } }; - - commands['~'].regex = [/^~([\d\w\s-]*)/, 2]; - commands['~q'].regex = [/^~q ([\d\w\s-]*)/, 2]; - commands['~qsearch'].regex = [/^~qsearch ([\d\w\s-]+?)[ ]?=[ ]?(.+)$/, 3]; - commands['~rm'].regex = [/^~rm ([\d\w\s-]+?)[ ]?=[ ]?(.+)$/, 3]; - commands['~rmlast'].regex = [/^~rmlast ([\d\w\s-]*)/, 2]; - commands['~qadd'].regex = [/^~qadd ([\d\w\s-]+?)[ ]?=[ ]?(.+)$/, 3]; - - return { - 'name': 'quotes', - 'ignorable': true, - 'commands': commands, - - 'onLoad': function() { - dbot.timers.addTimer(1000 * 60 * 3, function() { - rmAllowed = true; - }); - }, - - 'listener': function(event) { - // Reality Once listener - if((dbot.db.ignores.hasOwnProperty(event) && - dbot.db.ignores[event.user].include(name)) == false) { - if(event.user == 'reality') { - var once = event.message.valMatch(/^I ([\d\w\s,'-]* once)/, 2); - } else { - var once = event.message.valMatch(/^reality ([\d\w\s,'-]* once)/, 2); - } - - if(once) { - if((dbot.db.bans.hasOwnProperty('~qadd') && - dbot.db.bans['~qadd'].include(event.user)) || - dbot.db.bans['*'].include(event.user)) { - event.reply(dbot.t('command_ban', {'user': event.user})); - } else { - if(!dbot.db.quoteArrs.hasOwnProperty('realityonce')) { - dbot.db.quoteArrs['realityonce'] = []; - } - if(dbot.db.quoteArrs['realityonce'].include('reality ' + once[1] + '.')) { - event.reply(event.user + ': reality has already done that once.'); - } else { - dbot.db.quoteArrs['realityonce'].push('reality ' + once[1] + '.'); - addStack.push('realityonce'); - rmAllowed = true; - event.reply('\'reality ' + once[1] + '.\' saved.'); - } - } - } + + this.listener = function(event) { + if(event.action == 'PRIVMSG') { + if(event.user == 'reality') { + var once = event.message.valMatch(/^I ([\d\w\s,'-]* once)/, 2); + } else { + var once = event.message.valMatch(/^reality ([\d\w\s,'-]* once)/, 2); } - }, - 'on': 'PRIVMSG' - }; + if(once) { + event.message = '~qadd realityonce=reality ' + once[1]; + event.action = 'PRIVMSG'; + event.params = event.message.split(' '); + dbot.instance.emit(event); + } + } else if(event.action == 'JOIN') { + var userQuote = this.api.getQuote(event, event.user) + if(userQuote) { + event.reply(event.user + ': ' + this.api.getQuote(event, event.user)); + } + } + }.bind(this); + this.on = ['PRIVMSG', 'JOIN']; }; exports.fetch = function(dbot) { - return quotes(dbot); + return new quotes(dbot); }; diff --git a/modules/quotes/strings.json b/modules/quotes/strings.json index c013ac3..3d3a9c1 100644 --- a/modules/quotes/strings.json +++ b/modules/quotes/strings.json @@ -106,5 +106,25 @@ "spanish" : "{category} ({needle}): '{quote}' [{matches} resultados]", "na'vi": "{category} ({needle}): '{quote}' [kum a{matches}]", "welsh": "{category} ({needle}): '{quote}' [{matches} canlyniad]" + }, + "quote_cache_auto_remove": { + "english": "There are {count} quotes in the removal cache, which will be automatically cleared.", + "na'vi": "{count}a 'upxarel sngelit tok, Oel 'ayku sngelit lukenga." + }, + "quote_cache_manual_remove": { + "english": "There are {count} quotes in the removal cache, which must be manually cleared.", + "na'vi": "{count}a 'upxarel sngelit tok slä oel ke 'ayku sngelit tafral nga zene 'aivku" + }, + "quote_cache_cleared": { + "english": "{count} quotes cleared from the removal cache.", + "na'vi": "Oel 'aìmku {count}a 'upxareti ta sngel." + }, + "quote_cache_reinstated": { + "english": "{count} quotes reinstated from the removal cache.", + "na'vi": "{count}a 'upxare tolätxaw ta sngel." + }, + "rm_cache_limit": { + "english": "Attention: Too many quotes removed, rmCache must be cleared or reinstated manually with ~rmconfirm or ~rmdeny.", + "na'vi": "Oel zerok 'upxareti apxay set, sweylu txo nga 'aivku upxareti ìlä ~rmconfirm fu ~rmdeny." } } diff --git a/modules/regex/README.md b/modules/regex/README.md new file mode 100644 index 0000000..2db1f02 --- /dev/null +++ b/modules/regex/README.md @@ -0,0 +1,21 @@ +## Regex + +Apply regex and that. + +### Description + +Allows you to run regex replaces on both your own and others messages. One may +run a regex on their own last message like so: + + > user: I like turtles + > user: s/turtles/pizza/ + +One may run a regex on another user's last message simple by hilighting the nick +before the pattern: + + > batman: I like TURTLES + > user: batman: s/turtles/pizza/i + +Note: As this is JS regex, the second part of the regex is actually just a +string and therefore some regex features aren't available (such as lookaheads). +On a related note, the regex flags available for use are limited to i and g. diff --git a/modules/regex/config.json b/modules/regex/config.json new file mode 100644 index 0000000..a2a42c7 --- /dev/null +++ b/modules/regex/config.json @@ -0,0 +1,4 @@ +{ + "ignorable": true, + "help": "http://github.com/reality/depressionbot/blob/master/modules/regex/README.md" +} diff --git a/modules/regex/regex.js b/modules/regex/regex.js new file mode 100644 index 0000000..48b8e21 --- /dev/null +++ b/modules/regex/regex.js @@ -0,0 +1,40 @@ +var _ = require('underscore')._; + +var regex = function(dbot) { + this.last = {}; + this.listener = function(event) { + var q = event.message.valMatch(/^([\d\w\s]*)?:? ?s\/(.+)\/(.+)?\/([ig]*)?$/, 5); + if(q) { + var flags = q[4], + toMatch = new RegExp(q[2], flags), + replaceWith = q[3], + last, + replacement; + + if(!replaceWith) replaceWith = ""; + + if(q[1] != null) { + var user = q[1]; + last = this.last[event.channel.name][user]; + replacement = last.replace(toMatch, replaceWith); + if(replacement != last) event.reply(event.user + " thinks " + user + " meant: " + replacement); + } else { + last = this.last[event.channel.name][event.user]; + replacement = last.replace(toMatch, replaceWith); + if(replacement != last) event.reply(event.user + " meant: " + replacement); + } + } else { + if(_.has(this.last, event.channel.name)) { + this.last[event.channel.name][event.user] = event.message; + } else { + this.last[event.channel.name] = { }; + this.last[event.channel.name][event.user] = event.message; + } + } + }.bind(this); + this.on = [ 'PRIVMSG' ]; +}; + +exports.fetch = function(dbot) { + return new regex(dbot); +}; diff --git a/modules/report/README.md b/modules/report/README.md new file mode 100644 index 0000000..e0ca6ae --- /dev/null +++ b/modules/report/README.md @@ -0,0 +1,19 @@ +## Report + +Report users + +### Description +This module provides a command which allows users to report other users in a +channel to the operators of the channel, as well as posting an alert in the +administrative channel. It can be done either anonymously or publicly in the +channel. + +### Commands + +#### ~report [#channel] [username] [reason for reporting] +Report a user in a channel for a reason. This command can either be run publicly +in a channel or anonymously in a PM to the bot. The result of using this command +will be that all of the users which are currently marked as operators in the +reporting channel will receive a PM telling them a user has been reported, by +whom, in which channel and why. If there is an administrative channel for the +reporting channel (e.g. ##channel), the report will be posted there as well. diff --git a/modules/report/config.json b/modules/report/config.json new file mode 100644 index 0000000..424160e --- /dev/null +++ b/modules/report/config.json @@ -0,0 +1,5 @@ +{ + "ignorable": true, + "dependencies": [ "command", "users" ], + "help": "http://github.com/reality/depressionbot/blob/master/modules/report/README.md" +} diff --git a/modules/report/report.js b/modules/report/report.js index 1e6f03d..2e9bbe9 100644 --- a/modules/report/report.js +++ b/modules/report/report.js @@ -1,3 +1,5 @@ +var _ = require('underscore')._; + var report = function(dbot) { var commands = { '~report': function(event) { @@ -5,47 +7,37 @@ var report = function(dbot) { var nick = event.input[2]; var reason = event.input[3]; - if(event.allChannels.hasOwnProperty(channelName)) { + if(_.has(event.allChannels, channelName)) { var channel = event.allChannels[channelName]; - if(channel.nicks.hasOwnProperty(nick)) { - var ops = []; - for(var possibOps in channel.nicks) { - if(channel.nicks[possibOps].op == true) { - ops.push(possibOps); - } - } + if(dbot.api.users.isChannelUser(event.server, nick, channelName, true)) { + var nick = dbot.api.users.resolveUser(event.server, nick, true); + var ops = _.filter(channel.nicks, function(user) { + return user.op; + }); - // Does the channel have an admin channel? - if(event.allChannels.hasOwnProperty('#' + channelName)) { - ops.push('#' + channelName); - } + _.each(ops, function(user) { + dbot.say(event.server, user.name, dbot.t('report', { + 'reporter': event.user, + 'reported': nick, + 'channel': channelName, + 'reason': reason + })); + }, this); - for(var i=0;i<ops.length;i++) { - dbot.say(event.server, ops[i], - 'Attention: ' + event.user + ' has reported ' + - nick + ' in ' + channelName + '. The reason ' + - 'given was: "' + reason + '."'); - } - - event.reply('Thank you, ' + nick + ' has been reported the channel administrators.'); + event.reply(dbot.t('reported', { 'reported': nick })); } else { - event.reply('User is not in that channel.'); + event.reply(dbot.t('user_not_found', { 'reported': nick, + 'channel': channelName })); } } else { - event.reply('I am not in that channel.'); + event.reply(dbot.t('not_in_channel', { 'channel': channelName })); } } - }; commands['~report'].regex = [/^~report ([^ ]+) ([^ ]+) (.+)$/, 4]; - - return { - 'name': 'report', - 'ignorable': true, - 'commands': commands - }; + this.commands = commands; }; exports.fetch = function(dbot) { - return report(dbot); + return new report(dbot); }; diff --git a/modules/report/strings.json b/modules/report/strings.json new file mode 100644 index 0000000..3b0393d --- /dev/null +++ b/modules/report/strings.json @@ -0,0 +1,18 @@ +{ + "report": { + "english": "Attention: {reporter} has reported {reported} in {channel}. The reason given was: \"{reason}.\"", + "na'vi": "{reporter}ìl fpìl futa {reported} fe' lu taluna {reason}." + }, + "reported": { + "english": "Thank you, {reported} has been reported to the channel administrators.", + "na'vi": "Irayo si ngari, fìtsengìri ayeyktan omum teri {reported}it set." + }, + "user_not_found": { + "english": "{reported} isn't a known user in {channel}.", + "na'vi": "Oel ke omum {reported}it mì {channel}." + }, + "not_in_channel": { + "english": "I am not present in {channel}.", + "na'vi": "Oel {channel}it ke tok." + } +} diff --git a/modules/spelling/README.md b/modules/spelling/README.md new file mode 100644 index 0000000..73a2195 --- /dev/null +++ b/modules/spelling/README.md @@ -0,0 +1,22 @@ +## Spelling + +Fix your spelling. + +### Description + +Will attempt to correct a users' spelling by using the levenshtein distance +algorithm. One corrects the spelling of their previous message by simply posting +a message with their correction and an asterisk: + + > user: I am a tutrle. + > user: *turtle + user meant: I am a turtle. + +The regular expression for this module also accepts two asterisks at the +beginning of the correction, or at the end; it also accepts several words as the +correction and deals with these fairly intelligently. Users may also attempt +to correct another users like so: + + > userone: I am a tutrle. + > usertwo: userone: *turtle + > usertwo thinks userone meant: I am a turtle. diff --git a/modules/spelling/config.json b/modules/spelling/config.json new file mode 100644 index 0000000..3a54ca9 --- /dev/null +++ b/modules/spelling/config.json @@ -0,0 +1,4 @@ +{ + "ignorable": true, + "help": "http://github.com/reality/depressionbot/blob/master/modules/spelling/README.md" +} diff --git a/modules/spelling/spelling.js b/modules/spelling/spelling.js index 49b0403..5078e29 100644 --- a/modules/spelling/spelling.js +++ b/modules/spelling/spelling.js @@ -1,8 +1,84 @@ -var spelling = function(dbot) { - var last = {}; +var _ = require('underscore')._; + +var allGroupings = function(arr) { + if (arr.length == 0) { + return []; /* short-circuit the empty-array case */ + } + var groupings = []; + for(var n=1;n<=arr.length;n++) { + for(var i=0;i<(arr.length-(n-1));i++) { + groupings.push(arr.slice(i, i+n)); + } + } + return groupings; +} +var distance = function(s1, s2) { + // Calculate Levenshtein distance between two strings + // + // version: 1109.2015 + // discuss at: http://phpjs.org/functions/levenshtein + // + original by: Carlos R. L. Rodrigues (http://www.jsfromhell.com) + // + bugfixed by: Onno Marsman + // + revised by: Andrea Giammarchi (http://webreflection.blogspot.com) + // + reimplemented by: Brett Zamir (http://brett-zamir.me) + // + reimplemented by: Alexander M Beedie + if (s1 == s2) { + return 0; + } + var s1_len = s1.length; + var s2_len = s2.length; + if (s1_len === 0) { + return s2_len; } + if (s2_len === 0) { + return s1_len; + } + // BEGIN STATIC + var split = false; + try { + split = !('0')[0]; + } catch (e) { + split = true; // Earlier IE may not support access by string index + } + // END STATIC + if (split) { + s1 = s1.split(''); s2 = s2.split(''); + } + + var v0 = new Array(s1_len + 1); + var v1 = new Array(s1_len + 1); + var s1_idx = 0, + s2_idx = 0, + cost = 0; + for (s1_idx = 0; s1_idx < s1_len + 1; s1_idx++) { v0[s1_idx] = s1_idx; + } + var char_s1 = '', + char_s2 = ''; + for (s2_idx = 1; s2_idx <= s2_len; s2_idx++) { v1[0] = s2_idx; + char_s2 = s2[s2_idx - 1]; + + for (s1_idx = 0; s1_idx < s1_len; s1_idx++) { + char_s1 = s1[s1_idx]; cost = (char_s1 == char_s2) ? 0 : 1; + var m_min = v0[s1_idx + 1] + 1; + var b = v1[s1_idx] + 1; + var c = v0[s1_idx] + cost; + if (b < m_min) { m_min = b; + } + if (c < m_min) { + m_min = c; + } v1[s1_idx + 1] = m_min; + } + var v_tmp = v0; + v0 = v1; + v1 = v_tmp; } + return v0[s1_len]; +}; + +var spelling = function(dbot) { + this.last = {}; + this.internalAPI = {}; + this.internalAPI.correct = function (event, correction, candidate, output_callback) { + var rawCandidates = allGroupings(this.last[event.channel.name][candidate].split(' ')); - var correct = function (event, correction, candidate, output_callback) { - var rawCandidates = last[event.channel.name][candidate].split(' ').allGroupings(); var candidates = []; for(var i=0;i<rawCandidates.length;i++) { candidates.push(rawCandidates[i].join(' ')); @@ -11,20 +87,20 @@ var spelling = function(dbot) { var winnerDistance = Infinity; for(var i=0;i<candidates.length;i++) { - var distance = String.prototype.distance(correction.toLowerCase(), candidates[i].toLowerCase()); - if((distance < winnerDistance) && (distance > 0)) { + var d = distance(correction.toLowerCase(), candidates[i].toLowerCase()); + if((d < winnerDistance) && (d > 0)) { winner = candidates[i]; - winnerDistance = distance; + winnerDistance = d; } } if(winnerDistance < Math.ceil(winner.length * 1.33)) { if(winner !== correction) { - var fix = last[event.channel.name][candidate].replace(winner, correction); + var fix = this.last[event.channel.name][candidate].replace(winner, correction); if (/^.ACTION/.test(fix)) { fix = fix.replace(/^.ACTION/, '/me'); } - last[event.channel.name][candidate] = fix; + this.last[event.channel.name][candidate] = fix; var output = { 'fix': fix, 'correcter': event.user, @@ -33,36 +109,31 @@ var spelling = function(dbot) { output_callback(output); } } - } - - return { - 'name': 'spelling', - 'ignorable': true, + }.bind(this); - 'listener': function(event) { - var q = event.message.valMatch(/^(?:\*\*?([\d\w\s']*)|([\d\w\s']*)\*\*?)$/, 3); - var otherQ = event.message.valMatch(/^([\d\w\s]*): (?:\*\*?([\d\w\s']*)|([\d\w\s']*)\*\*?)$/, 4); - if(q) { - correct(event, q[1] || q[2], event.user, function (e) { - event.reply(dbot.t('spelling_self', e)); - }); - } else if(otherQ) { - correct(event, otherQ[2] || otherQ[3], otherQ[1], function (e) { - event.reply(dbot.t('spelling_other', e)); - }); + this.listener = function(event) { + var q = event.message.valMatch(/^(?:\*\*?([\d\w\s']*)|([\d\w\s']*)\*\*?)$/, 3); + var otherQ = event.message.valMatch(/^([\d\w\s]*)[:|,] (?:\*\*?([\d\w\s']*)|([\d\w\s']*)\*\*?)$/, 4); + if(q) { + this.internalAPI.correct(event, q[1] || q[2], event.user, function (e) { + event.reply(dbot.t('spelling_self', e)); + }); + } else if(otherQ) { + this.internalAPI.correct(event, otherQ[2] || otherQ[3], otherQ[1], function (e) { + event.reply(dbot.t('spelling_other', e)); + }); + } else { + if(_.has(this.last, event.channel.name)) { + this.last[event.channel.name][event.user] = event.message; } else { - if(last.hasOwnProperty(event.channel.name)) { - last[event.channel.name][event.user] = event.message; - } else { - last[event.channel.name] = { }; - last[event.channel.name][event.user] = event.message; - } + this.last[event.channel.name] = { }; + this.last[event.channel.name][event.user] = event.message; } - }, - 'on': 'PRIVMSG' - } + } + }.bind(this); + this.on = 'PRIVMSG'; } exports.fetch = function(dbot) { - return spelling(dbot); + return new spelling(dbot); }; diff --git a/modules/stats b/modules/stats new file mode 160000 index 0000000..ea795e4 --- /dev/null +++ b/modules/stats @@ -0,0 +1 @@ +Subproject commit ea795e4d17aa500923468366e73a10f6fbc94ade diff --git a/modules/timers/README.md b/modules/timers/README.md new file mode 100644 index 0000000..26845e6 --- /dev/null +++ b/modules/timers/README.md @@ -0,0 +1,32 @@ +## Timers + +Timers for fun and profit. + +### Description + +This is a utility module which allows other modules to more easily use timers to +execute functionality, as well as providing simple cron-type functionality for +timers. + +### API + +#### addTimer(interval, callback, [firstDate]) +Execute the given callback every time *interval* (in ms) passes. + +The firstDate parameter is a Date object used to sync a timer to a given point in +time, allowing for cron-type functionality. For example, if you wanted to call a +given function every day at 00:00, you would do the following: + + dbot.api.timers.addTimer(myCallback, 86400000, new Date([midnight tonight])); + +This works like so: + +1. Create a one-time timeout to be executed at the given firstDate (00:00, as +above). +2. Upon this timeout, your callback is executed. Then addTimer is called again +without the firstDate parameter, thus syncing a daily timer to be executed every +day at 00:00. + +The best place to create timers is in your module's onLoad function. Using this, +you may essentially create persistent jobs to be run at regular intervals while +your module is loaded.. diff --git a/modules/timers/timers.js b/modules/timers/timers.js new file mode 100644 index 0000000..b1e968d --- /dev/null +++ b/modules/timers/timers.js @@ -0,0 +1,54 @@ +/** + * Module Name: Timers + * Description: Persistent timers and shit + */ +var _ = require('underscore')._; + +var timers = function(dbot) { + this.timers = dbot.db.timers; + this.runningTimeouts = []; + this.runningIntervals = []; + + this.api = { + 'addTimer': function(timeout, callback, firstDate) { + var now = new Date().getTime(); + if(firstDate) { + console.log('Setting first timer to run at ' + firstDate); + firstTimeout = firstDate.getTime() - now; + this.runningTimeouts.push(setTimeout(function() { + console.log('Running first timer at ' + new Date().toUTCString()); + this.runningIntervals.push(this.api.addTimer(timeout, callback)); + try { + callback(); + } catch(err) { + console.log('Callback failed: ' + err); + } + }.bind(this), firstTimeout)); + } else { + this.runningIntervals.push(setInterval(function() { + console.log('Running subsequent timer at ' + new Date().toUTCString()); + try { + callback(); + } catch(err) { + console.log('Callback failed: ' + err); + } + }.bind(this), timeout)); + } + } + }; + + this.onDestroy = function() { + for(var i=0;i<this.runningTimeouts.length;i++) { + console.log('destroying ' +this.runningTimeouts[i]); + clearTimeout(this.runningTimeouts[i]); + } + for(i=0;i<this.runningIntervals.length;i++) { + console.log('destroying ' +this.runningIntervals[i]); + clearInterval(this.runningIntervals[i]); + } + }.bind(this); +}; + +exports.fetch = function(dbot) { + return new timers(dbot); +}; diff --git a/modules/users/README.md b/modules/users/README.md new file mode 100644 index 0000000..0a11e5b --- /dev/null +++ b/modules/users/README.md @@ -0,0 +1,56 @@ +## Users + +Track users. + +### Description + +This module tracks users and their aliases through nick changes and all that +kind of thing. It's mainly a utility module for other modules to use. It's +also totally !insaned. + +### Commands + +#### ~alias [user] +If an alias is provided, this command will return the primary user for which +this is an alias for. If a primary user is provided, it will return a +confirmation of this fact and a count of how many aliases belong to the user. + +#### ~setaliasparent [newparent] +Set a nick which is currently serving as an alias to the primary user, while +setting what was previously the primary user as an alias of the new primary +user. Requires moderator level access by default. + +#### ~mergeusers [primaryuser] [secondaryuser] +This command merges two nicks which are recorded as primary users into one user. +The secondary user and all of their aliases will be merged under primaryuser. +Requires moderator level access by default. + +### API + +#### resolveUser(server, nick, [useLowerCase]) +This resolves a given nick to its primary user and returns it. + +Note that if the useLowerCase argument is set to true, it will do a lower-case +search, however it will return the username in its properly capitalised form, so +remember to lower case the return value if you are using lower case values as +keys. + +#### resolveUser(server, user) +Return whether a user is known either as an alias or a primary user. + +#### isPrimaryUser(server, nick) +Return whether a nick is known as a primary user. + +#### getAliases(server, user) +Return a list of aliases for a given primary user. + +#### isOnline(server, user, channel, useLowerCase) +Return whether a user is online in a given channel on the given server. + +### Events + +#### nick_changed(server, newNick) +This is executed when a new alias is added for a user. + +#### new_user(server, nick) +This is executed when a new primary user is added to the known users DB. diff --git a/modules/users/api.js b/modules/users/api.js new file mode 100644 index 0000000..19c62a6 --- /dev/null +++ b/modules/users/api.js @@ -0,0 +1,108 @@ +var _ = require('underscore')._; + +var api = function(dbot) { + var escapeRegexen = function(str) { + return (str+'').replace(/([.?*+^$[\]\\(){}|-])/g, "\\$1"); + }; + + var api = { + 'resolveUser': function(server, nick, useLowerCase) { + var knownUsers = this.getServerUsers(server); + var user = nick; + + if(!useLowerCase) { + if(!_.include(knownUsers.users, nick) && _.has(knownUsers.aliases, nick)) { + user = knownUsers.aliases[nick]; + } + } else { + // this is retarded + user = user.toLowerCase(); + var toMatch = new RegExp("^" + escapeRegexen(user) + "$", "i"); + + var resolvedUser = _.find(knownUsers.users, function(nick) { + return nick.match(toMatch) !== null; + }, this); + + if(!resolvedUser) { + resolvedUser = _.find(knownUsers.aliases, function(nick, alias) { + if(alias.match(toMatch) !== null) return nick; + }, this); + if(!_.isUndefined(resolvedUser)) user = resolvedUser; + } + else{ + user = resolvedUser; + } + } + return user; + }, + + 'getRandomChannelUser': function(server, channel) { + var channelUsers = this.getServerUsers(server).channelUsers[channel]; + if(!_.isUndefined(channelUsers)) { + return channelUsers[_.random(0, channelUsers.length - 1)]; + } else { + return false; + } + }, + + 'getServerUsers': function(server) { + return dbot.db.knownUsers[server].users; + }, + + 'getAllUsers': function() { + return _.reduce(dbot.db.knownUsers, function(memo, server, name) { + memo[name] = server.users; + return memo; + }, {}, this); + }, + + 'isKnownUser': function(server, nick) { + var knownUsers = this.getServerUsers(server); + return (_.include(knownUsers.users, nick) || _.has(knownUsers.aliases, nick)); + }, + + 'isPrimaryUser': function(server, nick) { + var knownUsers = this.getServerUsers(server); + return _.include(knownUsers.users, nick); + }, + + 'getAliases': function(server, nick) { + var knownUsers = this.getServerUsers(server); + return _.chain(knownUsers.aliases) + .keys() + .filter(function(user) { + return knownUsers.aliases[user] == nick; + }, this) + .value(); + }, + + 'isOnline': function(server, user, channel, useLowerCase) { + var user = this.api.resolveUser(server, user, useLowerCase); + var possiNicks = [user].concat(this.api.getAliases(server, user)); + + if(!_.has(dbot.instance.connections[server].channels, channel)) return false; + var onlineNicks = dbot.instance.connections[server].channels[channel].nicks; + + return _.any(onlineNicks, function(nick) { + nick = nick.name; + return _.include(possiNicks, nick); + }, this); + }, + + 'isChannelUser': function(server, user, channel, useLowerCase) { + var knownUsers = this.getServerUsers(server); + var user = this.api.resolveUser(server, user, useLowerCase); + + if(!_.has(knownUsers.channelUsers, channel)) { + return false; + } + return _.include(knownUsers.channelUsers[channel], user); + } + }; + + return api; +}; + +exports.fetch = function(dbot) { + return api(dbot); +}; diff --git a/modules/users/commands.js b/modules/users/commands.js new file mode 100644 index 0000000..ed1d561 --- /dev/null +++ b/modules/users/commands.js @@ -0,0 +1,112 @@ +var _ = require('underscore')._; + +var commands = function(dbot) { + var commands = { + '~alias': function(event) { + var knownUsers = this.getServerUsers(event.server), + alias = event.params[1].trim(); + + if(_.include(knownUsers.users, alias)) { + var aliases = this.api.getAliases(event.server, alias); + var aliasCount = aliases.length; + + if(aliasCount != 0) { + var aliases = _.first(aliases, 10); + var including = 'including: '; + for(var i=0;i<aliases.length;i++) { + including += aliases[i] + ', '; + } + including = including.slice(0, -2) + '.'; + + event.reply(dbot.t('primary', { + 'user': alias, + 'count': aliasCount + }) + including); + } else { + event.reply(dbot.t('primary', { + 'user': alias, + 'count': aliasCount + })); + } + } else if(_.has(knownUsers.aliases, alias)) { + event.reply(dbot.t('alias', { + 'alias': alias, + 'user': knownUsers.aliases[alias] + })); + } else { + event.reply(dbot.t('unknown_alias', { 'alias': alias })); + } + }, + + '~setaliasparent': function(event) { + var knownUsers = this.getServerUsers(event.server); + var newParent = event.params[1]; + + if(_.has(knownUsers.aliases, newParent)) { + var newAlias = knownUsers.aliases[newParent]; + + // Replace user entry + knownUsers.users = _.without(knownUsers.users, newAlias); + knownUsers.users.push(newParent); + + // Replace channels entries with new primary user + this.updateChannels(event, newAlias, newParent); + + // Remove alias for new parent & add alias for new alias + delete knownUsers.aliases[newParent]; + knownUsers.aliases[newAlias] = newParent; + + // Update aliases to point to new primary user + this.updateAliases(event, newAlias, newParent); + + event.reply(dbot.t('aliasparentset', { + 'newParent': newParent, + 'newAlias': newAlias + })); + + return { + 'server': event.server, + 'alias': newAlias + }; + } else { + event.reply(dbot.t('unknown_alias', { 'alias': newParent })); + } + return false; + }, + + '~mergeusers': function(event) { + var knownUsers = this.getServerUsers(event.server); + var primaryUser = event.params[1]; + var secondaryUser = event.params[2]; + + if(_.include(knownUsers.users, primaryUser) && _.include(knownUsers.users, secondaryUser)) { + knownUsers.users = _.without(knownUsers.users, secondaryUser); + knownUsers.aliases[secondaryUser] = primaryUser; + this.updateAliases(event, secondaryUser, primaryUser); + this.updateChannels(event, secondaryUser, primaryUser); + + event.reply(dbot.t('merged_users', { + 'old_user': secondaryUser, + 'new_user': primaryUser + })); + + return { + 'server': event.server, + 'secondary': secondaryUser + }; + } else { + event.reply(dbot.t('unprimary_error')); + } + return false; + } + }; + + commands['~setaliasparent'].access = 'moderator'; + commands['~mergeusers'].access = 'moderator'; + + return commands; +}; + +exports.fetch = function(dbot) { + return commands(dbot); +}; diff --git a/modules/users/config.json b/modules/users/config.json new file mode 100644 index 0000000..866deb7 --- /dev/null +++ b/modules/users/config.json @@ -0,0 +1,6 @@ +{ + "ignorable": false, + "dependencies": [ "command", "event" ], + "dbKeys": [ "knownUsers" ], + "help": "https://github.com/reality/depressionbot/blob/master/modules/users/README.md" +} diff --git a/modules/users/pages.js b/modules/users/pages.js new file mode 100644 index 0000000..79924b1 --- /dev/null +++ b/modules/users/pages.js @@ -0,0 +1,102 @@ +var pages = function(dbot) { + var _ = require('underscore')._; + var connections = dbot.instance.connections; + + return { + '/connections': function(req, res) { + var connections = Object.keys(dbot.instance.connections); + res.render('connections', { 'name': dbot.config.name, 'connections': connections }); + }, + + '/channels/:connection': function(req, res) { + var connection = req.params.connection; + if(dbot.instance.connections.hasOwnProperty(connection)) { + var channels = Object.keys(dbot.instance.connections[connection].channels); + res.render('channels', { 'name': dbot.config.name, 'connection': connection, 'channels': channels}); + } else { + res.render_core('error', { 'name': dbot.config.name, 'message': 'No such connection.' }); + } + }, + + '/users/:connection/:channel': function(req, res) { + var connection = req.params.connection; + var channel = _.unescape(req.params.channel); + var connections = dbot.instance.connections; + + if(connections.hasOwnProperty(connection) && + connections[connection].channels.hasOwnProperty(channel)) { + + //TODO(samstudio8): Stats API Functionality + var chanData = dbot.api.stats.getChanStats(connection, channel, ["week"]); + var chanFreq = []; + var chanFreqLabel = []; + + if(chanData){ + var cur_ptr; + for(var i=0; i <= 6; i++){ + cur_ptr = ((i+1)+chanData.fields.week.raw.ptr) % 7; + for(var j=0; j <= 23; j++){ + chanFreq.push(chanData.fields.week.raw[cur_ptr][j]); + } + chanFreqLabel.push("'"+chanData.fields.week.raw[cur_ptr].name+"'"); + } + } + else{ + for (var i = 0; i < 168; i++) chanFreq[i] = 0; + chanFreqLabel = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"]; + } + + var userData = { "active": [], "inactive": [], "offline": []}; + var reply = dbot.api.stats.getChanUsersStats(connection, channel, [ + "lines", "words", "lincent", "wpl", "in_mentions" + ]); + _.each(reply.users, function(user, userName){ + if(userName != dbot.config.name){ + if(user.online){ + if(user.active){ + userData.active.push(user); + } + else{ + userData.inactive.push(user); + } + } + else{ + userData.offline.push(user); + } + } + }); + + var userSort = function(a, b){ + var x = a.display.toLowerCase(); + var y = b.display.toLowerCase(); + if(x > y) return 1; + if(x < y) return -1; + return 0; + } + userData.active.sort(userSort); + userData.inactive.sort(userSort); + userData.offline.sort(userSort); + + var userDataSorted = (userData.active.concat(userData.inactive)).concat(userData.offline); + + res.render('users', { + 'name': dbot.config.name, + 'connection': connection, + 'channel': channel, + 'userStats': userDataSorted, + 'chanFreq': chanFreq, + 'chanFreqLen': chanFreq.length, + "locals": { + 'chanFreqLabel': chanFreqLabel, + }}); + + } else { + res.render_core('error', { 'name': dbot.config.name, 'message': 'No such connection or channel.' }); + } + }, + }; +}; + +exports.fetch = function(dbot) { + return pages(dbot); +}; diff --git a/modules/users/strings.json b/modules/users/strings.json new file mode 100644 index 0000000..b3d7a52 --- /dev/null +++ b/modules/users/strings.json @@ -0,0 +1,26 @@ +{ + "alias": { + "english": "{alias} is an alias of {user}", + "na'vi": "ayfko syaw {user} {alias} nìteng" + }, + "primary": { + "english": "{user} is a primary user with {count} aliases, ", + "na'vi": "{user} lu txin ulte {count}a stxo lu poru, " + }, + "unknown_alias": { + "english": "{alias} does not currently exist as an alias or known user.", + "na'vi": "{alias} ke fkeytok nìfkrr" + }, + "aliasparentset": { + "english": "{newParent} is now the parent user, and {newAlias} is an alias.", + "na'vi": "{newParent} lu sa'sem set ulte {newAlias} lu stxo set nìteng." + }, + "unprimary_error": { + "english": "One of those users isn't currently recorded as a primary user.", + "na'vi": "fo sute txin ke lu." + }, + "merged_users": { + "english": "{old_user} and their aliases have been merged into {new_user}.", + "na'vi": "{old_user} ulte stxo alahe {new_user} lu set." + } +} diff --git a/modules/users/users.js b/modules/users/users.js new file mode 100644 index 0000000..37dab11 --- /dev/null +++ b/modules/users/users.js @@ -0,0 +1,99 @@ +/** + * Name: Users + * Description: Track known users + */ +var _ = require('underscore')._; + +var users = function(dbot) { + this.knownUsers = dbot.db.knownUsers; + this.getServerUsers = function(server) { + var knownUsers = this.knownUsers; + if(!_.has(knownUsers, server)) { + knownUsers[server] = { 'users': [], 'aliases': {}, 'channelUsers': {} }; + } + if(!_.has(knownUsers[server], 'channelUsers')) { + knownUsers[server].channelUsers = {}; + } + return knownUsers[server]; + }; + + this.updateAliases = function(event, oldUser, newUser) { + var knownUsers = this.getServerUsers(event.server); + _.each(knownUsers.aliases, function(user, alias) { + if(user == oldUser) { + knownUsers.aliases[alias] = newUser; + } + }, this); + }; + + this.updateChannels = function(event, oldUser, newUser) { + var channelUsers = this.getServerUsers(event.server).channelUsers; + channelUsers = _.each(channelUsers, function(channel, channelName) { + channelUsers[channelName] = _.without(channel, oldUser); + channelUsers[channelName].push(newUser); + }, this); + }; + + this.listener = function(event) { + var knownUsers = this.getServerUsers(event.server); + var nick = event.user; + + if(event.action == 'JOIN') { + if(!_.has(knownUsers.channelUsers, event.channel.name)) { + knownUsers.channelUsers[event.channel.name] = []; + } + var channelUsers = knownUsers.channelUsers[event.channel.name]; + + if(this.api.isKnownUser(event.server, nick)) { + nick = this.api.resolveUser(event.server, nick); + } else { + knownUsers.users.push(nick); + dbot.api.event.emit('new_user', [ event.server, nick ]); + } + + if(!_.include(channelUsers, nick)) { + channelUsers.push(nick); + } + } else if(event.action == 'NICK') { + var newNick = event.params.substr(1); + if(!this.api.isKnownUser(newNick)) { + knownUsers.aliases[newNick] = this.api.resolveUser(event.server, event.user); + dbot.api.event.emit('nick_change', [ event.server, newNick ]); + } + } + }.bind(this); + this.on = ['JOIN', 'NICK']; + + this.onLoad = function() { + // Trigger updateNickLists to stat current users in channel + dbot.instance.addListener('366', 'users', function(event) { + var knownUsers = this.getServerUsers(event.server); + if(!_.has(knownUsers.channelUsers, event.channel.name)) { + knownUsers.channelUsers[event.channel.name] = []; + } + var channelUsers = knownUsers.channelUsers[event.channel.name]; + + _.each(event.channel.nicks, function(nick) { + nick = nick.name; + if(this.api.isKnownUser(event.server, nick)) { + nick = this.api.resolveUser(event.server, nick); + } else { + knownUsers.users.push(nick); + dbot.api.emit('new_user', [ event.server, nick ]); + } + if(!_.include(channelUsers, nick)) { + channelUsers.push(nick); + } + }, this); + }.bind(this)); + + var connections = dbot.instance.connections; + _.each(connections, function(connection) { + connection.updateNickLists(); + }); + }; +}; + +exports.fetch = function(dbot) { + return new users(dbot); +}; diff --git a/modules/web/README.md b/modules/web/README.md new file mode 100644 index 0000000..c00af25 --- /dev/null +++ b/modules/web/README.md @@ -0,0 +1,42 @@ +# Web + +Web interface + +## Description + +It's a web interface for DBot. What of it? + +## Requirements +###Express and Jade@0.25 +``` +npm install express +npm install jade@0.25 +``` +###Express Patch +Express currently needs to be patched, edit ~/node_modules/express/lib/express.js as thus; +``` + 52 for (var key in connect.middleware) { +**53 if( !Object.prototype.hasOwnProperty(key) ) { + 54 Object.defineProperty( + 55 exports + 56 , key + 57 , Object.getOwnPropertyDescriptor(connect.middleware, key)); +**58 } + 59 } +``` +###Twitter Bootstrap +``` +cd depressionbot/public/ +wget http://twitter.github.com/bootstrap/assets/bootstrap.zip +unzip bootstrap.zip +rm bootstrap.zip +``` +###d3.js +``` +cd depressionbot/public/ +mkdir d3 +cd d3 +wget http://d3js.org/d3.v3.zip +unzip d3.v3.zip +rm d3.v3.zip +``` diff --git a/modules/web/config.json b/modules/web/config.json index 981e0f7..47a0b3a 100644 --- a/modules/web/config.json +++ b/modules/web/config.json @@ -1,4 +1,5 @@ { + "ignorable": false, "webHost": "localhost", - "webPort": 8080 + "webPort": 9001 } diff --git a/modules/web/web.js b/modules/web/web.js index 9f01c8d..a71dac3 100644 --- a/modules/web/web.js +++ b/modules/web/web.js @@ -1,130 +1,45 @@ -var express = require('express'); +var express = require('express'), + _ = require('underscore')._, + fs = require('fs'); var webInterface = function(dbot) { var pub = 'public'; - var app = express.createServer(); + var app = express(); - app.use(express.compiler({ src: pub, enable: ['sass'] })); app.use(express.static(pub)); app.set('view engine', 'jade'); app.get('/', function(req, res) { res.render('index', { 'name': dbot.config.name }); }); - - app.get('/connections', function(req, res) { - var connections = Object.keys(dbot.instance.connections); - res.render('connections', { 'name': dbot.config.name, 'connections': connections }); - }); - - app.get('/channels/:connection', function(req, res) { - var connection = req.params.connection; - if(dbot.instance.connections.hasOwnProperty(connection)) { - var channels = Object.keys(dbot.instance.connections[connection].channels); - res.render('channels', { 'name': dbot.config.name, 'connection': connection, 'channels': channels}); - } else { - res.render('error', { 'name': dbot.config.name, 'message': 'No such connection.' }); - } - }); - - app.get('/users/:connection/:channel', function(req, res) { - var connection = req.params.connection; - var channel = '#' + req.params.channel; - var connections = dbot.instance.connections; - - if(connections.hasOwnProperty(connection) && - connections[connection].channels.hasOwnProperty(channel)) { - var nicks = Object.keys(connections[connection].channels[channel].nicks); - res.render('users', { 'name': dbot.config.name, 'connection': connection, - 'channel': channel, 'nicks': nicks }); - } else { - res.render('error', { 'name': dbot.config.name, 'message': 'No such connection or channel.' }); - } - }); - - app.get('/user/:connection/:channel/:user', function(req, res) { - var connection = req.params.connection; - var channel = '#' + req.params.channel; - var user = dbot.cleanNick(req.params.user); - - var quoteCount = 'no'; - if(dbot.db.quoteArrs.hasOwnProperty(user)) { - var quoteCount = dbot.db.quoteArrs[user].length; - } - - if(!dbot.db.kicks.hasOwnProperty(req.params.user)) { - var kicks = '0'; - } else { - var kicks = dbot.db.kicks[req.params.user]; - } - - if(!dbot.db.kickers.hasOwnProperty(req.params.user)) { - var kicked = '0'; - } else { - var kicked = dbot.db.kickers[req.params.user]; - } - - res.render('user', { 'name': dbot.config.name, 'user': req.params.user, - 'channel': channel, 'connection': connection, 'cleanUser': user, - 'quotecount': quoteCount, 'kicks': kicks, 'kicked': kicked }); - }); - // Lists the quote categories - app.get('/quotes', function(req, res) { - res.render('quotelist', { 'name': dbot.config.name, 'quotelist': Object.keys(dbot.db.quoteArrs) }); - }); - - // Lists quotes in a category - app.get('/quotes/:key', function(req, res) { - var key = req.params.key.toLowerCase(); - if(dbot.db.quoteArrs.hasOwnProperty(key)) { - res.render('quotes', { 'name': dbot.config.name, 'quotes': dbot.db.quoteArrs[key], locals: { 'url_regex': RegExp.prototype.url_regex() } }); - } else { - res.render('error', { 'name': dbot.config.name, 'message': 'No quotes under that key.' }); - } - }); + var server = app.listen(dbot.config.web.webPort); - // Load random quote category page - app.get('/rq', function(req, res) { - var rCategory = Object.keys(dbot.db.quoteArrs).random(); - res.render('quotes', { 'name': dbot.config.name, 'quotes': dbot.db.quoteArrs[rCategory], locals: { 'url_regex': RegExp.prototype.url_regex() } }); - }); - - // Lists all of the polls - app.get('/polls', function(req, res) { - res.render('polllist', { 'name': dbot.config.name, 'polllist': Object.keys(dbot.db.polls) }); - }); - - // Shows the results of a poll - app.get('/polls/:key', function(req, res) { - var key = req.params.key.toLowerCase(); - if(dbot.db.polls.hasOwnProperty(key) && dbot.db.polls[key].hasOwnProperty('description')) { - // tally the votes - var totalVotes = 0; - for( var v in dbot.db.polls[key].votes ) { - var N = Number(dbot.db.polls[key].votes[v]); - if( !isNaN(N) ) { - totalVotes += N; - } + this.reloadPages = function() { + var pages = dbot.pages; + for(var p in pages) { + if(_.has(pages, p)) { + var func = pages[p]; + var mod = func.module; + app.get(p, (function(req, resp) { + // Crazy shim to seperate module views. + var shim = Object.create(resp); + shim.render = (function(view, one, two) { + // Render with express.js + resp.render(this.module + '/' + view, one, two); + }).bind(this); + shim.render_core = resp.render; + this.call(this.module, req, shim); + }).bind(func)); } - res.render('polls', { 'name': dbot.config.name, 'description': dbot.db.polls[key].description, 'votees': Object.keys(dbot.db.polls[key].votees), 'options': dbot.db.polls[key].votes, locals: { 'totalVotes': totalVotes, 'url_regex': RegExp.prototype.url_regex() } }); - } else { - res.render('error', { 'name': dbot.config.name, 'message': 'No polls under that key.' }); } - }); - - app.listen(dbot.config.web.webPort); + }.bind(this); - return { - 'name': 'web', - 'ignorable': false, - - 'onDestroy': function() { - app.close(); - } - }; + this.onDestroy = function() { + server.close(); + } }; exports.fetch = function(dbot) { - return webInterface(dbot); + return new webInterface(dbot); }; diff --git a/modules/youare/README.md b/modules/youare/README.md new file mode 100644 index 0000000..09a9940 --- /dev/null +++ b/modules/youare/README.md @@ -0,0 +1,10 @@ +## youare + +You're a loser! + +### Description + +This module occasionally comes back and says "You're a x" when somebody goes "y +is x" or "y are x," with the intention of annoying people who are calling +something crap. Warning: this module occasionally causes the bot to be nice to +people. diff --git a/modules/youare/config.json b/modules/youare/config.json new file mode 100644 index 0000000..c945e96 --- /dev/null +++ b/modules/youare/config.json @@ -0,0 +1,3 @@ +{ + "ignorable": true +} diff --git a/modules/youare/youare.js b/modules/youare/youare.js index ddb25e0..2aa4ecd 100644 --- a/modules/youare/youare.js +++ b/modules/youare/youare.js @@ -1,19 +1,14 @@ var youAre = function(dbot) { - return { - 'name': 'youare', - 'ignorable': false, + this.listener = function(event) { + var key = event.message.valMatch(/(\bis\b|\bare\b)\s+([\w\s\d]*?)(\s+)?(,|\.|\band\b|$)/, 5); - 'listener': function(event) { - var key = event.message.valMatch(/(\bis\b|\bare\b)\s+([\w\s\d]*?)(\s+)?(,|\.|\band\b|$)/, 5); - - if(key && key[2] != "" && Number.prototype.chanceIn(1, 100) && event.user != 'aisbot') { - event.reply(event.user + ': You\'re ' + key[2] + '.'); - } - }, - 'on': 'PRIVMSG' - }; + if(key && key[2] != "" && Number.prototype.chanceIn(1, 100) && event.user != 'aisbot') { + event.reply(event.user + ': You\'re ' + key[2] + '.'); + } + }.bind(this); + this.on = 'PRIVMSG'; }; exports.fetch = function(dbot) { - return youAre(dbot); + return new youAre(dbot); }; diff --git a/public/styles.css b/public/styles.css index fa45ded..50f73bd 100644 --- a/public/styles.css +++ b/public/styles.css @@ -6,19 +6,19 @@ */ html { - background: url("background.jpg") no-repeat center center fixed; - -webkit-background-size: cover; - -moz-background-size: cover; - -o-background-size: cover; - background-size: cover; } body { padding: 25px; margin: 0; - font-family: "Lucida Grande"; + font-family: "Source Sans Pro", sans-serif; color: #444; text-shadow: 1px 1px 2px #2B2B2B; + background: url("background.jpg") no-repeat center center fixed; + -webkit-background-size: cover; + -moz-background-size: cover; + -o-background-size: cover; + background-size: cover; } h1,h2 { @@ -31,7 +31,6 @@ p { } div#page { - width: 90%; margin: 0 auto 0 auto; } @@ -55,9 +54,8 @@ div#title a { } div#main { - position: relative; - padding: 15px 5px; - margin: 0px; + position: relative; + padding: 10px 5px; font-size: 21px; text-align:center; background: #FFF; @@ -172,3 +170,58 @@ li.option-votes { box-shadow: inset 0px 0px 3px #444; } + +/** + * DataTables + * Based on http://ajax.aspnetcdn.com/ajax/jquery.dataTables/1.9.4/css/jquery.dataTables.css + */ +.sorting { background: url('http://ajax.aspnetcdn.com/ajax/jquery.dataTables/1.9.4/images/sort_both.png') no-repeat center right; } +.sorting_asc { background: url('http://ajax.aspnetcdn.com/ajax/jquery.dataTables/1.9.4/images/sort_asc.png') no-repeat center right; } +.sorting_desc { background: url('http://ajax.aspnetcdn.com/ajax/jquery.dataTables/1.9.4/images/sort_desc.png') no-repeat center right; } + +.sorting_asc_disabled { background: url('http://ajax.aspnetcdn.com/ajax/jquery.dataTables/1.9.4/images/sort_asc_disabled.png') no-repeat center right; } +.sorting_desc_disabled { background: url('http://ajax.aspnetcdn.com/ajax/jquery.dataTables/1.9.4/images/sort_desc_disabled.png') no-repeat center right; } + +/** + * Bootstrap Overrides + */ +.profile_page-header{ + margin: 5px 0 10px 0; +} + +.profile_row{ + text-align: left; + margin-bottom: 10px; +} + +/** + * spaceinvader's thumbnails + */ +span.nicks { + display: none; + color: #fff; + text-decoration: none; + position: absolute; + left: 0px; + top: 10px; + width: 100%; + word-wrap: break-word; +} +div.imgwrap { + background-color: #000; + position: relative; +} +div.imgwrap > img { + -webkit-transition: all 0.2s ease-in-out; + -moz-transition: all 0.2s ease-in-out; + -o-transition: all 0.2s ease-in-out; + transition: all 0.2s ease-in-out; +} + +.thumbnail:hover > div.imgwrap > img { + opacity: 0.5; +} + +.thumbnail:hover > div.imgwrap > span.nicks { + display: inline; +} diff --git a/run.js b/run.js index e9e5132..97667cb 100644 --- a/run.js +++ b/run.js @@ -1,47 +1,54 @@ -var fs = require('fs'); -var timers = require('./timer'); -var jsbot = require('./jsbot/jsbot'); +var fs = require('fs'), + _ = require('underscore')._, + jsbot = require('./jsbot/jsbot'); require('./snippets'); -var DBot = function(timers) { - // Load external files - var requiredConfigKeys = [ 'name', 'servers', 'admins', 'moduleNames', 'language' ]; - try { - this.config = JSON.parse(fs.readFileSync('config.json', 'utf-8')); - } catch(err) { - console.log('Config file is screwed up. Attempting to load defaults.'); +var DBot = function() { + + /*** Load the DB ***/ + + if(fs.existsSync('db.json')) { try { - this.config = JSON.parse(fs.readFileSync('config.json.sample', 'utf-8')); + this.db = JSON.parse(fs.readFileSync('db.json', 'utf-8')); } catch(err) { - console.log('Error loading sample config. Bugger off. Stopping.'); + console.log('Error loading db.json. Stopping: ' + err); process.exit(); } - } - requiredConfigKeys.each(function(key) { - if(!this.config.hasOwnProperty(key)) { - console.log('Error: Please set a value for ' + key + ' in ' + - 'config.json. Stopping.'); - process.exit(); - } - }.bind(this)); - - var rawDB; - try { - var rawDB = fs.readFileSync('db.json', 'utf-8'); - } catch(err) { - this.db = {}; // If no db file, make empty one + } else { + this.db = {}; } + if(!_.has(this.db, 'config')) { + this.db.config = {}; + } + + /*** Load the Config ***/ + + if(!fs.existsSync('config.json')) { + console.log('Error: config.json file does not exist. Stopping'); + process.exit(); + } + + this.config = _.clone(this.db.config); try { - if(!this.db) { // If it wasn't empty - this.db = JSON.parse(rawDB); - } + _.defaults(this.config, JSON.parse(fs.readFileSync('config.json', 'utf-8'))); } catch(err) { - console.log('Syntax error in db.json. Stopping: ' + err); + console.log('Config file is invalid. Stopping: ' + err); process.exit(); } - // Load Strings file + try { + var defaultConfig = JSON.parse(fs.readFileSync('config.json.sample', 'utf-8')); + } catch(err) { + console.log('Error loading sample config. Bugger off this should not even be edited. Stopping.'); + process.exit(); + } + + // Load missing config directives from sample file + _.defaults(this.config, defaultConfig); + + /*** Load main strings ***/ + try { this.strings = JSON.parse(fs.readFileSync('strings.json', 'utf-8')); } catch(err) { @@ -51,24 +58,21 @@ var DBot = function(timers) { // Initialise run-time resources this.usage = {}; + this.status = {}; this.sessionData = {}; - this.timers = timers.create(); // Populate bot properties with config data // Create JSBot and connect to each server this.instance = jsbot.createJSBot(this.config.name); - for(var name in this.config.servers) { - if(this.config.servers.hasOwnProperty(name)) { - var server = this.config.servers[name]; - this.instance.addConnection(name, server.server, server.port, - this.config.admin, function(event) { - var server = this.config.servers[event.server]; - for(var i=0;i<server.channels.length;i++) { - this.instance.join(event, server.channels[i]); - } - }.bind(this), server.nickserv, server.password); - } - } + _.each(this.config.servers, function(server, name) { + this.instance.addConnection(name, server.server, server.port, + this.config.admin, function(event) { + var server = this.config.servers[event.server]; + for(var i=0;i<server.channels.length;i++) { + this.instance.join(event, server.channels[i]); + } + }.bind(this), server.nickserv, server.password); + }, this); // Load the modules and connect to the server this.reloadModules(); @@ -83,9 +87,9 @@ DBot.prototype.say = function(server, channel, message) { // Format given stored string in config language DBot.prototype.t = function(string, formatData) { var formattedString; - if(this.strings.hasOwnProperty(string)) { + if(_.has(this.strings, string)) { var lang = this.config.language; - if(!this.strings[string].hasOwnProperty(lang)) { + if(!_.has(this.strings[string], lang)) { lang = "english"; } @@ -103,7 +107,7 @@ DBot.prototype.t = function(string, formatData) { // Save the database file DBot.prototype.save = function() { - fs.writeFile('db.json', JSON.stringify(this.db, null, ' ')); + fs.writeFileSync('db.json', JSON.stringify(this.db, null, ' ')); }; // Hot-reload module files. @@ -117,11 +121,16 @@ DBot.prototype.reloadModules = function() { } this.rawModules = []; - this.modules = []; + this.pages = {}; + this.status = {}; + this.modules = {}; this.commands = {}; + this.api = {}; this.commandMap = {}; // Map of which commands belong to which modules this.usage = {}; - this.timers.clearTimers(); + + // Load config changes + _.extend(this.config, this.db.config); try { this.strings = JSON.parse(fs.readFileSync('strings.json', 'utf-8')); @@ -149,89 +158,151 @@ DBot.prototype.reloadModules = function() { var cacheKey = require.resolve(moduleDir + name); delete require.cache[cacheKey]; + try { + var webKey = require.resolve(moduleDir + 'web'); + } catch(err) { + } + if(webKey) { + delete require.cache[webKey]; + } + + this.status[name] = true; + try { // Load the module config data + var config = {}; + + if(_.has(this.db.config, name)) { + config = _.clone(this.db.config[name]); + } + try { - var config = JSON.parse(fs.readFileSync(moduleDir + 'config.json', 'utf-8')) - this.config[name] = config; - for(var i=0;i<config.dbKeys.length;i++) { - if(!this.db.hasOwnProperty(config.dbKeys[i])) { - this.db[config.dbKeys[i]] = {}; - } + var defaultConfig = fs.readFileSync(moduleDir + 'config.json', 'utf-8'); + try { + defaultConfig = JSON.parse(defaultConfig); + } catch(err) { // syntax error + this.status[name] = 'Error parsing config: ' + err + ' ' + err.stack.split('\n')[2].trim(); + return; } + config = _.defaults(config, defaultConfig); } catch(err) { // Invalid or no config data } + // Don't shit out if dependencies not met + if(_.has(config, 'dependencies')) { + _.each(config.dependencies, function(dependency) { + if(!_.include(moduleNames, dependency)) { + console.log('Warning: Automatically loading ' + dependency); + moduleNames.push(dependency); + } + }, this); + } + + // Generate missing DB keys + this.config[name] = config; + _.each(config.dbKeys, function(dbKey) { + if(!_.has(this.db, dbKey)) { + this.db[dbKey] = {}; + } + }, this); + // Load the module itself var rawModule = require(moduleDir + name); var module = rawModule.fetch(this); + module.name = name; this.rawModules.push(rawModule); + module.config = this.config[name]; + + // Load the module data + _.each([ 'commands', 'pages', 'api' ], function(property) { + var propertyObj = {}; + + if(fs.existsSync(moduleDir + property + '.js')) { + try { + var propertyKey = require.resolve(moduleDir + property); + if(propertyKey) delete require.cache[propertyKey]; + propertyObj = require(moduleDir + property).fetch(this); + } catch(err) { + this.status[name] = 'Error loading ' + propertyKey + ': ' + err + ' - ' + err.stack.split('\n')[1].trim(); + console.log('Module error (' + module.name + ') in ' + property + ': ' + err); + } + } + + if(!_.has(module, property)) module[property] = {}; + _.extend(module[property], propertyObj); + _.each(module[property], function(item, itemName) { + item.module = name; + if(_.has(config, property) && _.has(config[property], itemName)) { + _.extend(item, config[property][itemName]); + } + module[property][itemName] = _.bind(item, module); + _.extend(module[property][itemName], item); + }, this); + + if(property == 'api') { + this[property][name] = module[property]; + } else { + _.extend(this[property], module[property]); + } + }, this); + + // Load the module listener if(module.listener) { - this.instance.addListener(module.on, module.name, module.listener); - } - - if(module.onLoad) { - module.onLoad(); - } - - // Load module commands - if(module.commands) { - var newCommands = module.commands; - for(key in newCommands) { - if(newCommands.hasOwnProperty(key) && Object.prototype.isFunction(newCommands[key])) { - this.commands[key] = newCommands[key]; - this.commandMap[key] = name; - } + if(!_.isArray(module.on)) { + module.on = [ module.on ]; } + _.each(module.on, function(on) { + this.instance.addListener(on, module.name, module.listener); + }, this); } - // Load the module usage data - try { - var usage = JSON.parse(fs.readFileSync(moduleDir + 'usage.json', 'utf-8')); - for(key in usage) { - if(usage.hasOwnProperty(key)) { - if(this.usage.hasOwnProperty(key)) { - console.log('Usage key clash for ' + key + ' in ' + name); - } else { - this.usage[key] = usage[key]; - } - } - } - } catch(err) { - // Invalid or no usage info + // Load string data for the module + _.each([ 'usage', 'strings' ], function(property) { + var propertyData = {}; + try { + propertyData = JSON.parse(fs.readFileSync(moduleDir + property + '.json', 'utf-8')); + } catch(err) {}; + _.extend(this[property], propertyData); + }, this); + + // Provide toString for module name + module.toString = function() { + return this.name; } - // Load the module string data - try { - var strings = JSON.parse(fs.readFileSync(moduleDir + 'strings.json', 'utf-8')); - for(key in strings) { - if(strings.hasOwnProperty(key)) { - if(this.strings.hasOwnProperty(key)) { - console.log('Strings key clash for ' + key + ' in ' + name); - } else { - this.strings[key] = strings[key]; - } - } - } - } catch(err) { - // Invalid or no string info - } - - this.modules.push(module); + this.modules[module.name] = module; } catch(err) { console.log(this.t('module_load_error', {'moduleName': name})); - console.log('MODULE ERROR: ' + name + ' ' + err); + this.status[name] = err + ' - ' + err.stack.split('\n')[1].trim(); + if(this.config.debugMode) { + console.log('MODULE ERROR (' + name + '): ' + err.stack ); + } else { + console.log('MODULE ERROR (' + name + '): ' + err ); + } } }.bind(this)); + + if(_.has(this.modules, 'web')) this.modules.web.reloadPages(); + + _.each(this.modules, function(module, name) { + if(module.onLoad) { + try { + module.onLoad(); + } catch(err) { + this.status[name] = 'Error in onLoad: ' + err + ' ' + err.stack.split('\n')[1].trim(); + } + } + }, this); + this.save(); }; DBot.prototype.cleanNick = function(key) { key = key.toLowerCase(); while(key.endsWith("_")) { - if(this.db.quoteArrs.hasOwnProperty(key)) { + if(_.has(this.db.quoteArrs, key)) { return key; } key = key.substring(0, key.length-1); @@ -239,4 +310,4 @@ DBot.prototype.cleanNick = function(key) { return key; } -new DBot(timers); +new DBot(); diff --git a/snippets.js b/snippets.js index e82f506..f8fcfe4 100644 --- a/snippets.js +++ b/snippets.js @@ -35,19 +35,6 @@ Array.prototype.sum = function() { return sum; }; -Array.prototype.allGroupings = function() { - if (this.length == 0) { - return []; /* short-circuit the empty-array case */ - } - var groupings = []; - for(var n=1;n<=this.length;n++) { - for(var i=0;i<(this.length-(n-1));i++) { - groupings.push(this.slice(i, i+n)); - } - } - return groupings; -} - Array.prototype.uniq = function() { var hash = {} var result = []; @@ -79,66 +66,6 @@ String.prototype.startsWith = function(needle) { return needle === this.slice(0, needle.length); }; -String.prototype.distance = function(s1, s2) { - // Calculate Levenshtein distance between two strings - // - // version: 1109.2015 - // discuss at: http://phpjs.org/functions/levenshtein // + original by: Carlos R. L. Rodrigues (http://www.jsfromhell.com) - // + bugfixed by: Onno Marsman - // + revised by: Andrea Giammarchi (http://webreflection.blogspot.com) - // + reimplemented by: Brett Zamir (http://brett-zamir.me) - // + reimplemented by: Alexander M Beedie // * example 1: levenshtein('Kevin van Zonneveld', 'Kevin van Sommeveld'); - // * returns 1: 3 - if (s1 == s2) { - return 0; - } - var s1_len = s1.length; - var s2_len = s2.length; - if (s1_len === 0) { - return s2_len; } - if (s2_len === 0) { - return s1_len; - } - // BEGIN STATIC - var split = false; - try { - split = !('0')[0]; - } catch (e) { split = true; // Earlier IE may not support access by string index - } - // END STATIC - if (split) { - s1 = s1.split(''); s2 = s2.split(''); - } - - var v0 = new Array(s1_len + 1); - var v1 = new Array(s1_len + 1); - var s1_idx = 0, - s2_idx = 0, - cost = 0; - for (s1_idx = 0; s1_idx < s1_len + 1; s1_idx++) { v0[s1_idx] = s1_idx; - } - var char_s1 = '', - char_s2 = ''; - for (s2_idx = 1; s2_idx <= s2_len; s2_idx++) { v1[0] = s2_idx; - char_s2 = s2[s2_idx - 1]; - - for (s1_idx = 0; s1_idx < s1_len; s1_idx++) { - char_s1 = s1[s1_idx]; cost = (char_s1 == char_s2) ? 0 : 1; - var m_min = v0[s1_idx + 1] + 1; - var b = v1[s1_idx] + 1; - var c = v0[s1_idx] + cost; - if (b < m_min) { m_min = b; - } - if (c < m_min) { - m_min = c; - } v1[s1_idx + 1] = m_min; - } - var v_tmp = v0; - v0 = v1; - v1 = v_tmp; } - return v0[s1_len]; -} - String.prototype.format = function() { // format takes either multiple indexed arguments, or a single object, whose keys/values will be used var targetStr = this; var replacements = [].splice.call(arguments, 0); @@ -204,7 +131,15 @@ Object.prototype.filter = function(fun) { } } return filtered; -} +}; + +Object.prototype.each = function(fun) { + for(var key in this) { + if(this.hasOwnProperty(key)) { + fun(this[key]); + } + } +}; /*** Integer ***/ @@ -253,3 +188,18 @@ RegExp.prototype.url_regex = function() { ); return reg; } + +Number.prototype.numberFormat = function(dec_places){ + //TODO Possibly abstract this to some sort of localisation module in future? + var dec_point = '.'; + var sep = ','; + + var parts = this.toFixed(dec_places).toString().split(dec_point); + parts[0] = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, sep); + return parts.join(dec_point); +} + +// http://simonwillison.net/2006/Jan/20/escape/#p-6 +String.prototype.escape = function() { + return this.replace(/[-[\]{}()*+?.,\\^$|#\s]/g, "\\$&"); +} diff --git a/timer.js b/timer.js deleted file mode 100644 index 7d494a6..0000000 --- a/timer.js +++ /dev/null @@ -1,27 +0,0 @@ -var timers = function() { - var timers = []; - var timeouts = []; - - return { - 'addTimer': function(interval, callback) { // Because who puts the callback first. Really. - timers.push(setInterval(callback, interval)); - }, - - 'addOnceTimer': function(delay, callback) { // Because who seriously puts the callback first here too? - timeouts.push(setTimeout(callback, delay)); - }, - - 'clearTimers': function() { - for(var i;i<timers.length;i++) { - clearInterval(timers[i]); - } - for(var i;i<timeouts.length;i++) { - clearTimeout(timeouts[i]); - } - } - }; -}; - -exports.create = function() { - return timers(); -} diff --git a/views/channels.jade b/views/channels.jade deleted file mode 100644 index d0e3faa..0000000 --- a/views/channels.jade +++ /dev/null @@ -1,7 +0,0 @@ -h3 Channels on #{connection} -div#backlink - a(href='/connections') « Connection List -ul#quotelist - -each channel in channels - a(href='/users/'+connection+'/'+channel.substr(1,channel.length)) - li.quotes #{channel} diff --git a/views/connections.jade b/views/connections.jade deleted file mode 100644 index f4e1531..0000000 --- a/views/connections.jade +++ /dev/null @@ -1,6 +0,0 @@ -h3 Current Connections -div#backlink - a(href='/') « Home -#modulelinks - -each connection in connections - a.module(href='/channels/'+connection) #{connection} diff --git a/views/index.jade b/views/index.jade index 4558fbf..05d931e 100644 --- a/views/index.jade +++ b/views/index.jade @@ -1,4 +1,7 @@ -#modulelinks +extends layout + +block content + #modulelinks a.module(href='/quotes') Quotes a.module(href='/polls') Polls a.module(href='/connections') Users diff --git a/views/layout.jade b/views/layout.jade index be03d24..bb3cf2e 100644 --- a/views/layout.jade +++ b/views/layout.jade @@ -2,12 +2,18 @@ html(lang='en') head meta(charset='utf-8') + script(type="text/javascript", src="//ajax.googleapis.com/ajax/libs/jquery/1.9.0/jquery.min.js") + link(rel="stylesheet", type="text/css", href="http://fonts.googleapis.com/css?family=Source+Sans+Pro") + link(rel="stylesheet", type="text/css", href="/bootstrap/css/bootstrap.min.css") link(rel='stylesheet', type='text/css', href='/styles.css') title #{name} web interface body - div#page - div#title - a(href='/') #{name} web interface - div#main - !{body} + div.container + div#page + div#title + a(href='/') #{name} web interface + div.container#main + block content + script(type="text/javascript", src="/bootstrap/js/bootstrap.min.js") + script(type="text/javascript", src="/d3/d3.v3.min.js") script(type="text/javascript", src="/script.js") diff --git a/views/poll/polllist.jade b/views/poll/polllist.jade new file mode 100644 index 0000000..1623492 --- /dev/null +++ b/views/poll/polllist.jade @@ -0,0 +1,12 @@ +extends ../layout + +block content + div#backlink + a(href='/') « Home + div#controls + input(type="text", name="search", id="search-text", oninput="search(this.value)") + ul#quotelist + -each poll in polllist + a(href='/polls/'+poll) + li.quotes #{poll} + diff --git a/views/poll/polls.jade b/views/poll/polls.jade new file mode 100644 index 0000000..80e25f6 --- /dev/null +++ b/views/poll/polls.jade @@ -0,0 +1,39 @@ +extends ../layout + +block content + div#backlink + a(href='/polls/') « Poll list + h2 #{description} + p Voters (#{locals.totalVotes}): + -each voter in votees + | #{voter} + ul#votelist + -var hasYouTubeVids=false + -each votes,option in options + -var percentage = votes/locals.totalVotes*100 + -if(options.hasOwnProperty(option)) + -if(option.match(locals.url_regex)) + li.option + -if(option.match(/(jpg|png|gif|jpeg|tiff)$/)) + a(href=option) + img(src=option) + -else if(option.match(/youtube.com\/watch/)) + -hasYouTubeVids = true + span(class='ytplaceholder') + =option + -else + a(href=option) + =option + -else + li.option #{option} + li.option-votes + .vote-track + -if(!isNaN(percentage)) + .vote-percentage(style="width: #{percentage}%") + case votes + when 1: #{votes} vote + default: #{votes} votes + -if(!isNaN(percentage)) + |(#{percentage.toFixed(2)}%) + -if(hasYouTubeVids) + script(src='/ytembed.js') diff --git a/views/polllist.jade b/views/polllist.jade deleted file mode 100644 index 4de9297..0000000 --- a/views/polllist.jade +++ /dev/null @@ -1,9 +0,0 @@ -div#backlink - a(href='/') « Home -div#controls - input(type="text", name="search", id="search-text", oninput="search(this.value)") -ul#quotelist - -each poll in polllist - a(href='/polls/'+poll) - li.quotes #{poll} - diff --git a/views/polls.jade b/views/polls.jade deleted file mode 100644 index 504f3d3..0000000 --- a/views/polls.jade +++ /dev/null @@ -1,36 +0,0 @@ -div#backlink - a(href='/polls/') « Poll list -h2 #{description} -p Voters (#{locals.totalVotes}): - -each voter in votees - | #{voter} -ul#votelist - -var hasYouTubeVids=false - -each votes,option in options - -var percentage = votes/locals.totalVotes*100 - -if(options.hasOwnProperty(option)) - -if(option.match(locals.url_regex)) - li.option - -if(option.match(/(jpg|png|gif|jpeg|tiff)$/)) - a(href=option) - img(src=option) - -else if(option.match(/youtube.com\/watch/)) - -hasYouTubeVids = true - span(class='ytplaceholder') - =option - -else - a(href=option) - =option - -else - li.option #{option} - li.option-votes - .vote-track - -if(!isNaN(percentage)) - .vote-percentage(style="width: #{percentage}%") - case votes - when 1: #{votes} vote - default: #{votes} votes - -if(!isNaN(percentage)) - |(#{percentage.toFixed(2)}%) - -if(hasYouTubeVids) - script(src='/ytembed.js') diff --git a/views/profile/profile.jade b/views/profile/profile.jade new file mode 100644 index 0000000..8ea224b --- /dev/null +++ b/views/profile/profile.jade @@ -0,0 +1,98 @@ +extends ../layout + +block content + script(type="text/javascript", src="http://ajax.aspnetcdn.com/ajax/jquery.dataTables/1.9.4/jquery.dataTables.min.js") + + script + $(document).ready(function(){ + // Allowing forcing of string stats data to sort as numeric + jQuery.extend( jQuery.fn.dataTableExt.oSort, { + "forcenum-pre": function ( a ) { + a = a.replace("\,", ""); + return parseFloat( a ); + }, + + "forcenum-asc": function ( a, b ) { + return a - b; + }, + + "forcenum-desc": function ( a, b ) { + return b - a; + } + } ); + + $('.tip').tooltip(); + $('.data').dataTable({ + "aoColumnDefs": [ + { "sType": "forcenum", + "asSorting": [ "desc", "asc" ], + "aTargets": [ 1, 2, 3, 4, 5 ] } + ], + "bPaginate": false, + "bFilter": false, + "bLengthChange": false, + "oLanguage": { + "sInfo": "", + "sInfoEmpty": "", + "sInfoFiltered": "" + }, + }); + }); + + div.page-header.profile_page-header + h1 + #{primary} + small + if(profile.tagline) + "#{profile.tagline}" + div#backlink + a(href='/profile/'+connection) « Profiles + + div.row.profile_row#profile_data + div.span3 + if profile.avatar + img.profile_avatar.img-polaroid(src="#{profile.avatar}") + else + img.profile_avatar.img-polaroid(src="http://placehold.it/270x180&text=Hello,%20World") + div.span9 + h4 Bio + p #{profile.bio} + + hr + h3 Channel Statistics + div#profile_datatable + table.table.table-hover.data + thead + tr + th Channel + th Lines + th Words + th Lincent + th Verbosity + th Mentions + tbody + if stats + for chan, key in stats + if stats.hasOwnProperty(key) + tr + td + a(href='/users/'+connection+'/'+encodeURIComponent(key)) + #{key} + span + if chan.online + if chan.active.active + span.label.label-success.tip(data-original-title="#{chan.active.ago}", data-placement="right") Active + else + span.label.label-important.tip(data-original-title="#{chan.active.ago}", data-placement="right") Inactive + else + span.label.tip(data-original-title="#{chan.active.ago}", data-placement="right") Offline + td + #{chan.fields.lines.data} + td + #{chan.fields.words.data} + td + #{chan.fields.lincent.data} + td + #{chan.fields.wpl.data} + td + #{chan.fields.in_mentions.data} diff --git a/views/profile/profile_grid.jade b/views/profile/profile_grid.jade new file mode 100644 index 0000000..ba7b108 --- /dev/null +++ b/views/profile/profile_grid.jade @@ -0,0 +1,26 @@ +extends ../layout + +block content + script(src='http://masonry.desandro.com/jquery.masonry.min.js') + script + $(function() { + var $container = $('.thumbnails'); + $container.imagesLoaded(function(){ + $container.masonry({ + itemSelector : '.thumbnails > li', + }); + }); + }); + div.page-header.profile_page-header + h1 + #{connection} + div#backlink + a(href='../connections') « Connections + + ul.thumbnails + each nick in nicks + li.span2 + a.thumbnail(href='/profile/'+connection+'/'+encodeURIComponent(nick)) + div.imgwrap + img(src="#{profiles[nick].profile.avatar}", alt="#{profiles[nick].profile.primary}'s photo") + span.nicks #{profiles[nick].profile.primary} diff --git a/views/quotelist.jade b/views/quotelist.jade deleted file mode 100644 index 9643069..0000000 --- a/views/quotelist.jade +++ /dev/null @@ -1,9 +0,0 @@ -div#backlink - a(href='/') « Home -div#controls - input(type="text", name="search", id="search-text", oninput="search(this.value)") -ul#quotelist - -each quote in quotelist - a(href='/quotes/'+quote) - li.quotes #{quote} - diff --git a/views/quotes.jade b/views/quotes.jade deleted file mode 100644 index 35dc519..0000000 --- a/views/quotes.jade +++ /dev/null @@ -1,26 +0,0 @@ -div#backlink - a(href='/quotes/') « Quote list -ul#quotelist - -var hasYouTubeVids=false - -each quote in quotes - -if(quote.match(locals.url_regex)) - li.quote - -if(quote.match(/(jpg|png|gif|jpeg|tiff)$/)) - a(href=quote) - img(src=quote) - -else if(quote.match(/youtube.com\/watch/)) - -hasYouTubeVids = true - span(class='ytplaceholder') - =quote - -else - a(href=quote) - =quote - -else if(quote.match(/~~([^~]+)~~/)) - -var res = quote.match(/~~([^~]+)~~/) - -if(res != null) - li.quote - a(href="/quotes/#{res[1]}") #{quote} - -else - li.quote #{quote} - -if(hasYouTubeVids) - script(src='/ytembed.js') diff --git a/views/quotes/quotelist.jade b/views/quotes/quotelist.jade new file mode 100644 index 0000000..d038e0a --- /dev/null +++ b/views/quotes/quotelist.jade @@ -0,0 +1,12 @@ +extends ../layout + +block content + div#backlink + a(href='/') « Home + div#controls + input(type="text", name="search", id="search-text", oninput="search(this.value)") + ul#quotelist + -each quote in quotelist + a(href='/quotes/'+quote) + li.quotes #{quote} + diff --git a/views/quotes/quotes.jade b/views/quotes/quotes.jade new file mode 100644 index 0000000..a40d12e --- /dev/null +++ b/views/quotes/quotes.jade @@ -0,0 +1,29 @@ +extends ../layout + +block content + div#backlink + a(href='/quotes/') « Quote list + ul#quotelist + -var hasYouTubeVids=false + -each quote in quotes + -if(quote.match(locals.url_regex)) + li.quote + -if(quote.match(/(jpg|png|gif|jpeg|tiff)$/)) + a(href=quote) + img(src=quote) + -else if(quote.match(/youtube.com\/watch/)) + -hasYouTubeVids = true + span(class='ytplaceholder') + =quote + -else + a(href=quote) + =quote + -else if(quote.match(/~~([^~]+)~~/)) + -var res = quote.match(/~~([^~]+)~~/) + -if(res != null) + li.quote + a(href="/quotes/#{res[1]}") #{quote} + -else + li.quote #{quote} + -if(hasYouTubeVids) + script(src='/ytembed.js') diff --git a/views/user.jade b/views/user.jade deleted file mode 100644 index 5ba3828..0000000 --- a/views/user.jade +++ /dev/null @@ -1,11 +0,0 @@ -h3 #{user}'s profile -div#backlink - a(href='/users/'+connection+'/'+channel.substr(1,channel.length)) « #{channel} Nick List - -div.quotecount - #{cleanUser} is filled with - a(href='/quotes/'+cleanUser) #{quotecount} quotes - . - -div.kickcount - #{cleanUser} has kicked people #{kicked} times and has been kicked #{kicks} times. diff --git a/views/users.jade b/views/users.jade deleted file mode 100644 index 89c6a11..0000000 --- a/views/users.jade +++ /dev/null @@ -1,7 +0,0 @@ -h3 Users currently in #{channel} on #{connection} -div#backlink - a(href='/channels/'+connection) « Channel List -ul#quotelist - -each nick in nicks - a(href='/user/'+connection+'/'+channel.substr(1,channel.length)+'/'+nick) - li.quotes #{nick} diff --git a/views/users/channels.jade b/views/users/channels.jade new file mode 100644 index 0000000..bb3cac4 --- /dev/null +++ b/views/users/channels.jade @@ -0,0 +1,10 @@ +extends ../layout + +block content + h3 Channels on #{connection} + div#backlink + a(href='/connections') « Connection List + ul#quotelist + -each channel in channels + a(href='/users/'+connection+'/'+encodeURIComponent(channel)) + li.quotes #{channel} diff --git a/views/users/connections.jade b/views/users/connections.jade new file mode 100644 index 0000000..27681e9 --- /dev/null +++ b/views/users/connections.jade @@ -0,0 +1,10 @@ +extends ../layout + +block content + h3 Current Connections + div#backlink + a(href='/') « Home + #modulelinks + -each connection in connections + a.module(href='/channels/'+connection) #{connection} + a.module(href='/profile/'+connection) #{connection} headshots diff --git a/views/users/user.jade b/views/users/user.jade new file mode 100644 index 0000000..072362e --- /dev/null +++ b/views/users/user.jade @@ -0,0 +1,14 @@ +extends ../layout + +block content + h3 #{user}'s profile + div#backlink + a(href='/users/'+connection+'/'+channel.substr(1,channel.length)) « #{channel} Nick List + + div.quotecount + #{cleanUser} is filled with + a(href='/quotes/'+cleanUser) #{quotecount} quotes + . + + div.kickcount + #{cleanUser} has kicked people #{kicked} times and has been kicked #{kicks} times. diff --git a/views/users/users.jade b/views/users/users.jade new file mode 100644 index 0000000..2b97de5 --- /dev/null +++ b/views/users/users.jade @@ -0,0 +1,197 @@ +extends ../layout + +block content + script(type="text/javascript", src="http://ajax.aspnetcdn.com/ajax/jquery.dataTables/1.9.4/jquery.dataTables.min.js") + style + .chart rect { + fill: steelblue; + } + .chart rect:hover { + fill: #000; + } + .yTicks { + stroke: #aaa; + stroke-width: 0.5; + } + + script + $(document).ready(function(){ + // d3.js Graph + var ticks = 25; + var timelabels = 30; + var chartw = 940; + var toppad = 10; + var w = (chartw - (ticks * 2)) / 168; + var h = 130; + + var x = d3.scale.linear() + .domain([0,1]) + .range([0,w]); + + var y = d3.scale.linear() + .domain([0,d3.max([#{chanFreq}])]) + .rangeRound([0,h-toppad]); + + var chart = d3.select($("#chanFreqChart")[0]).append("svg") + .attr("class", "chart") + .attr("width", chartw) + .attr("height", h + timelabels); + + chart.selectAll(".yTicks") + .data(y.ticks(5).slice(1)) + .enter().append("svg:line") + .attr("class", "yTicks") + .attr("y1", function(d) { return -1 * y(d) + h; }) + .attr("x1", 0) + .attr("y2", function(d) { return -1 * y(d) + h; }) + .attr("x2", chartw) + + chart.selectAll("rect") + .data([#{chanFreq}]) + .enter().append("rect") + .attr("x", function(d, i) { return x(i) - .5 + ticks; }) + .attr("y", function(d) { return h - y(d) - .5; }) + .attr("width", w) + .attr("height", function(d) { return y(d); }) + .attr("title", function(d){ return d; }); + + chart.selectAll(".l_rule") + .data(y.ticks(5).slice(1)) + .enter().append("text") + .attr("class", "rule") + .attr("y", function(d) { return -1 * y(d) + h; }) + .attr("x", 0) + .attr("text-anchor", "right") + .attr("font-size", 10) + .attr("dy", 3) + .text(String); + chart.selectAll(".r_rule") + .data(y.ticks(5).slice(1)) + .enter().append("text") + .attr("class", "rule") + .attr("y", function(d) { return -1 * y(d) + h; }) + .attr("x", chartw) + .attr("text-anchor", "end") + .attr("font-size", 10) + .attr("dy", 3) + .text(String); + + chart.append("line") + .attr("x1", 0 + ticks) + .attr("x2", chartw - ticks) + .attr("y1", h - .5) + .attr("y2", h - .5) + .style("stroke", "#000"); + + chart.selectAll("text.times") + .data(["0", "6", "12", "18","0", "6", "12", "18","0", "6", "12", "18","0", "6", "12", "18" ,"0", "6", "12", "18" ,"0", "6", "12", "18" ,"0", "6", "12", "18"]) + .enter().append("text") + .attr("x", function(d, i){ return ((i * w * 6)+(w/2)) + ticks; }) + .attr("y", h + 10) + .attr("text-anchor", "middle") + .attr('class', 'name') + .attr("font-size", 10) + .text(String); + + chart.selectAll("text.days") + .data([!{locals.chanFreqLabel}]) + .enter().append("text") + .attr("x", function(d, i){ return ((i+0.5) * w * 24) + ticks; }) + .attr("y", h + 25) + .attr("text-anchor", "middle") + .attr('class', 'name') + .attr("font-size", 12) + .text(String); + + + // Allowing forcing of string stats data to sort as numeric + jQuery.extend( jQuery.fn.dataTableExt.oSort, { + "forcenum-pre": function ( a ) { + a = a.replace("\,", ""); + return parseFloat( a ); + }, + + "forcenum-asc": function ( a, b ) { + return a - b; + }, + + "forcenum-desc": function ( a, b ) { + return b - a; + } + } ); + + $('.tip').tooltip(); + $('rect').tooltip({ + }); + $('.data').dataTable({ + "aoColumnDefs": [ + { "aDataSort": [ 1, 0 ], "asSorting": [ "asc" ], "aTargets": [ 0 ] }, + { "bVisible": false, "aTargets": [ 1 ] }, + { "sType": "forcenum", + "asSorting": [ "desc", "asc" ], + "aTargets": [ 2, 3, 4, 5, 6 ] } + ], + "bPaginate": false, + "bLengthChange": false, + "oLanguage": { + "sInfo": "_TOTAL_ users", + "sInfoEmpty": "No users", + "sInfoFiltered": "filtered (_MAX_ total)" + }, + }); + }); + + div.page-header.profile_page-header + h1 + #{channel} + small + #{connection} + div#backlink + a(href='/channels/'+connection) « Channel List + div#row + h4 Seven Day Activity + div.barchart#chanFreqChart + hr + div#row + table.table.table-hover.data + thead + tr + th Users + th + th Lines + th Words + th Lincent + th Verbosity + th Mentions + tbody + -each nick in userStats + tr + td + a(href='/profile/'+connection+'/'+nick.primary) + #{nick.display} + span + if nick.online + if nick.active.active + span.label.label-success.tip(data-original-title="#{nick.active.ago}", data-placement="right") Active + else + span.label.label-important.tip(data-original-title="#{nick.active.ago}", data-placement="right") Inactive + else + span.label.tip(data-original-title="#{nick.active.ago}", data-placement="right") Offline + td + if nick.online + if nick.active.active + -1 + else + 0 + else + 1 + td + #{nick.fields.lines.data} + td + #{nick.fields.words.data} + td + #{nick.fields.lincent.data} + td + #{nick.fields.wpl.data} + td + #{nick.fields.in_mentions.data}