From c719020e17cb1c98da55be6cc7efe0e50ab51ffa Mon Sep 17 00:00:00 2001
From: marzavec <admin@marzavec.com>
Date: Sat, 29 Sep 2018 23:44:36 -0700
Subject: Added hooks, modules and cleaned up code

---
 CHANGELOG.md                                |  16 +++
 package-lock.json                           |   2 +-
 package.json                                |   2 +-
 server/package-lock.json                    |   2 +-
 server/package.json                         |   2 +-
 server/src/commands/admin/addmod.js         |  20 ++--
 server/src/commands/admin/listusers.js      |  10 +-
 server/src/commands/admin/reload.js         |  14 ++-
 server/src/commands/admin/removemod.js      |  50 ++++++++++
 server/src/commands/admin/saveconfig.js     |  14 +--
 server/src/commands/admin/shout.js          |  12 +--
 server/src/commands/core/changenick.js      |  30 +++---
 server/src/commands/core/chat.js            |  90 +++++++++++++----
 server/src/commands/core/help.js            |  95 ++++++++++++------
 server/src/commands/core/invite.js          |  18 ++--
 server/src/commands/core/join.js            | 119 +++++++++++-----------
 server/src/commands/core/morestats.js       |  30 +++++-
 server/src/commands/core/move.js            |  12 +--
 server/src/commands/core/ping.js            |   2 +
 server/src/commands/core/stats.js           |   6 +-
 server/src/commands/core/whisper.js         | 110 ++++++++++++++++++++
 server/src/commands/internal/disconnect.js  |   7 +-
 server/src/commands/internal/socketreply.js |   7 +-
 server/src/commands/mod/ban.js              |  22 ++--
 server/src/commands/mod/dumb.js             |  98 ++++++++++++++----
 server/src/commands/mod/kick.js             |  18 ++--
 server/src/commands/mod/moveuser.js         | 105 +++++++++++++++++++
 server/src/commands/mod/speak.js            |  27 +++--
 server/src/commands/mod/unban.js            |  17 ++--
 server/src/commands/mod/unbanall.js         |  36 +++++++
 server/src/core/server.js                   | 150 +++++++++++++++++++++++-----
 server/src/managers/commands.js             |  14 ++-
 32 files changed, 891 insertions(+), 266 deletions(-)
 create mode 100644 server/src/commands/admin/removemod.js
 create mode 100644 server/src/commands/core/whisper.js
 create mode 100644 server/src/commands/mod/moveuser.js
 create mode 100644 server/src/commands/mod/unbanall.js

diff --git a/CHANGELOG.md b/CHANGELOG.md
index a35183f..2dc9860 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -3,6 +3,22 @@ All notable changes to this project will be documented in this file.
 
 ## [Unreleased]
 
+## [2.1.0] - 2018-09-29
+### Added
+- Module hook framework, isolating modules and making them truly drop-to-install
+- `./server/src/commands/core/whisper.js` module to send in-channel private messages, `/whisper` hook
+- `muzzle` and `mute` aliases to `./server/src/commands/mod/dumb.js`
+- `unmuzzle` and `unmute` aliases to `./server/src/commands/mod/speak.js`
+- `./server/src/commands/admin/removemod.js` module to remove mods
+- `./server/src/commands/mod/unbanall.js` module to clear all bans and ratelimiting
+
+### Changed
+- Further code cleanup on all modules
+- Adjusted `ipSalt` entropy
+- `./server/src/commands/core/help.js` output is now helpful, added `/help` hook
+- `./server/src/commands/core/chat.js` added `/myhash` and `/me` hooks
+- `./server/src/commands/core/morestats.js` added `/stats` hook
+
 ## [2.0.3] - 2018-06-03
 ### Added
 - `./server/src/commands/mod/dumb.js` module for server-wide shadow muting
