Merge branch 'development'
authorDarren <darren@darrenwhitlen.com>
Tue, 27 Nov 2012 00:29:25 +0000 (00:29 +0000)
committerDarren <darren@darrenwhitlen.com>
Tue, 27 Nov 2012 00:29:25 +0000 (00:29 +0000)
client/assets/css/style.css
client/assets/dev/applet_settings.js
client/assets/dev/model_applet.js
client/assets/dev/model_application.js
client/assets/dev/view.js
config.example.js
server/clientcommands.js
server/kiwi.js
server/modules.js [new file with mode: 0644]
server_modules/example.js [new file with mode: 0644]

index 61af74b36b1083c4fd2b964ea5651aa193ad7688..6c27cf74320f889b2b7b236afbe9de5dcb96c19c 100644 (file)
@@ -314,7 +314,7 @@ html, body { height:100%; }
 
 #kiwi.theme_relaxed .messages .msg { border-bottom: 1px solid #DEDEDE; padding: 1px; font-family:arial; font-size:0.9em; }
 #kiwi.theme_relaxed .messages .msg .time { width:6em; float:left; color:#777; display:none; }
-#kiwi.theme_relaxed .messages .msg .nick { width:11em; float:left; font-size:12px; font-family:Arial; text-align:left; padding: 5px; }
+#kiwi.theme_relaxed .messages .msg .nick { width:11em; float:left; font-size:12px; font-family:Arial; text-align:left; padding: 5px; overflow:hidden; }
 #kiwi.theme_relaxed .messages .msg .text { display:block; margin-left:12em; border-left: 1px solid #DEDEDE; white-space:pre-wrap; word-wrap:break-word; font-family:arial; padding:5px; }
 
 #kiwi.theme_relaxed .messages .msg.action .nick { }
@@ -341,6 +341,37 @@ html, body { height:100%; }
 #kiwi.theme_relaxed .messages .msg.highlight { background:#D9D9D9; }
 
 
+#kiwi.theme_relaxed .messages .msg .media { margin-left:0.5em; }
+#kiwi.theme_relaxed .messages .msg .media .media_close { font-size:0.9em; }
+#kiwi.theme_relaxed .messages .msg .media .media_content { margin:10px 0 0 10px; overflow:hidden; }
+#kiwi.theme_relaxed .messages .msg .media .media_content img { padding:3px; border:1px solid gray; }
+#kiwi.theme_relaxed .messages .msg .media .media_content > .content {
+    background: white;
+    overflow: hidden;
+    padding: 10px;
+    border: #DDD 1px solid;
+    border-top-color: #EEE;
+    border-bottom-color: #BBB;
+    -webkit-box-shadow: 0 1px 3px rgba(0, 0, 0, 0.15);
+    -moz-box-shadow: 0 1px 3px rgba(0,0,0,0.15);
+    box-shadow: 0 1px 3px rgba(0, 0, 0, 0.15);
+    border-radius: 5px;
+    float: left;
+}
+
+#kiwi.theme_relaxed .messages .msg .media.twitter .media_content > .content {
+    background: transparent;
+    border:none;
+    overflow: hidden;
+    box-shadow: none;
+    padding: 0;
+}
+#kiwi.theme_relaxed .messages .msg .media.reddit .thumbnail_nsfw {
+    display: inline-block;
+    float: left;
+}
+#kiwi.theme_relaxed .messages .msg .media.reddit .thumbnail { float:left; margin-right: 0.5em; }
+
 
 #kiwi.theme_relaxed #memberlists {
     background-color: #DADADA;
@@ -372,7 +403,7 @@ html, body { height:100%; }
     -webkit-border-radius:5px;
     -khtml-border-radius:5px;
 }
-#kiwi.theme_relaxed #controlbox .input .nick { text-align: right; width:11em; left:0px; position:absolute; padding:2px; }
+#kiwi.theme_relaxed #controlbox .input .nick { text-align: right; width:11em; left:0px; position:absolute; padding:2px; overflow:hidden; }
 #kiwi.theme_relaxed #controlbox .input .nick a { text-decoration:none; color:black; }
 #kiwi.theme_relaxed #controlbox .input .input_wrap {
     position:absolute;
@@ -706,14 +737,14 @@ html, body { height:100%; }
 #kiwi.theme_cli #controlbox { background:#111111; border-top:1px solid #444444; color:#909090; font-size:1.3em; line-height:2em; margin:3px; }
 #kiwi.theme_cli #controlbox .input_wrap:before { content:"> " }
 #kiwi.theme_cli #controlbox .input { background:none; border:none;}
-#kiwi.theme_cli #controlbox .input .nick { line-height:1.7em; padding:0; text-align: right; width:11em; left:0px; position:absolute; }
+#kiwi.theme_cli #controlbox .input .nick { line-height:1.7em; padding:0; text-align: right; width:11em; left:0px; position:absolute; overflow:hidden; }
 #kiwi.theme_cli #controlbox .input .input_wrap {
     position:absolute;
     right:7px; left: 12.2em;
     height:1.7em;
 }
 #kiwi.theme_cli #controlbox .input .inp {
-    line-height:1.7em;
+    line-height:1.4em;
     font-size:1.3em;
     background:transparent; color:#909090;
     border: medium none;
@@ -722,7 +753,7 @@ html, body { height:100%; }
     outline:none; resize:none;
     overflow:hidden;
     position:absolute;
-    height:100%; width:98%;
+    height:99%; width:98%;
     display: inline;
     padding-left:0.5em;
 }
@@ -850,6 +881,40 @@ html, body { height:100%; }
 #kiwi.theme_cli .messages .msg.global_nick_highlight { background:#111111; }
 
 
+
+#kiwi.theme_cli .messages .msg .media { margin-left:0.5em; }
+#kiwi.theme_cli .messages .msg .media .media_close { font-size:0.9em; }
+#kiwi.theme_cli .messages .msg .media .media_content { margin:10px 0 0 6em; overflow:hidden; }
+#kiwi.theme_cli .messages .msg .media .media_content img { padding:3px; border:1px solid gray; }
+#kiwi.theme_cli .messages .msg .media .media_content > .content {
+    background: white;
+    overflow: hidden;
+    padding: 10px;
+    border: #DDD 1px solid;
+    border-top-color: #EEE;
+    border-bottom-color: #BBB;
+    -webkit-box-shadow: 0 1px 3px rgba(0, 0, 0, 0.15);
+    -moz-box-shadow: 0 1px 3px rgba(0,0,0,0.15);
+    box-shadow: 0 1px 3px rgba(0, 0, 0, 0.15);
+    border-radius: 5px;
+    float: left;
+}
+
+#kiwi.theme_cli .messages .msg .media.twitter .media_content > .content {
+    background: transparent;
+    border:none;
+    overflow: hidden;
+    box-shadow: none;
+    padding: 0;
+}
+#kiwi.theme_cli .messages .msg .media.reddit .thumbnail_nsfw {
+    display: inline-block;
+    float: left;
+}
+#kiwi.theme_cli .messages .msg .media.reddit .thumbnail { float:left; margin-right: 0.5em; }
+
+
+
 /* The server select dialog */
 #kiwi.theme_cli .server_select { width:730px;  padding:3em 0 2em 0; margin: 0 auto; }
 #kiwi.theme_cli .server_select .more { display: none; width:270px; margin:0 auto; }
index 2072fe183f66844fd56532fc00ac6c4f92c0ae34..8b9a06166f257eec5ec7222d48b860ddbcec012b 100644 (file)
@@ -12,6 +12,8 @@
 \r
             // Now actually show the current settings\r
             this.loadSettings();\r
+\r
+\r
         },\r
         \r
 \r
 \r
 \r
 \r
-    _kiwi.applets.Settings = Backbone.Model.extend({\r
+    var Applet = Backbone.Model.extend({\r
         initialize: function () {\r
             this.set('title', 'Settings');\r
             this.view = new View();\r
         }\r
     });\r
+\r
+\r
+    _kiwi.model.Applet.register('kiwi_settings', Applet);\r
 })();