diff --git a/package-lock.json b/package-lock.json
index 8ddaa40..c730b29 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,6 +1,6 @@
 {
   "name": "hack.chat-v2",
-  "version": "2.0.3",
+  "version": "2.1.0",
   "lockfileVersion": 1,
   "requires": true,
   "dependencies": {
diff --git a/package.json b/package.json
index a5371cd..c55e5f1 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
 {
   "name": "hack.chat-v2",
-  "version": "2.0.3",
+  "version": "2.1.0",
   "description": "a minimal distraction free chat application",
   "main": "index.js",
   "repository": {
diff --git a/server/package-lock.json b/server/package-lock.json
index b18faf3..ef02881 100644
--- a/server/package-lock.json
+++ b/server/package-lock.json
@@ -1,6 +1,6 @@
 {
   "name": "hack.chat-v2",
-  "version": "2.0.3",
+  "version": "2.1.0",
   "lockfileVersion": 1,
   "requires": true,
   "dependencies": {
diff --git a/server/package.json b/server/package.json
index 8094564..7973595 100644
--- a/server/package.json
+++ b/server/package.json
@@ -1,6 +1,6 @@
 {
   "name": "hack.chat-v2",
-  "version": "2.0.3",
+  "version": "2.1.0",
   "description": "a minimal distraction free chat application",
   "main": "main.js",
   "repository": {
diff --git a/server/src/commands/admin/addmod.js b/server/src/commands/admin/addmod.js
index bde54a1..c4fcdd5 100644
--- a/server/src/commands/admin/addmod.js
+++ b/server/src/commands/admin/addmod.js
@@ -2,23 +2,24 @@
   Description: Adds the target trip to the mod list then elevates the uType
 */
 
+// module main
 exports.run = async (core, server, socket, data) => {
   // increase rate limit chance and ignore if not admin
   if (socket.uType != 'admin') {
-    server._police.frisk(socket.remoteAddress, 20);
-
-    return;
+    return server._police.frisk(socket.remoteAddress, 20);
   }
 
   // add new trip to config
   core.config.mods.push({ trip: data.trip }); // purposely not using `config.set()` to avoid auto-save
 
-  // upgarde existing connections & notify user
+  // find targets current connections
   let newMod = server.findSockets({ trip: data.trip });
   if (newMod.length !== 0) {
     for (let i = 0, l = newMod.length; i < l; i++) {
+      // upgrade privilages
       newMod[i].uType = 'mod';
 
+      // inform new mod
       server.send({
         cmd: 'info',
         text: 'You are now a mod.'
@@ -29,20 +30,21 @@ exports.run = async (core, server, socket, data) => {
   // return success message
   server.reply({
     cmd: 'info',
-    text: `Added mod trip: ${data.trip}`
+    text: `Added mod trip: ${data.trip}, remember to run 'saveconfig' to make it permanent`
   }, socket);
 
   // notify all mods
   server.broadcast({
     cmd: 'info',
-    text: `Added mod trip: ${data.trip}`
+    text: `Added mod: ${data.trip}`
   }, { uType: 'mod' });
 };
 
+// module meta
 exports.requiredData = ['trip'];
-
 exports.info = {
   name: 'addmod',
-  usage: 'addmod {trip}',
-  description: 'Adds target trip to the config as a mod and upgrades the socket type'
+  description: 'Adds target trip to the config as a mod and upgrades the socket type',
+  usage: `
+    API: { cmd: 'addmod', trip: '<target trip>' }`
 };
diff --git a/server/src/commands/admin/listusers.js b/server/src/commands/admin/listusers.js
index d3dddc2..85fc078 100644
--- a/server/src/commands/admin/listusers.js
+++ b/server/src/commands/admin/listusers.js
@@ -2,12 +2,11 @@
   Description: Outputs all current channels and their user nicks
 */
 
+// module main
 exports.run = async (core, server, socket, data) => {
   // increase rate limit chance and ignore if not admin
   if (socket.uType != 'admin') {
-    server._police.frisk(socket.remoteAddress, 20);
-
-    return;
+    return server._police.frisk(socket.remoteAddress, 20);
   }
 
   // find all users currently in a channel
@@ -37,7 +36,10 @@ exports.run = async (core, server, socket, data) => {
   }, socket);
 };
 
+// module meta
 exports.info = {
   name: 'listusers',
-  description: 'Outputs all current channels and sockets in those channels'
+  description: 'Outputs all current channels and sockets in those channels',
+  usage: `
+    API: { cmd: 'listusers' }`
 };
diff --git a/server/src/commands/admin/reload.js b/server/src/commands/admin/reload.js
index 7a0ffdc..f7f7464 100644
--- a/server/src/commands/admin/reload.js
+++ b/server/src/commands/admin/reload.js
@@ -2,18 +2,21 @@
   Description: Clears and resets the command modules, outputting any errors
 */
 
+// module main
 exports.run = async (core, server, socket, data) => {
   // increase rate limit chance and ignore if not admin
   if (socket.uType != 'admin') {
-    server._police.frisk(socket.remoteAddress, 20);
-
-    return;
+    return server._police.frisk(socket.remoteAddress, 20);
   }
 
   // do command reloads and store results
   let loadResult = core.managers.dynamicImports.reloadDirCache('src/commands');
   loadResult += core.commands.loadCommands();
 
+  // clear and rebuild all module hooks
+  server.clearHooks();
+  core.commands.initCommandHooks(server);
+
   // build reply based on reload results
   if (loadResult == '') {
     loadResult = `Loaded ${core.commands._commands.length} commands, 0 errors`;
@@ -34,7 +37,10 @@ exports.run = async (core, server, socket, data) => {
   }, { uType: 'mod' });
 };
 
+// module meta
 exports.info = {
   name: 'reload',
-  description: '(Re)loads any new commands into memory, outputs errors if any'
+  description: '(Re)loads any new commands into memory, outputs errors if any',
+  usage: `
+    API: { cmd: 'reload' }`
 };
diff --git a/server/src/commands/admin/removemod.js b/server/src/commands/admin/removemod.js
new file mode 100644
index 0000000..dda822c
--- /dev/null
+++ b/server/src/commands/admin/removemod.js
@@ -0,0 +1,50 @@
+/*
+  Description: Removes target trip from the config as a mod and downgrades the socket type
+*/
+
+// module main
+exports.run = async (core, server, socket, data) => {
+  // increase rate limit chance and ignore if not admin
+  if (socket.uType != 'admin') {
+    return server._police.frisk(socket.remoteAddress, 20);
+  }
+
+  // remove trip from config
+  core.config.mods = core.config.mods.filter(mod => mod.trip !== data.trip);
+
+  // find targets current connections
+  let targetMod = server.findSockets({ trip: data.trip });
+  if (newMod.length !== 0) {
+    for (let i = 0, l = newMod.length; i < l; i++) {
+      // downgrade privilages
+      targetMod[i].uType = 'user';
+
+      // inform ex-mod
+      server.send({
+        cmd: 'info',
+        text: 'You are now a user.'
+      }, targetMod[i]);
+    }
+  }
+
+  // return success message
+  server.reply({
+    cmd: 'info',
+    text: `Removed mod trip: ${data.trip}, remember to run 'saveconfig' to make it permanent`
+  }, socket);
+
+  // notify all mods
+  server.broadcast({
+    cmd: 'info',
+    text: `Removed mod: ${data.trip}`
+  }, { uType: 'mod' });
+};
+
+// module meta
+exports.requiredData = ['trip'];
+exports.info = {
+  name: 'removemod',
+  description: 'Removes target trip from the config as a mod and downgrades the socket type',
+  usage: `
+    API: { cmd: 'removemod', trip: '<target trip>' }`
+};
diff --git a/server/src/commands/admin/saveconfig.js b/server/src/commands/admin/saveconfig.js
index 20927e7..b8a769b 100644
--- a/server/src/commands/admin/saveconfig.js
+++ b/server/src/commands/admin/saveconfig.js
@@ -2,22 +2,19 @@
   Description: Writes the current config to disk
 */
 
+// module main
 exports.run = async (core, server, socket, data) => {
   // increase rate limit chance and ignore if not admin
   if (socket.uType != 'admin') {
-    server._police.frisk(socket.remoteAddress, 20);
-
-    return;
+    return server._police.frisk(socket.remoteAddress, 20);
   }
 
   // attempt save, notify of failure
   if (!core.managers.config.save()) {
-    server.reply({
+    return server.reply({
       cmd: 'warn',
       text: 'Failed to save config, check logs.'
     }, client);
-
-    return;
   }
 
   // return success message
@@ -33,7 +30,10 @@ exports.run = async (core, server, socket, data) => {
   }, { uType: 'mod' });
 };
 
+// module meta
 exports.info = {
   name: 'saveconfig',
-  description: 'Writes the current config to disk'
+  description: 'Writes the current config to disk',
+  usage: `
+    API: { cmd: 'saveconfig' }`
 };
diff --git a/server/src/commands/admin/shout.js b/server/src/commands/admin/shout.js
index 80a6470..e9c69e0 100644
--- a/server/src/commands/admin/shout.js
+++ b/server/src/commands/admin/shout.js
@@ -2,12 +2,11 @@
   Description: Emmits a server-wide message as `info`
 */
 
+// module main
 exports.run = async (core, server, socket, data) => {
   // increase rate limit chance and ignore if not admin
   if (socket.uType != 'admin') {
-    server._police.frisk(socket.remoteAddress, 20);
-
-    return;
+    return server._police.frisk(socket.remoteAddress, 20);
   }
 
   // send text to all channels
@@ -17,10 +16,11 @@ exports.run = async (core, server, socket, data) => {
   }, {});
 };
 
+// module meta
 exports.requiredData = ['text'];
-
 exports.info = {
   name: 'shout',
-  usage: 'shout {text}',
-  description: 'Displays passed text to every client connected'
+  description: 'Displays passed text to every client connected',
+  usage: `
+    API: { cmd: 'shout', text: '<shout text>' }`
 };
diff --git a/server/src/commands/core/changenick.js b/server/src/commands/core/changenick.js
index 5a8b5c2..6cc967c 100644
--- a/server/src/commands/core/changenick.js
+++ b/server/src/commands/core/changenick.js
@@ -1,17 +1,17 @@
 /*
-  Description: Generates a semi-unique channel name then broadcasts it to each client
+  Description: Allows calling client to change their current nickname
 */
 
+// module support functions
 const verifyNickname = (nick) => /^[a-zA-Z0-9_]{1,24}$/.test(nick);
 
+// module main
 exports.run = async (core, server, socket, data) => {
   if (server._police.frisk(socket.remoteAddress, 6)) {
-    server.reply({
+    return server.reply({
       cmd: 'warn',
       text: 'You are changing nicknames too fast. Wait a moment before trying again.'
     }, socket);
-
-    return;
   }
 
   // verify user data is string
@@ -22,12 +22,10 @@ exports.run = async (core, server, socket, data) => {
   // make sure requested nickname meets standards
   let newNick = data.nick.trim();
   if (!verifyNickname(newNick)) {
-    server.reply({
+    return server.reply({
       cmd: 'warn',
       text: 'Nickname must consist of up to 24 letters, numbers, and underscores'
     }, socket);
-
-    return;
   }
 
   // prevent admin impersonation
@@ -35,12 +33,10 @@ exports.run = async (core, server, socket, data) => {
   if (newNick.toLowerCase() == core.config.adminName.toLowerCase()) {
     server._police.frisk(socket.remoteAddress, 4);
 
-    server.reply({
+    return server.reply({
       cmd: 'warn',
-      text: 'Gtfo'
+      text: 'You are not the admin, liar!'
     }, socket);
-
-    return;
   }
 
   // find any sockets that have the same nickname
@@ -52,12 +48,10 @@ exports.run = async (core, server, socket, data) => {
   // return error if found
   if (userExists.length > 0) {
     // That nickname is already in that channel
-    server.reply({
+    return server.reply({
       cmd: 'warn',
       text: 'Nickname taken'
     }, socket);
-
-    return;
   }
 
   // build join and leave notices
@@ -65,6 +59,7 @@ exports.run = async (core, server, socket, data) => {
     cmd: 'onlineRemove',
     nick: socket.nick
   };
+
   let joinNotice = {
     cmd: 'onlineAdd',
     nick: newNick,
@@ -86,10 +81,11 @@ exports.run = async (core, server, socket, data) => {
   socket.nick = newNick;
 };
 
+// module meta
 exports.requiredData = ['nick'];
-
 exports.info = {
   name: 'changenick',
-  usage: 'changenick {nick}',
-  description: 'This will change your current connections nickname'
+  description: 'This will change your current connections nickname',
+  usage: `
+    API: { cmd: 'changenick', nick: '<new nickname>' }`
 };
diff --git a/server/src/commands/core/chat.js b/server/src/commands/core/chat.js
index 89d497e..70b9876 100644
--- a/server/src/commands/core/chat.js
+++ b/server/src/commands/core/chat.js
@@ -2,6 +2,7 @@
   Description: Rebroadcasts any `text` to all clients in a `channel`
 */
 
+// module support functions
 const parseText = (text) => {
   // verifies user input is text
   if (typeof text !== 'string') {
@@ -16,25 +17,23 @@ const parseText = (text) => {
   return text;
 };
 
+// module main
 exports.run = async (core, server, socket, data) => {
   // check user input
   let text = parseText(data.text);
+
   if (!text) {
     // lets not send objects or empty text, yea?
-    server._police.frisk(socket.remoteAddress, 6);
-
-    return;
+    return server._police.frisk(socket.remoteAddress, 13);
   }
 
   // check for spam
   let score = text.length / 83 / 4;
   if (server._police.frisk(socket.remoteAddress, score)) {
-    server.reply({
+    return server.reply({
       cmd: 'warn',
       text: 'You are sending too much text. Wait a moment and try again.\nPress the up arrow key to restore your last message.'
     }, socket);
-
-    return;
   }
 
   // build chat payload
@@ -54,26 +53,77 @@ exports.run = async (core, server, socket, data) => {
     payload.trip = socket.trip;
   }
 
-  // check if the users connection is muted
-  // TODO: Add a more contained way for modules to interact, event hooks or something?
-  if(core.muzzledHashes && core.muzzledHashes[socket.hash]){
-      server.broadcast( payload, { channel: socket.channel, hash: socket.hash });
-      if(core.muzzledHashes[socket.hash].allies){
-          server.broadcast( payload, { channel: socket.channel, nick: core.muzzledHashes[socket.hash].allies });
-      }
-  } else {
-      //else send it to everyone
-      server.broadcast( payload, { channel: socket.channel});
-  }
+  // broadcast to channel peers
+  server.broadcast( payload, { channel: socket.channel});
 
   // stats are fun
   core.managers.stats.increment('messages-sent');
 };
 
-exports.requiredData = ['text'];
+// module hook functions
+exports.initHooks = (server) => {
+  server.registerHook('in', 'chat', this.commandCheckIn);
+  server.registerHook('out', 'chat', this.commandCheckOut);
+};
+
+// checks for miscellaneous '/' based commands
+exports.commandCheckIn = (core, server, socket, payload) => {
+  if (typeof payload.text !== 'string') {
+    return false;
+  }
+
+  if (payload.text.startsWith('/myhash')) {
+    server.reply({
+      cmd: 'info',
+      text: `Your hash: ${socket.hash}`
+    }, socket);
+
+    return false;
+  }
+
+  return payload;
+};
+
+// checks for miscellaneous '/' based commands
+exports.commandCheckOut = (core, server, socket, payload) => {
+  if (!payload.text.startsWith('/')) {
+    return payload;
+  }
+
+  if (payload.text.startsWith('//me ')) {
+    payload.text = payload.text.substr(1, payload.text.length);
+
+    return payload;
+  }
 
+  // TODO: make emotes their own module #lazydev
+  if (payload.text.startsWith('/me ')) {
+    let emote = payload.text.substr(4);
+    if (emote.trim() === '') {
+      emote = 'fails at life';
+    }
+
+    let newPayload = {
+      cmd: 'info',
+      type: 'emote',
+      text: `@${payload.nick} ${emote}`
+    };
+
+    return newPayload;
+  }
+
+  return payload;
+};
+
+// module meta
+exports.requiredData = ['text'];
 exports.info = {
   name: 'chat',
-  usage: 'chat {text}',
-  description: 'Broadcasts passed `text` field to the calling users channel'
+  description: 'Broadcasts passed `text` field to the calling users channel',
+  usage: `
+    API: { cmd: 'chat', text: '<text to send>' }
+    Text: Uuuuhm. Just kind type in that little box at the bottom and hit enter.\n
+    Bonus super secret hidden commands:
+    /me <emote>
+    /myhash`
 };
diff --git a/server/src/commands/core/help.js b/server/src/commands/core/help.js
index 8fd02a1..60a7280 100644
--- a/server/src/commands/core/help.js
+++ b/server/src/commands/core/help.js
@@ -2,41 +2,50 @@
   Description: Outputs the current command module list or command categories
 */
 
+// module support functions
 const stripIndents = require('common-tags').stripIndents;
 
-exports.run = async (core, server, socket, data) => {
-  // TODO: this module needs to be clean up badly :(
+// module main
+exports.run = async (core, server, socket, payload) => {
+  // check for spam
+  if (server._police.frisk(socket.remoteAddress, 2)) {
+    return server.reply({
+      cmd: 'warn',
+      text: 'You are sending too much text. Wait a moment and try again.\nPress the up arrow key to restore your last message.'
+    }, socket);
+  }
 
-  // verify passed arguments
-  let typeDt = typeof data.type;
-  let catDt = typeof data.category;
-  let cmdDt = typeof data.command;
-  if (typeDt !== 'undefined' && typeDt !== 'string' ) {
-    return;
-  } else if (catDt !== 'undefined' && catDt !== 'string' ) {
-    return;
-  } else if (cmdDt !== 'undefined' && cmdDt !== 'string' ) {
+  // verify user input
+  if (typeof payload.command !== 'undefined' && typeof payload.command !== 'string') {
     return;
   }
 
-  // set default reply
-  let reply = stripIndents`Help usage:
-    Show all categories -> { cmd: 'help', type: 'categories' }
-    Show all commands in category -> { cmd: 'help', category: '<category name>' }
-    Show specific command -> { cmd: 'help', command: '<command name>' }`;
+  let reply = '';
+  if (typeof payload.command === 'undefined') {
+    reply = stripIndents`Listing all current commands. For specific help on certain commands, use either:
+      Text: /help <command name>
+      API:  {cmd: 'help', command: '<command name>'}`;
+    reply += '\n\n-------------------------------------\n\n';
 
-  // change reply based on query
-  if (typeDt !== 'undefined') {
     let categories = core.commands.categories().sort();
-    reply = `Command Categories:\n${categories.map(c => `- ${c.replace('../src/commands/', '')}`).join('\n')}`;
-  } else if (catDt !== 'undefined') {
-    let catCommands = core.commands.all('../src/commands/' + data.category).sort((a, b) => a.info.name.localeCompare(b.info.name));
-    reply = `${data.category} commands:\n${catCommands.map(c => `- ${c.info.name}`).join('\n')}`;
-  } else if (cmdDt !== 'undefined') {
-    let command = core.commands.get(data.command);
-    reply = stripIndents`
-      Usage: ${command.info.usage || command.info.name}
-      Description: ${command.info.description || '¯\_(ツ)_/¯'}`;
+    for (let i = 0, j = categories.length; i < j; i++) {
+      reply += `${categories[i].replace('../src/commands/', '').replace(/^\w/, c => c.toUpperCase())} Commands:\n`;
+      let catCommands = core.commands.all(categories[i]).sort((a, b) => a.info.name.localeCompare(b.info.name));
+      reply += `  ${catCommands.map(c => `${c.info.name}`).join(', ')}\n\n`;
+    }
+  } else {
+    let command = core.commands.get(payload.command);
+
+    if (typeof command === 'undefined') {
+      reply = 'Unknown command';
+    } else {
+      reply = stripIndents`Name: ${command.info.name}
+        Aliases: ${typeof command.info.aliases !== 'undefined' ? command.info.aliases.join(', ') : 'None'}
+        Category: ${command.info.category.replace('../src/commands/', '').replace(/^\w/, c => c.toUpperCase())}
+        Required Parameters: ${command.requiredData || 'None'}\n
+        Description: ${command.info.description || '¯\_(ツ)_/¯'}\n
+        Usage: ${command.info.usage || command.info.name}`;
+    }
   }
 
   // output reply
@@ -46,8 +55,36 @@ exports.run = async (core, server, socket, data) => {
   }, socket);
 };
 
+// module hook functions
+exports.initHooks = (server) => {
+  server.registerHook('in', 'chat', this.helpCheck);
+};
+
+// hooks chat commands checking for /whisper
+exports.helpCheck = (core, server, socket, payload) => {
+  if (typeof payload.text !== 'string') {
+    return false;
+  }
+
+  if (payload.text.startsWith('/help')) {
+    let input = payload.text.substr(1, payload.text.length).split(' ', 2);
+
+    this.run(core, server, socket, {
+      cmd: input[0],
+      command: input[1]
+    });
+
+    return false;
+  }
+
+  return payload;
+};
+
+// module meta
 exports.info = {
   name: 'help',
-  usage: 'help ([ type:categories] | [category:<category name> | command:<command name> ])',
-  description: 'Outputs information about the servers current protocol'
+  description: 'Outputs information about the servers current protocol',
+  usage: `
+    API: { cmd: 'help', command: '<optional command name>' }
+    Text: /help <optional command name>`
 };
diff --git a/server/src/commands/core/invite.js b/server/src/commands/core/invite.js
index d616193..f44779b 100644
--- a/server/src/commands/core/invite.js
+++ b/server/src/commands/core/invite.js
@@ -2,17 +2,17 @@
   Description: Generates a semi-unique channel name then broadcasts it to each client
 */
 
+// module support functions
 const verifyNickname = (nick) => /^[a-zA-Z0-9_]{1,24}$/.test(nick);
 
+// module main
 exports.run = async (core, server, socket, data) => {
   // check for spam
   if (server._police.frisk(socket.remoteAddress, 2)) {
-    server.reply({
+    return server.reply({
       cmd: 'warn',
       text: 'You are sending invites too fast. Wait a moment before trying again.'
     }, socket);
-
-    return;
   }
 
   // verify user input
@@ -34,16 +34,15 @@ exports.run = async (core, server, socket, data) => {
     invite: channel,
     text: `${socket.nick} invited you to ?${channel}`
   };
+
   let inviteSent = server.broadcast( payload, { channel: socket.channel, nick: data.nick });
 
   // server indicates the user was not found
   if (!inviteSent) {
-    server.reply({
+    return server.reply({
       cmd: 'warn',
       text: 'Could not find user in channel'
     }, socket);
-
-    return;
   }
 
   // reply with common channel
@@ -56,10 +55,11 @@ exports.run = async (core, server, socket, data) => {
   core.managers.stats.increment('invites-sent');
 };
 
+// module meta
 exports.requiredData = ['nick'];
-
 exports.info = {
   name: 'invite',
-  usage: 'invite {nick}',
-  description: 'Generates a unique (more or less) room name and passes it to two clients'
+  description: 'Generates a unique (more or less) room name and passes it to two clients',
+  usage: `
+    API: { cmd: 'invite', nick: '<target nickname>' }`
 };
diff --git a/server/src/commands/core/join.js b/server/src/commands/core/join.js
index 31458a2..31bc3c1 100644
--- a/server/src/commands/core/join.js
+++ b/server/src/commands/core/join.js
@@ -2,6 +2,7 @@
   Description: Initial entry point, applies `channel` and `nick` to the calling socket
 */
 
+// module support functions
 const crypto = require('crypto');
 
 const hash = (password) => {
@@ -12,15 +13,54 @@ const hash = (password) => {
 
 const verifyNickname = (nick) => /^[a-zA-Z0-9_]{1,24}$/.test(nick);
 
+// exposed "login" function to allow hooks to verify user join events
+// returns object containing user info or string if error
+exports.parseNickname = (core, data) => {
+  let userInfo = {
+    nick: '',
+    uType: 'user',
+    trip: null,
+  };
+
+  // seperate nick from password
+  let nickArray = data.nick.split('#', 2);
+  userInfo.nick = nickArray[0].trim();
+
+  if (!verifyNickname(userInfo.nick)) {
+    // return error as string
+    return 'Nickname must consist of up to 24 letters, numbers, and underscores';
+  }
+
+  let password = nickArray[1];
+  if (userInfo.nick.toLowerCase() == core.config.adminName.toLowerCase()) {
+    if (password !== core.config.adminPass) {
+      return 'You are not the admin, liar!';
+    } else {
+      userInfo.uType = 'admin';
+      userInfo.trip = 'Admin';
+    }
+  } else if (password) {
+    userInfo.trip = hash(password + core.config.tripSalt);
+  }
+
+  // TODO: disallow moderator impersonation
+  for (let mod of core.config.mods) {
+    if (userInfo.trip === mod.trip) {
+      userInfo.uType = 'mod';
+    }
+  }
+
+  return userInfo;
+};
+
+// module main
 exports.run = async (core, server, socket, data) => {
   // check for spam
   if (server._police.frisk(socket.remoteAddress, 3)) {
-    server.reply({
+    return server.reply({
       cmd: 'warn',
       text: 'You are joining channels too fast. Wait a moment and try again.'
     }, socket);
-
-    return;
   }
 
   // calling socket already in a channel
@@ -39,75 +79,39 @@ exports.run = async (core, server, socket, data) => {
     return;
   }
 
-  // process nickname
-  let nick = data.nick;
-  let nickArray = nick.split('#', 2);
-  nick = nickArray[0].trim();
-
-  if (!verifyNickname(nick)) {
-    server.reply({
+  let userInfo = this.parseNickname(core, data);
+  if (typeof userInfo === 'string') {
+    return server.reply({
       cmd: 'warn',
-      text: 'Nickname must consist of up to 24 letters, numbers, and underscores'
+      text: userInfo
     }, socket);
-
-    return;
   }
 
   // check if the nickname already exists in the channel
   let userExists = server.findSockets({
     channel: data.channel,
-    nick: (targetNick) => targetNick.toLowerCase() === nick.toLowerCase()
+    nick: (targetNick) => targetNick.toLowerCase() === userInfo.nick.toLowerCase()
   });
 
   if (userExists.length > 0) {
     // that nickname is already in that channel
-    server.reply({
+    return server.reply({
       cmd: 'warn',
       text: 'Nickname taken'
     }, socket);
-
-    return;
   }
 
-  // TODO: should we check for mod status first to prevent overwriting of admin status somehow? Meh, w/e, cba.
-  let uType = 'user';
-  let trip = null;
-  let password = nickArray[1];
-  if (nick.toLowerCase() == core.config.adminName.toLowerCase()) {
-    if (password != core.config.adminPass) {
-      server._police.frisk(socket.remoteAddress, 4);
-
-      server.reply({
-        cmd: 'warn',
-        text: 'Gtfo'
-      }, socket);
-
-      return;
-    } else {
-      uType = 'admin';
-      trip = 'Admin';
-    }
-  } else if (password) {
-    trip = hash(password + core.config.tripSalt);
-  }
-
-  // TODO: disallow moderator impersonation
-  for (let mod of core.config.mods) {
-    if (trip === mod.trip) {
-      uType = 'mod';
-    }
-  }
+  userInfo.userHash = server.getSocketHash(socket);
 
   // prepare to notify channel peers
   let newPeerList = server.findSockets({ channel: data.channel });
-  let userHash = server.getSocketHash(socket);
   let nicks = [];
 
   let joinAnnouncement = {
     cmd: 'onlineAdd',
-    nick: nick,
-    trip: trip || 'null',
-    hash: userHash
+    nick: userInfo.nick,
+    trip: userInfo.trip || 'null',
+    hash: userInfo.userHash
   };
 
   // send join announcement and prep online set
@@ -117,11 +121,11 @@ exports.run = async (core, server, socket, data) => {
   }
 
   // store user info
-  socket.uType = uType;
-  socket.nick = nick;
-  socket.channel = channel;
-  socket.hash = userHash;
-  if (trip !== null) socket.trip = trip;
+  socket.uType = userInfo.uType;
+  socket.nick = userInfo.nick;
+  socket.channel = data.channel;
+  socket.hash = userInfo.userHash;
+  if (userInfo.trip !== null) socket.trip = userInfo.trip;
 
   nicks.push(socket.nick);
 
@@ -135,10 +139,11 @@ exports.run = async (core, server, socket, data) => {
   core.managers.stats.increment('users-joined');
 };
 
+// module meta
 exports.requiredData = ['channel', 'nick'];
-
 exports.info = {
   name: 'join',
-  usage: 'join {channel} {nick}',
-  description: 'Place calling socket into target channel with target nick & broadcast event to channel'
+  description: 'Place calling socket into target channel with target nick & broadcast event to channel',
+  usage: `
+    API: { cmd: 'join', nick: '<your nickname>', channel: '<target channel>' }`
 };
diff --git a/server/src/commands/core/morestats.js b/server/src/commands/core/morestats.js
index b64d478..e8eed05 100644
--- a/server/src/commands/core/morestats.js
+++ b/server/src/commands/core/morestats.js
@@ -2,6 +2,7 @@
   Description: Outputs more info than the legacy stats command
 */
 
+// module support functions
 const stripIndents = require('common-tags').stripIndents;
 
 const formatTime = (time) => {
@@ -19,6 +20,7 @@ const formatTime = (time) => {
   return `${days.toFixed(0)}d ${hours.toFixed(0)}h ${minutes.toFixed(0)}m ${seconds.toFixed(0)}s`;
 };
 
+// module main
 exports.run = async (core, server, socket, data) => {
   // gather connection and channel count
   let ips = {};
@@ -54,7 +56,33 @@ exports.run = async (core, server, socket, data) => {
   core.managers.stats.increment('stats-requested');
 };
 
+// module hook functions
+exports.initHooks = (server) => {
+  server.registerHook('in', 'chat', this.statsCheck);
+};
+
+// hooks chat commands checking for /stats
+exports.statsCheck = (core, server, socket, payload) => {
+  if (typeof payload.text !== 'string') {
+    return false;
+  }
+
+  if (payload.text.startsWith('/stats')) {
+    this.run(core, server, socket, {
+      cmd: 'morestats'
+    });
+
+    return false;
+  }
+
+  return payload;
+};
+
+// module meta
 exports.info = {
   name: 'morestats',
-  description: 'Sends back current server stats to the calling client'
+  description: 'Sends back current server stats to the calling client',
+  usage: `
+    API: { cmd: 'morestats' }
+    Text: /stats`
 };
diff --git a/server/src/commands/core/move.js b/server/src/commands/core/move.js
index 284a38d..e85f481 100644
--- a/server/src/commands/core/move.js
+++ b/server/src/commands/core/move.js
@@ -2,15 +2,14 @@
   Description: Changes the current channel of the calling socket
 */
 
+// module main
 exports.run = async (core, server, socket, data) => {
   // check for spam
   if (server._police.frisk(socket.remoteAddress, 6)) {
-    server.reply({
+    return server.reply({
       cmd: 'warn',
       text: 'You are changing channels too fast. Wait a moment before trying again.'
     }, socket);
-
-    return;
   }
 
   // check user input
@@ -81,10 +80,11 @@ exports.run = async (core, server, socket, data) => {
   socket.channel = data.channel;
 };
 
+// module meta
 exports.requiredData = ['channel'];
-
 exports.info = {
   name: 'move',
-  usage: 'move {channel}',
-  description: 'This will change the current channel to the new one provided'
+  description: 'This will change your current channel to the new one provided',
+  usage: `
+    API: { cmd: 'move', channel: '<target channel>' }`
 };
diff --git a/server/src/commands/core/ping.js b/server/src/commands/core/ping.js
index cf1d8a4..1e710e5 100644
--- a/server/src/commands/core/ping.js
+++ b/server/src/commands/core/ping.js
@@ -2,10 +2,12 @@
   Description: This module is only in place to supress error notices legacy sources may get
 */
 
+// module main
 exports.run = async (core, server, socket, data) => {
   return;
 };
 
+// module meta
 exports.info = {
   name: 'ping',
   description: 'This module is only in place to supress error notices legacy sources may get'
diff --git a/server/src/commands/core/stats.js b/server/src/commands/core/stats.js
index 74bd4a5..b271bb1 100644
--- a/server/src/commands/core/stats.js
+++ b/server/src/commands/core/stats.js
@@ -2,6 +2,7 @@
   Description: Legacy stats output, kept for compatibility, outputs user and channel count
 */
 
+// module main
 exports.run = async (core, server, socket, data) => {
   // gather connection and channel count
   let ips = {};
@@ -29,7 +30,10 @@ exports.run = async (core, server, socket, data) => {
   core.managers.stats.increment('stats-requested');
 };
 
+// module meta
 exports.info = {
   name: 'stats',
-  description: 'Sends back legacy server stats to the calling client'
+  description: 'Sends back legacy server stats to the calling client',
+  usage: `
+    API: { cmd: 'stats' }`
 };
diff --git a/server/src/commands/core/whisper.js b/server/src/commands/core/whisper.js
new file mode 100644
index 0000000..2ad2df0
--- /dev/null
+++ b/server/src/commands/core/whisper.js
@@ -0,0 +1,110 @@
+/*
+  Description: Display text on targets screen that only they can see
+*/
+
+// module support functions
+const verifyNickname = (nick) => /^[a-zA-Z0-9_]{1,24}$/.test(nick);
+
+const parseText = (text) => {
+  // verifies user input is text
+  if (typeof text !== 'string') {
+    return false;
+  }
+
+  // strip newlines from beginning and end
+  text = text.replace(/^\s*\n|^\s+$|\n\s*$/g, '');
+  // replace 3+ newlines with just 2 newlines
+  text = text.replace(/\n{3,}/g, "\n\n");
+
+  return text;
+};
+
+// module main
+exports.run = async (core, server, socket, payload) => {
+  // check user input
+  let text = parseText(payload.text);
+
+  if (!text) {
+    // lets not send objects or empty text, yea?
+    return server._police.frisk(socket.remoteAddress, 13);
+  }
+
+  // check for spam
+  let score = text.length / 83 / 4;
+  if (server._police.frisk(socket.remoteAddress, score)) {
+    return server.reply({
+      cmd: 'warn',
+      text: 'You are sending too much text. Wait a moment and try again.\nPress the up arrow key to restore your last message.'
+    }, socket);
+  }
+
+  let targetNick = payload.nick;
+  if (!verifyNickname(targetNick)) {
+    return;
+  }
+
+  // find target user
+  let targetClient = server.findSockets({ channel: socket.channel, nick: targetNick });
+
+  if (targetClient.length === 0) {
+    return server.reply({
+      cmd: 'warn',
+      text: 'Could not find user in channel'
+    }, socket);
+  }
+
+  targetClient = targetClient[0];
+
+  server.reply({
+    cmd: 'info',
+    type: 'whisper',
+    from: socket.nick,
+    trip: socket.trip || 'null',
+    text: `${socket.nick} whispered: ${text}`
+  }, targetClient);
+
+  server.reply({
+    cmd: 'info',
+    type: 'whisper',
+    text: `You whispered to @${targetNick}: ${text}`
+  }, socket);
+};
+
+// module hook functions
+exports.initHooks = (server) => {
+  server.registerHook('in', 'chat', this.whisperCheck);
+};
+
+// hooks chat commands checking for /whisper
+exports.whisperCheck = (core, server, socket, payload) => {
+  if (typeof payload.text !== 'string') {
+    return false;
+  }
+
+  if (payload.text.startsWith('/whisper')) {
+    let input = payload.text.split(' ');
+    let target = input[1].replace(/@/g, '');
+    input.splice(0, 2);
+    let whisperText = input.join(' ');
+
+    this.run(core, server, socket, {
+      cmd: 'whisper',
+      nick: target,
+      text: whisperText
+    });
+
+    return false;
+  }
+
+  return payload;
+};
+
+// module meta
+exports.requiredData = ['nick', 'text'];
+exports.info = {
+  name: 'whisper',
+  description: 'Display text on targets screen that only they can see',
+  usage: `
+    API: { cmd: 'whisper', nick: '<target name>', text: '<text to whisper>' }
+    Text: /whisper <target name> <text to whisper>`
+};
diff --git a/server/src/commands/internal/disconnect.js b/server/src/commands/internal/disconnect.js
index 7b9b299..8656779 100644
--- a/server/src/commands/internal/disconnect.js
+++ b/server/src/commands/internal/disconnect.js
@@ -3,12 +3,11 @@
                when a socket connection is closed or lost.
 */
 
+// module main
 exports.run = async (core, server, socket, data) => {
   if (data.cmdKey !== server._cmdKey) {
     // internal command attempt by client, increase rate limit chance and ignore
-    server._police.frisk(socket.remoteAddress, 20);
-
-    return;
+    return server._police.frisk(socket.remoteAddress, 20);
   }
 
   // send leave notice to client peers
@@ -23,8 +22,8 @@ exports.run = async (core, server, socket, data) => {
   socket.terminate();
 };
 
+// module meta
 exports.requiredData = ['cmdKey'];
-
 exports.info = {
   name: 'disconnect',
   usage: 'Internal Use Only',
diff --git a/server/src/commands/internal/socketreply.js b/server/src/commands/internal/socketreply.js
index e77d080..fa3b8cf 100644
--- a/server/src/commands/internal/socketreply.js
+++ b/server/src/commands/internal/socketreply.js
@@ -2,20 +2,19 @@
   Description: Used to relay warnings to clients internally
 */
 
+// module main
 exports.run = async (core, server, socket, data) => {
   if (data.cmdKey !== server._cmdKey) {
     // internal command attempt by client, increase rate limit chance and ignore
-    server._police.frisk(socket.remoteAddress, 20);
-
-    return;
+    return server._police.frisk(socket.remoteAddress, 20);
   }
 
   // send warning to target socket
   server.reply({ cmd: 'warn', text: data.text }, socket);
 };
 
+// module meta
 exports.requiredData = ['cmdKey', 'text'];
-
 exports.info = {
   name: 'socketreply',
   usage: 'Internal Use Only',
diff --git a/server/src/commands/mod/ban.js b/server/src/commands/mod/ban.js
index 721ad27..8236136 100644
--- a/server/src/commands/mod/ban.js
+++ b/server/src/commands/mod/ban.js
@@ -2,12 +2,11 @@
   Description: Adds the target socket's ip to the ratelimiter
 */
 
+// module main
 exports.run = async (core, server, socket, data) => {
   // increase rate limit chance and ignore if not admin or mod
-  if (socket.uType == 'user') {
-    server._police.frisk(socket.remoteAddress, 10);
-
-    return;
+  if (socket.uType === 'user') {
+    return server._police.frisk(socket.remoteAddress, 10);
   }
 
   // check user input
@@ -20,24 +19,20 @@ exports.run = async (core, server, socket, data) => {
   let badClient = server.findSockets({ channel: socket.channel, nick: targetNick });
 
   if (badClient.length === 0) {
-    server.reply({
+    return server.reply({
       cmd: 'warn',
       text: 'Could not find user in channel'
     }, socket);
-
-    return;
   }
 
   badClient = badClient[0];
 
   // i guess banning mods or admins isn't the best idea?
   if (badClient.uType !== 'user') {
-    server.reply({
+    return server.reply({
       cmd: 'warn',
       text: 'Cannot ban other mods, how rude'
     }, socket);
-
-    return;
   }
 
   // commit arrest record
@@ -64,10 +59,11 @@ exports.run = async (core, server, socket, data) => {
   core.managers.stats.increment('users-banned');
 };
 
+// module meta
 exports.requiredData = ['nick'];
-
 exports.info = {
   name: 'ban',
-  usage: 'ban {nick}',
-  description: 'Disconnects the target nickname in the same channel as calling socket & adds to ratelimiter'
+  description: 'Disconnects the target nickname in the same channel as calling socket & adds to ratelimiter',
+  usage: `
+    API: { cmd: 'ban', nick: '<target nickname>' }`
 };
diff --git a/server/src/commands/mod/dumb.js b/server/src/commands/mod/dumb.js
index 675ecd6..ecb4e0d 100644
--- a/server/src/commands/mod/dumb.js
+++ b/server/src/commands/mod/dumb.js
@@ -1,18 +1,20 @@
 /*
- * Description: Make a user (spammer) dumb
+ * Description: Make a user (spammer) dumb (mute)
  * Author: simple
  */
 
+// module constructor
 exports.init = (core) => {
+  if (typeof core.muzzledHashes === 'undefined') {
     core.muzzledHashes = {};
-}
+  }
+};
 
+// module main
 exports.run = async (core, server, socket, data) => {
   // increase rate limit chance and ignore if not admin or mod
-  if (socket.uType == 'user') {
-    server._police.frisk(socket.remoteAddress, 10);
-
-    return;
+  if (socket.uType === 'user') {
+    return server._police.frisk(socket.remoteAddress, 10);
   }
 
   // check user input
@@ -24,29 +26,25 @@ exports.run = async (core, server, socket, data) => {
   let badClient = server.findSockets({ channel: socket.channel, nick: data.nick });
 
   if (badClient.length === 0) {
-    server.reply({
+    return server.reply({
       cmd: 'warn',
       text: 'Could not find user in channel'
     }, socket);
-
-    return;
   }
 
   badClient = badClient[0];
 
   // likely dont need this, muting mods and admins is fine
   if (badClient.uType !== 'user') {
-    server.reply({
+    return server.reply({
       cmd: 'warn',
       text: 'This trick wont work on mods and admin'
     }, socket);
-
-    return;
   }
 
   // store hash in mute list
   let record = core.muzzledHashes[badClient.hash] = {
-      dumb:true
+      dumb: true
   }
 
   // store allies if needed
@@ -59,12 +57,78 @@ exports.run = async (core, server, socket, data) => {
     cmd: 'info',
     text: `${socket.nick} muzzled ${data.nick} in ${socket.channel}, userhash: ${badClient.hash}`
   }, { uType: 'mod' });
-}
+};
 
-exports.requiredData = ['nick'];
+// module hook functions
+exports.initHooks = (server) => {
+  server.registerHook('in', 'chat', this.chatCheck);
+  server.registerHook('in', 'invite', this.inviteCheck);
+  // TODO: add whisper hook, need hook priorities todo finished first
+};
+
+// hook incoming chat commands, shadow-prevent chat if they are muzzled
+exports.chatCheck = (core, server, socket, payload) => {
+  if (typeof payload.text !== 'string') {
+    return false;
+  }
+
+  if(core.muzzledHashes[socket.hash]){
+    // build fake chat payload
+    mutedPayload = {
+      cmd: 'chat',
+      nick: socket.nick,
+      text: payload.text
+    };
+
+    if (socket.trip) {
+      mutedPayload.trip = socket.trip;
+    }
+
+    // broadcast to any duplicate connections in channel
+    server.broadcast( mutedPayload, { channel: socket.channel, hash: socket.hash });
 
+    // broadcast to allies, if any
+    if(core.muzzledHashes[socket.hash].allies){
+      server.broadcast( mutedPayload, { channel: socket.channel, nick: core.muzzledHashes[socket.hash].allies });
+    }
+
+    // blanket "spam" protection, may expose the ratelimiting lines from `chat` and use that, TODO: one day #lazydev
+    server._police.frisk(socket.remoteAddress, 9);
+
+    return false;
+  }
+
+  return payload;
+};
+
+// shadow-prevent all invites from muzzled users
+exports.inviteCheck = (core, server, socket, payload) => {
+  if (typeof payload.nick !== 'string') {
+    return false;
+  }
+
+  if(core.muzzledHashes[socket.hash]){
+    // generate common channel
+    let channel = Math.random().toString(36).substr(2, 8);
+
+    // send fake reply
+    server.reply({
+      cmd: 'info',
+      text: `You invited ${payload.nick} to ?${channel}`
+    }, socket);
+
+    return false;
+  }
+
+  return payload;
+};
+
+// module meta
+exports.requiredData = ['nick'];
 exports.info = {
   name: 'dumb',
-  usage: 'dumb {nick} [allies...]',
-  description: 'Globally shadow mute a connection. Optional allies array will see muted messages.'
+  description: 'Globally shadow mute a connection. Optional allies array will see muted messages.',
+  usage: `
+    API: { cmd: 'dumb', nick: '<target nick>', allies: ['<optional nick array>', ...] }`
 };
+exports.info.aliases = ['muzzle', 'mute'];
diff --git a/server/src/commands/mod/kick.js b/server/src/commands/mod/kick.js
index 75c0d40..0e8ee0a 100644
--- a/server/src/commands/mod/kick.js
+++ b/server/src/commands/mod/kick.js
@@ -2,12 +2,11 @@
   Description: Forces a change on the target(s) socket's channel, then broadcasts event
 */
 
+// module main
 exports.run = async (core, server, socket, data) => {
   // increase rate limit chance and ignore if not admin or mod
-  if (socket.uType == 'user') {
-    server._police.frisk(socket.remoteAddress, 10);
-
-    return;
+  if (socket.uType === 'user') {
+    return server._police.frisk(socket.remoteAddress, 10);
   }
 
   // check user input
@@ -21,12 +20,10 @@ exports.run = async (core, server, socket, data) => {
   let badClients = server.findSockets({ channel: socket.channel, nick: data.nick });
 
   if (badClients.length === 0) {
-    server.reply({
+    return server.reply({
       cmd: 'warn',
       text: 'Could not find user(s) in channel'
     }, socket);
-
-    return;
   }
 
   // check if found targets are kickable, commit kick
@@ -75,10 +72,11 @@ exports.run = async (core, server, socket, data) => {
   core.managers.stats.increment('users-kicked', kicked.length);
 };
 
+// module meta
 exports.requiredData = ['nick'];
-
 exports.info = {
   name: 'kick',
-  usage: 'kick {nick}',
-  description: 'Silently forces target client(s) into another channel. `nick` may be string or array of strings'
+  description: 'Silently forces target client(s) into another channel. `nick` may be string or array of strings',
+  usage: `
+    API: { cmd: 'kick', nick: '<target nick>' }`
 };
diff --git a/server/src/commands/mod/moveuser.js b/server/src/commands/mod/moveuser.js
new file mode 100644
index 0000000..e4f6c22
--- /dev/null
+++ b/server/src/commands/mod/moveuser.js
@@ -0,0 +1,105 @@
+/*
+  Description: Removes the target socket from the current channel and forces a join event in another
+*/
+
+// module main
+exports.run = async (core, server, socket, data) => {
+  // increase rate limit chance and ignore if not admin or mod
+  if (socket.uType === 'user') {
+    return server._police.frisk(socket.remoteAddress, 10);
+  }
+
+  // check user input
+  if (typeof data.nick !== 'string' || typeof data.channel !== 'string') {
+    return;
+  }
+
+  if (data.channel === socket.channel) {
+    // moving them into the same channel? y u do this?
+    return;
+  }
+
+  let badClients = server.findSockets({ channel: socket.channel, nick: data.nick });
+
+  if (badClients.length === 0) {
+    return server.reply({
+      cmd: 'warn',
+      text: 'Could not find user in channel'
+    }, socket);
+  }
+
+  let badClient = badClients[0];
+
+  if (badClient.uType !== 'user') {
+    return server.reply({
+      cmd: 'warn',
+      text: 'Cannot move other mods, how rude'
+    }, socket);
+  }
+
+  const currentNick = badClient.nick.toLowerCase();
+  let userExists = server.findSockets({
+    channel: data.channel,
+    nick: (targetNick) => targetNick.toLowerCase() === currentNick
+  });
+
+  if (userExists.length > 0) {
+    // That nickname is already in that channel
+    return;
+  }
+
+  let peerList = server.findSockets({ channel: socket.channel });
+
+  if (peerList.length > 1) {
+    for (let i = 0, l = peerList.length; i < l; i++) {
+      server.reply({
+        cmd: 'onlineRemove',
+        nick: peerList[i].nick
+      }, badClient);
+
+      if (badClient.nick !== peerList[i].nick){
+        server.reply({
+          cmd: 'onlineRemove',
+          nick: badClient.nick
+        }, peerList[i]);
+      }
+    }
+  }
+
+  let newPeerList = server.findSockets({ channel: data.channel });
+  let moveAnnouncement = {
+    cmd: 'onlineAdd',
+    nick: badClient.nick,
+    trip: badClient.trip || 'null',
+    hash: server.getSocketHash(badClient)
+  };
+  let nicks = [];
+
+  for (let i = 0, l = newPeerList.length; i < l; i++) {
+    server.reply(moveAnnouncement, newPeerList[i]);
+    nicks.push(newPeerList[i].nick);
+  }
+
+  nicks.push(badClient.nick);
+
+  server.reply({
+    cmd: 'onlineSet',
+    nicks: nicks
+  }, badClient);
+
+  badClient.channel = data.channel;
+
+  server.broadcast( {
+    cmd: 'info',
+    text: `${badClient.nick} was moved into ?${data.channel}`
+  }, { channel: data.channel });
+};
+
+// module meta
+exports.requiredData = ['nick', 'channel'];
+exports.info = {
+  name: 'moveuser',
+  description: 'This will move the target user nick into another channel',
+  usage: `
+    API: { cmd: 'moveuser', nick: '<target nick>', channel: '<new channel>' }`
+};
diff --git a/server/src/commands/mod/speak.js b/server/src/commands/mod/speak.js
index 454ca94..e2a3ef7 100644
--- a/server/src/commands/mod/speak.js
+++ b/server/src/commands/mod/speak.js
@@ -3,22 +3,26 @@
  * Author: simple
  */
 
+ // module constructor
+ exports.init = (core) => {
+   if (typeof core.muzzledHashes === 'undefined') {
+     core.muzzledHashes = {};
+   }
+ };
+
+// module main
 exports.run = async (core, server, socket, data) => {
   // increase rate limit chance and ignore if not admin or mod
-  if (socket.uType == 'user') {
-    server._police.frisk(socket.remoteAddress, 10);
-
-    return;
+  if (socket.uType === 'user') {
+    return server._police.frisk(socket.remoteAddress, 10);
   }
 
   // check user input
   if (typeof data.ip !== 'string' && typeof data.hash !== 'string') {
-    server.reply({
+    return server.reply({
       cmd: 'warn',
       text: "hash:'targethash' or ip:'1.2.3.4' is required"
     }, socket);
-
-    return;
   }
 
   // find target & remove mute status
@@ -36,10 +40,13 @@ exports.run = async (core, server, socket, data) => {
     cmd: 'info',
     text: `${socket.nick} unmuzzled : ${target}`
   }, { uType: 'mod' });
-}
+};
 
+// module meta
 exports.info = {
   name: 'speak',
-  usage: 'speak {[ip || hash]}',
-  description: 'Pardon a dumb user to be able to speak again'
+  description: 'Pardon a dumb user to be able to speak again',
+  usage: `
+    API: { cmd: 'speak', ip/hash: '<target ip or hash' }`
 };
+exports.info.aliases = ['unmuzzle', 'unmute'];
diff --git a/server/src/commands/mod/unban.js b/server/src/commands/mod/unban.js
index 9115dbd..0cd8ca7 100644
--- a/server/src/commands/mod/unban.js
+++ b/server/src/commands/mod/unban.js
@@ -2,22 +2,19 @@
   Description: Removes a target ip from the ratelimiter
 */
 
+// module main
 exports.run = async (core, server, socket, data) => {
   // increase rate limit chance and ignore if not admin or mod
-  if (socket.uType == 'user') {
-    server._police.frisk(socket.remoteAddress, 10);
-
-    return;
+  if (socket.uType === 'user') {
+    return server._police.frisk(socket.remoteAddress, 10);
   }
 
   // check user input
   if (typeof data.ip !== 'string' && typeof data.hash !== 'string') {
-    server.reply({
+    return server.reply({
       cmd: 'warn',
       text: "hash:'targethash' or ip:'1.2.3.4' is required"
     }, socket);
-
-    return;
   }
 
   // find target
@@ -55,8 +52,10 @@ exports.run = async (core, server, socket, data) => {
   core.managers.stats.decrement('users-banned');
 };
 
+// module meta
 exports.info = {
   name: 'unban',
-  usage: 'unban {[ip || hash]}',
-  description: 'Removes target ip from the ratelimiter'
+  description: 'Removes target ip from the ratelimiter',
+  usage: `
+    API: { cmd: 'unban', ip/hash: '<target ip or hash>' }`
 };
diff --git a/server/src/commands/mod/unbanall.js b/server/src/commands/mod/unbanall.js
new file mode 100644
index 0000000..a9cf682
--- /dev/null
+++ b/server/src/commands/mod/unbanall.js
@@ -0,0 +1,36 @@
+/*
+  Description: Clears all bans and ratelimits
+*/
+
+// module main
+exports.run = async (core, server, socket, data) => {
+  // increase rate limit chance and ignore if not admin or mod
+  if (socket.uType === 'user') {
+    return server._police.frisk(socket.remoteAddress, 10);
+  }
+
+  // remove arrest records
+  server._police._records = {};
+
+  console.log(`${socket.nick} [${socket.trip}] unbanned all`);
+
+  // reply with success
+  server.reply({
+    cmd: 'info',
+    text: `Unbanned all ip addresses`
+  }, socket);
+
+  // notify mods
+  server.broadcast({
+    cmd: 'info',
+    text: `${socket.nick} unbanned all ip addresses`
+  }, { uType: 'mod' });
+};
+
+// module meta
+exports.info = {
+  name: 'unbanall',
+  description: 'Clears all banned ip addresses',
+  usage: `
+    API: { cmd: 'unbanall' }`
+};
diff --git a/server/src/core/server.js b/server/src/core/server.js
index ded7cac..08ddad7 100644
--- a/server/src/core/server.js
+++ b/server/src/core/server.js
@@ -10,8 +10,8 @@
 const wsServer = require('ws').Server;
 const socketReady = require('ws').OPEN;
 const crypto = require('crypto');
-const ipSalt = (Math.random().toString(36).substring(2, 16) + Math.random().toString(36).substring(2, (Math.random() * 16))).repeat(16);
-const internalCmdKey = (Math.random().toString(36).substring(2, 16) + Math.random().toString(36).substring(2, (Math.random() * 16))).repeat(16);
+const ipSalt = [...Array(Math.floor(Math.random()*128)+128)].map(i=>(~~(Math.random()*36)).toString(36)).join('');
+const internalCmdKey = [...Array(Math.floor(Math.random()*128)+128)].map(i=>(~~(Math.random()*36)).toString(36)).join('');
 const Police = require('./rateLimiter');
 const pulseSpeed = 16000; // ping all clients every X ms
 
@@ -25,6 +25,7 @@ class server extends wsServer {
     super({ port: core.config.websocketPort });
 
     this._core = core;
+    this._hooks = {};
     this._police = new Police();
     this._cmdBlacklist = {};
     this._cmdKey = internalCmdKey;
@@ -40,6 +41,8 @@ class server extends wsServer {
     this.on('connection', (socket, request) => {
       this.newConnection(socket, request);
     });
+
+    this._core.commands.initCommandHooks(this);
   }
 
   /**
@@ -105,42 +108,59 @@ class server extends wsServer {
     // Penalize here, but don't do anything about it
     this._police.frisk(socket.remoteAddress, 1);
 
-    // ignore ridiculously large packets
+    // Ignore ridiculously large packets
     if (data.length > 65536) {
       return;
     }
 
     // Start sent data verification
-    var args = null;
+    var payload = null;
     try {
-      args = JSON.parse(data);
+      payload = JSON.parse(data);
     } catch (e) {
       // Client sent malformed json, gtfo
       socket.close();
     }
 
-    if (args === null) {
+    if (payload === null) {
       return;
     }
 
-    if (typeof args.cmd === 'undefined') {
+    if (typeof payload.cmd === 'undefined') {
       return;
     }
 
-    if (typeof args.cmd !== 'string') {
+    if (typeof payload.cmd !== 'string') {
       return;
     }
 
-    if (typeof socket.channel === 'undefined' && args.cmd !== 'join') {
+    if (typeof socket.channel === 'undefined' && (payload.cmd !== 'join' && payload.cmd !== 'chat')) {
       return;
     }
 
-    if (typeof this._cmdBlacklist[args.cmd] === 'function') {
+    if (typeof this._cmdBlacklist[payload.cmd] === 'function') {
       return;
     }
 
-    // Finished verification, pass to command modules
-    this._core.commands.handleCommand(this, socket, args);
+    // Execute `in` (incoming data) hooks and process results
+    payload = this.executeHooks('in', socket, payload);
+
+    if (typeof payload === 'string') {
+      // A hook malfunctioned, reply with error
+      this._core.commands.handleCommand(this, socket, {
+        cmd: 'socketreply',
+        cmdKey: this._cmdKey,
+        text: payload
+      });
+
+      return;
+    } else if (payload === false) {
+      // A hook requested this data be dropped
+      return;
+    }
+
+    // Finished verification & hooks, pass to command modules
+    this._core.commands.handleCommand(this, socket, payload);
   }
 
   /**
@@ -168,16 +188,33 @@ class server extends wsServer {
   /**
     * Send data payload to specific socket/client
     *
-    * @param {Object} data Object to convert to json for transmission
+    * @param {Object} payload Object to convert to json for transmission
     * @param {Object} socket The target client
     */
-  send (data, socket) {
+  send (payload, socket) {
     // Add timestamp to command
-    data.time = Date.now();
+    payload.time = Date.now();
+
+    // Execute `in` (incoming data) hooks and process results
+    payload = this.executeHooks('out', socket, payload);
+
+    if (typeof payload === 'string') {
+      // A hook malfunctioned, reply with error
+      this._core.commands.handleCommand(this, socket, {
+        cmd: 'socketreply',
+        cmdKey: this._cmdKey,
+        text: payload
+      });
+
+      return;
+    } else if (payload === false) {
+      // A hook requested this data be dropped
+      return;
+    }
 
     try {
       if (socket.readyState === socketReady) {
-        socket.send(JSON.stringify(data));
+        socket.send(JSON.stringify(payload));
       }
     } catch (e) { }
   }
@@ -185,20 +222,20 @@ class server extends wsServer {
   /**
     * Overload function for `this.send()`
     *
-    * @param {Object} data Object to convert to json for transmission
+    * @param {Object} payload Object to convert to json for transmission
     * @param {Object} socket The target client
     */
-  reply (data, socket) {
-    this.send(data, socket);
+  reply (payload, socket) {
+    this.send(payload, socket);
   }
 
   /**
     * Finds sockets/clients that meet the filter requirements, then passes the data to them
     *
-    * @param {Object} data Object to convert to json for transmission
+    * @param {Object} payload Object to convert to json for transmission
     * @param {Object} filter see `this.findSockets()`
     */
-  broadcast (data, filter) {
+  broadcast (payload, filter) {
     let targetSockets = this.findSockets(filter);
 
     if (targetSockets.length === 0) {
@@ -206,7 +243,7 @@ class server extends wsServer {
     }
 
     for (let i = 0, l = targetSockets.length; i < l; i++) {
-      this.send(data, targetSockets[i]);
+      this.send(payload, targetSockets[i]);
     }
 
     return true;
@@ -271,7 +308,7 @@ class server extends wsServer {
   }
 
   /**
-    * Encrypts target socket's remote address using non-static variable length salt
+    * Hashes target socket's remote address using non-static variable length salt
     * encodes and shortens the output, returns that value
     *
     * @param {Object||String} target Either the target socket or ip as string
@@ -287,6 +324,73 @@ class server extends wsServer {
 
     return sha.digest('base64').substr(0, 15);
   }
+
+  /**
+    * Adds a target function to an array of hooks. Hooks are executed either before
+    * processing user input (`in`) or before sending data back to the client (`out`)
+    * and allows a module to modify each payload before moving forward
+    *
+    * @param {String} type The type of event, typically `in` (incoming) or `out` (outgoing)
+    * @param {String} command Should match the desired `cmd` attrib of the payload
+    * @param {Function} hookFunction Target function to execute, should accept `server`, `socket` and `payload` as parameters
+    */
+    // TODO: add hook priority levels
+  registerHook (type, command, hookFunction) {
+    if (typeof this._hooks[type] === 'undefined') {
+      this._hooks[type] = new Map();
+    }
+
+    if (!this._hooks[type].has(command)) {
+      this._hooks[type].set(command, []);
+    }
+
+    this._hooks[type].get(command).push(hookFunction);
+  }
+
+  /**
+    * Loops through registered hooks & processes the results. Returned data will
+    * be one of three possiblities:
+    * A payload (modified or not) that will continue through the data flow
+    * A boolean false to indicate halting the data through flow
+    * A string which indicates an error occured in executing the hook
+    *
+    * @param {String} type The type of event, typically `in` (incoming) or `out` (outgoing)
+    * @param {Object} socket Either the target client or the client triggering the hook (depending on `type`)
+    * @param {Object} payload Either incoming data from client or outgoing data (depending on `type`)
+    */
+  executeHooks (type, socket, payload) {
+    let command = payload.cmd;
+
+    if (typeof this._hooks[type] !== 'undefined') {
+      if (this._hooks[type].has(command)) {
+        let hooks = this._hooks[type].get(command);
+
+        for (let i = 0, j = hooks.length; i < j; i++) {
+          try {
+            payload = hooks[i](this._core, this, socket, payload);
+          } catch (err) {
+            let errText = `Hook failure, '${type}', '${command}': ${err}`;
+            console.log(errText);
+            return errText;
+          }
+
+          // A payload may choose to return false to prevent all further processing
+          if (payload === false) {
+            return false;
+          }
+        }
+      }
+    }
+
+    return payload;
+  }
+
+  /**
+    * Wipe server hooks to make ready for module reload calls
+    */
+  clearHooks () {
+    this._hooks = {};
+  }
 }
 
 module.exports = server;
diff --git a/server/src/managers/commands.js b/server/src/managers/commands.js
index fd743fb..a5ef464 100644
--- a/server/src/managers/commands.js
+++ b/server/src/managers/commands.js
@@ -71,8 +71,9 @@ class CommandManager {
 
       command.info.category = category;
 
-      if (this._categories.indexOf(category) === -1)
+      if (this._categories.indexOf(category) === -1) {
         this._categories.push(category);
+      }
     }
 
     if (typeof command.init === 'function') {
@@ -114,7 +115,7 @@ class CommandManager {
   /**
     * Pulls all command names from a passed `category`
     *
-    * @param {String} category reference to the newly loaded object
+    * @param {String} category [Optional] filter return results by this category
     */
   all (category) {
     return !category ? this._commands : this._commands.filter(c => c.info.category.toLowerCase() === category.toLowerCase());
@@ -148,6 +149,15 @@ class CommandManager {
     return this._commands.find(c => c.info[key] === value);
   }
 
+  /**
+    * Runs `initHooks` function on any modules that utilize the event
+    *
+    * @param {Object} server main server object
+    */
+  initCommandHooks (server) {
+    this._commands.filter(c => typeof c.initHooks !== 'undefined').forEach(c => c.initHooks(server));
+  }
+
   /**
     * Finds and executes the requested command, or fails with semi-intelligent error
     *
-- 
cgit v1.2.1