\ No newline at end of file
index e4477499498b770eef30d645275c0826147f9c54..07cc5f90ea7985de22b84d8d0141db2471067560 100644 (file)
@@ -17,6 +17,7 @@ _kiwi.model.Applet = _kiwi.model.Panel.extend({
         this.loaded_applet = null;\r
     },\r
 \r
+\r
     // Load an applet within this panel\r
     load: function (applet_object, applet_name) {\r
         if (typeof applet_object === 'object') {\r
@@ -49,6 +50,7 @@ _kiwi.model.Applet = _kiwi.model.Panel.extend({
         return this;\r
     },\r
 \r
+\r
     loadFromUrl: function(applet_url, applet_name) {\r
         var that = this;\r
 \r
@@ -65,6 +67,7 @@ _kiwi.model.Applet = _kiwi.model.Panel.extend({
         });\r
     },\r
 \r
+\r
     close: function () {\r
         this.view.$el.remove();\r
         this.destroy();\r
@@ -78,4 +81,53 @@ _kiwi.model.Applet = _kiwi.model.Panel.extend({
 \r
         this.closePanel();\r
     }\r
+},\r
+\r
+\r
+{\r
+    // Load an applet type once only. If it already exists, return that\r
+    loadOnce: function (applet_name) {\r
+\r
+        // See if we have an instance loaded already\r
+        var applet = _.find(_kiwi.app.panels.models, function(panel) {\r
+            // Ignore if it's not an applet\r
+            if (!panel.isApplet()) return;\r
+\r
+            // Ignore if it doesn't have an applet loaded\r
+            if (!panel.loaded_applet) return;\r
+\r
+            if (panel.loaded_applet.get('_applet_name') === applet_name) {\r
+                return true;\r
+            }\r
+        });\r
+\r
+        if (applet) return applet;\r
+\r
+\r
+        // If we didn't find an instance, load a new one up\r
+        return this.load(applet_name);\r
+    },\r
+\r
+\r
+    load: function (applet_name) {\r
+        var applet;\r
+\r
+        // Find the applet within the registered applets\r
+        if (!_kiwi.applets[applet_name]) return;\r
+\r
+        // Create the applet and load the content\r
+        applet = new _kiwi.model.Applet();\r
+        applet.load(new _kiwi.applets[applet_name]({_applet_name: applet_name}));\r
+\r
+        // Add it into the tab list\r
+        _kiwi.app.panels.add(applet);\r
+\r
+\r
+        return applet;\r
+    },\r
+\r
+\r
+    register: function (applet_name, applet) {\r
+        _kiwi.applets[applet_name] = applet;\r
+    }\r
 });
\ No newline at end of file
index 262383b4567929d8959b194ee9ce6b4a526d2def..156b0f8db50d37dc853c9314e1ac817acf782772 100644 (file)
@@ -648,7 +648,7 @@ _kiwi.model.Application = function () {
                         member.set('away', !(!event.trailing));\r
                     }\r
                 });\r
-            });
+            });\r
 \r
 \r
             gw.on('onlist_start', function (data) {\r
@@ -989,11 +989,8 @@ _kiwi.model.Application = function () {
         }\r
 \r
         function settingsCommand (ev) {\r
-            var panel = new _kiwi.model.Applet();\r
-            panel.load(new _kiwi.applets.Settings());\r
-            \r
-            _kiwi.app.panels.add(panel);\r
-            panel.view.show();\r
+            var settings = _kiwi.model.Applet.loadOnce('kiwi_settings');\r
+            settings.view.show();\r
         }\r
 \r
         function appletCommand (ev) {\r
index cb67ad081d716ea75aff42640439a53a8f32ff5a..0d6ca8e7368abe0546240d521bbc75268e938ece 100644 (file)
@@ -249,6 +249,7 @@ _kiwi.view.Panel = Backbone.View.extend({
     className: "messages",\r
     events: {\r
         "click .chan": "chanClick",\r
+        'click .media .open': 'mediaClick',\r
         'mouseenter .msg .nick': 'msgEnter',\r
         'mouseleave .msg .nick': 'msgLeave'\r
     },\r
@@ -297,27 +298,32 @@ _kiwi.view.Panel = Backbone.View.extend({
         msg.msg =  $('<div />').text(msg.msg).html();\r
 \r
         // Make the channels clickable\r
-        re = new RegExp('\\B([' + _kiwi.gateway.get('channel_prefix') + '][^ ,.\\007]+)', 'g');\r
+        re = new RegExp('(?:^|\\s)([' + _kiwi.gateway.get('channel_prefix') + '][^ ,.\\007]+)', 'g');\r
         msg.msg = msg.msg.replace(re, function (match) {\r
-            return '<a class="chan">' + match + '</a>';\r
+            return '<a class="chan" data-channel="' + match.trim() + '">' + match + '</a>';\r
         });\r
 \r
 \r
-        // Make links clickable\r
-        msg.msg = msg.msg.replace(/((https?\:\/\/|ftp\:\/\/)|(www\.))(\S+)(\w{2,4})(:[0-9]+)?(\/|\/([\w#!:.?+=&%@!\-\/]*))?/gi, function (url) {\r
-            var nice;\r
+        // Parse any links found\r
+        msg.msg = msg.msg.replace(/(([A-Za-z0-9\-]+\:\/\/)|(www\.))([\w.]+)([a-zA-Z]{2,6})(:[0-9]+)?(\/[\w#!:.?$'()[\]*,;~+=&%@!\-\/]*)?/gi, function (url) {\r
+            var nice, extra_html = '';\r
 \r
-            // Add the http is no protoocol was found\r
+            // Add the http if no protoocol was found\r
             if (url.match(/^www\./)) {\r
                 url = 'http://' + url;\r
             }\r
 \r
+            // Shorten the displayed URL if it's going to be too long\r
             nice = url;\r
             if (nice.length > 100) {\r
                 nice = nice.substr(0, 100) + '...';\r
             }\r
 \r
-            return '<a class="link_ext" target="_blank" rel="nofollow" href="' + url + '">' + nice + '</a>';\r
+            // Get any media HTML if supported\r
+            extra_html = _kiwi.view.MediaMessage.buildHtml(url);\r
+\r
+            // Make the link clickable\r
+            return '<a class="link_ext" target="_blank" rel="nofollow" href="' + url + '">' + nice + '</a> ' + extra_html;\r
         });\r
 \r
 \r
@@ -377,13 +383,29 @@ _kiwi.view.Panel = Backbone.View.extend({
     },\r
     chanClick: function (event) {\r
         if (event.target) {\r
-            _kiwi.gateway.join($(event.target).text());\r
+            _kiwi.gateway.join($(event.target).data('channel'));\r
         } else {\r
             // IE...\r
-            _kiwi.gateway.join($(event.srcElement).text());\r
+            _kiwi.gateway.join($(event.srcElement).data('channel'));\r
         }\r
     },\r
 \r
+    mediaClick: function (event) {\r
+        var $media = $(event.target).parents('.media');\r
+        var media_message;\r
+\r
+        if ($media.data('media')) {\r
+            media_message = $media.data('media');\r
+        } else {\r
+            media_message = new _kiwi.view.MediaMessage({el: $media[0]});\r
+            $media.data('media', media_message);\r
+        }\r
+\r
+        $media.data('media', media_message);\r
+\r
+        media_message.open();\r
+    },\r
+\r
     msgEnter: function (event) {\r
         var nick_class;\r
 \r
@@ -1059,12 +1081,14 @@ _kiwi.view.Application = Backbone.View.extend({
 \r
         // Set the panels width depending on the memberlist visibility\r
         if (el_memberlists.css('display') != 'none') {\r
-            // Handle + panels to the side of the memberlist\r
-            el_panels.css('right', el_memberlists.outerWidth(true) + el_resize_handle.outerWidth(true));\r
-            el_resize_handle.css('left', el_memberlists.position().left - el_resize_handle.outerWidth(true));\r
+            // Panels to the side of the memberlist\r
+            el_panels.css('right', el_memberlists.outerWidth(true));\r
+            // The resize handle sits overlapping the panels and memberlist\r
+            el_resize_handle.css('left', el_memberlists.position().left - (el_resize_handle.outerWidth(true) / 2));\r
         } else {\r
-            // Memberlist is hidden so handle + panels to the right edge\r
-            el_panels.css('right', el_resize_handle.outerWidth(true));\r
+            // Memberlist is hidden so panels to the right edge\r
+            el_panels.css('right', 0);\r
+            // And move the handle just out of sight to the right\r
             el_resize_handle.css('left', el_panels.outerWidth(true));\r
         }\r
     },\r
@@ -1166,4 +1190,133 @@ _kiwi.view.Application = Backbone.View.extend({
             this.doLayout();\r
         }\r
     }\r
+});\r
+\r
+\r
+\r
+\r
+\r
+\r
+\r
+\r
+\r
+_kiwi.view.MediaMessage = Backbone.View.extend({\r
+    events: {\r
+        'click .media_close': 'close'\r
+    },\r
+\r
+    initialize: function () {\r
+        // Get the URL from the data\r
+        this.url = this.$el.data('url');\r
+    },\r
+\r
+    // Close the media content and remove it from display\r
+    close: function () {\r
+        var that = this;\r
+        this.$content.slideUp('fast', function () {\r
+            that.$content.remove();\r
+        });\r
+    },\r
+\r
+    // Open the media content within its wrapper\r
+    open: function () {\r
+        // Create the content div if we haven't already\r
+        if (!this.$content) {\r
+            this.$content = $('<div class="media_content"><a class="media_close"><i class="icon-chevron-up"></i> Close media</a><br /><div class="content"></div></div>');\r
+            this.$content.find('.content').append(this.mediaTypes[this.$el.data('type')].apply(this, []) || 'Not found :(');\r
+        }\r
+\r
+        // Now show the content if not already\r
+        if (!this.$content.is(':visible')) {\r
+            // Hide it first so the slideDown always plays\r
+            this.$content.hide();\r
+\r
+            // Add the media content and slide it into view\r
+            this.$el.append(this.$content);\r
+            this.$content.slideDown();\r
+        }\r
+    },\r
+\r
+\r
+\r
+    // Generate the media content for each recognised type\r
+    mediaTypes: {\r
+        twitter: function () {\r
+            var tweet_id = this.$el.data('tweetid');\r
+            var that = this;\r
+\r
+            $.getJSON('https://api.twitter.com/1/statuses/oembed.json?id=' + tweet_id + '&callback=?', function (data) {\r
+                that.$content.find('.content').html(data.html);\r
+            });\r
+\r
+            return $('<div>Loading tweet..</div>');\r
+        },\r
+\r
+\r
+        image: function () {\r
+            return $('<a href="' + this.url + '" target="_blank"><img height="100" src="' + this.url + '" /></a>');\r
+        },\r
+\r
+\r
+        reddit: function () {\r
+            var that = this;\r
+            var matches = (/reddit\.com\/r\/([a-zA-Z0-9_\-]+)\/comments\/([a-z0-9]+)\/([^\/]+)?/gi).exec(this.url);\r
+\r
+            $.getJSON('http://www.' + matches[0] + '.json?jsonp=?', function (data) {\r
+                console.log('Loaded reddit data', data);\r
+                var post = data[0].data.children[0].data;\r
+                var thumb = '';\r
+\r
+                // Show a thumbnail if there is one\r
+                if (post.thumbnail) {\r
+                    //post.thumbnail = 'http://www.eurotunnel.com/uploadedImages/commercial/back-steps-icon-arrow.png';\r
+\r
+                    // Hide the thumbnail if an over_18 image\r
+                    if (post.over_18) {\r
+                        thumb = '<span class="thumbnail_nsfw" onclick="$(this).find(\'p\').remove(); $(this).find(\'img\').css(\'visibility\', \'visible\');">';\r
+                        thumb += '<p style="font-size:0.9em;line-height:1.2em;cursor:pointer;">Show<br />NSFW</p>';\r
+                        thumb += '<img src="' + post.thumbnail + '" class="thumbnail" style="visibility:hidden;" />';\r
+                        thumb += '</span>';\r
+                    } else {\r
+                        thumb = '<img src="' + post.thumbnail + '" class="thumbnail" />';\r
+                    }\r
+                }\r
+\r
+                // Build the template string up\r
+                var tmpl = '<div>' + thumb + '<b><%- title %></b><br />Posted by <%- author %>. &nbsp;&nbsp; ';\r
+                tmpl += '<i class="icon-arrow-up"></i> <%- ups %> &nbsp;&nbsp; <i class="icon-arrow-down"></i> <%- downs %><br />';\r
+                tmpl += '<%- num_comments %> comments made. <a href="http://www.reddit.com<%- permalink %>">View post</a></div>';\r
+\r
+                that.$content.find('.content').html(_.template(tmpl, post));\r
+            });\r
+\r
+            return $('<div>Loading Reddit thread..</div>');\r
+        }\r
+    }\r
+\r
+}, {\r
+\r
+    // Build the closed media HTML from a URL\r
+    buildHtml: function (url) {\r
+        var html = '', matches;\r
+\r
+        // Is it an image?\r
+        if (url.match(/(\.jpe?g|\.gif|\.bmp|\.png)\??$/i)) {\r
+            html += '<span class="media image" data-type="image" data-url="' + url + '" title="Open Image"><a class="open"><i class="icon-chevron-right"></i></a></span>';\r
+        }\r
+\r
+        // Is it a tweet?\r
+        matches = (/https?:\/\/twitter.com\/([a-zA-Z0-9_]+)\/status\/([0-9]+)/ig).exec(url);\r
+        if (matches) {\r
+            html += '<span class="media twitter" data-type="twitter" data-url="' + url + '" data-tweetid="' + matches[2] + '" title="Show tweet information"><a class="open"><i class="icon-chevron-right"></i></a></span>';\r
+        }\r
+\r
+        // Is reddit?\r
+        matches = (/reddit\.com\/r\/([a-zA-Z0-9_\-]+)\/comments\/([a-z0-9]+)\/([^\/]+)?/gi).exec(url);\r
+        if (matches) {\r
+            html += '<span class="media reddit" data-type="reddit" data-url="' + url + '" title="Reddit thread"><a class="open"><i class="icon-chevron-right"></i></a></span>';\r
+        }\r
+\r
+        return html;\r
+    }\r
 });
\ No newline at end of file
index bf107638ba8e0e17151d087ac81ccb295d280245..e17c8f39498e6865ee1a76b13da1f9ff6702fb91 100644 (file)
@@ -66,7 +66,7 @@ conf.cap_options = [];
 conf.module_dir = "./kiwi_modules/";
 
 // Which modules to load
-conf.modules = ["spamfilter", "statistics"];
+conf.modules = [];
 
 
 
index 779b44e717ab722a4bfeb10aa4c690c04f62089b..f5c870b02f27d35bc4852ff23912b7fcc14aa448 100644 (file)
@@ -22,12 +22,8 @@ ClientCommands.prototype.run = function (command, args, irc_connection, callback
 \r
 var listeners = {\r
     PRIVMSG: function (args, irc_connection, callback) {\r
-        if (args.target && (args.msg)) {\r
-            // TODO: Enable plugin support here again\r
-            //obj = kiwi.kiwi_mod.run('msgsend', args, {websocket: websocket});\r
-            //if (obj !== null) {\r
-                irc_connection.write('PRIVMSG ' + args.target + ' :' + args.msg, callback);\r
-            //}\r
+         if (args.target && (args.msg)) {\r
+            irc_connection.write('PRIVMSG ' + args.target + ' :' + args.msg, callback);\r
         }\r
     },\r
     \r
index 8151db4aa9459a8c547a8ecc8e41735a80b189ed..1c7520d33cae028a249d5b17ffa918476248460b 100755 (executable)
@@ -2,7 +2,8 @@ var fs          = require('fs'),
     _           = require('lodash'),
     WebListener = require('./weblistener.js'),
     config      = require('./configuration.js'),
-    rehash      = require('./rehash.js');
+    rehash      = require('./rehash.js'),
+    modules     = require('./modules.js');
 
 
 
@@ -55,6 +56,24 @@ if ((!global.config.servers) || (global.config.servers.length < 1)) {
 
 
 
+// Create a plugin interface
+global.modules = new modules.Publisher();
+
+// Register as the active interface
+modules.registerPublisher(global.modules);
+
+// Load any modules in the config
+(global.config.modules || []).forEach(function (module_name) {
+    if (modules.load('../server_modules/' + module_name + '.js')) {
+        console.log('Module ' + module_name + ' loaded successfuly');
+    } else {
+        console.log('Module ' + module_name + ' failed to load');
+    }
+});
+
+
+
+
 
 // Holder for all the connected clients
 global.clients = {
diff --git a/server/modules.js b/server/modules.js
new file mode 100644 (file)
index 0000000..aed7743
--- /dev/null
@@ -0,0 +1,160 @@
+var events = require('events'),
+    util = require('util'),
+    _ = require('lodash');
+
+
+/**
+ * Publisher
+ * The main point in which events are fired and bound to
+ */
+
+// Where events are bound to
+var active_publisher;
+
+
+// Create a publisher to allow event subscribing
+function Publisher (obj) {
+    var EventPublisher = function modulePublisher() {};
+    util.inherits(EventPublisher, events.EventEmitter);
+
+    return new EventPublisher();
+}
+
+
+// Register an already created Publisher() as the active instance
+function registerPublisher (obj) {
+    active_publisher = obj;
+}
+
+
+
+
+
+
+/**
+ * Keeping track of modules
+ */
+
+// Hold the loaded modules
+var registered_modules = {};
+
+function loadModule (module_file) {
+    var module;
+
+    // Get an instance of the module and remove it from the cache
+    try {
+        module = require(module_file);
+        delete require.cache[require.resolve(module_file)];
+    } catch (err) {
+        // Module was not found
+        return false;
+    }
+
+    return module;
+}
+
+
+// Find a registered collection, .dispose() of it and remove it
+function unloadModule (module) {
+    registered_modules = _.reject(registered_modules, function (registered_module) {
+        if (module === registered_module) {
+            module.dispose();
+            return true;
+        }
+    });
+}
+
+
+
+
+
+
+/**
+ * Module object
+ * To be created by modules to bind to server events
+ */
+function Module (module_name) {
+    registered_modules[module_name] = this;
+}
+
+
+// Holder for all the bound events by this module
+Module.prototype._events = {};
+
+
+// Keep track of this modules events and bind
+Module.prototype.on = function (event_name, fn) {
+    var internal_events = ['dispose'];
+
+    this._events[event_name] = this._events[event_name] || [];
+    this._events[event_name].push(fn);
+
+    // If this is an internal event, do not propogate the event
+    if (internal_events.indexOf(event_name) !== -1) {
+        active_publisher.on(event_name, fn);
+    }
+};
+
+
+// Keep track of this modules events and bind once
+Module.prototype.once = function (event_name, fn) {
+    this._events[event_name] = this._events[event_name] || [];
+    this._events[event_name].push(fn);
+
+    active_publisher.once(event_name, fn);
+};
+
+
+// Remove any events by this module only
+Module.prototype.off = function (event_name, fn) {
+    var idx;
+
+    if (typeof event_name === 'undefined') {
+        // Remove all events
+        this._events = [];
+
+    } else if (typeof fn === 'undefined') {
+        // Remove all of 1 event type
+        delete this._events[event_name];
+
+    } else {
+        // Remove a single event + callback
+        for (idx in (this._events[event_name] || [])) {
+            if (this._events[event_name][idx] === fn) {
+                delete this._events[event_name][idx];
+            }
+        }
+    }
+
+    active_publisher.removeListener(event_name, fn);
+};
+
+
+
+// Clean up anything used by this module
+Module.prototype.dispose = function () {
+    // Call any dispose callbacks
+    (this._events['dispose'] || []).forEach(function (callback) {
+        callback();
+    });
+
+    // Remove all bound event listeners
+    this.off();
+};
+
+
+
+
+
+
+module.exports = {
+    // Objects
+    Module: Module,
+    Publisher: Publisher,
+
+    // Methods
+    registerPublisher: registerPublisher,
+    load: loadModule,
+    unload: unloadModule,
+    getRegisteredModules: function () { return registered_modules; }
+};
\ No newline at end of file
diff --git a/server_modules/example.js b/server_modules/example.js
new file mode 100644 (file)
index 0000000..df11360
--- /dev/null
@@ -0,0 +1,14 @@
+var kiwiModules = require('../server/modules');
+
+var module = new kiwiModules.Module('Example Module');
+
+
+module.subscribe('client:connected', function(data) {
+    console.log('Client connection:', data);
+});
+
+
+module.subscribe('client:commands:msg', function(data) {
+    console.log('Client msg:', data.args.target, ': ', data.args.msg);
+    data.args.msg += ' - modified!';
+});
\ No newline at end of file