From 559539952c9ae534b06ec6a62377b7daf02b65d5 Mon Sep 17 00:00:00 2001
From: 2Cubed <2Cubed@users.noreply.github.com>
Date: Sat, 4 Mar 2017 22:39:16 -0500
Subject: [PATCH 001/122] Fix tag modifier

---
 cactusbot/handlers/command.py | 8 ++++----
 1 file changed, 4 insertions(+), 4 deletions(-)

diff --git a/cactusbot/handlers/command.py b/cactusbot/handlers/command.py
index f368885..5fabcd8 100644
--- a/cactusbot/handlers/command.py
+++ b/cactusbot/handlers/command.py
@@ -18,7 +18,7 @@ class CommandHandler(Handler):
         "lower": str.lower,
         "title": str.title,
         "reverse": lambda text: text[::-1],
-        "tag": lambda tag: tag[1:] if tag[0] == '@' and len(tag) > 1 else tag,
+        "tag": lambda tag: tag[1:] if len(tag) > 1 and tag[0] == '@' else tag,
         "shuffle": lambda text: ''.join(random.sample(text, len(text)))
     }
 
@@ -135,7 +135,7 @@ def sub_argn(match):
                 result = args[argn] if argn < len(args) else default
 
             if modifiers is not None:
-                result = self._modify(result, *modifiers.split('|')[1:])
+                result = self.modify(result, *modifiers.split('|')[1:])
 
             return result
 
@@ -155,7 +155,7 @@ def sub_args(match):
                 result = ' '.join(args[1:])
 
             if modifiers is not None:
-                result = self._modify(result, *modifiers.split('|')[1:])
+                result = self.modify(result, *modifiers.split('|')[1:])
 
             return result
 
@@ -172,7 +172,7 @@ def sub_args(match):
 
         return _packet
 
-    def _modify(self, argument, *modifiers):
+    def modify(self, argument, *modifiers):
         """Apply modifiers to an argument."""
 
         for modifier in modifiers:

From 18a9eabedb87b49a01064e16a6ec7fb48a753efc Mon Sep 17 00:00:00 2001
From: 2Cubed <2Cubed@users.noreply.github.com>
Date: Sat, 4 Mar 2017 22:39:20 -0500
Subject: [PATCH 002/122] Add tests for modifiers

---
 tests/handlers/test_command.py | 21 +++++++++++++++++++++
 1 file changed, 21 insertions(+)

diff --git a/tests/handlers/test_command.py b/tests/handlers/test_command.py
index 6b24832..6945f5e 100644
--- a/tests/handlers/test_command.py
+++ b/tests/handlers/test_command.py
@@ -117,3 +117,24 @@ def test_inject_channel():
         "Welcome to %CHANNEL%'s stream!",
         "welcome"
     )
+
+
+def test_modify():
+
+    assert command_handler.modify("Para", "upper") == "PARA"
+    assert command_handler.modify("hOI", "lower") == "hoi"
+    assert command_handler.modify("taco salad", "title") == "Taco Salad"
+
+    assert command_handler.modify("taco", "reverse") == "ocat"
+
+    assert command_handler.modify("@Innectic", "tag") == "Innectic"
+    assert command_handler.modify("Innectic", "tag") == "Innectic"
+    assert command_handler.modify("", "tag") == ""
+
+    import random
+    random.seed(8)
+
+    assert command_handler.modify("potato", "shuffle") == "otapto"
+    assert command_handler.modify("", "shuffle") == ""
+
+    assert command_handler.modify("Jello", "reverse", "title") == "Ollej"

From 3fb0be9bd9135eadfb6574b3417747fd1c010e6f Mon Sep 17 00:00:00 2001
From: 2Cubed <2Cubed@users.noreply.github.com>
Date: Sat, 4 Mar 2017 23:09:16 -0500
Subject: [PATCH 003/122] Fix checking for %ARGS%

Use regex, rather than `in`, so that modifiers are accepted.
---
 cactusbot/handlers/command.py | 5 +++--
 1 file changed, 3 insertions(+), 2 deletions(-)

diff --git a/cactusbot/handlers/command.py b/cactusbot/handlers/command.py
index f368885..5eb35b4 100644
--- a/cactusbot/handlers/command.py
+++ b/cactusbot/handlers/command.py
@@ -1,6 +1,7 @@
 """Handle commands."""
 
 import random
+import re
 
 from ..commands import COMMANDS
 from ..commands.command import ROLES
@@ -12,7 +13,7 @@ class CommandHandler(Handler):
     """Command handler."""
 
     ARGN_EXPR = r'%ARG(\d+)(?:=([^|]+))?(?:((?:\|\w+)+))?%'
-    ARGS_EXPR = r'%ARGS(?:=([^|]+))?(?:((?:\|\w+)+))?%'
+    ARGS_EXPR = r'%ARGS(?:=([^|]+))?((?:\|\w+)+)?%'
     MODIFIERS = {
         "upper": str.upper,
         "lower": str.lower,
@@ -159,7 +160,7 @@ def sub_args(match):
 
             return result
 
-        if "%ARGS%" in _packet and len(args) < 2:
+        if len(args) < 2 and re.search(self.ARGS_EXPR, _packet.text):
             return MessagePacket("Not enough arguments!")
 
         _packet.sub(self.ARGS_EXPR, sub_args)

From ae83db952798ed9054e98540866688b360227140 Mon Sep 17 00:00:00 2001
From: Innectic <innectic@gmail.com>
Date: Wed, 8 Mar 2017 20:03:00 +0000
Subject: [PATCH 004/122] Fix install instructions

---
 INSTALL.md | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/INSTALL.md b/INSTALL.md
index f6b12b2..9c6f10a 100644
--- a/INSTALL.md
+++ b/INSTALL.md
@@ -8,7 +8,7 @@ cp config.template.py config.py
 ```
 
 Next, open `config.py` with your favorite text editor, and set 
-`USERNAME` and `PASSWORD` to the bot's credentials, and set `CHANNEL` to your channel's name.
+`TOKEN` to the bot's token, which can be obtained from [Beam's Documentation](https://dev.beam.pro/tutorials/chatbot.html) and set `CHANNEL` to your channel's name.
 
 # Usage
 

From 8b300566b46485d0e528b86b59092b100bc0d9ae Mon Sep 17 00:00:00 2001
From: Innectic <innectic@gmail.com>
Date: Wed, 8 Mar 2017 12:44:24 -0800
Subject: [PATCH 005/122] Formatting is nice

---
 INSTALL.md | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/INSTALL.md b/INSTALL.md
index 9c6f10a..c0f2c23 100644
--- a/INSTALL.md
+++ b/INSTALL.md
@@ -8,7 +8,7 @@ cp config.template.py config.py
 ```
 
 Next, open `config.py` with your favorite text editor, and set 
-`TOKEN` to the bot's token, which can be obtained from [Beam's Documentation](https://dev.beam.pro/tutorials/chatbot.html) and set `CHANNEL` to your channel's name.
+`TOKEN` to the bot's OAuth token, which can be obtained from [Beam's Documentation](https://dev.beam.pro/tutorials/chatbot.html). Then, set `CHANNEL` to your channel's name.
 
 # Usage
 

From af32723312de14d6306f481b6955080faf6585e1 Mon Sep 17 00:00:00 2001
From: Innectic <innectic@gmail.com>
Date: Mon, 13 Mar 2017 19:42:01 -0700
Subject: [PATCH 006/122] Fix repeat add error message

---
 cactusbot/commands/magic/repeat.py | 3 +--
 1 file changed, 1 insertion(+), 2 deletions(-)

diff --git a/cactusbot/commands/magic/repeat.py b/cactusbot/commands/magic/repeat.py
index 8095465..864dcf6 100644
--- a/cactusbot/commands/magic/repeat.py
+++ b/cactusbot/commands/magic/repeat.py
@@ -20,8 +20,7 @@ async def add(self, period: r"[1-9]\d*", command: "?command"):
         elif response.status == 409:
             return "Repeat already exists!"
         else:
-            return (await response.json()).get("errors",
-                                               "Unknown error occured")
+            return "An error occured."
 
     @Command.command(role="moderator")
     async def remove(self, repeat: "?command"):

From 3fd65b3e5275c3b6321d471b8ba5c400e10c41e3 Mon Sep 17 00:00:00 2001
From: RPiAwesomeness <keyboardmailtesting@gmail.com>
Date: Wed, 15 Mar 2017 23:39:26 -0400
Subject: [PATCH 007/122] Fixed !multi not responding when run incorrectly

---
 cactusbot/commands/magic/multi.py | 7 ++++++-
 1 file changed, 6 insertions(+), 1 deletion(-)

diff --git a/cactusbot/commands/magic/multi.py b/cactusbot/commands/magic/multi.py
index 1e57293..9752e87 100644
--- a/cactusbot/commands/magic/multi.py
+++ b/cactusbot/commands/magic/multi.py
@@ -23,7 +23,12 @@ async def default(self, *channels):
         link = _BASE_URL
 
         for channel in channels:
-            service, channel_name = channel.split(':')
+            split = channel.split(':')
+            if len(split) < 2:
+                return "'{}' must be in <service>:<username> form!".format(channel)
+            else:
+                service = split[0]
+                channel_name = split[1]
 
             if service not in _SERVICES:
                 return "'{}' is not a valid service.".format(service)

From 6cd3f49a34e3c5ce6c68df1a28624101321a9245 Mon Sep 17 00:00:00 2001
From: RPiAwesomeness <keyboardmailtesting@gmail.com>
Date: Wed, 15 Mar 2017 23:52:16 -0400
Subject: [PATCH 008/122] Oh fine PEP8

---
 cactusbot/commands/magic/multi.py | 3 ++-
 1 file changed, 2 insertions(+), 1 deletion(-)

diff --git a/cactusbot/commands/magic/multi.py b/cactusbot/commands/magic/multi.py
index 9752e87..4b644af 100644
--- a/cactusbot/commands/magic/multi.py
+++ b/cactusbot/commands/magic/multi.py
@@ -25,7 +25,8 @@ async def default(self, *channels):
         for channel in channels:
             split = channel.split(':')
             if len(split) < 2:
-                return "'{}' must be in <service>:<username> form!".format(channel)
+                return "'{}' must be in <service>:<username> " \
+                       "form!".format(channel)
             else:
                 service = split[0]
                 channel_name = split[1]

From 7620aeb838c894c7d7b73c9849df5242c38c80e3 Mon Sep 17 00:00:00 2001
From: RPiAwesomeness <keyboardmailtesting@gmail.com>
Date: Thu, 16 Mar 2017 19:57:05 -0400
Subject: [PATCH 009/122] Added minor error catching

---
 cactusbot/handlers/events.py | 11 +++++++----
 1 file changed, 7 insertions(+), 4 deletions(-)

diff --git a/cactusbot/handlers/events.py b/cactusbot/handlers/events.py
index f4c7a66..ff6826a 100644
--- a/cactusbot/handlers/events.py
+++ b/cactusbot/handlers/events.py
@@ -124,10 +124,13 @@ async def on_config(self, packet):
             }
 
     async def _cache(self, packet, event):
-        response = MessagePacket(
-            self.alert_messages[event]["message"].replace(
-                "%USER%", packet.user
-            ))
+        if hasattr(packet, "user"):
+            response = MessagePacket(
+                self.alert_messages[event]["message"].replace(
+                    "%USER%", packet.user
+                ))
+        else:
+            return None
 
         if packet.success:
             if self.cache_data["cache_{}".format(event)]:

From 085f86d80ae4c11dedb0fd8c65447906026e22cb Mon Sep 17 00:00:00 2001
From: Innectic <innectic@gmail.com>
Date: Fri, 17 Mar 2017 01:55:42 -0700
Subject: [PATCH 010/122] Update to new sepal spec

---
 cactusbot/handlers/command.py | 1 +
 cactusbot/sepal.py            | 6 ++++--
 2 files changed, 5 insertions(+), 2 deletions(-)

diff --git a/cactusbot/handlers/command.py b/cactusbot/handlers/command.py
index 4221cf5..f40e44f 100644
--- a/cactusbot/handlers/command.py
+++ b/cactusbot/handlers/command.py
@@ -183,4 +183,5 @@ def modify(self, argument, *modifiers):
         return argument
 
     async def on_repeat(self, packet):
+        """Repeat event."""
         return packet
diff --git a/cactusbot/sepal.py b/cactusbot/sepal.py
index cba3e48..22fb6ad 100644
--- a/cactusbot/sepal.py
+++ b/cactusbot/sepal.py
@@ -24,7 +24,9 @@ async def send(self, packet_type, **kwargs):
 
         packet = {
             "type": packet_type,
-            "channel": self.channel
+            "data": {
+                "channel": self.channel
+            }
         }
 
         packet.update(kwargs)
@@ -33,7 +35,7 @@ async def send(self, packet_type, **kwargs):
     async def initialize(self):
         """Send a subscribe packet."""
 
-        await self.send("subscribe")
+        await self.send("join")
 
     async def parse(self, packet):
         """Parse a Sepal packet."""

From c29c520a81dba03120eb84620a7b335a4115fd0b Mon Sep 17 00:00:00 2001
From: RPiAwesomeness <keyboardmailtesting@gmail.com>
Date: Tue, 21 Mar 2017 15:48:55 -0400
Subject: [PATCH 011/122] Check for alias

---
 cactusbot/handlers/command.py | 3 ++-
 1 file changed, 2 insertions(+), 1 deletion(-)

diff --git a/cactusbot/handlers/command.py b/cactusbot/handlers/command.py
index f40e44f..04eb72d 100644
--- a/cactusbot/handlers/command.py
+++ b/cactusbot/handlers/command.py
@@ -99,12 +99,13 @@ async def custom_response(self, _packet, command, *args, **data):
                     *json["data"]["attributes"]["arguments"]
                 ).text.split()), *args[1:])
 
+        is_alias = True if json["data"]["type"] == "alias" else False
         json = json["data"]["attributes"]
 
         if not json.get("enabled", True):
             return MessagePacket("Command is disabled.", target=_packet.user)
 
-        if _packet.role < json["response"]["role"]:
+        if not is_alias and _packet.role < json["response"]["role"]:
             return MessagePacket(
                 "Role level '{role}' or higher required.".format(
                     role=ROLES[max(k for k in ROLES.keys()

From 5b3615d1ebf8197deea961e8ec54379131fc9f21 Mon Sep 17 00:00:00 2001
From: RPiAwesomeness <keyboardmailtesting@gmail.com>
Date: Tue, 21 Mar 2017 19:39:48 -0400
Subject: [PATCH 012/122] Trying to fix stuff.

---
 cactusbot/handlers/command.py | 7 +++++--
 1 file changed, 5 insertions(+), 2 deletions(-)

diff --git a/cactusbot/handlers/command.py b/cactusbot/handlers/command.py
index 04eb72d..fa1b2dc 100644
--- a/cactusbot/handlers/command.py
+++ b/cactusbot/handlers/command.py
@@ -89,9 +89,12 @@ async def custom_response(self, _packet, command, *args, **data):
             return
 
         json = await response.json()
+        is_alias = False
 
         if json["data"].get("type") == "aliases":
 
+            is_alias = True
+
             command = json["data"]["attributes"]["commandName"]
 
             if "arguments" in json["data"]["attributes"]:
@@ -99,7 +102,6 @@ async def custom_response(self, _packet, command, *args, **data):
                     *json["data"]["attributes"]["arguments"]
                 ).text.split()), *args[1:])
 
-        is_alias = True if json["data"]["type"] == "alias" else False
         json = json["data"]["attributes"]
 
         if not json.get("enabled", True):
@@ -113,7 +115,8 @@ async def custom_response(self, _packet, command, *args, **data):
                 target=_packet.user if _packet.target else None
             )
 
-        json["response"]["target"] = _packet.user if _packet.target else None
+        if _packet.target:
+            json["response"]["target"] = _packet.user
 
         await self.api.update_command_count(command, "+1")
         if "count" not in data:

From f01f4cd56eb3fe810d24c777d90d5a41970e0394 Mon Sep 17 00:00:00 2001
From: RPiAwesomeness <keyboardmailtesting@gmail.com>
Date: Tue, 21 Mar 2017 19:41:15 -0400
Subject: [PATCH 013/122] Oh yeah, fix that thing

---
 cactusbot/handlers/command.py | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/cactusbot/handlers/command.py b/cactusbot/handlers/command.py
index fa1b2dc..2701b61 100644
--- a/cactusbot/handlers/command.py
+++ b/cactusbot/handlers/command.py
@@ -91,7 +91,7 @@ async def custom_response(self, _packet, command, *args, **data):
         json = await response.json()
         is_alias = False
 
-        if json["data"].get("type") == "aliases":
+        if json["data"].get("type") == "alias":
 
             is_alias = True
 

From 041c27068318324096965e6843ad920074d20781 Mon Sep 17 00:00:00 2001
From: RPiAwesomeness <keyboardmailtesting@gmail.com>
Date: Tue, 21 Mar 2017 20:12:58 -0400
Subject: [PATCH 014/122] Aliases are returning a response now

---
 cactusbot/handlers/command.py | 11 +++++++++--
 1 file changed, 9 insertions(+), 2 deletions(-)

diff --git a/cactusbot/handlers/command.py b/cactusbot/handlers/command.py
index 2701b61..8f4f2c3 100644
--- a/cactusbot/handlers/command.py
+++ b/cactusbot/handlers/command.py
@@ -101,13 +101,20 @@ async def custom_response(self, _packet, command, *args, **data):
                 args = (args[0], *tuple(MessagePacket(
                     *json["data"]["attributes"]["arguments"]
                 ).text.split()), *args[1:])
+        cmd = await self.api.get_command(name=command)
+        if cmd.status != 200:
+            return MessagePacket("Command does not exist for that alias",
+                                 target=_packet.user)
+        cmd_response = await cmd.json()
+        cmd_response = cmd_response["data"]["attributes"]["response"]
+        json["data"]["attributes"]["response"] = cmd_response
 
         json = json["data"]["attributes"]
 
         if not json.get("enabled", True):
             return MessagePacket("Command is disabled.", target=_packet.user)
 
-        if not is_alias and _packet.role < json["response"]["role"]:
+        if _packet.role < json["response"]["role"]:
             return MessagePacket(
                 "Role level '{role}' or higher required.".format(
                     role=ROLES[max(k for k in ROLES.keys()
@@ -119,7 +126,7 @@ async def custom_response(self, _packet, command, *args, **data):
             json["response"]["target"] = _packet.user
 
         await self.api.update_command_count(command, "+1")
-        if "count" not in data:
+        if not is_alias and "count" not in data:
             data["count"] = str(json["count"] + 1)
 
         return self._inject(MessagePacket.from_json(json["response"]),

From 767642e20b37772d53f0728e867d933cf2984d7b Mon Sep 17 00:00:00 2001
From: RPiAwesomeness <keyboardmailtesting@gmail.com>
Date: Tue, 21 Mar 2017 20:13:59 -0400
Subject: [PATCH 015/122] Fix !alias list

---
 cactusbot/commands/magic/alias.py | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/cactusbot/commands/magic/alias.py b/cactusbot/commands/magic/alias.py
index 6e11fb0..65056dc 100644
--- a/cactusbot/commands/magic/alias.py
+++ b/cactusbot/commands/magic/alias.py
@@ -53,6 +53,6 @@ async def list_aliases(self):
                     command["attributes"]["name"],
                     command["attributes"]["commandName"])
                 for command in commands
-                if command.get("type") == "aliases"
+                if command.get("type") == "alias"
             )))
         return "No aliases added!"

From 305001bf5b041042f129342659ddbbefaab510bc Mon Sep 17 00:00:00 2001
From: RPiAwesomeness <keyboardmailtesting@gmail.com>
Date: Tue, 21 Mar 2017 20:17:06 -0400
Subject: [PATCH 016/122] Make the !alias list command be a little less silly

---
 cactusbot/commands/magic/alias.py | 7 ++++---
 1 file changed, 4 insertions(+), 3 deletions(-)

diff --git a/cactusbot/commands/magic/alias.py b/cactusbot/commands/magic/alias.py
index 65056dc..fc6cb43 100644
--- a/cactusbot/commands/magic/alias.py
+++ b/cactusbot/commands/magic/alias.py
@@ -48,11 +48,12 @@ async def list_aliases(self):
 
         if response.status == 200:
             commands = (await response.json())["data"]
-            return "Aliases: {}.".format(', '.join(sorted(
+            aliases = [cmd for cmd in commands if cmd.get("type") == "alias"]
+            response = "Aliases: {}".format(', '.join(sorted(
                 "{} ({})".format(
                     command["attributes"]["name"],
                     command["attributes"]["commandName"])
-                for command in commands
-                if command.get("type") == "alias"
+                for command in aliases
             )))
+            return response if len(aliases) > 0 else "No aliases added!"
         return "No aliases added!"

From a9bc82049a75bde858f799e6794099df8d95742d Mon Sep 17 00:00:00 2001
From: RPiAwesomeness <keyboardmailtesting@gmail.com>
Date: Tue, 21 Mar 2017 20:26:42 -0400
Subject: [PATCH 017/122] Repeats are now 73% more accepting to change

---
 cactusbot/commands/magic/repeat.py | 4 ++++
 1 file changed, 4 insertions(+)

diff --git a/cactusbot/commands/magic/repeat.py b/cactusbot/commands/magic/repeat.py
index 864dcf6..79ff3f4 100644
--- a/cactusbot/commands/magic/repeat.py
+++ b/cactusbot/commands/magic/repeat.py
@@ -17,6 +17,10 @@ async def add(self, period: r"[1-9]\d*", command: "?command"):
         if response.status == 201:
             return "Repeat !{command} added on interval {period}.".format(
                 command=command, period=period)
+        elif response.status == 200:
+            return "Repeat !{command} updated with interval {period}".format(
+                command=command, period=period
+            )
         elif response.status == 409:
             return "Repeat already exists!"
         else:

From 13b0d0d89674b270fc41bd1bf836c030f02cbdd9 Mon Sep 17 00:00:00 2001
From: RPiAwesomeness <keyboardmailtesting@gmail.com>
Date: Tue, 21 Mar 2017 20:29:48 -0400
Subject: [PATCH 018/122] Make it return a proper error for low periods

---
 cactusbot/commands/magic/repeat.py | 6 ++++--
 1 file changed, 4 insertions(+), 2 deletions(-)

diff --git a/cactusbot/commands/magic/repeat.py b/cactusbot/commands/magic/repeat.py
index 79ff3f4..2ff3181 100644
--- a/cactusbot/commands/magic/repeat.py
+++ b/cactusbot/commands/magic/repeat.py
@@ -21,8 +21,10 @@ async def add(self, period: r"[1-9]\d*", command: "?command"):
             return "Repeat !{command} updated with interval {period}".format(
                 command=command, period=period
             )
-        elif response.status == 409:
-            return "Repeat already exists!"
+        elif response.status == 400:
+            json = await response.json()
+            if len(json["errors"].get("period", [])) > 0:
+                return json["errors"].get("period")[0]
         else:
             return "An error occured."
 

From 82019f3f8df00f96a4b0c88d8f7204b6827e2d61 Mon Sep 17 00:00:00 2001
From: RPiAwesomeness <keyboardmailtesting@gmail.com>
Date: Tue, 21 Mar 2017 20:53:10 -0400
Subject: [PATCH 019/122] Make the quote ID error less dumb looking

---
 cactusbot/commands/magic/quote.py | 8 ++++----
 1 file changed, 4 insertions(+), 4 deletions(-)

diff --git a/cactusbot/commands/magic/quote.py b/cactusbot/commands/magic/quote.py
index efa0058..f7ba205 100644
--- a/cactusbot/commands/magic/quote.py
+++ b/cactusbot/commands/magic/quote.py
@@ -12,18 +12,18 @@ class Quote(Command):
     COMMAND = "quote"
 
     @Command.command(hidden=True)
-    async def default(self, quote_id: r'[1-9]\d*'=None):
+    async def default(self, quote: r'[1-9]\d*'=None):
         """Get a quote based on ID. If no ID is provided, pick a random one."""
 
-        if quote_id is None:
+        if quote is None:
             response = await self.api.get_quote()
             if response.status == 404:
                 return "No quotes have been added!"
             return (await response.json())["data"][0]["attributes"]["quote"]
         else:
-            response = await self.api.get_quote(quote_id)
+            response = await self.api.get_quote(quote)
             if response.status == 404:
-                return "Quote {} does not exist!".format(quote_id)
+                return "Quote {} does not exist!".format(quote)
             return (await response.json())["data"]["attributes"]["quote"]
 
     @Command.command(role="moderator")

From 7b89f3301a425e67ee4f8505ae330c58d213734d Mon Sep 17 00:00:00 2001
From: RPiAwesomeness <keyboardmailtesting@gmail.com>
Date: Tue, 21 Mar 2017 21:24:20 -0400
Subject: [PATCH 020/122] Social command boi

---
 cactusbot/commands/magic/social.py | 5 +++++
 1 file changed, 5 insertions(+)

diff --git a/cactusbot/commands/magic/social.py b/cactusbot/commands/magic/social.py
index 99f54e2..6ca8542 100644
--- a/cactusbot/commands/magic/social.py
+++ b/cactusbot/commands/magic/social.py
@@ -56,6 +56,11 @@ async def add(self, service, url):
             return "Added social service {}.".format(service)
         elif response.status == 200:
             return "Updated social service {}".format(service)
+        elif response.status == 400:
+            json = await response.json()
+            if len(json["errors"].get("quote", {}).get("url", [])) > 0:
+                # TODO: Add detection/hard-coded errors
+                return json["errors"]["quote"]["url"][0]
 
     @Command.command()
     async def remove(self, service):

From 1ffcc9e7454dac3af2e8f5b64b00bc23ae0eea76 Mon Sep 17 00:00:00 2001
From: Innectic <innectic@gmail.com>
Date: Wed, 22 Mar 2017 10:02:57 -0700
Subject: [PATCH 021/122] Fix repeat parsing

---
 cactusbot/sepal.py | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/cactusbot/sepal.py b/cactusbot/sepal.py
index 22fb6ad..9b40f9c 100644
--- a/cactusbot/sepal.py
+++ b/cactusbot/sepal.py
@@ -80,8 +80,8 @@ class SepalParser:
     async def parse_repeat(self, packet):
         """Parse the incoming repeat packets."""
 
-        if "response" in packet["data"]:
-            return MessagePacket.from_json(packet["data"]["response"])
+        if "message" in packet["data"]:
+            return MessagePacket.from_json(packet["data"])
 
     async def parse_config(self, packet):
         """Parse the incoming config packets."""

From cce0e1dbacbef8c7b609fa775b85f4b67a0f6fc4 Mon Sep 17 00:00:00 2001
From: Innectic <innectic@gmail.com>
Date: Wed, 22 Mar 2017 17:46:21 -0700
Subject: [PATCH 022/122] Configurable Sepal url

---
 cactusbot/cactus.py | 4 ++--
 cactusbot/sepal.py  | 6 ++++--
 config.template.py  | 1 +
 run.py              | 4 ++--
 4 files changed, 9 insertions(+), 6 deletions(-)

diff --git a/cactusbot/cactus.py b/cactusbot/cactus.py
index dca443a..25b5ee7 100644
--- a/cactusbot/cactus.py
+++ b/cactusbot/cactus.py
@@ -30,7 +30,7 @@
 """.format(version=__version__)
 
 
-async def run(api, service, *auth):
+async def run(api, service, url, *auth):
     """Run bot."""
 
     logger = logging.getLogger(__name__)
@@ -38,7 +38,7 @@ async def run(api, service, *auth):
 
     await api.login(*api.SCOPES)
 
-    sepal = Sepal(api.token, service)
+    sepal = Sepal(api.token, service, url)
 
     try:
         await sepal.connect()
diff --git a/cactusbot/sepal.py b/cactusbot/sepal.py
index 9b40f9c..ca93a3e 100644
--- a/cactusbot/sepal.py
+++ b/cactusbot/sepal.py
@@ -10,8 +10,10 @@
 class Sepal(WebSocket):
     """Interact with Sepal."""
 
-    def __init__(self, channel, service=None):
-        super().__init__("wss://cactus.exoz.one/sepal")
+    URL = "wss://cactus.exoz.one/sepal"
+
+    def __init__(self, channel, url=URL, service=None):
+        super().__init__(self.URL)
 
         self.logger = logging.getLogger(__name__)
 
diff --git a/config.template.py b/config.template.py
index 2403270..48c7992 100644
--- a/config.template.py
+++ b/config.template.py
@@ -12,6 +12,7 @@
 API_TOKEN = "CactusAPI_Token"
 API_PASSWORD = "CactusAPI_Password"
 API_URL = "https://cactus.exoz.one/api/v1/"
+SEPAL_URL = "wss://cactus.exoz.one/sepal"
 api = CactusAPI(API_TOKEN, API_PASSWORD, url=API_URL)
 
 # CACHE_FOLLOWS: Cache to remove chat spam (Default: True)
diff --git a/run.py b/run.py
index f9234af..9047682 100755
--- a/run.py
+++ b/run.py
@@ -7,7 +7,7 @@
 from asyncio import get_event_loop
 
 from cactusbot.cactus import run
-from config import SERVICE, api
+from config import SERVICE, SEPAL_URL, api
 
 if __name__ == "__main__":
 
@@ -35,7 +35,7 @@
 
     try:
         # TODO: Convert this to be able to have multiple services
-        loop.run_until_complete(run(api, SERVICE))
+        loop.run_until_complete(run(api, SERVICE, SEPAL_URL))
         loop.run_forever()
     finally:
         loop.close()

From cba62d159261f3470a6dbd09809b1f05b542ff1b Mon Sep 17 00:00:00 2001
From: Innectic <innectic@gmail.com>
Date: Wed, 22 Mar 2017 20:18:43 -0700
Subject: [PATCH 023/122] Fix aiohttp version

---
 requirements.txt | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/requirements.txt b/requirements.txt
index 855f04e..38c3812 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1 +1 @@
-aiohttp>=1.2.0
+aiohttp==1.2.0

From 510f61d910c1a4365637845fee866c11e8760b59 Mon Sep 17 00:00:00 2001
From: RPiAwesomeness <keyboardmailtesting@gmail.com>
Date: Sat, 25 Mar 2017 15:59:53 -0400
Subject: [PATCH 024/122] Add support for 400 errors for aliases

---
 cactusbot/commands/magic/alias.py | 7 ++++++-
 1 file changed, 6 insertions(+), 1 deletion(-)

diff --git a/cactusbot/commands/magic/alias.py b/cactusbot/commands/magic/alias.py
index fc6cb43..77396cf 100644
--- a/cactusbot/commands/magic/alias.py
+++ b/cactusbot/commands/magic/alias.py
@@ -1,4 +1,5 @@
-"""Alias command."""
+elif response.status == 400:
+    return "Command already exists with the requested alias name""""Alias command."""
 
 from . import Command
 from ...packets import MessagePacket
@@ -30,6 +31,10 @@ async def add(self, alias: "?command", command: "?command", *_: False,
             return "Alias !{} for command !{} updated.".format(alias, command)
         elif response.status == 404:
             return "Command !{} does not exist.".format(command)
+        elif response.status == 400:
+            json = await response.json()
+            if len(json.get("errors", [])) > 0:
+                return json["errors"][0]
 
     @Command.command(role="moderator")
     async def remove(self, alias: "?command"):

From 1cb2674595a32c3fb45a6b9e6e134c25189755a2 Mon Sep 17 00:00:00 2001
From: RPiAwesomeness <keyboardmailtesting@gmail.com>
Date: Sat, 25 Mar 2017 16:01:53 -0400
Subject: [PATCH 025/122] Well that was odd

---
 cactusbot/commands/magic/alias.py | 3 +--
 1 file changed, 1 insertion(+), 2 deletions(-)

diff --git a/cactusbot/commands/magic/alias.py b/cactusbot/commands/magic/alias.py
index 77396cf..b24afbb 100644
--- a/cactusbot/commands/magic/alias.py
+++ b/cactusbot/commands/magic/alias.py
@@ -1,5 +1,4 @@
-elif response.status == 400:
-    return "Command already exists with the requested alias name""""Alias command."""
+"""Alias command."""
 
 from . import Command
 from ...packets import MessagePacket

From dc9777d62055ab3c20e7ee7886f0f4ec045894f1 Mon Sep 17 00:00:00 2001
From: RPiAwesomeness <keyboardmailtesting@gmail.com>
Date: Sat, 25 Mar 2017 16:13:34 -0400
Subject: [PATCH 026/122] Changed TODO to NOTE, not actually a TODO

---
 cactusbot/commands/magic/social.py | 3 ++-
 1 file changed, 2 insertions(+), 1 deletion(-)

diff --git a/cactusbot/commands/magic/social.py b/cactusbot/commands/magic/social.py
index 6ca8542..3684eda 100644
--- a/cactusbot/commands/magic/social.py
+++ b/cactusbot/commands/magic/social.py
@@ -59,7 +59,8 @@ async def add(self, service, url):
         elif response.status == 400:
             json = await response.json()
             if len(json["errors"].get("quote", {}).get("url", [])) > 0:
-                # TODO: Add detection/hard-coded errors
+                # NOTE: Add detection/hard-coded errors if more errors are
+                #       added in the future
                 return json["errors"]["quote"]["url"][0]
 
     @Command.command()

From 828937d9ab038c7cab7bb56093d67337567610eb Mon Sep 17 00:00:00 2001
From: RPiAwesomeness <keyboardmailtesting@gmail.com>
Date: Sat, 25 Mar 2017 16:28:35 -0400
Subject: [PATCH 027/122] =?UTF-8?q?Maybe=20make=20travis=20happy=3F=20?=
 =?UTF-8?q?=C2=AF\=5F(=E3=83=84)=5F/=C2=AF?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 cactusbot/commands/magic/alias.py | 3 ++-
 1 file changed, 2 insertions(+), 1 deletion(-)

diff --git a/cactusbot/commands/magic/alias.py b/cactusbot/commands/magic/alias.py
index b24afbb..d7442e5 100644
--- a/cactusbot/commands/magic/alias.py
+++ b/cactusbot/commands/magic/alias.py
@@ -59,5 +59,6 @@ async def list_aliases(self):
                     command["attributes"]["commandName"])
                 for command in aliases
             )))
-            return response if len(aliases) > 0 else "No aliases added!"
+            if len(aliases) > 0:
+                return response
         return "No aliases added!"

From 710f0c787be9d1d9495992273450afe73a53b91a Mon Sep 17 00:00:00 2001
From: RPiAwesomeness <keyboardmailtesting@gmail.com>
Date: Sat, 25 Mar 2017 16:32:27 -0400
Subject: [PATCH 028/122] We did a dumb. Code is now 42% less dumb

---
 tests/commands/test_alias.py | 9 +++++++--
 1 file changed, 7 insertions(+), 2 deletions(-)

diff --git a/tests/commands/test_alias.py b/tests/commands/test_alias.py
index da8f7bc..4cd30ee 100644
--- a/tests/commands/test_alias.py
+++ b/tests/commands/test_alias.py
@@ -197,12 +197,14 @@ async def json(self):
 
 alias = Alias(MockAPI())
 
+
 @pytest.mark.asyncio
 async def test_create_alias():
     """Create an alias."""
     assert (await alias("add", "test", "testing", packet=MessagePacket(
         "!alias add test testing", role=5))
-           ) == "Alias !test for command !testing updated."
+    ) == "Alias !test for command !testing updated."
+
 
 @pytest.mark.asyncio
 async def test_remove_alias():
@@ -210,8 +212,11 @@ async def test_remove_alias():
     assert (await alias("remove", "test", packet=MessagePacket(
         "!alias remove test", role=5))) == "Alias !test removed."
 
+
 @pytest.mark.asyncio
 async def test_list_alias():
-    """Lis aliases."""
+    """List aliases."""
+    await alias("add", "test", "testing", packet=MessagePacket(
+        "!alias add test testing", role=5))
     assert (await alias("list", packet=MessagePacket(
         "!alias list", role=5))) == "Aliases: test (testing)."

From 04245c7211cf92c1918861e83f1d12d040813667 Mon Sep 17 00:00:00 2001
From: RPiAwesomeness <keyboardmailtesting@gmail.com>
Date: Sat, 25 Mar 2017 16:50:20 -0400
Subject: [PATCH 029/122] =?UTF-8?q?=C2=AF\=5F(=E3=83=84)=5F/=C2=AF?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 tests/commands/test_alias.py | 3 ++-
 1 file changed, 2 insertions(+), 1 deletion(-)

diff --git a/tests/commands/test_alias.py b/tests/commands/test_alias.py
index 4cd30ee..def2242 100644
--- a/tests/commands/test_alias.py
+++ b/tests/commands/test_alias.py
@@ -216,7 +216,8 @@ async def test_remove_alias():
 @pytest.mark.asyncio
 async def test_list_alias():
     """List aliases."""
-    await alias("add", "test", "testing", packet=MessagePacket(
+    assert(alias("add", "test", "testing", packet=MessagePacket(
         "!alias add test testing", role=5))
+    ) == "Alias !test for command !testing updated."
     assert (await alias("list", packet=MessagePacket(
         "!alias list", role=5))) == "Aliases: test (testing)."

From 390de5c2b55cdba54109bfc884a8824c3b74aef7 Mon Sep 17 00:00:00 2001
From: RPiAwesomeness <keyboardmailtesting@gmail.com>
Date: Sat, 25 Mar 2017 16:55:58 -0400
Subject: [PATCH 030/122] lol, who knows. This is weird

---
 tests/commands/test_alias.py | 17 +++++++----------
 1 file changed, 7 insertions(+), 10 deletions(-)

diff --git a/tests/commands/test_alias.py b/tests/commands/test_alias.py
index def2242..8cdbded 100644
--- a/tests/commands/test_alias.py
+++ b/tests/commands/test_alias.py
@@ -206,18 +206,15 @@ async def test_create_alias():
     ) == "Alias !test for command !testing updated."
 
 
-@pytest.mark.asyncio
-async def test_remove_alias():
-    """Remove an alias."""
-    assert (await alias("remove", "test", packet=MessagePacket(
-        "!alias remove test", role=5))) == "Alias !test removed."
-
-
 @pytest.mark.asyncio
 async def test_list_alias():
     """List aliases."""
-    assert(alias("add", "test", "testing", packet=MessagePacket(
-        "!alias add test testing", role=5))
-    ) == "Alias !test for command !testing updated."
     assert (await alias("list", packet=MessagePacket(
         "!alias list", role=5))) == "Aliases: test (testing)."
+
+
+@pytest.mark.asyncio
+async def test_remove_alias():
+    """Remove an alias."""
+    assert (await alias("remove", "test", packet=MessagePacket(
+        "!alias remove test", role=5))) == "Alias !test removed."

From 8f48eaf01b70614f386dbd8cf9b389e3772b484e Mon Sep 17 00:00:00 2001
From: RPiAwesomeness <keyboardmailtesting@gmail.com>
Date: Sat, 25 Mar 2017 17:01:06 -0400
Subject: [PATCH 031/122] Maybe this thing?

---
 tests/commands/test_alias.py | 6 +++---
 1 file changed, 3 insertions(+), 3 deletions(-)

diff --git a/tests/commands/test_alias.py b/tests/commands/test_alias.py
index 8cdbded..df161c5 100644
--- a/tests/commands/test_alias.py
+++ b/tests/commands/test_alias.py
@@ -51,7 +51,7 @@ def json(self):
                             "token": "Stanley"
                         },
                         "id": "312ab175-fb52-4a7b-865d-4202176f9234",
-                        "type": "aliases"
+                        "type": "alias"
                     }
                 }
         return Response()
@@ -97,7 +97,7 @@ def json(self):
                             "token": "Stanley"
                         },
                         "id": "312ab175-fb52-4a7b-865d-4202176f9234",
-                        "type": "aliases"
+                        "type": "alias"
                     },
                     "meta": {
                         "edited": True
@@ -188,7 +188,7 @@ async def json(self):
                                 "token": "Stanley"
                             },
                             "id": "312ab175-fb52-4a7b-865d-4202176f9234",
-                            "type": "aliases"
+                            "type": "alias"
                         }
                     ]
                 }

From ce198388a379d0a7176ee2c58c5bccc0b2e46d13 Mon Sep 17 00:00:00 2001
From: RPiAwesomeness <keyboardmailtesting@gmail.com>
Date: Sat, 25 Mar 2017 17:02:53 -0400
Subject: [PATCH 032/122] Removed period

---
 tests/commands/test_alias.py | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/tests/commands/test_alias.py b/tests/commands/test_alias.py
index df161c5..69c051d 100644
--- a/tests/commands/test_alias.py
+++ b/tests/commands/test_alias.py
@@ -210,7 +210,7 @@ async def test_create_alias():
 async def test_list_alias():
     """List aliases."""
     assert (await alias("list", packet=MessagePacket(
-        "!alias list", role=5))) == "Aliases: test (testing)."
+        "!alias list", role=5))) == "Aliases: test (testing)"
 
 
 @pytest.mark.asyncio

From 551fdf1e6a12a8de21c8b1cf1f310c03b10d783e Mon Sep 17 00:00:00 2001
From: Innectic <innectic@gmail.com>
Date: Sat, 25 Mar 2017 21:34:51 -0700
Subject: [PATCH 033/122] Start on new config command

---
 cactusbot/commands/magic/config.py | 281 +++++++++++++++++++++--------
 1 file changed, 203 insertions(+), 78 deletions(-)

diff --git a/cactusbot/commands/magic/config.py b/cactusbot/commands/magic/config.py
index 9ccd93d..2a70f81 100644
--- a/cactusbot/commands/magic/config.py
+++ b/cactusbot/commands/magic/config.py
@@ -6,7 +6,9 @@
 VALID_TOGGLE_OFF_STATES = ("off", "disallow", "disable", "false")
 
 
-async def _update_config(api, scope, field, section, value):
+async def _update_deep_config(api, scope, field, section, value):
+    """Update a deep section of the config."""
+
     return await api.update_config({
         scope: {
             field: {
@@ -16,7 +18,9 @@ async def _update_config(api, scope, field, section, value):
     })
 
 
-async def _update_spam_config(api, scope, field, value):
+async def _update_config(api, scope, field, value):
+    """Update a config value."""
+
     return await api.update_config({
         scope: {
             field: value
@@ -24,123 +28,244 @@ async def _update_spam_config(api, scope, field, value):
     })
 
 
+async def _get_event_data(api, event):
+    """Get data about an event."""
+
+    data = (await (await api.get_config()).json())["data"]
+    event = data["attributes"]["announce"][event]
+    return event
+
+
 class Config(Command):
-    """Config command"""
+    """Config Command."""
 
     COMMAND = "config"
 
     @Command.command(role="moderator")
-    class Announce(Command):
-        """Announce sub command."""
+    class Follow(Command):
+        """Follow subcommand."""
 
-        @Command.command()
-        async def follow(self, value):
-            """Follow subcommand."""
+        @Command.command(role="moderator", name="follow")
+        async def default(self, *value: False):
+            """Get status, and message of the follow event, or toggle."""
+
+            value = ' '.join(value)
+
+            if not value:
+                data = await _get_event_data(self.api, "follow")
+                return "{dis}abled, message: `{message}`".format(
+                    dis='En' if data["announce"] else 'Dis', message=data["message"])
 
             if value in VALID_TOGGLE_ON_STATES:
-                await _update_config(
-                    self.api, "announce", "follow", "announce", True)
-                return "Follow announcements are enabled."
+                await _update_deep_config(self.api, "announce", "follow", "announce", True)
+                return "Follow announcements are now enabled."
             elif value in VALID_TOGGLE_OFF_STATES:
-                await _update_config(
-                    self.api, "announce", "follow", "announce", False)
-                return "Follow announcements are disabled."
+                await _update_deep_config(self.api, "announce", "follow", "announce", False)
+                return "Follow announcements are now disabled."
             else:
-                return "Invalid boolean value: '{value}'".format(value=value)
+                return "Invalid boolean value: `{}`!".format(value)
 
-        @Command.command()
-        async def subscribe(self, value):
-            """Subscribe subcommand."""
+        @Command.command(role="moderator")
+        async def message(self, *message: False):
+            """Set the follow message."""
+
+            if not message:
+                data = (await (await self.api.get_config()).json())["data"]
+                message = data["attributes"]["announce"]["follow"]["message"]
+                return "Current response: `{}`".format(message)
+
+            await _update_deep_config(self.api, "announce", "follow", "message", ' '.join(message))
+            return "Set new follow message response."
+
+    @Command.command(role="moderator")
+    class Subscribe(Command):
+        """Subcommand subcommand."""
+
+        @Command.command(role="moderator", name="subscribe")
+        async def default(self, *value: False):
+            """Get status, and message of the subscribe event, or toggle."""
+
+            value = ' '.join(value)
+
+            if not value:
+                data = await _get_event_data(self.api, "subscribe")
+                return "{dis}abled, message: `{message}`".format(
+                    dis='En' if data["announce"] else 'Dis', message=data["message"])
 
             if value in VALID_TOGGLE_ON_STATES:
-                await _update_config(
-                    self.api, "announce", "subscribe", "announce", True)
-                return "Subscribe announcements are enabled."
+                await _update_deep_config(self.api, "announce", "subscribe", "announce", True)
+                return "Subscribe announcements are now enabled."
             elif value in VALID_TOGGLE_OFF_STATES:
-                await _update_config(
-                    self.api, "announce", "subscribe", "announce", False)
-                return "Subscribe announcements are disabled."
+                await _update_deep_config(self.api, "announce", "subscribe", "announce", False)
+                return "Subscribe announcements are now disabled."
             else:
-                return "Invalid boolean value: '{value}'".format(value=value)
+                return "Invalid boolean value: `{}`!".format(value)
 
-        @Command.command()
-        async def host(self, value):
-            """Host subcommand."""
+        @Command.command(role="moderator")
+        async def message(self, *message: False):
+            """Set the subscribe message."""
+
+            if not message:
+                data = (await (await self.api.get_config()).json())["data"]
+                message = data["attributes"]["announce"]["subscribe"]["message"]
+                return "Current response: `{}`".format(message)
+
+            await _update_deep_config(
+                self.api, "announce", "subscribe", "message", ' '.join(message))
+            return "Set new subscribe message response."
+
+    @Command.command(role="moderator")
+    class Host(Command):
+        """Host subcommand."""
+
+        @Command.command(role="moderator", name="host")
+        async def default(self, *value: False):
+            """Get status, and message of the host event, or toggle."""
+
+            value = ' '.join(value)
+
+            if not value:
+                data = await _get_event_data(self.api, "host")
+                return "{dis}abled, message: `{message}`".format(
+                    dis='En' if data["announce"] else 'Dis', message=data["message"])
 
             if value in VALID_TOGGLE_ON_STATES:
-                await _update_config(
-                    self.api, "announce", "host", "announce", True)
-                return "Host announcements are enabled."
+                await _update_deep_config(self.api, "announce", "host", "announce", True)
+                return "Host announcements are now enabled."
             elif value in VALID_TOGGLE_OFF_STATES:
-                await _update_config(
-                    self.api, "announce", "host", "announce", False)
-                return "Host announcements are disabled."
+                await _update_deep_config(self.api, "announce", "host", "announce", False)
+                return "Host announcements are now disabled."
             else:
-                return "Invalid boolean value: '{value}'".format(value=value)
+                return "Invalid boolean value: `{}`!".format(value)
 
-        @Command.command()
-        async def leave(self, value):
-            """Leave subcommand."""
+        @Command.command(role="moderator")
+        async def message(self, *message: False):
+            """Set the host message."""
+
+            if not message:
+                data = (await (await self.api.get_config()).json())["data"]
+                message = data["attributes"]["announce"]["host"]["message"]
+                return "Current response: `{}`".format(message)
+
+            await _update_deep_config(self.api, "announce", "host", "message", ' '.join(message))
+            return "Set new host message response."
+
+    @Command.command(role="moderator")
+    class Leave(Command):
+        """Leave subcommand."""
+
+        @Command.command(role="moderator", name="leave")
+        async def default(self, *value: False):
+            """Get status, and message of the leave event, or toggle."""
+
+            value = ' '.join(value)
+
+            if not value:
+                data = await _get_event_data(self.api, "leave")
+                return "{dis}abled, message: `{message}`".format(
+                    dis='En' if data["announce"] else 'Dis', message=data["message"])
 
             if value in VALID_TOGGLE_ON_STATES:
-                await _update_config(
-                    self.api, "announce", "leave", "announce", True)
-                return "Leave announcements are enabled."
+                await _update_deep_config(self.api, "announce", "leave", "announce", True)
+                return "Leave announcements are now enabled."
             elif value in VALID_TOGGLE_OFF_STATES:
-                await _update_config(
-                    self.api, "announce", "leave", "announce", False)
-                return "Leave announcements are disabled."
+                await _update_deep_config(self.api, "announce", "leave", "announce", False)
+                return "Leave announcements are now disabled."
+            else:
+                return "Invalid boolean value: `{}`!".format(value)
 
-        @Command.command()
-        async def join(self, value):
-            """Join subcommand."""
+        @Command.command(role="moderator")
+        async def message(self, *message: False):
+            """Set the leave message."""
+
+            if not message:
+                data = (await (await self.api.get_config()).json())["data"]
+                message = data["attributes"]["announce"]["leave"]["message"]
+                return "Current response: `{}`".format(message)
+
+            await _update_deep_config(self.api, "announce", "leave", "message", ' '.join(message))
+            return "Set new leave message response."
+
+    @Command.command(role="moderator")
+    class Join(Command):
+        """Join subcommand."""
+
+        @Command.command(role="moderator", name="join")
+        async def default(self, *value: False):
+            """Get status, and message of the join event, or toggle."""
+
+            value = ' '.join(value)
+
+            if not value:
+                data = await _get_event_data(self.api, "join")
+                return "{dis}abled, message: `{message}`".format(
+                    dis='En' if data["announce"] else 'Dis', message=data["message"])
 
             if value in VALID_TOGGLE_ON_STATES:
-                await _update_config(
-                    self.api, "announce", "join", "announce", True)
-                return "Join announcements are enabled."
+                await _update_deep_config(self.api, "announce", "join", "announce", True)
+                return "Join announcements are now enabled."
             elif value in VALID_TOGGLE_OFF_STATES:
-                await _update_config(
-                    self.api, "announce", "join", "announce", False)
-                return "Join announcements are disabled."
+                await _update_deep_config(self.api, "announce", "join", "announce", False)
+                return "Join announcements are now disabled."
+            else:
+                return "Invalid boolean value: `{}`!".format(value)
+
+        @Command.command(role="moderator")
+        async def message(self, *message: False):
+            """Set the join message."""
+
+            if not message:
+                data = (await (await self.api.get_config()).json())["data"]
+                message = data["attributes"]["announce"]["join"]["message"]
+                return "Current response: `{}`".format(message)
+
+            await _update_deep_config(self.api, "announce", "join", "message", ' '.join(message))
+            return "Set new join message response."
 
     @Command.command(role="moderator")
     class Spam(Command):
         """Spam subcommand."""
 
         @Command.command()
-        async def urls(self, value):
+        class Urls(Command):
             """Urls subcommand."""
 
-            if value in VALID_TOGGLE_ON_STATES:
-                await _update_spam_config(
-                    self.api, "spam", "allowUrls", True)
-                return "URLs are now allowed."
+            @Command.command(name="urls")
+            async def default(self, *value: False):
+                """Amount subcommand."""
 
-            elif value in VALID_TOGGLE_OFF_STATES:
-                await _update_spam_config(
-                    self.api, "spam", "allowUrls", False)
-                return "URLs are now disallowed."
+                if not value:
+                    return ""
 
-            else:
-                return "Invalid boolean value: '{value}'.".format(value=value)
+                values = ' '.join(value)
 
-        @Command.command()
-        async def emoji(self, value: r"\d+"):
-            """Emoji subcommand."""
+                if value in VALID_TOGGLE_ON_STATES:
+                    await _update_config(
+                        self.api, "spam", "allowUrls", True)
+                    return "URLs are now allowed."
+                elif value in VALID_TOGGLE_OFF_STATES:
+                    await _update_config(
+                        self.api, "spam", "allowUrls", False)
+                    return "URLs are now disallowed."
+                else:
+                    return "Invalid boolean value: '{value}'.".format(value=value)
 
-            await _update_spam_config(
-                self.api, "spam", "maxEmoji", int(value))
+        # @Command.command()
+        # async def amount(self, value: r"\d+"):
+        #     """Emoji subcommand."""
 
-            return "Maximum number of emoji is now {value}.".format(
-                value=value)
+        #     await _update_config(
+        #         self.api, "spam", "maxEmoji", int(value))
 
-        @Command.command()
-        async def caps(self, value: r"\d+"):
-            """Caps subcommand."""
+        #     return "Maximum number of emoji is now {value}.".format(
+        #         value=value)
+
+        # @Command.command()
+        # async def amount(self, value: r"\d+"):
+        #     """Caps subcommand."""
 
-            await _update_spam_config(
-                self.api, "spam", "maxCapsScore", int(value))
+        #     await _update_config(
+        #         self.api, "spam", "maxCapsScore", int(value))
 
-            return "Maximum capitals score is now {value}.".format(
-                value=value)
+        #     return "Maximum capitals score is now {value}.".format(
+        #         value=value)

From f6b1238f24311e08d940155b52c81e528488c1a9 Mon Sep 17 00:00:00 2001
From: Innectic <innectic@gmail.com>
Date: Sat, 25 Mar 2017 21:55:29 -0700
Subject: [PATCH 034/122] Fix alias count

---
 cactusbot/handlers/command.py | 5 +++++
 1 file changed, 5 insertions(+)

diff --git a/cactusbot/handlers/command.py b/cactusbot/handlers/command.py
index 8f4f2c3..ffd3b39 100644
--- a/cactusbot/handlers/command.py
+++ b/cactusbot/handlers/command.py
@@ -128,6 +128,11 @@ async def custom_response(self, _packet, command, *args, **data):
         await self.api.update_command_count(command, "+1")
         if not is_alias and "count" not in data:
             data["count"] = str(json["count"] + 1)
+        elif is_alias:
+            command_data = (await (await self.api.get_command(
+                name=command)).json())["data"]["attributes"]
+
+            data["count"] = str(command_data["count"])
 
         return self._inject(MessagePacket.from_json(json["response"]),
                             *args, **data)

From 7a0e2414d15a71a94de1de7affc6b92a6029ea05 Mon Sep 17 00:00:00 2001
From: Innectic <innectic@gmail.com>
Date: Sat, 25 Mar 2017 22:03:58 -0700
Subject: [PATCH 035/122] Add response status check

---
 cactusbot/handlers/command.py | 11 +++++++----
 1 file changed, 7 insertions(+), 4 deletions(-)

diff --git a/cactusbot/handlers/command.py b/cactusbot/handlers/command.py
index ffd3b39..72a6d54 100644
--- a/cactusbot/handlers/command.py
+++ b/cactusbot/handlers/command.py
@@ -129,10 +129,13 @@ async def custom_response(self, _packet, command, *args, **data):
         if not is_alias and "count" not in data:
             data["count"] = str(json["count"] + 1)
         elif is_alias:
-            command_data = (await (await self.api.get_command(
-                name=command)).json())["data"]["attributes"]
-
-            data["count"] = str(command_data["count"])
+            response = await self.api.get_command(
+                name=command)
+            if response.status == 200:
+                data = (await (response.json()))["data"]["attributes"]
+                data["count"] = str(data["count"])
+            else:
+                return MessagePacket("An error has occured.")
 
         return self._inject(MessagePacket.from_json(json["response"]),
                             *args, **data)

From 740c310f7fabb3e317bb4c18efa808eb79dada5e Mon Sep 17 00:00:00 2001
From: Innectic <innectic@gmail.com>
Date: Sat, 25 Mar 2017 22:15:34 -0700
Subject: [PATCH 036/122] Fix username getting in alias

---
 cactusbot/handlers/command.py | 9 ++++++++-
 1 file changed, 8 insertions(+), 1 deletion(-)

diff --git a/cactusbot/handlers/command.py b/cactusbot/handlers/command.py
index 72a6d54..b3cd622 100644
--- a/cactusbot/handlers/command.py
+++ b/cactusbot/handlers/command.py
@@ -184,8 +184,15 @@ def sub_args(match):
 
         _packet.sub(self.ARGS_EXPR, sub_args)
 
+        username = ""
+
+        if "token" in data:
+            username = data["token"]
+        else:
+            username = data["username"]
+
         _packet.replace(**{
-            "%USER%": data.get("username"),
+            "%USER%": username,
             "%COUNT%": data.get("count"),
             "%CHANNEL%": data.get("channel")
         })

From 15c52e068e6f97f0d9979eadbb81659d56e1d6ce Mon Sep 17 00:00:00 2001
From: Innectic <innectic@gmail.com>
Date: Sat, 25 Mar 2017 22:18:28 -0700
Subject: [PATCH 037/122] Fix username getting for alias

---
 cactusbot/handlers/command.py | 9 ++++++++-
 1 file changed, 8 insertions(+), 1 deletion(-)

diff --git a/cactusbot/handlers/command.py b/cactusbot/handlers/command.py
index ffd3b39..f908ab5 100644
--- a/cactusbot/handlers/command.py
+++ b/cactusbot/handlers/command.py
@@ -181,8 +181,15 @@ def sub_args(match):
 
         _packet.sub(self.ARGS_EXPR, sub_args)
 
+        username = ""
+
+        if "token" in data:
+            username = data["token"]
+        else:
+            username = data["username"]
+
         _packet.replace(**{
-            "%USER%": data.get("username"),
+            "%USER%": username,
             "%COUNT%": data.get("count"),
             "%CHANNEL%": data.get("channel")
         })

From c6aafbcacf76382dfc5fd8b44f9f5ebf83fce67f Mon Sep 17 00:00:00 2001
From: Innectic <innectic@gmail.com>
Date: Sat, 25 Mar 2017 22:22:32 -0700
Subject: [PATCH 038/122] Fix tests

---
 cactusbot/handlers/command.py | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/cactusbot/handlers/command.py b/cactusbot/handlers/command.py
index f908ab5..01941a6 100644
--- a/cactusbot/handlers/command.py
+++ b/cactusbot/handlers/command.py
@@ -186,7 +186,7 @@ def sub_args(match):
         if "token" in data:
             username = data["token"]
         else:
-            username = data["username"]
+            username = data.get("username")
 
         _packet.replace(**{
             "%USER%": username,

From 71bfc066c8a8e60895e45ef0e3e4babfe98417d4 Mon Sep 17 00:00:00 2001
From: Innectic <innectic@gmail.com>
Date: Sat, 25 Mar 2017 22:38:54 -0700
Subject: [PATCH 039/122] Fix alias username (again)

---
 cactusbot/handlers/command.py | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/cactusbot/handlers/command.py b/cactusbot/handlers/command.py
index 482bf8c..7bb3cfa 100644
--- a/cactusbot/handlers/command.py
+++ b/cactusbot/handlers/command.py
@@ -132,8 +132,8 @@ async def custom_response(self, _packet, command, *args, **data):
             response = await self.api.get_command(
                 name=command)
             if response.status == 200:
-                data = (await (response.json()))["data"]["attributes"]
-                data["count"] = str(data["count"])
+                command_data = (await (response.json()))["data"]["attributes"]
+                command_data["count"] = str(command_data["count"])
             else:
                 return MessagePacket("An error has occured.")
 

From 36998af32498568f4761b2290397580a00331a1e Mon Sep 17 00:00:00 2001
From: Innectic <innectic@gmail.com>
Date: Sat, 25 Mar 2017 22:46:29 -0700
Subject: [PATCH 040/122] Fix alias count (again)

---
 cactusbot/handlers/command.py | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/cactusbot/handlers/command.py b/cactusbot/handlers/command.py
index b3cd622..ff01465 100644
--- a/cactusbot/handlers/command.py
+++ b/cactusbot/handlers/command.py
@@ -132,8 +132,8 @@ async def custom_response(self, _packet, command, *args, **data):
             response = await self.api.get_command(
                 name=command)
             if response.status == 200:
-                data = (await (response.json()))["data"]["attributes"]
-                data["count"] = str(data["count"])
+                command_data = (await (response.json()))["data"]["attributes"]
+                data["count"] = str(command_data["count"])
             else:
                 return MessagePacket("An error has occured.")
 

From fa93390e8189325db49ae028cb747461847e1d82 Mon Sep 17 00:00:00 2001
From: Innectic <innectic@gmail.com>
Date: Sun, 26 Mar 2017 09:54:43 -0700
Subject: [PATCH 041/122] Finish new config command.

---
 cactusbot/commands/magic/config.py | 88 +++++++++++++++++-------------
 1 file changed, 49 insertions(+), 39 deletions(-)

diff --git a/cactusbot/commands/magic/config.py b/cactusbot/commands/magic/config.py
index 2a70f81..9a29219 100644
--- a/cactusbot/commands/magic/config.py
+++ b/cactusbot/commands/magic/config.py
@@ -36,6 +36,13 @@ async def _get_event_data(api, event):
     return event
 
 
+async def _get_spam_data(api, section):
+    """Get data about a section of spam config."""
+
+    data = (await (await api.get_config()).json())["data"]
+    spam_section = data["attributes"]["spam"][section]
+    return spam_section
+
 class Config(Command):
     """Config Command."""
 
@@ -46,11 +53,9 @@ class Follow(Command):
         """Follow subcommand."""
 
         @Command.command(role="moderator", name="follow")
-        async def default(self, *value: False):
+        async def default(self, value=""):
             """Get status, and message of the follow event, or toggle."""
 
-            value = ' '.join(value)
-
             if not value:
                 data = await _get_event_data(self.api, "follow")
                 return "{dis}abled, message: `{message}`".format(
@@ -82,21 +87,19 @@ class Subscribe(Command):
         """Subcommand subcommand."""
 
         @Command.command(role="moderator", name="subscribe")
-        async def default(self, *value: False):
+        async def default(self, value=""):
             """Get status, and message of the subscribe event, or toggle."""
 
-            value = ' '.join(value)
-
             if not value:
-                data = await _get_event_data(self.api, "subscribe")
+                data = await _get_event_data(self.api, "sub")
                 return "{dis}abled, message: `{message}`".format(
                     dis='En' if data["announce"] else 'Dis', message=data["message"])
 
             if value in VALID_TOGGLE_ON_STATES:
-                await _update_deep_config(self.api, "announce", "subscribe", "announce", True)
+                await _update_deep_config(self.api, "announce", "sub", "announce", True)
                 return "Subscribe announcements are now enabled."
             elif value in VALID_TOGGLE_OFF_STATES:
-                await _update_deep_config(self.api, "announce", "subscribe", "announce", False)
+                await _update_deep_config(self.api, "announce", "sub", "announce", False)
                 return "Subscribe announcements are now disabled."
             else:
                 return "Invalid boolean value: `{}`!".format(value)
@@ -107,11 +110,11 @@ async def message(self, *message: False):
 
             if not message:
                 data = (await (await self.api.get_config()).json())["data"]
-                message = data["attributes"]["announce"]["subscribe"]["message"]
+                message = data["attributes"]["announce"]["sub"]["message"]
                 return "Current response: `{}`".format(message)
 
             await _update_deep_config(
-                self.api, "announce", "subscribe", "message", ' '.join(message))
+                self.api, "announce", "sub", "message", ' '.join(message))
             return "Set new subscribe message response."
 
     @Command.command(role="moderator")
@@ -119,11 +122,9 @@ class Host(Command):
         """Host subcommand."""
 
         @Command.command(role="moderator", name="host")
-        async def default(self, *value: False):
+        async def default(self, value=""):
             """Get status, and message of the host event, or toggle."""
 
-            value = ' '.join(value)
-
             if not value:
                 data = await _get_event_data(self.api, "host")
                 return "{dis}abled, message: `{message}`".format(
@@ -155,11 +156,9 @@ class Leave(Command):
         """Leave subcommand."""
 
         @Command.command(role="moderator", name="leave")
-        async def default(self, *value: False):
+        async def default(self, value=""):
             """Get status, and message of the leave event, or toggle."""
 
-            value = ' '.join(value)
-
             if not value:
                 data = await _get_event_data(self.api, "leave")
                 return "{dis}abled, message: `{message}`".format(
@@ -191,11 +190,9 @@ class Join(Command):
         """Join subcommand."""
 
         @Command.command(role="moderator", name="join")
-        async def default(self, *value: False):
+        async def default(self, value=""):
             """Get status, and message of the join event, or toggle."""
 
-            value = ' '.join(value)
-
             if not value:
                 data = await _get_event_data(self.api, "join")
                 return "{dis}abled, message: `{message}`".format(
@@ -231,13 +228,12 @@ class Urls(Command):
             """Urls subcommand."""
 
             @Command.command(name="urls")
-            async def default(self, *value: False):
-                """Amount subcommand."""
+            async def default(self, value=""):
+                """Urls subcommand."""
 
                 if not value:
-                    return ""
-
-                values = ' '.join(value)
+                    urls = await _get_spam_data(self.api, "allowUrls")
+                    return "URLs are {dis}abled.".format(dis='en' if urls else 'dis')
 
                 if value in VALID_TOGGLE_ON_STATES:
                     await _update_config(
@@ -250,22 +246,36 @@ async def default(self, *value: False):
                 else:
                     return "Invalid boolean value: '{value}'.".format(value=value)
 
-        # @Command.command()
-        # async def amount(self, value: r"\d+"):
-        #     """Emoji subcommand."""
+        @Command.command()
+        class Emoji(Command):
+            """Emoji subcommand."""
 
-        #     await _update_config(
-        #         self.api, "spam", "maxEmoji", int(value))
+            @Command.command(name="emoji")
+            async def default(self, value=""):
+                """Emoji subcommand."""
 
-        #     return "Maximum number of emoji is now {value}.".format(
-        #         value=value)
+                if not value:
+                    emoji = await _get_spam_data(self.api, "maxEmoji")
+                    return "Maximum amount of emojis allowed is {}".format(emoji)
 
-        # @Command.command()
-        # async def amount(self, value: r"\d+"):
-        #     """Caps subcommand."""
+                response = await _update_config(self.api, "spam", "maxEmoji", value)
+                if response.status == 200:
+                    return "Max emojis updated to {}".format(value)
+                return "An error occurred."
 
-        #     await _update_config(
-        #         self.api, "spam", "maxCapsScore", int(value))
+        @Command.command()
+        class Caps(Command):
+            """Caps subcommand."""
+
+            @Command.command(name="caps")
+            async def default(self, value=""):
+                """Caps subcommand."""
+
+                if not value:
+                    caps = await _get_spam_data(self.api, "maxCapsScore")
+                    return "Max caps score is {}".format(caps)
 
-        #     return "Maximum capitals score is now {value}.".format(
-        #         value=value)
+                response = await _update_config(self.api, "spam", "maxCapsScore", value)
+                if response.status == 200:
+                    return "Max caps score is now {}".format(value)
+                return "An error occurred."

From 2c87ac11d7525a103c3aefddf33d6646cbfae137 Mon Sep 17 00:00:00 2001
From: Innectic <innectic@gmail.com>
Date: Sun, 26 Mar 2017 10:05:52 -0700
Subject: [PATCH 042/122] Update docs

---
 docs/user/config.md | 20 +++++++++++++++++---
 1 file changed, 17 insertions(+), 3 deletions(-)

diff --git a/docs/user/config.md b/docs/user/config.md
index 62fb808..f31f3ea 100644
--- a/docs/user/config.md
+++ b/docs/user/config.md
@@ -4,11 +4,20 @@ Minimum Role Required: **Moderator**
 
 Set a configuration option.
 
-## `!config announce <follow|subscribe|host>`
+## `!config <follow|subscribe|host>`
 
 Edit the announcement messages for events.
 
-### `!config announce <follow|subscribe|host> <response...>`
+### `!config <follow|subscribe|host> message`
+
+Get the response for the event announcement.
+
+```
+[acg1000] !config follow message
+[CactusBot] Current Response: `Thanks for following, %USER%!`
+```
+
+### `!config <follow|subscribe|host> message <response...>`
 
 Update the response for an event announcement. The `%USER%` variable may be used for username substitution.
 
@@ -20,7 +29,7 @@ Update the response for an event announcement. The `%USER%` variable may be used
 [CactusBot] Thanks for following the channel, ParadigmShift3d!
 ```
 
-### `!config announce <follow|subscribe|host> toggle [on|off]`
+### `!config announce <follow|subscribe|host> [on|off]`
 
 Toggle a specific type of event announcement. Either `on` or `off` may be used to set the exact state.
 
@@ -36,6 +45,8 @@ Toggle a specific type of event announcement. Either `on` or `off` may be used t
 
 Change the configuration value for a spam filter.
 
+Running the command without the `value` will return the current value in the config.
+
 - `urls` accepts either `on` or `off`, which allows or disallows URLs, respectively.
 - `emoji` accepts a number, which is the maximum amount of emoji which one message may contain.
 - `caps` accepts a number, which is the maximum "score" which a message may have before being considered spam.
@@ -54,6 +65,9 @@ Change the configuration value for a spam filter.
 [QueenofArt] !config emoji 5
 [CactusBot] Maximum number of emoji is now 5.
 
+[LordOfTheUndead] !config emoji
+[CactusBot] Maximum amount of emojis allowed is 5
+
 [pingpong1109] Wow! :O :O :O :D :D :D
 *CactusBot times out pingpong1109*
 ```

From 88d6bb700e640d4e9a8536f6db434d0ed7b02625 Mon Sep 17 00:00:00 2001
From: Innectic <innectic@gmail.com>
Date: Sun, 26 Mar 2017 11:07:35 -0700
Subject: [PATCH 043/122] Fix linting

---
 cactusbot/commands/magic/config.py | 72 ++++++++++++++++++++----------
 1 file changed, 48 insertions(+), 24 deletions(-)

diff --git a/cactusbot/commands/magic/config.py b/cactusbot/commands/magic/config.py
index 9a29219..cc00de6 100644
--- a/cactusbot/commands/magic/config.py
+++ b/cactusbot/commands/magic/config.py
@@ -59,13 +59,16 @@ async def default(self, value=""):
             if not value:
                 data = await _get_event_data(self.api, "follow")
                 return "{dis}abled, message: `{message}`".format(
-                    dis='En' if data["announce"] else 'Dis', message=data["message"])
+                    dis='En' if data["announce"] else 'Dis',
+                    message=data["message"])
 
             if value in VALID_TOGGLE_ON_STATES:
-                await _update_deep_config(self.api, "announce", "follow", "announce", True)
+                await _update_deep_config
+                (self.api, "announce", "follow", "announce", True)
                 return "Follow announcements are now enabled."
             elif value in VALID_TOGGLE_OFF_STATES:
-                await _update_deep_config(self.api, "announce", "follow", "announce", False)
+                await _update_deep_config(
+                    self.api, "announce", "follow", "announce", False)
                 return "Follow announcements are now disabled."
             else:
                 return "Invalid boolean value: `{}`!".format(value)
@@ -79,7 +82,8 @@ async def message(self, *message: False):
                 message = data["attributes"]["announce"]["follow"]["message"]
                 return "Current response: `{}`".format(message)
 
-            await _update_deep_config(self.api, "announce", "follow", "message", ' '.join(message))
+            await _update_deep_config(
+                self.api, "announce", "follow", "message", ' '.join(message))
             return "Set new follow message response."
 
     @Command.command(role="moderator")
@@ -93,13 +97,16 @@ async def default(self, value=""):
             if not value:
                 data = await _get_event_data(self.api, "sub")
                 return "{dis}abled, message: `{message}`".format(
-                    dis='En' if data["announce"] else 'Dis', message=data["message"])
+                    dis='En' if data["announce"] else 'Dis',
+                    message=data["message"])
 
             if value in VALID_TOGGLE_ON_STATES:
-                await _update_deep_config(self.api, "announce", "sub", "announce", True)
+                await _update_deep_config(
+                    self.api, "announce", "sub", "announce", True)
                 return "Subscribe announcements are now enabled."
             elif value in VALID_TOGGLE_OFF_STATES:
-                await _update_deep_config(self.api, "announce", "sub", "announce", False)
+                await _update_deep_config(
+                    self.api, "announce", "sub", "announce", False)
                 return "Subscribe announcements are now disabled."
             else:
                 return "Invalid boolean value: `{}`!".format(value)
@@ -128,13 +135,16 @@ async def default(self, value=""):
             if not value:
                 data = await _get_event_data(self.api, "host")
                 return "{dis}abled, message: `{message}`".format(
-                    dis='En' if data["announce"] else 'Dis', message=data["message"])
+                    dis='En' if data["announce"] else 'Dis',
+                    message=data["message"])
 
             if value in VALID_TOGGLE_ON_STATES:
-                await _update_deep_config(self.api, "announce", "host", "announce", True)
+                await _update_deep_config(
+                    self.api, "announce", "host", "announce", True)
                 return "Host announcements are now enabled."
             elif value in VALID_TOGGLE_OFF_STATES:
-                await _update_deep_config(self.api, "announce", "host", "announce", False)
+                await _update_deep_config(
+                    self.api, "announce", "host", "announce", False)
                 return "Host announcements are now disabled."
             else:
                 return "Invalid boolean value: `{}`!".format(value)
@@ -148,7 +158,8 @@ async def message(self, *message: False):
                 message = data["attributes"]["announce"]["host"]["message"]
                 return "Current response: `{}`".format(message)
 
-            await _update_deep_config(self.api, "announce", "host", "message", ' '.join(message))
+            await _update_deep_config(
+                self.api, "announce", "host", "message", ' '.join(message))
             return "Set new host message response."
 
     @Command.command(role="moderator")
@@ -162,13 +173,16 @@ async def default(self, value=""):
             if not value:
                 data = await _get_event_data(self.api, "leave")
                 return "{dis}abled, message: `{message}`".format(
-                    dis='En' if data["announce"] else 'Dis', message=data["message"])
+                    dis='En' if data["announce"] else 'Dis',
+                    message=data["message"])
 
             if value in VALID_TOGGLE_ON_STATES:
-                await _update_deep_config(self.api, "announce", "leave", "announce", True)
+                await _update_deep_config(
+                    self.api, "announce", "leave", "announce", True)
                 return "Leave announcements are now enabled."
             elif value in VALID_TOGGLE_OFF_STATES:
-                await _update_deep_config(self.api, "announce", "leave", "announce", False)
+                await _update_deep_config(
+                    self.api, "announce", "leave", "announce", False)
                 return "Leave announcements are now disabled."
             else:
                 return "Invalid boolean value: `{}`!".format(value)
@@ -182,7 +196,8 @@ async def message(self, *message: False):
                 message = data["attributes"]["announce"]["leave"]["message"]
                 return "Current response: `{}`".format(message)
 
-            await _update_deep_config(self.api, "announce", "leave", "message", ' '.join(message))
+            await _update_deep_config(
+                self.api, "announce", "leave", "message", ' '.join(message))
             return "Set new leave message response."
 
     @Command.command(role="moderator")
@@ -196,13 +211,16 @@ async def default(self, value=""):
             if not value:
                 data = await _get_event_data(self.api, "join")
                 return "{dis}abled, message: `{message}`".format(
-                    dis='En' if data["announce"] else 'Dis', message=data["message"])
+                    dis='En' if data["announce"] else 'Dis',
+                    message=data["message"])
 
             if value in VALID_TOGGLE_ON_STATES:
-                await _update_deep_config(self.api, "announce", "join", "announce", True)
+                await _update_deep_config(
+                    self.api, "announce", "join", "announce", True)
                 return "Join announcements are now enabled."
             elif value in VALID_TOGGLE_OFF_STATES:
-                await _update_deep_config(self.api, "announce", "join", "announce", False)
+                await _update_deep_config(
+                    self.api, "announce", "join", "announce", False)
                 return "Join announcements are now disabled."
             else:
                 return "Invalid boolean value: `{}`!".format(value)
@@ -216,7 +234,8 @@ async def message(self, *message: False):
                 message = data["attributes"]["announce"]["join"]["message"]
                 return "Current response: `{}`".format(message)
 
-            await _update_deep_config(self.api, "announce", "join", "message", ' '.join(message))
+            await _update_deep_config(
+                self.api, "announce", "join", "message", ' '.join(message))
             return "Set new join message response."
 
     @Command.command(role="moderator")
@@ -233,7 +252,8 @@ async def default(self, value=""):
 
                 if not value:
                     urls = await _get_spam_data(self.api, "allowUrls")
-                    return "URLs are {dis}abled.".format(dis='en' if urls else 'dis')
+                    return "URLs are {dis}abled.".format(
+                        dis='en' if urls else 'dis')
 
                 if value in VALID_TOGGLE_ON_STATES:
                     await _update_config(
@@ -244,7 +264,8 @@ async def default(self, value=""):
                         self.api, "spam", "allowUrls", False)
                     return "URLs are now disallowed."
                 else:
-                    return "Invalid boolean value: '{value}'.".format(value=value)
+                    return "Invalid boolean value: '{value}'.".format(
+                        value=value)
 
         @Command.command()
         class Emoji(Command):
@@ -256,9 +277,11 @@ async def default(self, value=""):
 
                 if not value:
                     emoji = await _get_spam_data(self.api, "maxEmoji")
-                    return "Maximum amount of emojis allowed is {}".format(emoji)
+                    return "Maximum amount of emojis allowed is {}".format(
+                        emoji)
 
-                response = await _update_config(self.api, "spam", "maxEmoji", value)
+                response = await _update_config(
+                    self.api, "spam", "maxEmoji", value)
                 if response.status == 200:
                     return "Max emojis updated to {}".format(value)
                 return "An error occurred."
@@ -275,7 +298,8 @@ async def default(self, value=""):
                     caps = await _get_spam_data(self.api, "maxCapsScore")
                     return "Max caps score is {}".format(caps)
 
-                response = await _update_config(self.api, "spam", "maxCapsScore", value)
+                response = await _update_config(
+                    self.api, "spam", "maxCapsScore", value)
                 if response.status == 200:
                     return "Max caps score is now {}".format(value)
                 return "An error occurred."

From daf9f5de5ffe646124c582dc0b9957e3286d7c59 Mon Sep 17 00:00:00 2001
From: Innectic <innectic@gmail.com>
Date: Sun, 26 Mar 2017 11:09:06 -0700
Subject: [PATCH 044/122] Forgot whitespace

---
 cactusbot/commands/magic/config.py | 1 +
 1 file changed, 1 insertion(+)

diff --git a/cactusbot/commands/magic/config.py b/cactusbot/commands/magic/config.py
index cc00de6..0f5260a 100644
--- a/cactusbot/commands/magic/config.py
+++ b/cactusbot/commands/magic/config.py
@@ -43,6 +43,7 @@ async def _get_spam_data(api, section):
     spam_section = data["attributes"]["spam"][section]
     return spam_section
 
+
 class Config(Command):
     """Config Command."""
 

From 2accd1d46de05fc4835f7cc65d7cf705a007d5da Mon Sep 17 00:00:00 2001
From: Innectic <innectic@gmail.com>
Date: Sun, 26 Mar 2017 11:10:50 -0700
Subject: [PATCH 045/122] Fix config update

---
 cactusbot/commands/magic/config.py | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/cactusbot/commands/magic/config.py b/cactusbot/commands/magic/config.py
index 0f5260a..c5c89bb 100644
--- a/cactusbot/commands/magic/config.py
+++ b/cactusbot/commands/magic/config.py
@@ -64,8 +64,8 @@ async def default(self, value=""):
                     message=data["message"])
 
             if value in VALID_TOGGLE_ON_STATES:
-                await _update_deep_config
-                (self.api, "announce", "follow", "announce", True)
+                await _update_deep_config(
+                    self.api, "announce", "follow", "announce", True)
                 return "Follow announcements are now enabled."
             elif value in VALID_TOGGLE_OFF_STATES:
                 await _update_deep_config(

From a2e0b27a4ead13f8b91f80283c37b2aa8545216d Mon Sep 17 00:00:00 2001
From: Innectic <innectic@gmail.com>
Date: Mon, 27 Mar 2017 08:34:07 -0700
Subject: [PATCH 046/122] Fix argument order for sepal

---
 cactusbot/cactus.py | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/cactusbot/cactus.py b/cactusbot/cactus.py
index 25b5ee7..4573226 100644
--- a/cactusbot/cactus.py
+++ b/cactusbot/cactus.py
@@ -38,7 +38,7 @@ async def run(api, service, url, *auth):
 
     await api.login(*api.SCOPES)
 
-    sepal = Sepal(api.token, service, url)
+    sepal = Sepal(api.token, url, service)
 
     try:
         await sepal.connect()

From cc4c230ec142305125def106fa1cd52013c573dc Mon Sep 17 00:00:00 2001
From: 2Cubed <2Cubed@users.noreply.github.com>
Date: Tue, 28 Mar 2017 04:16:49 -0400
Subject: [PATCH 047/122] Move CactusAPI methods to individual buckets

---
 cactusbot/api.py                    | 232 +++++++++++++++++-----------
 cactusbot/commands/magic/alias.py   |   6 +-
 cactusbot/commands/magic/command.py |  14 +-
 cactusbot/commands/magic/config.py  |  18 +--
 cactusbot/commands/magic/cube.py    |   1 +
 cactusbot/commands/magic/quote.py   |   8 +-
 cactusbot/commands/magic/repeat.py  |   6 +-
 cactusbot/commands/magic/social.py  |   8 +-
 cactusbot/commands/magic/trust.py   |  13 +-
 9 files changed, 180 insertions(+), 126 deletions(-)

diff --git a/cactusbot/api.py b/cactusbot/api.py
index 2774083..ec0c2d7 100644
--- a/cactusbot/api.py
+++ b/cactusbot/api.py
@@ -26,7 +26,20 @@ def __init__(self, token, password, url=URL, auth_token="", **kwargs):
         self.token = token
         self.auth_token = auth_token
         self.password = password
-        self.URL = url
+        self.url = url
+
+        self.buckets = {
+            "command": Command(self),
+            "alias": Alias(self),
+            "quote": Quote(self),
+            "config": Config(self),
+            "repeat": Repeat(self),
+            "social": Social(self),
+            "trust": Trust(self)
+        }
+
+    def __getattr__(self, attr):
+        return self.buckets.get(attr)
 
     async def request(self, method, endpoint, is_json=True, **kwargs):
         """Send HTTP request to endpoint."""
@@ -54,6 +67,7 @@ async def request(self, method, endpoint, is_json=True, **kwargs):
         return response
 
     async def get(self, endpoint, **kwargs):
+        """Perform a GET request without requesting a JSON response."""
         return await self.request("GET", endpoint, is_json=False, **kwargs)
 
     async def login(self, *scopes, password=None):
@@ -78,17 +92,30 @@ async def login(self, *scopes, password=None):
 
         return response
 
-    async def get_command(self, name=None):
+
+class CactusAPIBucket:
+    """CactusAPI bucket."""
+
+    # pylint: disable=R0903
+
+    def __init__(self, api):
+        self.api = api
+
+
+class Command(CactusAPIBucket):
+    """CactusAPI /command bucket."""
+
+    async def get(self, name=None):
         """Get a command."""
 
         if name is not None:
-            return await self.get(
+            return await self.api.get(
                 "/user/{token}/command/{command}".format(
-                    token=self.token, command=name))
-        return await self.get("/user/{token}/command".format(
-            token=self.token))
+                    token=self.api.token, command=name))
+        return await self.api.get("/user/{token}/command".format(
+            token=self.api.token))
 
-    async def add_command(self, name, response, *, user_level=1):
+    async def add(self, name, response, *, user_level=1):
         """Add a command."""
 
         data = {
@@ -96,24 +123,45 @@ async def add_command(self, name, response, *, user_level=1):
             "userLevel": user_level
         }
 
-        return await self.patch(
+        return await self.api.patch(
             "/user/{token}/command/{command}".format(
-                token=self.token, command=name),
+                token=self.api.token, command=name),
             data=json.dumps(data)
         )
 
-    async def remove_command(self, name):
+    async def remove(self, name):
         """Remove a command."""
 
-        return await self.delete("/user/{token}/command/{command}".format(
-            token=self.token, command=name))
+        return await self.api.delete("/user/{token}/command/{command}".format(
+            token=self.api.token, command=name))
+
+    async def toggle(self, command, state):
+        """Toggle the enabled state of a command"""
+
+        data = {"enabled": state}
+
+        return await self.api.patch("/user/{token}/command/{command}".format(
+            token=self.api.token, command=command), data=json.dumps(data))
+
+    async def update_count(self, command, action):
+        """Set the count of a command."""
+
+        data = {"count": action}
 
-    async def get_command_alias(self, command):
+        return await(
+            self.api.patch("/user/{token}/command/{command}/count".format(
+                token=self.api.token, command=command), data=json.dumps(data)))
+
+
+class Alias(CactusAPIBucket):
+    """CactusAPI /alias bucket."""
+
+    async def get(self, command):
         """Get a command alias."""
-        return await self.get("/user/{token}/alias/{command}".format(
-            token=self.token, command=command))
+        return await self.api.get("/user/{token}/alias/{command}".format(
+            token=self.api.token, command=command))
 
-    async def add_alias(self, command, alias, args=None):
+    async def add(self, command, alias, args=None):
         """Create a command alias."""
 
         data = {
@@ -123,85 +171,85 @@ async def add_alias(self, command, alias, args=None):
         if args is not None:
             data["arguments"] = args
 
-        return await self.patch("/user/{user}/alias/{alias}".format(
-            user=self.token, alias=alias), data=json.dumps(data))
+        return await self.api.patch("/user/{user}/alias/{alias}".format(
+            user=self.api.token, alias=alias), data=json.dumps(data))
 
-    async def remove_alias(self, alias):
+    async def remove(self, alias):
         """Remove a command alias."""
 
-        return await self.delete("/user/{user}/alias/{alias}".format(
-            user=self.token, alias=alias))
+        return await self.api.delete("/user/{user}/alias/{alias}".format(
+            user=self.api.token, alias=alias))
 
-    async def toggle_command(self, command, state):
-        """Toggle the enabled state of a command"""
 
-        data = {"enabled": state}
-
-        return await self.patch("/user/{token}/command/{command}".format(
-            token=self.token, command=command), data=json.dumps(data))
-
-    async def update_command_count(self, command, action):
-        """Set the count of a command."""
-
-        data = {"count": action}
+class Quote(CactusAPIBucket):
+    """CactusAPI /quote bucket."""
 
-        return await(
-            self.patch("/user/{token}/command/{command}/count".format(
-                token=self.token, command=command), data=json.dumps(data)))
-
-    async def get_quote(self, quote_id=None):
+    async def get(self, quote_id=None):
         """Get a quote."""
 
         if quote_id is not None:
-            return await self.get("/user/{token}/quote/{id}".format(
-                token=self.token, id=quote_id))
-        return await self.get("/user/{token}/quote".format(
-            token=self.token), params={"random": True})
+            return await self.api.get("/user/{token}/quote/{id}".format(
+                token=self.api.token, id=quote_id))
+        return await self.api.get("/user/{token}/quote".format(
+            token=self.api.token), params={"random": True})
 
-    async def add_quote(self, quote):
+    async def add(self, quote):
         """Add a quote."""
 
         data = {"quote": quote}
 
-        return await self.post(
-            "/user/{token}/quote".format(token=self.token),
+        return await self.api.post(
+            "/user/{token}/quote".format(token=self.api.token),
             data=json.dumps(data)
         )
 
-    async def edit_quote(self, quote_id, quote):
+    async def edit(self, quote_id, quote):
         """Edit a quote."""
 
         data = {"quote": quote}
 
-        return await self.patch(
+        return await self.api.patch(
             "/user/{token}/quote/{quote_id}".format(
-                token=self.token, quote_id=quote_id),
+                token=self.api.token, quote_id=quote_id),
             data=json.dumps(data)
         )
 
-    async def remove_quote(self, quote_id):
+    async def remove(self, quote_id):
         """Remove a quote."""
 
-        return await self.delete("/user/{token}/quote/{id}".format(
-            token=self.token, id=quote_id))
+        return await self.api.delete("/user/{token}/quote/{id}".format(
+            token=self.api.token, id=quote_id))
+
 
-    async def get_config(self, *keys):
+class Config(CactusAPIBucket):
+    """CactusAPI /config bucket."""
+
+    async def get(self, *keys):
         """Get the token config."""
 
         if keys:
-            return await self.get("/user/{token}/config".format(
-                token=self.token), data=json.dumps({"keys": keys}))
+            return await self.api.get("/user/{token}/config".format(
+                token=self.api.token), data=json.dumps({"keys": keys}))
 
-        return await self.get("/user/{token}/config".format(
-            token=self.token))
+        return await self.api.get("/user/{token}/config".format(
+            token=self.api.token))
 
-    async def update_config(self, value):
+    async def update(self, value):
         """Update config attributes."""
 
-        return await self.patch("/user/{user}/config".format(
-            user=self.token), data=json.dumps(value))
+        return await self.api.patch("/user/{user}/config".format(
+            user=self.api.token), data=json.dumps(value))
+
 
-    async def add_repeat(self, command, period):
+class Repeat(CactusAPIBucket):
+    """CactusAPI /repeat bucket."""
+
+    async def get(self):
+        """Get all repeats."""
+        return await self.api.get("/user/{user}/repeat".format(
+            user=self.api.token))
+
+    async def add(self, command, period):
         """Add a repeat."""
 
         data = {
@@ -209,62 +257,66 @@ async def add_repeat(self, command, period):
             "period": period
         }
 
-        return await self.patch("/user/{user}/repeat/{command}".format(
-            user=self.token, command=command), data=json.dumps(data))
+        return await self.api.patch("/user/{user}/repeat/{command}".format(
+            user=self.api.token, command=command), data=json.dumps(data))
 
-    async def remove_repeat(self, repeat):
+    async def remove(self, repeat):
         """Remove a repeat."""
 
-        return await self.delete("/user/{user}/repeat/{repeat}".format(
-            user=self.token, repeat=repeat))
+        return await self.api.delete("/user/{user}/repeat/{repeat}".format(
+            user=self.api.token, repeat=repeat))
 
-    async def get_repeats(self):
-        """Get all repeats."""
 
-        return await self.get("/user/{user}/repeat".format(user=self.token))
+class Social(CactusAPIBucket):
+    """CactusAPI /social bucket."""
+
+    async def get(self, service=None):
+        """Get social service."""
+
+        if service is None:
+            return await self.api.get("/user/{user}/social".format(
+                user=self.api.token))
+        return await self.api.get("/user/{user}/social/{service}".format(
+            user=self.api.token, service=service))
 
-    async def add_social(self, service, url):
+    async def add(self, service, url):
         """Add a social service."""
 
         data = {"url": url}
 
-        return await self.patch("/user/{user}/social/{service}".format(
-            user=self.token, service=service), data=json.dumps(data))
+        return await self.api.patch("/user/{user}/social/{service}".format(
+            user=self.api.token, service=service), data=json.dumps(data))
 
-    async def remove_social(self, service):
+    async def remove(self, service):
         """Remove a social service."""
 
-        return await self.delete("/user/{user}/social/{service}".format(
-            user=self.token, service=service))
+        return await self.api.delete("/user/{user}/social/{service}".format(
+            user=self.api.token, service=service))
 
-    async def get_social(self, service=None):
-        """Get social service."""
 
-        if service is None:
-            return await self.get("/user/{user}/social".format(
-                user=self.token))
-        return await self.get("/user/{user}/social/{service}".format(
-            user=self.token, service=service))
+class Trust(CactusAPIBucket):
+    """CactusAPI /trust bucket."""
 
-    async def get_trust(self, user_id=None):
+    async def get(self, user_id=None):
         """Get trusted users."""
 
         if user_id is None:
-            return await self.get("/user/{user}/trust".format(user=self.token))
+            return await self.api.get("/user/{user}/trust".format(
+                user=self.api.token))
 
-        return await self.get("/user/{user}/trust/{user_id}".format(
-            user=self.token, user_id=user_id))
+        return await self.api.get("/user/{user}/trust/{user_id}".format(
+            user=self.api.token, user_id=user_id))
 
-    async def add_trust(self, user_id, username):
+    async def add(self, user_id, username):
         """Trust new user."""
 
         data = {"userName": username}
 
-        return await self.patch("/user/{user}/trust/{user_id}".format(
-            user=self.token, user_id=user_id), data=json.dumps(data))
+        return await self.api.patch("/user/{user}/trust/{user_id}".format(
+            user=self.api.token, user_id=user_id), data=json.dumps(data))
 
-    async def remove_trust(self, user_id):
+    async def remove(self, user_id):
         """Remove user trust."""
 
-        return await self.delete("/user/{user}/trust/{user_id}".format(
-            user=self.token, user_id=user_id))
+        return await self.api.delete("/user/{user}/trust/{user_id}".format(
+            user=self.api.token, user_id=user_id))
diff --git a/cactusbot/commands/magic/alias.py b/cactusbot/commands/magic/alias.py
index d7442e5..d37553f 100644
--- a/cactusbot/commands/magic/alias.py
+++ b/cactusbot/commands/magic/alias.py
@@ -22,7 +22,7 @@ async def add(self, alias: "?command", command: "?command", *_: False,
         else:
             packet_args = None
 
-        response = await self.api.add_alias(command, alias, packet_args)
+        response = await self.api.alias.add(command, alias, packet_args)
 
         if response.status == 201:
             return "Alias !{} for !{} created.".format(alias, command)
@@ -39,7 +39,7 @@ async def add(self, alias: "?command", command: "?command", *_: False,
     async def remove(self, alias: "?command"):
         """Remove a command alias."""
 
-        response = await self.api.remove_alias(alias)
+        response = await self.api.alias.remove(alias)
         if response.status == 200:
             return "Alias !{} removed.".format(alias)
         elif response.status == 404:
@@ -48,7 +48,7 @@ async def remove(self, alias: "?command"):
     @Command.command("list", role="moderator")
     async def list_aliases(self):
         """List all aliases."""
-        response = await self.api.get_command()
+        response = await self.api.command.get()
 
         if response.status == 200:
             commands = (await response.json())["data"]
diff --git a/cactusbot/commands/magic/command.py b/cactusbot/commands/magic/command.py
index e0a1085..7b9b5fd 100644
--- a/cactusbot/commands/magic/command.py
+++ b/cactusbot/commands/magic/command.py
@@ -24,7 +24,7 @@ async def add(self, command: r'!?([+$]?)([\w-]{1,32})', *response,
 
         raw.role = user_level  # HACK
         raw.target = None
-        response = await self.api.add_command(
+        response = await self.api.command.add(
             name, raw.split(maximum=3)[-1].json, user_level=user_level)
         data = await response.json()
 
@@ -35,7 +35,7 @@ async def add(self, command: r'!?([+$]?)([\w-]{1,32})', *response,
     @Command.command(role="moderator")
     async def remove(self, name: "?command"):
         """Remove a command."""
-        response = await self.api.remove_command(name)
+        response = await self.api.command.remove(name)
         if response.status == 200:
             return "Removed command !{}.".format(name)
         return "Command !{} does not exist!".format(name)
@@ -43,7 +43,7 @@ async def remove(self, name: "?command"):
     @Command.command("list", role="moderator")
     async def list_commands(self):
         """List all custom commands."""
-        response = await self.api.get_command()
+        response = await self.api.command.get()
 
         if response.status == 200:
             commands = (await response.json())["data"]
@@ -59,7 +59,7 @@ async def list_commands(self):
     async def enable(self, command: "?command"):
         """Enable a command."""
 
-        response = await self.api.toggle_command(command, True)
+        response = await self.api.command.toggle(command, True)
         if response.status == 200:
             return "Command !{} has been enabled.".format(command)
 
@@ -67,7 +67,7 @@ async def enable(self, command: "?command"):
     async def disable(self, command: "?command"):
         """Disable a command."""
 
-        response = await self.api.toggle_command(command, False)
+        response = await self.api.command.toggle(command, False)
         if response.status == 200:
             return "Command !{} has been disabled.".format(command)
 
@@ -77,7 +77,7 @@ async def count(self, command: r'?command',
         """Update the count of a command."""
 
         if action is None:
-            response = await self.api.get_command(command)
+            response = await self.api.command.get(command)
             data = await response.json()
             if response.status == 404:
                 return "Command !{} does not exist.".format(command)
@@ -88,6 +88,6 @@ async def count(self, command: r'?command',
         operator, value = action
         action_string = (operator or '=') + value
 
-        response = await self.api.update_command_count(command, action_string)
+        response = await self.api.command.update_count(command, action_string)
         if response.status == 200:
             return "Count updated."
diff --git a/cactusbot/commands/magic/config.py b/cactusbot/commands/magic/config.py
index c5c89bb..ce72972 100644
--- a/cactusbot/commands/magic/config.py
+++ b/cactusbot/commands/magic/config.py
@@ -9,7 +9,7 @@
 async def _update_deep_config(api, scope, field, section, value):
     """Update a deep section of the config."""
 
-    return await api.update_config({
+    return await api.config.update({
         scope: {
             field: {
                 section: value
@@ -21,7 +21,7 @@ async def _update_deep_config(api, scope, field, section, value):
 async def _update_config(api, scope, field, value):
     """Update a config value."""
 
-    return await api.update_config({
+    return await api.config.update({
         scope: {
             field: value
         }
@@ -31,7 +31,7 @@ async def _update_config(api, scope, field, value):
 async def _get_event_data(api, event):
     """Get data about an event."""
 
-    data = (await (await api.get_config()).json())["data"]
+    data = (await (await api.config.get()).json())["data"]
     event = data["attributes"]["announce"][event]
     return event
 
@@ -39,7 +39,7 @@ async def _get_event_data(api, event):
 async def _get_spam_data(api, section):
     """Get data about a section of spam config."""
 
-    data = (await (await api.get_config()).json())["data"]
+    data = (await (await api.config.get()).json())["data"]
     spam_section = data["attributes"]["spam"][section]
     return spam_section
 
@@ -79,7 +79,7 @@ async def message(self, *message: False):
             """Set the follow message."""
 
             if not message:
-                data = (await (await self.api.get_config()).json())["data"]
+                data = (await (await self.api.config.get()).json())["data"]
                 message = data["attributes"]["announce"]["follow"]["message"]
                 return "Current response: `{}`".format(message)
 
@@ -117,7 +117,7 @@ async def message(self, *message: False):
             """Set the subscribe message."""
 
             if not message:
-                data = (await (await self.api.get_config()).json())["data"]
+                data = (await (await self.api.config.get()).json())["data"]
                 message = data["attributes"]["announce"]["sub"]["message"]
                 return "Current response: `{}`".format(message)
 
@@ -155,7 +155,7 @@ async def message(self, *message: False):
             """Set the host message."""
 
             if not message:
-                data = (await (await self.api.get_config()).json())["data"]
+                data = (await (await self.api.config.get()).json())["data"]
                 message = data["attributes"]["announce"]["host"]["message"]
                 return "Current response: `{}`".format(message)
 
@@ -193,7 +193,7 @@ async def message(self, *message: False):
             """Set the leave message."""
 
             if not message:
-                data = (await (await self.api.get_config()).json())["data"]
+                data = (await (await self.api.config.get()).json())["data"]
                 message = data["attributes"]["announce"]["leave"]["message"]
                 return "Current response: `{}`".format(message)
 
@@ -231,7 +231,7 @@ async def message(self, *message: False):
             """Set the join message."""
 
             if not message:
-                data = (await (await self.api.get_config()).json())["data"]
+                data = (await (await self.api.config.get()).json())["data"]
                 message = data["attributes"]["announce"]["join"]["message"]
                 return "Current response: `{}`".format(message)
 
diff --git a/cactusbot/commands/magic/cube.py b/cactusbot/commands/magic/cube.py
index e19efc2..d484e25 100644
--- a/cactusbot/commands/magic/cube.py
+++ b/cactusbot/commands/magic/cube.py
@@ -51,6 +51,7 @@ def cube(self, value: str):
 
     @staticmethod
     def join(iterable, delimeter):
+        """Join an iterable."""
         iterable = iter(iterable)
         yield next(iterable)
         for item in iterable:
diff --git a/cactusbot/commands/magic/quote.py b/cactusbot/commands/magic/quote.py
index f7ba205..62d8977 100644
--- a/cactusbot/commands/magic/quote.py
+++ b/cactusbot/commands/magic/quote.py
@@ -16,12 +16,12 @@ async def default(self, quote: r'[1-9]\d*'=None):
         """Get a quote based on ID. If no ID is provided, pick a random one."""
 
         if quote is None:
-            response = await self.api.get_quote()
+            response = await self.api.quote.get()
             if response.status == 404:
                 return "No quotes have been added!"
             return (await response.json())["data"][0]["attributes"]["quote"]
         else:
-            response = await self.api.get_quote(quote)
+            response = await self.api.quote.get(quote)
             if response.status == 404:
                 return "Quote {} does not exist!".format(quote)
             return (await response.json())["data"]["attributes"]["quote"]
@@ -29,7 +29,7 @@ async def default(self, quote: r'[1-9]\d*'=None):
     @Command.command(role="moderator")
     async def add(self, *quote):
         """Add a quote."""
-        response = await self.api.add_quote(' '.join(quote))
+        response = await self.api.quote.add(' '.join(quote))
         data = await response.json()
         return "Added quote #{}.".format(
             data["data"]["attributes"]["quoteId"])
@@ -58,7 +58,7 @@ async def inspirational(self):
                 "http://api.forismatic.com/api/1.0/",
                 params=dict(method="getQuote", lang="en", format="json")
             )).json()
-        except Exception:
+        except Exception:  # pylint: disable=W0703
             return MessagePacket(
                 "Unable to get an inspirational quote. Have a ",
                 ("emoji", "🐹"),
diff --git a/cactusbot/commands/magic/repeat.py b/cactusbot/commands/magic/repeat.py
index 2ff3181..becc7a1 100644
--- a/cactusbot/commands/magic/repeat.py
+++ b/cactusbot/commands/magic/repeat.py
@@ -12,7 +12,7 @@ class Repeat(Command):
     async def add(self, period: r"[1-9]\d*", command: "?command"):
         """Add a repeat."""
 
-        response = await self.api.add_repeat(command, int(period))
+        response = await self.api.repeat.add(command, int(period))
 
         if response.status == 201:
             return "Repeat !{command} added on interval {period}.".format(
@@ -32,7 +32,7 @@ async def add(self, period: r"[1-9]\d*", command: "?command"):
     async def remove(self, repeat: "?command"):
         """Remove a repeat"""
 
-        response = await self.api.remove_repeat(repeat)
+        response = await self.api.repeat.remove(repeat)
 
         if response.status == 200:
             return "Repeat removed."
@@ -43,7 +43,7 @@ async def remove(self, repeat: "?command"):
     async def list_repeats(self):
         """List all repeats."""
 
-        response = await self.api.get_repeats()
+        response = await self.api.repeat.get()
         data = (await response.json())["data"]
 
         if not data:
diff --git a/cactusbot/commands/magic/social.py b/cactusbot/commands/magic/social.py
index 3684eda..8942a14 100644
--- a/cactusbot/commands/magic/social.py
+++ b/cactusbot/commands/magic/social.py
@@ -19,7 +19,7 @@ async def default(self, *services: False):
         response = []
         if services:
             for service in services:
-                social = await self.api.get_social(service)
+                social = await self.api.social.get(service)
                 if social.status == 200:
                     data = await social.json()
                     response.append(
@@ -33,7 +33,7 @@ async def default(self, *services: False):
 
             return MessagePacket(*response[:-1])
         else:
-            social = await self.api.get_social()
+            social = await self.api.social.get()
             if social.status == 200:
                 data = await social.json()
 
@@ -51,7 +51,7 @@ async def default(self, *services: False):
     async def add(self, service, url):
         """Add a social service."""
 
-        response = await self.api.add_social(service, url)
+        response = await self.api.social.add(service, url)
         if response.status == 201:
             return "Added social service {}.".format(service)
         elif response.status == 200:
@@ -67,7 +67,7 @@ async def add(self, service, url):
     async def remove(self, service):
         """Remove a social service."""
 
-        response = await self.api.remove_social(service)
+        response = await self.api.social.remove(service)
         if response.status == 200:
             return "Removed social service {}.".format(service)
         elif response.status == 404:
diff --git a/cactusbot/commands/magic/trust.py b/cactusbot/commands/magic/trust.py
index d34f99f..95b5739 100644
--- a/cactusbot/commands/magic/trust.py
+++ b/cactusbot/commands/magic/trust.py
@@ -9,6 +9,7 @@
 
 
 async def check_user(username):
+    """Check if a Beam username exists."""
     if username.startswith('@'):
         username = username[1:]
     async with aiohttp.get(BASE_URL.format(username=username)) as response:
@@ -28,12 +29,12 @@ async def default(self, username: check_user):
 
         user, user_id = username
 
-        is_trusted = (await self.api.get_trust(user_id)).status == 200
+        is_trusted = (await self.api.trust.get(user_id)).status == 200
 
         if is_trusted:
-            await self.api.remove_trust(user_id)
+            await self.api.trust.remove(user_id)
         else:
-            await self.api.add_trust(user_id, user)
+            await self.api.trust.add(user_id, user)
 
         return MessagePacket(
             ("tag", user), " is {modifier} trusted.".format(
@@ -45,7 +46,7 @@ async def add(self, username: check_user):
 
         user, user_id = username
 
-        response = await self.api.add_trust(user_id, user)
+        response = await self.api.trust.add(user_id, user)
 
         if response.status in (201, 200):
             return MessagePacket("User ", ("tag", user), " has been trusted.")
@@ -56,7 +57,7 @@ async def remove(self, username: check_user):
 
         user, user_id = username
 
-        response = await self.api.remove_trust(user_id)
+        response = await self.api.trust.remove(user_id)
 
         if response.status == 200:
             return MessagePacket("Removed trust for user ", ("tag", user), '.')
@@ -67,7 +68,7 @@ async def remove(self, username: check_user):
     async def list_trusts(self):
         """Get the trused users in a channel."""
 
-        data = await (await self.api.get_trust()).json()
+        data = await (await self.api.trust.get()).json()
 
         if not data["data"]:
             return "No trusted users."

From 084e0938e36ba0de83dfb524d4b3337dd072eb80 Mon Sep 17 00:00:00 2001
From: 2Cubed <2Cubed@users.noreply.github.com>
Date: Tue, 28 Mar 2017 04:19:02 -0400
Subject: [PATCH 048/122] Update MockAPI in tests to match CactusAPI bucket
 changes

---
 tests/commands/test_alias.py           | 339 +++++++++++++------------
 tests/commands/test_command_command.py | 289 ++++++++++-----------
 tests/commands/test_trust.py           | 157 ++++++------
 tests/handlers/test_events.py          |  66 ++---
 tests/handlers/test_spam.py            |  11 +-
 5 files changed, 439 insertions(+), 423 deletions(-)

diff --git a/tests/commands/test_alias.py b/tests/commands/test_alias.py
index 69c051d..539b3d4 100644
--- a/tests/commands/test_alias.py
+++ b/tests/commands/test_alias.py
@@ -9,190 +9,195 @@
 class MockAPI:
     """Fake API."""
 
-    async def get_command_alias(self, command):
-        """Get aliases."""
-
-        class Response:
-            """Fake API response object."""
-
-            @property
-            def status(self):
-                """Response status."""
-                return 200
-
-            def json(self):
-                """Response from the api."""
-
-                return {
-                    "data": {
-                        "attributes": {
-                            "command": {
-                                "count": 1,
-                                "enabled": True,
-                                "id": "d23779ce-4522-431d-9095-7bf34718c39d",
-                                "name": "testing",
-                                "response": {
-                                    "action": None,
-                                    "message": [
-                                        {
-                                            "data": "testing!",
-                                            "text": "testing!",
-                                            "type": "text"
-                                        }
-                                    ],
-                                    "role": 1,
-                                    "target": None,
-                                    "user": "Stanley"
-                                },
-                                "token": "Stanley"
-                            },
-                            "commandName": "testing",
-                            "name": "test",
-                            "token": "Stanley"
-                        },
-                        "id": "312ab175-fb52-4a7b-865d-4202176f9234",
-                        "type": "alias"
-                    }
-                }
-        return Response()
-
-    async def add_alias(self, command, alias, args=None):
-        """Add a new alias."""
-
-        class Response:
-            """Fake API response object."""
-
-            @property
-            def status(self):
-                """Response status."""
-                return 200
-
-            def json(self):
-                """Response from the api."""
-                return {
-                    "data": {
-                        "attributes": {
-                            "command": {
-                                "count": 1,
-                                "enabled": True,
-                                "id": "d23779ce-4522-431d-9095-7bf34718c39d",
-                                "name": "testing",
-                                "response": {
-                                    "action": False,
-                                    "message": [
-                                        {
-                                            "data": "testing!",
-                                            "text": "testing!",
-                                            "type": "text"
-                                        }
-                                    ],
-                                    "role": 1,
-                                    "target": None,
-                                    "user": "Stanley"
+    class Alias:
+        async def get(self, command):
+            """Get aliases."""
+
+            class Response:
+                """Fake API response object."""
+
+                @property
+                def status(self):
+                    """Response status."""
+                    return 200
+
+                def json(self):
+                    """Response from the api."""
+
+                    return {
+                        "data": {
+                            "attributes": {
+                                "command": {
+                                    "count": 1,
+                                    "enabled": True,
+                                    "id": "d23779ce-4522-431d-9095-7bf34718c39d",
+                                    "name": "testing",
+                                    "response": {
+                                        "action": None,
+                                        "message": [
+                                            {
+                                                "data": "testing!",
+                                                "text": "testing!",
+                                                "type": "text"
+                                            }
+                                        ],
+                                        "role": 1,
+                                        "target": None,
+                                        "user": "Stanley"
+                                    },
+                                    "token": "Stanley"
                                 },
+                                "commandName": "testing",
+                                "name": "test",
                                 "token": "Stanley"
                             },
-                            "commandName": "testing",
-                            "name": "test",
-                            "token": "Stanley"
-                        },
-                        "id": "312ab175-fb52-4a7b-865d-4202176f9234",
-                        "type": "alias"
-                    },
-                    "meta": {
-                        "edited": True
-                    }
-                }
-        return Response()
-
-    async def remove_alias(self, alias):
-        """Remove an alias."""
-
-        class Response:
-            """Fake API response."""
-
-            @property
-            def status(self):
-                """Response status."""
-                return 200
-
-            def json(self):
-                """JSON response."""
-                return {
-                    "meta": {
-                        "deleted": [
-                            "312ab175-fb52-4a7b-865d-4202176f9234"
-                        ]
+                            "id": "312ab175-fb52-4a7b-865d-4202176f9234",
+                            "type": "alias"
+                        }
                     }
-                }
-        return Response()
+            return Response()
 
-    async def get_command(self):
-        """Get all the commands."""
+        async def add(self, command, alias, args=None):
+            """Add a new alias."""
 
-        class Response:
-            """Fake API response."""
+            class Response:
+                """Fake API response object."""
 
-            @property
-            def status(self):
-                """Status of the response."""
-                return 200
+                @property
+                def status(self):
+                    """Response status."""
+                    return 200
 
-            async def json(self):
-                """JSON response."""
-                return {
-                    "data": [
-                        {
+                def json(self):
+                    """Response from the api."""
+                    return {
+                        "data": {
                             "attributes": {
-                                "count": 2,
-                                "enabled": True,
-                                "name": "testing",
-                                "response": {
-                                    "action": False,
-                                    "message": [
-                                        {
-                                            "data": "testing!",
-                                            "text": "testing!",
-                                            "type": "text"
-                                        }
-                                    ],
-                                    "role": 1,
-                                    "target": None,
-                                    "user": "Stanley"
+                                "command": {
+                                    "count": 1,
+                                    "enabled": True,
+                                    "id": "d23779ce-4522-431d-9095-7bf34718c39d",
+                                    "name": "testing",
+                                    "response": {
+                                        "action": False,
+                                        "message": [
+                                            {
+                                                "data": "testing!",
+                                                "text": "testing!",
+                                                "type": "text"
+                                            }
+                                        ],
+                                        "role": 1,
+                                        "target": None,
+                                        "user": "Stanley"
+                                    },
+                                    "token": "Stanley"
                                 },
-                                "token": "Stanley"
-                            },
-                            "id": "d23779ce-4522-431d-9095-7bf34718c39d",
-                            "type": "command"
-                        },
-                        {
-                            "attributes": {
                                 "commandName": "testing",
-                                "count": 2,
-                                "enabled": True,
-                                "id": "d23779ce-4522-431d-9095-7bf34718c39d",
                                 "name": "test",
-                                "response": {
-                                    "action": False,
-                                    "message": [
-                                        {
-                                            "data": "testing!",
-                                            "text": "testing!",
-                                            "type": "text"
-                                        }
-                                    ],
-                                    "role": 1,
-                                    "target": None,
-                                    "user": "Stanley"
-                                },
                                 "token": "Stanley"
                             },
                             "id": "312ab175-fb52-4a7b-865d-4202176f9234",
                             "type": "alias"
+                        },
+                        "meta": {
+                            "edited": True
                         }
-                    ]
-                }
-        return Response()
+                    }
+            return Response()
+
+        async def remove(self, alias):
+            """Remove an alias."""
+
+            class Response:
+                """Fake API response."""
+
+                @property
+                def status(self):
+                    """Response status."""
+                    return 200
+
+                def json(self):
+                    """JSON response."""
+                    return {
+                        "meta": {
+                            "deleted": [
+                                "312ab175-fb52-4a7b-865d-4202176f9234"
+                            ]
+                        }
+                    }
+            return Response()
+    alias = Alias()
+    
+    class Command:
+
+        async def get(self):
+            """Get all the commands."""
+
+            class Response:
+                """Fake API response."""
+
+                @property
+                def status(self):
+                    """Status of the response."""
+                    return 200
+
+                async def json(self):
+                    """JSON response."""
+                    return {
+                        "data": [
+                            {
+                                "attributes": {
+                                    "count": 2,
+                                    "enabled": True,
+                                    "name": "testing",
+                                    "response": {
+                                        "action": False,
+                                        "message": [
+                                            {
+                                                "data": "testing!",
+                                                "text": "testing!",
+                                                "type": "text"
+                                            }
+                                        ],
+                                        "role": 1,
+                                        "target": None,
+                                        "user": "Stanley"
+                                    },
+                                    "token": "Stanley"
+                                },
+                                "id": "d23779ce-4522-431d-9095-7bf34718c39d",
+                                "type": "command"
+                            },
+                            {
+                                "attributes": {
+                                    "commandName": "testing",
+                                    "count": 2,
+                                    "enabled": True,
+                                    "id": "d23779ce-4522-431d-9095-7bf34718c39d",
+                                    "name": "test",
+                                    "response": {
+                                        "action": False,
+                                        "message": [
+                                            {
+                                                "data": "testing!",
+                                                "text": "testing!",
+                                                "type": "text"
+                                            }
+                                        ],
+                                        "role": 1,
+                                        "target": None,
+                                        "user": "Stanley"
+                                    },
+                                    "token": "Stanley"
+                                },
+                                "id": "312ab175-fb52-4a7b-865d-4202176f9234",
+                                "type": "alias"
+                            }
+                        ]
+                    }
+            return Response()
+    command = Command()
 
 
 alias = Alias(MockAPI())
diff --git a/tests/commands/test_command_command.py b/tests/commands/test_command_command.py
index 226c284..ce972db 100644
--- a/tests/commands/test_command_command.py
+++ b/tests/commands/test_command_command.py
@@ -8,21 +8,125 @@
 class MockAPI:
     """Fake API."""
 
-    async def get_command(self, command=None):
-        """Get commands."""
+    class Command:
 
-        class Response:
-            """API response."""
+        async def get(self, command=None):
+            """Get commands."""
 
-            @property
-            def status(self):
-                """Status of the response."""
-                return 200
+            class Response:
+                """API response."""
 
-            async def json(self):
-                """JSON response."""
+                @property
+                def status(self):
+                    """Status of the response."""
+                    return 200
+
+                async def json(self):
+                    """JSON response."""
+
+                    if command:
+                        return {
+                            "data": {
+                                "attributes": {
+                                    "count": 0,
+                                    "enabled": True,
+                                    "name": "testing",
+                                    "response": {
+                                        "action": False,
+                                        "message": [
+                                            {
+                                                "data": "testing!",
+                                                "text": "testing!",
+                                                "type": "text"
+                                            },
+                                            {
+                                                "data": ":smile:",
+                                                "text": ":)",
+                                                "type": "emoji"
+                                            }
+                                        ],
+                                        "target": None,
+                                        "user": "Stanley"
+                                    },
+                                    "role": 0,
+                                    "token": "Stanley"
+                                },
+                                "id": "3f51fc4d-d012-41c0-b98e-ff6257394f75",
+                                "type": "command"
+                            },
+                            "meta": {
+                                "created": True
+                            }
+                        }
+                    else:
+                        return {
+                            "data": [
+                                {
+                                    "attributes": {
+                                        "count": 2,
+                                        "enabled": True,
+                                        "name": "testing",
+                                        "response": {
+                                            "action": False,
+                                            "message": [
+                                                {
+                                                    "data": "testing!",
+                                                    "text": "testing!",
+                                                    "type": "text"
+                                                }
+                                            ],
+                                            "role": 1,
+                                            "target": None,
+                                            "user": "Stanley"
+                                        },
+                                        "token": "Stanley"
+                                    },
+                                    "id": "d23779ce-4522-431d-9095-7bf34718c39d",
+                                    "type": "command"
+                                },
+                                {
+                                    "attributes": {
+                                        "commandName": "testing",
+                                        "count": 2,
+                                        "enabled": True,
+                                        "id": "d23779ce-4522-431d-9095-7bf34718c39d",
+                                        "name": "test",
+                                        "response": {
+                                            "action": False,
+                                            "message": [
+                                                {
+                                                    "data": "testing!",
+                                                    "text": "testing!",
+                                                    "type": "text"
+                                                }
+                                            ],
+                                            "role": 1,
+                                            "target": None,
+                                            "user": "Stanley"
+                                        },
+                                        "token": "Stanley"
+                                    },
+                                    "id": "312ab175-fb52-4a7b-865d-4202176f9234",
+                                    "type": "aliases"
+                                }
+                            ]
+                        }
+            return Response()
+
+        async def add(self, name, response, *, user_level=1):
+            """Add a command."""
+
+            class Response:
+                """API response."""
+
+                @property
+                def status(self):
+                    """Status of the request."""
+                    return 200
+
+                async def json(self):
+                    """JSON response."""
 
-                if command:
                     return {
                         "data": {
                             "attributes": {
@@ -33,8 +137,8 @@ async def json(self):
                                     "action": False,
                                     "message": [
                                         {
-                                            "data": "testing!",
-                                            "text": "testing!",
+                                            "data": "lol!",
+                                            "text": "lol!",
                                             "type": "text"
                                         },
                                         {
@@ -43,148 +147,47 @@ async def json(self):
                                             "type": "emoji"
                                         }
                                     ],
+                                    "role": 0,
                                     "target": None,
-                                    "user": "Stanley"
+                                    "user": ""
                                 },
-                                "role": 0,
-                                "token": "Stanley"
+                                "token": "innectic2"
                             },
-                            "id": "3f51fc4d-d012-41c0-b98e-ff6257394f75",
+                            "id": "d23779ce-4522-431d-9095-7bf34718c39d",
                             "type": "command"
                         },
                         "meta": {
-                            "created": True
+                            "edited": True
                         }
                     }
-                else:
+            return Response()
+
+        async def remove(self, name):
+            """Remove a command."""
+
+            class Response:
+                """API response."""
+
+                @property
+                def status(self):
+                    """Status of the request."""
+                    return 200
+
+                async def json(self):
+                    """JSON response."""
                     return {
-                        "data": [
-                            {
-                                "attributes": {
-                                    "count": 2,
-                                    "enabled": True,
-                                    "name": "testing",
-                                    "response": {
-                                        "action": False,
-                                        "message": [
-                                            {
-                                                "data": "testing!",
-                                                "text": "testing!",
-                                                "type": "text"
-                                            }
-                                        ],
-                                        "role": 1,
-                                        "target": None,
-                                        "user": "Stanley"
-                                    },
-                                    "token": "Stanley"
-                                },
-                                "id": "d23779ce-4522-431d-9095-7bf34718c39d",
-                                "type": "command"
-                            },
-                            {
-                                "attributes": {
-                                    "commandName": "testing",
-                                    "count": 2,
-                                    "enabled": True,
-                                    "id": "d23779ce-4522-431d-9095-7bf34718c39d",
-                                    "name": "test",
-                                    "response": {
-                                        "action": False,
-                                        "message": [
-                                            {
-                                                "data": "testing!",
-                                                "text": "testing!",
-                                                "type": "text"
-                                            }
-                                        ],
-                                        "role": 1,
-                                        "target": None,
-                                        "user": "Stanley"
-                                    },
-                                    "token": "Stanley"
-                                },
-                                "id": "312ab175-fb52-4a7b-865d-4202176f9234",
-                                "type": "aliases"
-                            }
-                        ]
-                    }
-        return Response()
-
-    async def add_command(self, name, response, *, user_level=1):
-        """Add a command."""
-
-        class Response:
-            """API response."""
-
-            @property
-            def status(self):
-                """Status of the request."""
-                return 200
-
-            async def json(self):
-                """JSON response."""
-
-                return {
-                    "data": {
-                        "attributes": {
-                            "count": 0,
-                            "enabled": True,
-                            "name": "testing",
-                            "response": {
-                                "action": False,
-                                "message": [
-                                    {
-                                        "data": "lol!",
-                                        "text": "lol!",
-                                        "type": "text"
-                                    },
-                                    {
-                                        "data": ":smile:",
-                                        "text": ":)",
-                                        "type": "emoji"
-                                    }
+                        "meta": {
+                            "deleted": {
+                                "aliases": None,
+                                "command": [
+                                    "d23779ce-4522-431d-9095-7bf34718c39d"
                                 ],
-                                "role": 0,
-                                "target": None,
-                                "user": ""
-                            },
-                            "token": "innectic2"
-                        },
-                        "id": "d23779ce-4522-431d-9095-7bf34718c39d",
-                        "type": "command"
-                    },
-                    "meta": {
-                        "edited": True
-                    }
-                }
-        return Response()
-
-    async def remove_command(self, name):
-        """Remove a command."""
-
-        class Response:
-            """API response."""
-
-            @property
-            def status(self):
-                """Status of the request."""
-                return 200
-
-            async def json(self):
-                """JSON response."""
-                return {
-                    "meta": {
-                        "deleted": {
-                            "aliases": None,
-                            "command": [
-                                "d23779ce-4522-431d-9095-7bf34718c39d"
-                            ],
-                            "repeats": None
+                                "repeats": None
+                            }
                         }
                     }
-                }
-        return Response()
+            return Response()
+    command = Command()
 
 command = Meta(MockAPI())
 
diff --git a/tests/commands/test_trust.py b/tests/commands/test_trust.py
index 69cdd6b..c41589c 100644
--- a/tests/commands/test_trust.py
+++ b/tests/commands/test_trust.py
@@ -9,91 +9,94 @@
 class MockAPI:
     """Fake API."""
 
-    async def get_trust(self, user_id=None):
-        """Get trusts."""
-
-        class Response:
-            """Fake API response object."""
-            @property
-            def status(self):
-                """Response status."""
-                return 200
-
-            async def json(self):
-                """JSON version of the response."""
-
-                if user_id:
-                    return {
-                        "data": {
-                            {
-                                "attributes": {
-                                    "token": "TestChannel",
-                                    "userId": "95845",
-                                    "userName": "Stanley"
+    class Trust:
+
+        async def get(self, user_id=None):
+            """Get trusts."""
+
+            class Response:
+                """Fake API response object."""
+                @property
+                def status(self):
+                    """Response status."""
+                    return 200
+
+                async def json(self):
+                    """JSON version of the response."""
+
+                    if user_id:
+                        return {
+                            "data": {
+                                {
+                                    "attributes": {
+                                        "token": "TestChannel",
+                                        "userId": "95845",
+                                        "userName": "Stanley"
+                                    }
                                 }
                             }
                         }
-                    }
-                else:
-                    return {
-                        "data": [
-                            {
-                                "attributes": {
-                                    "token": "TestChannel",
-                                    "userId": "95845",
-                                    "userName": "Stanley"
+                    else:
+                        return {
+                            "data": [
+                                {
+                                    "attributes": {
+                                        "token": "TestChannel",
+                                        "userId": "95845",
+                                        "userName": "Stanley"
+                                    }
                                 }
-                            }
-                        ]
-                    }
-        return Response()
-
-    async def add_trust(self, user_id, username):
-        """Add a new trust."""
-        class Response:
-            """Fake API response object."""
-            @property
-            def status(self):
-                """Response status."""
-                return 200
-
-            async def json(self):
-                """JSON response."""
-                return {
-                    "attributes": {
+                            ]
+                        }
+            return Response()
+
+        async def add(self, user_id, username):
+            """Add a new trust."""
+            class Response:
+                """Fake API response object."""
+                @property
+                def status(self):
+                    """Response status."""
+                    return 200
+
+                async def json(self):
+                    """JSON response."""
+                    return {
                         "attributes": {
-                            "token": "TestChannel",
-                            "userId": "95845",
-                            "userName": "Stanley"
+                            "attributes": {
+                                "token": "TestChannel",
+                                "userId": "95845",
+                                "userName": "Stanley"
+                            },
+                            "id": "7875b898-fbb3-426f-aca3-7375d97326b0",
+                            "type": "trust"
                         },
-                        "id": "7875b898-fbb3-426f-aca3-7375d97326b0",
-                        "type": "trust"
-                    },
-                    "meta": {
-                        "created": True
+                        "meta": {
+                            "created": True
+                        }
                     }
-                }
-        return Response()
-
-    async def remove_trust(self, user_id):
-        """Remove a trust."""
-        class Response:
-            """Fake API response."""
-            @property
-            def status(self):
-                """Response status."""
-                return 200
-
-            async def json(self):
-                """JSON response."""
-                return {
-                    "meta": {
-                        "deleted": [
-                            "7875b898-fbb3-426f-aca3-7375d97326b0"
-                        ]
+            return Response()
+
+        async def remove(self, user_id):
+            """Remove a trust."""
+            class Response:
+                """Fake API response."""
+                @property
+                def status(self):
+                    """Response status."""
+                    return 200
+
+                async def json(self):
+                    """JSON response."""
+                    return {
+                        "meta": {
+                            "deleted": [
+                                "7875b898-fbb3-426f-aca3-7375d97326b0"
+                            ]
+                        }
                     }
-                }
-        return Response()
+            return Response()
+    trust = Trust()
 
 trust = Trust(MockAPI())
 
diff --git a/tests/handlers/test_events.py b/tests/handlers/test_events.py
index 932bd24..df35d90 100644
--- a/tests/handlers/test_events.py
+++ b/tests/handlers/test_events.py
@@ -6,38 +6,40 @@
 
 class MockAPI:
 
-    async def get_config(self):
-
-        class Response:
-
-            async def json(self):
-
-                return {
-                    "data": {"attributes": {"announce": {
-                        "follow": {
-                            "announce": True,
-                            "message": "Thanks for following, %USER%!"
-                        },
-                        "sub": {
-                            "announce": True,
-                            "message": "Thanks for subscribing, %USER%!"
-                        },
-                        "host": {
-                            "announce": True,
-                            "message": "Thanks for hosting, %USER%!"
-                        },
-                        "join": {
-                            "announce": True,
-                            "message": "Welcome to the channel, %USER%!"
-                        },
-                        "leave": {
-                            "announce": True,
-                            "message": "Thanks for watching, %USER%!"
-                        }
-                    }}}
-                }
-
-        return Response()
+    class Config:
+        async def get(self):
+
+            class Response:
+
+                async def json(self):
+
+                    return {
+                        "data": {"attributes": {"announce": {
+                            "follow": {
+                                "announce": True,
+                                "message": "Thanks for following, %USER%!"
+                            },
+                            "sub": {
+                                "announce": True,
+                                "message": "Thanks for subscribing, %USER%!"
+                            },
+                            "host": {
+                                "announce": True,
+                                "message": "Thanks for hosting, %USER%!"
+                            },
+                            "join": {
+                                "announce": True,
+                                "message": "Welcome to the channel, %USER%!"
+                            },
+                            "leave": {
+                                "announce": True,
+                                "message": "Thanks for watching, %USER%!"
+                            }
+                        }}}
+                    }
+
+            return Response()
+    config = Config()
 
 event_handler = EventHandler({
     "cache_follow": True,
diff --git a/tests/handlers/test_spam.py b/tests/handlers/test_spam.py
index 04d33c6..3588cce 100644
--- a/tests/handlers/test_spam.py
+++ b/tests/handlers/test_spam.py
@@ -9,12 +9,15 @@ async def get_user_id(_):
 
 class MockAPI:
 
-    async def get_trust(self, _):
+    class Trust:
 
-        class Response:
-            status = 404
+        async def get(self, _):
 
-        return Response()
+            class Response:
+                status = 404
+
+            return Response()
+    trust = Trust()
 
 spam_handler = SpamHandler(MockAPI())
 

From a52e1375add2bd064346d0b1754190d4a2a7ba67 Mon Sep 17 00:00:00 2001
From: 2Cubed <2Cubed@users.noreply.github.com>
Date: Tue, 28 Mar 2017 04:22:50 -0400
Subject: [PATCH 049/122] Move duplicated websocket JSON code to WebSocket
 class

---
 cactusbot/handlers/command.py            |  9 +++---
 cactusbot/handlers/events.py             |  2 +-
 cactusbot/handlers/spam.py               |  5 ++-
 cactusbot/services/beam/chat.py          | 17 +---------
 cactusbot/services/beam/constellation.py | 15 +--------
 cactusbot/services/websocket.py          | 41 ++++++++++++++++++++----
 6 files changed, 46 insertions(+), 43 deletions(-)

diff --git a/cactusbot/handlers/command.py b/cactusbot/handlers/command.py
index cb9a99f..39e7440 100644
--- a/cactusbot/handlers/command.py
+++ b/cactusbot/handlers/command.py
@@ -80,10 +80,11 @@ async def on_message(self, packet):
                 return MessagePacket("Command not found.", target=packet.user)
 
     async def custom_response(self, _packet, command, *args, **data):
+        """Custom command response to a packet."""
 
         args = (command, *args)
 
-        response = await self.api.get_command(command)
+        response = await self.api.command.get(command)
 
         if response.status != 200:
             return
@@ -101,7 +102,7 @@ async def custom_response(self, _packet, command, *args, **data):
                 args = (args[0], *tuple(MessagePacket(
                     *json["data"]["attributes"]["arguments"]
                 ).text.split()), *args[1:])
-        cmd = await self.api.get_command(name=command)
+        cmd = await self.api.command.get(name=command)
         if cmd.status != 200:
             return MessagePacket("Command does not exist for that alias",
                                  target=_packet.user)
@@ -125,11 +126,11 @@ async def custom_response(self, _packet, command, *args, **data):
         if _packet.target:
             json["response"]["target"] = _packet.user
 
-        await self.api.update_command_count(command, "+1")
+        await self.api.command.update_count(command, "+1")
         if not is_alias and "count" not in data:
             data["count"] = str(json["count"] + 1)
         elif is_alias:
-            response = await self.api.get_command(
+            response = await self.api.command.get(
                 name=command)
             if response.status == 200:
                 command_data = (await (response.json()))["data"]["attributes"]
diff --git a/cactusbot/handlers/events.py b/cactusbot/handlers/events.py
index ff6826a..9dc9942 100644
--- a/cactusbot/handlers/events.py
+++ b/cactusbot/handlers/events.py
@@ -50,7 +50,7 @@ def __init__(self, cache_data, api):
     async def load_messages(self):
         """Load alert messages."""
 
-        data = await (await self.api.get_config()).json()
+        data = await (await self.api.config.get()).json()
 
         messages = data["data"]["attributes"]["announce"]
 
diff --git a/cactusbot/handlers/spam.py b/cactusbot/handlers/spam.py
index 84a997c..a8519af 100644
--- a/cactusbot/handlers/spam.py
+++ b/cactusbot/handlers/spam.py
@@ -9,6 +9,7 @@
 
 
 async def get_user_id(username):
+    """Retrieve Beam user ID from username."""
     async with aiohttp.get(BASE_URL.format(username=username)) as response:
         if response.status == 404:
             return 0
@@ -35,8 +36,10 @@ async def on_message(self, packet):
         if packet.role >= 4:
             return
 
+        return BanPacket(packet.user)  # FIXME
+
         user_id = await get_user_id(packet.user)
-        if (await self.api.get_trust(user_id)).status == 200:
+        if (await self.api.trust.get(user_id)).status == 200:
             return
 
         exceeds_caps = self.check_caps(''.join(
diff --git a/cactusbot/services/beam/chat.py b/cactusbot/services/beam/chat.py
index 7c808da..b1b5590 100644
--- a/cactusbot/services/beam/chat.py
+++ b/cactusbot/services/beam/chat.py
@@ -24,8 +24,6 @@ def __init__(self, channel, *endpoints):
     async def send(self, *args, max_length=360, **kwargs):
         """Send a packet."""
 
-        # TODO: lock before auth
-
         packet = {
             "type": "method",
             "method": "msg",
@@ -52,20 +50,7 @@ async def initialize(self, *auth):
         else:
             await self.send(self.channel, method="auth")
 
-    async def parse(self, packet):
-        """Parse a chat packet."""
-
-        try:
-            packet = json.loads(packet)
-        except (TypeError, ValueError):
-            self.logger.exception("Invalid JSON: %s.", packet)
-            return None
-        else:
-            if packet.get("error") is not None:
-                self.logger.error(packet)
-            else:
-                self.logger.debug(packet)
-            return packet
+    parse = WebSocket._parse_json()
 
     @property
     def _packet_id(self):
diff --git a/cactusbot/services/beam/constellation.py b/cactusbot/services/beam/constellation.py
index 9997fda..5ba8127 100644
--- a/cactusbot/services/beam/constellation.py
+++ b/cactusbot/services/beam/constellation.py
@@ -59,17 +59,4 @@ async def initialize(self, *interfaces):
         self.logger.info(
             "Successfully subscribed to Constellation interfaces.")
 
-    async def parse(self, packet):
-        """Parse a chat packet."""
-
-        try:
-            packet = json.loads(packet)
-        except (TypeError, ValueError):
-            self.logger.exception("Invalid JSON: %s.", packet)
-            return None
-        else:
-            if packet.get("error") is not None:
-                self.logger.error(packet)
-            else:
-                self.logger.debug(packet)
-            return packet
+    parse = WebSocket._parse_json()
diff --git a/cactusbot/services/websocket.py b/cactusbot/services/websocket.py
index da79d95..ef8d062 100644
--- a/cactusbot/services/websocket.py
+++ b/cactusbot/services/websocket.py
@@ -1,16 +1,18 @@
 """Interact with WebSockets safely."""
 
-import logging
-
 import asyncio
-
 import itertools
+import json
+import logging
+
+import aiohttp
 
-from aiohttp import ClientSession
-from aiohttp.errors import DisconnectedError, HttpProcessingError, ClientError
+_AIOHTTP_ERRORS = tuple(
+    getattr(aiohttp.errors, error) for error in aiohttp.errors.__all__
+)
 
 
-class WebSocket(ClientSession):
+class WebSocket(aiohttp.ClientSession):
     """Interact with WebSockets safely."""
 
     def __init__(self, *endpoints):
@@ -39,7 +41,7 @@ async def connect(self, *args, base=2, maximum=60, **kwargs):
         while True:
             try:
                 self.websocket = await self.ws_connect(self._endpoint)
-            except (DisconnectedError, HttpProcessingError, ClientError):
+            except _AIOHTTP_ERRORS:  # pylint: disable=E0712
                 backoff = min(base**next(_backoff_count), maximum)
                 self.logger.debug("Retrying in %s seconds...", backoff)
                 await asyncio.sleep(backoff)
@@ -82,6 +84,31 @@ async def parse(self, packet):
         """Parse a packet from the WebSocket."""
         return packet
 
+    @staticmethod
+    def _parse_json(success_function=None):
+        """Return a function to parse a JSON packet."""
+
+        if success_function is None:
+            async def _success_function(self, packet):
+                if packet.get("error") is not None:
+                    self.logger.error(packet)
+                else:
+                    self.logger.debug(packet)
+                return packet
+            success_function = _success_function
+
+        async def parse_json(self, packet):
+            """Parse a JSON packet."""
+            try:
+                packet = json.loads(packet)
+            except (TypeError, ValueError):
+                self.logger.exception("Invalid JSON: %s.", packet)
+                return None
+            else:
+                return await success_function(self, packet)
+
+        return parse_json
+
     @property
     def _endpoint(self):
         return next(self._endpoint_cycle)

From b5f33ff099aae917eae25dd3f95ccfb4b6bbeaf8 Mon Sep 17 00:00:00 2001
From: 2Cubed <2Cubed@users.noreply.github.com>
Date: Tue, 28 Mar 2017 04:24:49 -0400
Subject: [PATCH 050/122] Satisfy pylint

Unnecessary messages have been disabled. (Base classes, required use of general Exception catching, etc.)
---
 cactusbot/commands/command.py      |  2 ++
 cactusbot/handler.py               | 16 ++++++-----
 cactusbot/packets/__init__.py      |  2 ++
 cactusbot/packets/ban.py           |  2 ++
 cactusbot/packets/event.py         |  2 ++
 cactusbot/packets/packet.py        |  2 ++
 cactusbot/sepal.py                 | 19 ++++---------
 cactusbot/services/__init__.py     |  1 +
 cactusbot/services/api.py          |  6 ++--
 cactusbot/services/beam/handler.py | 45 ++++++++++++++----------------
 cactusbot/services/beam/parser.py  |  1 -
 11 files changed, 50 insertions(+), 48 deletions(-)

diff --git a/cactusbot/commands/command.py b/cactusbot/commands/command.py
index 0c1592e..7ee9545 100644
--- a/cactusbot/commands/command.py
+++ b/cactusbot/commands/command.py
@@ -1,3 +1,5 @@
+# pylint: skip-file
+
 """Magic command internals (and magic)."""
 
 import inspect
diff --git a/cactusbot/handler.py b/cactusbot/handler.py
index 0d4292f..2fa913a 100644
--- a/cactusbot/handler.py
+++ b/cactusbot/handler.py
@@ -5,7 +5,7 @@
 from .packets import MessagePacket, Packet
 
 
-class Handlers(object):
+class Handlers:
     """Evented controller for individual handlers.
 
     For a method to have the ability to be used as an event handler, it must
@@ -76,16 +76,16 @@ async def handle(self, event, packet):
             if hasattr(handler, "on_" + event):
                 try:
                     response = await getattr(handler, "on_" + event)(packet)
-                except Exception:
+                except Exception:  # pylint: disable=W0703
                     self.logger.warning(
                         "Exception in handler %s:", type(handler).__name__,
                         exc_info=1)
                 else:
-                    for packet in self.translate(response, handler):
-                        if packet is StopIteration:
+                    for translated in self.translate(response, handler):
+                        if translated is StopIteration:
                             return result
-                        result.append(packet)
-                        # TODO: In Python 3.6, with asynchronous generators:
+                        result.append(translated)
+                        # In Python 3.6, with asynchronous generators:
                         # yield packet
 
         return result
@@ -149,7 +149,7 @@ def translate(self, packet, handler):
                                 type(handler).__name__, type(packet).__name__)
 
 
-class Handler(object):
+class Handler:
     """Parent class to all event handlers.
 
     Examples
@@ -161,5 +161,7 @@ class Handler(object):
 
     """
 
+    # pylint: disable=R0903
+
     def __init__(self):
         self.logger = logging.getLogger(__name__)
diff --git a/cactusbot/packets/__init__.py b/cactusbot/packets/__init__.py
index 65d40b0..97b4318 100644
--- a/cactusbot/packets/__init__.py
+++ b/cactusbot/packets/__init__.py
@@ -1,3 +1,5 @@
+"""CactusBot packets."""
+
 from .ban import BanPacket
 from .event import EventPacket
 from .message import MessagePacket
diff --git a/cactusbot/packets/ban.py b/cactusbot/packets/ban.py
index 09c4205..2ca3eb4 100644
--- a/cactusbot/packets/ban.py
+++ b/cactusbot/packets/ban.py
@@ -16,6 +16,8 @@ class BanPacket(Packet):
         If set to ``0``, the ban lasts for an unlimited amount of time.
     """
 
+    # pylint: disable=R0903
+
     def __init__(self, user, duration=0):
         super().__init__()
 
diff --git a/cactusbot/packets/event.py b/cactusbot/packets/event.py
index 33f6746..1fc8367 100644
--- a/cactusbot/packets/event.py
+++ b/cactusbot/packets/event.py
@@ -16,6 +16,8 @@ class EventPacket(Packet):
         Whether or not the event was positive or successful.
     """
 
+    # pylint: disable=R0903
+
     def __init__(self, event_type, user, success=True, streak=1):
         super().__init__()
 
diff --git a/cactusbot/packets/packet.py b/cactusbot/packets/packet.py
index f513919..003cd85 100644
--- a/cactusbot/packets/packet.py
+++ b/cactusbot/packets/packet.py
@@ -16,6 +16,8 @@ class Packet:
         Packet attributes.
     """
 
+    # pylint: disable=R0903
+
     def __init__(self, packet_type=None, **kwargs):
         self.type = packet_type or type(self).__name__
         self.kwargs = kwargs
diff --git a/cactusbot/sepal.py b/cactusbot/sepal.py
index ca93a3e..b43357f 100644
--- a/cactusbot/sepal.py
+++ b/cactusbot/sepal.py
@@ -12,8 +12,8 @@ class Sepal(WebSocket):
 
     URL = "wss://cactus.exoz.one/sepal"
 
-    def __init__(self, channel, url=URL, service=None):
-        super().__init__(self.URL)
+    def __init__(self, channel, service, url=URL):
+        super().__init__(url)
 
         self.logger = logging.getLogger(__name__)
 
@@ -39,17 +39,10 @@ async def initialize(self):
 
         await self.send("join")
 
-    async def parse(self, packet):
-        """Parse a Sepal packet."""
-
-        try:
-            packet = json.loads(packet)
-        except (TypeError, ValueError):
-            self.logger.exception("Invalid JSON: %s.", packet)
-            return None
-        else:
-            self.logger.debug(packet)
-            return packet
+    async def _success_function(self, packet):
+        self.logger.debug(packet)
+        return packet
+    parse = WebSocket._parse_json(_success_function)
 
     async def handle(self, packet):
         """Convert a JSON packet to a CactusBot packet."""
diff --git a/cactusbot/services/__init__.py b/cactusbot/services/__init__.py
index a9dc99e..c2160c4 100644
--- a/cactusbot/services/__init__.py
+++ b/cactusbot/services/__init__.py
@@ -1,3 +1,4 @@
+"""CactusBot service base classes."""
 
 from .api import API
 from .websocket import WebSocket
diff --git a/cactusbot/services/api.py b/cactusbot/services/api.py
index d40b932..bbc7a99 100644
--- a/cactusbot/services/api.py
+++ b/cactusbot/services/api.py
@@ -34,9 +34,9 @@ async def request(self, method, endpoint, **kwargs):
                 raise ClientHttpProcessingError("Response was not JSON!")
             else:
                 self.logger.debug(
-                    "{method} {endpoint} {data}:\n{code} {text}".format(
-                        method=method, endpoint=endpoint, data=kwargs,
-                        code=response.status, text=text))
+                    "%s %s %s:\n%s %s",
+                    method, endpoint, kwargs, response.status, text
+                )
                 return response
 
     async def get(self, endpoint, **kwargs):
diff --git a/cactusbot/services/beam/handler.py b/cactusbot/services/beam/handler.py
index 3547a00..710fdb8 100644
--- a/cactusbot/services/beam/handler.py
+++ b/cactusbot/services/beam/handler.py
@@ -10,6 +10,19 @@
 from .constellation import BeamConstellation
 from .parser import BeamParser
 
+CHAT_EVENTS = {
+    "ChatMessage": "message",
+    "UserJoin": "join",
+    "UserLeave": "leave"
+}
+
+CONSTELLATION_EVENTS = {
+    "channel:followed": "follow",
+    "channel:subscribed": "subscribe",
+    "channel:resubscribed": "resubscribe",
+    "channel:hosted": "host"
+}
+
 
 class BeamHandler:
     """Handle data from Beam services."""
@@ -18,37 +31,21 @@ def __init__(self, channel, token, handlers):
 
         self.logger = logging.getLogger(__name__)
 
-        self.api = BeamAPI()
-        self.api.authorize(token)
+        self.api = BeamAPI(token)
 
         self.parser = BeamParser()
         self.handlers = handlers  # HACK, potentially
 
-        self._channel = channel
-        self.channel = ""
+        self.channel = channel
 
         self.chat = None
         self.constellation = None
 
-        self.chat_events = {
-            "ChatMessage": "message",
-            "UserJoin": "join",
-            "UserLeave": "leave"
-        }
-
-        self.constellation_events = {
-            "channel:followed": "follow",
-            "channel:subscribed": "subscribe",
-            "channel:resubscribed": "resubscribe",
-            "channel:hosted": "host"
-        }
-
     async def run(self):
         """Connect to Beam chat and handle incoming packets."""
 
-        channel = await self.api.get_channel(self._channel)
-        self.channel = str(channel["id"])
-        self.api.channel = self.channel  # HACK
+        channel = await self.api.get_channel(self.channel)
+        self.api.channel = str(channel["id"])
 
         user_id = channel["userId"]
         chat = await self.api.get_chat(channel["id"])
@@ -83,8 +80,8 @@ async def handle_chat(self, packet):
 
         event = packet.get("event")
 
-        if event in self.chat_events:
-            event = self.chat_events[event]
+        if event in CHAT_EVENTS:
+            event = CHAT_EVENTS[event]
 
             # HACK?
             if hasattr(self.parser, "parse_" + event):
@@ -102,8 +99,8 @@ async def handle_constellation(self, packet):
         scope, _, event = packet["data"]["channel"].split(":")
         event = scope + ':' + event
 
-        if event in self.constellation_events:
-            event = self.constellation_events[event]
+        if event in CONSTELLATION_EVENTS:
+            event = CONSTELLATION_EVENTS[event]
 
             # HACK
             if hasattr(self.parser, "parse_" + event):
diff --git a/cactusbot/services/beam/parser.py b/cactusbot/services/beam/parser.py
index c23fe50..fc4b2ed 100644
--- a/cactusbot/services/beam/parser.py
+++ b/cactusbot/services/beam/parser.py
@@ -9,7 +9,6 @@
 class BeamParser:
     """Parse Beam packets."""
 
-    # TODO: update with accurate values
     ROLES = {
         "Owner": 5,
         "Staff": 4,  # Not necessarily bot staff.

From 8a6a66f050e104aad9e5a1fa38bbb2ba025ba29d Mon Sep 17 00:00:00 2001
From: 2Cubed <2Cubed@users.noreply.github.com>
Date: Tue, 28 Mar 2017 04:31:01 -0400
Subject: [PATCH 051/122] Add missed pylint disables

---
 cactusbot/cactus.py                | 10 +---------
 cactusbot/commands/magic/cactus.py |  4 +++-
 cactusbot/handlers/spam.py         |  2 --
 3 files changed, 4 insertions(+), 12 deletions(-)

diff --git a/cactusbot/cactus.py b/cactusbot/cactus.py
index 4573226..1190947 100644
--- a/cactusbot/cactus.py
+++ b/cactusbot/cactus.py
@@ -2,7 +2,6 @@
 
 import asyncio
 import logging
-import time
 
 from .sepal import Sepal
 
@@ -48,12 +47,5 @@ async def run(api, service, url, *auth):
     except KeyboardInterrupt:
         logger.info("Removing thorns... done.")
 
-    except Exception:
+    except Exception:  # pylint: disable=W0703
         logger.critical("Oh no, I crashed!", exc_info=True)
-
-        logger.info("Restarting in 10 seconds...")
-
-        try:
-            time.sleep(10)
-        except KeyboardInterrupt:
-            logger.info("CactusBot deactivated.")
diff --git a/cactusbot/commands/magic/cactus.py b/cactusbot/commands/magic/cactus.py
index ce01064..c282ebb 100644
--- a/cactusbot/commands/magic/cactus.py
+++ b/cactusbot/commands/magic/cactus.py
@@ -40,7 +40,9 @@ async def twitter(self):
 
     @Command.command()
     async def github(self, project=None):
-        """Github response."""
+        """GitHub response."""
+
+        # pylint: disable=R0911
 
         if project is None or project.lower() in ("bot", "cactusbot"):
             return MessagePacket(
diff --git a/cactusbot/handlers/spam.py b/cactusbot/handlers/spam.py
index a8519af..2d9d3b7 100644
--- a/cactusbot/handlers/spam.py
+++ b/cactusbot/handlers/spam.py
@@ -36,8 +36,6 @@ async def on_message(self, packet):
         if packet.role >= 4:
             return
 
-        return BanPacket(packet.user)  # FIXME
-
         user_id = await get_user_id(packet.user)
         if (await self.api.trust.get(user_id)).status == 200:
             return

From d3665aafb4a0e58040ce6e217cdb0d38f1cf2315 Mon Sep 17 00:00:00 2001
From: 2Cubed <2Cubed@users.noreply.github.com>
Date: Tue, 28 Mar 2017 04:37:54 -0400
Subject: [PATCH 052/122] Add pylint to Travis file

---
 .travis.yml | 3 ++-
 1 file changed, 2 insertions(+), 1 deletion(-)

diff --git a/.travis.yml b/.travis.yml
index 35fcf40..b1db1c7 100644
--- a/.travis.yml
+++ b/.travis.yml
@@ -7,7 +7,7 @@ branches:
     - develop
     - /^release-v(\d+.){0,2}\d+$/
 before_install:
-    - pip install flake8
+    - pip install flake8 pylint
     - pip install pytest pytest-asyncio --upgrade
 install:
   - pip install -r requirements.txt
@@ -18,5 +18,6 @@ before_script:
 script:
   - nosetests
   - flake8 run.py config.template.py cactusbot/
+  - pylint cactusbot/
   - pytest tests/
   - pytest cactusbot/ --doctest-modules

From 1253040c6863318b97a5c16c6de9250dfb7d15fa Mon Sep 17 00:00:00 2001
From: 2Cubed <2Cubed@users.noreply.github.com>
Date: Tue, 28 Mar 2017 11:52:02 -0400
Subject: [PATCH 053/122] Clean up command.py

So much cleaner and more logical! Such good, very wow! :tada:
---
 cactusbot/api.py                  |   2 +-
 cactusbot/cactus.py               |   2 +-
 cactusbot/commands/command.py     | 115 ++++++++++++++++--------------
 cactusbot/commands/magic/quote.py |   5 +-
 4 files changed, 65 insertions(+), 59 deletions(-)

diff --git a/cactusbot/api.py b/cactusbot/api.py
index ec0c2d7..81ca537 100644
--- a/cactusbot/api.py
+++ b/cactusbot/api.py
@@ -191,7 +191,7 @@ async def get(self, quote_id=None):
             return await self.api.get("/user/{token}/quote/{id}".format(
                 token=self.api.token, id=quote_id))
         return await self.api.get("/user/{token}/quote".format(
-            token=self.api.token), params={"random": True})
+            token=self.api.token), params={"random": "true"})
 
     async def add(self, quote):
         """Add a quote."""
diff --git a/cactusbot/cactus.py b/cactusbot/cactus.py
index 1190947..8270811 100644
--- a/cactusbot/cactus.py
+++ b/cactusbot/cactus.py
@@ -37,7 +37,7 @@ async def run(api, service, url, *auth):
 
     await api.login(*api.SCOPES)
 
-    sepal = Sepal(api.token, url, service)
+    sepal = Sepal(api.token, service, url)
 
     try:
         await sepal.connect()
diff --git a/cactusbot/commands/command.py b/cactusbot/commands/command.py
index 7ee9545..1f9a9d1 100644
--- a/cactusbot/commands/command.py
+++ b/cactusbot/commands/command.py
@@ -1,5 +1,3 @@
-# pylint: skip-file
-
 """Magic command internals (and magic)."""
 
 import inspect
@@ -18,6 +16,26 @@
 }
 
 
+class ArgsError(Exception):
+    """Error raised when an unexpected number of arguments was received.
+
+    Parameters
+    ----------
+    direction : :obj:`bool`
+        Whether there were too many (:obj:`True`) or too few (:obj:`False`)
+        arguments passed.
+    args : :obj:`tuple` of :obj:`inspect.Parameter` s
+        :obj:`tuple` of the method's positional or keyword arguments.
+    """
+
+    def __init__(self, direction, args):
+
+        super().__init__()
+
+        self.direction = direction
+        self.args = args
+
+
 class Command:
     """Parent class to all magic commands.
 
@@ -106,65 +124,41 @@ async def __call__(self, *args, **meta):
         commands = self.commands()
         assert self.default is None or callable(self.default)
 
-        if args:
+        if args and args[0] in commands:
 
             command, *arguments = args
 
-            if command in commands:
-
-                role = commands[command].COMMAND_META.get("role", 1)
+            to_run = [(commands[command], arguments)]
+            if getattr(commands[command], "default", None) is not None:
+                to_run.append((commands[command].default, arguments))
+            if self.default is not None:
+                to_run.append((self.default, args))
 
-                if isinstance(role, str):
-                    role = list(ROLES.keys())[list(map(
-                        str.lower, ROLES.values())).index(role.lower())]
-                if "packet" in meta and meta["packet"].role < role:
-                    return "Role level '{role}' or higher required.".format(
-                        role=ROLES[max(k for k in ROLES.keys() if k <= role)])
+            for index, (running, _args) in enumerate(to_run):
 
                 try:
+                    return await self._run_safe(running, *_args, **meta)
 
-                    return await self._run_safe(
-                        commands[command], *arguments, **meta)
+                except ArgsError as error:
 
-                except IndexError as error:
+                    if index > 0:
+                        continue
 
-                    if error.args[0] == 0:
-
-                        has_default = hasattr(commands[command], "default")
-                        has_commands = hasattr(commands[command], "commands")
-                        if not (has_default or has_commands):
-                            return "Not enough arguments. <{0}>".format(
-                                '> <'.join(arg.name for arg in error.args[1]))
-
-                        response = "Not enough arguments. <{0}>".format(
-                            '|'.join(
-                                commands[command].commands(hidden=False).keys()
-                            ))
-
-                        if commands[command].default is not None:
-
-                            try:
-                                return await self._run_safe(
-                                    commands[command].default,
-                                    *arguments, **meta)
-
-                            except IndexError:
-                                return response
-
-                        return response
-
-                    else:
+                    if error.direction:
                         return "Too many arguments."
 
-        if self.default is not None:
-            try:
-                return await self._run_safe(self.default, *args, **meta)
-            except IndexError:
-                pass
+                    has_default = hasattr(running, "default")
+                    has_commands = hasattr(running, "commands")
+                    if not (has_default or has_commands):
+                        return "Not enough arguments. <{0}>".format(
+                            '> <'.join(arg.name for arg in error.args[1]))
+
+            return "Not enough arguments. <{0}>".format('|'.join(
+                commands[command].commands(hidden=False).keys()
+            ))
 
         if args:
-            command, *_ = args
-            return "Invalid argument: '{0}'.".format(command)
+            return "Invalid argument: '{0}'.".format(args[0])
 
         return "Not enough arguments. <{0}>".format(
             '|'.join(self.commands(hidden=False).keys()))
@@ -226,11 +220,11 @@ def decorator(function):
             function.COMMAND_META = meta
 
             if inspect.isclass(function):
-                COMMAND = getattr(function, "COMMAND", None)
+                command = getattr(function, "COMMAND", None)
                 function = function(Command.api)
                 function.__name__ = function.__class__.__name__
-                if COMMAND is not None:
-                    function.COMMAND = COMMAND
+                if command is not None:
+                    function.COMMAND = command
 
             if name is not None:
                 assert ' ' not in name, "Command name may not contain spaces"
@@ -246,6 +240,15 @@ def decorator(function):
 
     async def _run_safe(self, function, *args, **meta):
 
+        role = function.COMMAND_META.get("role", 1)
+
+        if isinstance(role, str):
+            role = list(ROLES.keys())[list(map(
+                str.lower, ROLES.values())).index(role.lower())]
+        if "packet" in meta and meta["packet"].role < role:
+            return "Role level '{r}' or higher required.".format(
+                r=ROLES[max(k for k in ROLES.keys() if k <= role)])
+
         self._check_safe(function, *args)
 
         args = await self._clean_args(function, *args)
@@ -276,7 +279,7 @@ def _check_safe(function, *args):
             if star_arg is not None:
                 pos_args += (star_arg,)
 
-            raise IndexError(len(args) > arg_range[0], pos_args)
+            raise ArgsError(len(args) > arg_range[0], pos_args)
         return True
 
     @staticmethod
@@ -310,7 +313,7 @@ async def _clean_args(function, *args):
                 elif callable(arg.annotation):
                     try:
                         args[index] = await arg.annotation(args[index])
-                    except Exception:
+                    except Exception:  # pylint: disable=W0703
                         return error_response
                 else:
                     raise TypeError("Invalid annotation: {0}".format(
@@ -356,9 +359,11 @@ def commands(self, **meta):
         dict_keys(['simple'])
         """
 
-        disallowed = ["commands", "__class__"]
+        disallowed = ["commands", "api", "__class__"]
+
         return {
-            method.COMMAND: method for attr in dir(self)
+            method.COMMAND: method
+            for attr in dir(self)
             if attr not in disallowed
             for method in (getattr(self, attr),)
             if hasattr(method, "COMMAND") and
diff --git a/cactusbot/commands/magic/quote.py b/cactusbot/commands/magic/quote.py
index 62d8977..9faf4d2 100644
--- a/cactusbot/commands/magic/quote.py
+++ b/cactusbot/commands/magic/quote.py
@@ -17,9 +17,10 @@ async def default(self, quote: r'[1-9]\d*'=None):
 
         if quote is None:
             response = await self.api.quote.get()
-            if response.status == 404:
+            data = (await response.json())["data"]
+            if not data:
                 return "No quotes have been added!"
-            return (await response.json())["data"][0]["attributes"]["quote"]
+            return data[0]["attributes"]["quote"]
         else:
             response = await self.api.quote.get(quote)
             if response.status == 404:

From 3709dde672a7b235c18f9f77e79a4d7045499aef Mon Sep 17 00:00:00 2001
From: 2Cubed <2Cubed@users.noreply.github.com>
Date: Tue, 28 Mar 2017 13:31:07 -0400
Subject: [PATCH 054/122] Write unit tests for Command

---
 cactusbot/commands/command.py      |  40 ++++++--
 cactusbot/packets/message.py       |   3 +
 cactusbot/services/beam/api.py     |  17 +++-
 cactusbot/services/beam/handler.py |   4 +-
 tests/handlers/test_command.py     | 147 ++++++++++++++++++++++++++++-
 5 files changed, 198 insertions(+), 13 deletions(-)

diff --git a/cactusbot/commands/command.py b/cactusbot/commands/command.py
index 1f9a9d1..51bb092 100644
--- a/cactusbot/commands/command.py
+++ b/cactusbot/commands/command.py
@@ -121,6 +121,8 @@ def __init__(self, api=None):
 
     async def __call__(self, *args, **meta):
 
+        # pylint: disable=R0911
+
         commands = self.commands()
         assert self.default is None or callable(self.default)
 
@@ -128,16 +130,14 @@ async def __call__(self, *args, **meta):
 
             command, *arguments = args
 
-            to_run = [(commands[command], arguments)]
+            to_run = [commands[command]]
             if getattr(commands[command], "default", None) is not None:
-                to_run.append((commands[command].default, arguments))
-            if self.default is not None:
-                to_run.append((self.default, args))
+                to_run.append(commands[command].default)
 
-            for index, (running, _args) in enumerate(to_run):
+            for index, running in enumerate(to_run):
 
                 try:
-                    return await self._run_safe(running, *_args, **meta)
+                    return await self._run_safe(running, *arguments, **meta)
 
                 except ArgsError as error:
 
@@ -150,19 +150,43 @@ async def __call__(self, *args, **meta):
                     has_default = hasattr(running, "default")
                     has_commands = hasattr(running, "commands")
                     if not (has_default or has_commands):
-                        return "Not enough arguments. <{0}>".format(
-                            '> <'.join(arg.name for arg in error.args[1]))
+                        return "Not enough arguments. {0}".format(
+                            ' '.join(map(self._display, error.args)))
 
             return "Not enough arguments. <{0}>".format('|'.join(
                 commands[command].commands(hidden=False).keys()
             ))
 
+        if self.default is not None:
+            try:
+                return await self._run_safe(self.default, *args, **meta)
+            except ArgsError:
+                pass
+
         if args:
             return "Invalid argument: '{0}'.".format(args[0])
 
         return "Not enough arguments. <{0}>".format(
             '|'.join(self.commands(hidden=False).keys()))
 
+    @staticmethod
+    def _display(arg):
+
+        if arg._kind is arg.VAR_POSITIONAL:
+
+            if arg.annotation is False:
+                syntax = "[{}...]"
+            else:
+                syntax = "<{}...>"
+
+        elif arg.default is inspect._empty:
+            syntax = "<{}>"
+
+        else:
+            syntax = "[{}]"
+            
+        return syntax.format(arg.name)
+
     @classmethod
     def command(cls, name=None, **meta):
         """Accept arguments for command decorator.
diff --git a/cactusbot/packets/message.py b/cactusbot/packets/message.py
index de64c8d..828eec0 100644
--- a/cactusbot/packets/message.py
+++ b/cactusbot/packets/message.py
@@ -143,6 +143,9 @@ def __add__(self, other):
 
     def _condense(self):
 
+        if not self.message:
+            return self
+
         message = [self.message[0]]
 
         for component in self.message[1:]:
diff --git a/cactusbot/services/beam/api.py b/cactusbot/services/beam/api.py
index d446fd2..a538a36 100644
--- a/cactusbot/services/beam/api.py
+++ b/cactusbot/services/beam/api.py
@@ -1,5 +1,7 @@
 """Interact with the Beam API."""
 
+import json
+
 from ..api import API
 
 
@@ -12,9 +14,13 @@ class BeamAPI(API):
         "Content-Type": "application/json"
     }
 
-    def authorize(self, token):
-        self.token = token
+    def __init__(self, channel, token):
+
+        super().__init__()
+
+        self.channel = channel
 
+        self.token = token
         self.headers["Authorization"] = "Bearer {}".format(token)
 
     async def get_bot_channel(self, **params):
@@ -34,3 +40,10 @@ async def get_chat(self, chat):
         response = await self.get("/chats/{chat}".format(chat=chat),
                                   headers=self.headers)
         return await response.json()
+
+    async def update_roles(self, user, add, remove):
+        """Update a user's roles."""
+        response = await self.patch("/channels/{channel}/users/{user}".format(
+            channel=self.channel, user=user
+        ), data=json.dumps({"add": add, "remove": remove}))
+        return await response.json()
diff --git a/cactusbot/services/beam/handler.py b/cactusbot/services/beam/handler.py
index 710fdb8..3e0c6f6 100644
--- a/cactusbot/services/beam/handler.py
+++ b/cactusbot/services/beam/handler.py
@@ -31,7 +31,7 @@ def __init__(self, channel, token, handlers):
 
         self.logger = logging.getLogger(__name__)
 
-        self.api = BeamAPI(token)
+        self.api = BeamAPI(channel, token)
 
         self.parser = BeamParser()
         self.handlers = handlers  # HACK, potentially
@@ -124,7 +124,7 @@ async def handle(self, event, data):
                         method="timeout"
                     )
                 else:
-                    pass  # TODO: full ban
+                    await self.api.update_roles(response.user, ["Banned"], [])
 
     async def send(self, *args, **kwargs):
         """Send a packet to Beam."""
diff --git a/tests/handlers/test_command.py b/tests/handlers/test_command.py
index 6945f5e..42beb63 100644
--- a/tests/handlers/test_command.py
+++ b/tests/handlers/test_command.py
@@ -1,6 +1,6 @@
 import pytest
-
 from cactusbot.api import CactusAPI
+from cactusbot.commands.command import Command
 from cactusbot.handlers import CommandHandler
 from cactusbot.packets import MessagePacket
 
@@ -138,3 +138,148 @@ def test_modify():
     assert command_handler.modify("", "shuffle") == ""
 
     assert command_handler.modify("Jello", "reverse", "title") == "Ollej"
+
+
+###
+
+async def add_title(name):
+    """Add 'Potato Master' title to a name."""
+    return "Potato Master " + name.title()
+
+
+class Potato(Command):
+
+    COMMAND = "potato"
+
+    def __init__(self, api, count=0):
+
+        super().__init__(api)
+
+        self.count = count
+
+    @Command.command(name="count")
+    async def default(self):
+        """The number of potatoes."""
+        return "You have {number} potatoes.".format(number=self.count)
+
+    @Command.command()
+    async def add(self, number: r'\d+'):
+        """Add potatoes."""
+
+        self.count += int(number)
+        return "Added {} potatoes.".format(number)
+
+    @Command.command()
+    async def eat(self, number: r'\d+', friend: add_title=None, *,
+                  user: "username"):
+        """Eat potatoes."""
+
+        number = int(number)
+
+        if friend is None:
+            return "{user} ate {number} potatoes!".format(
+                user=user, number=number)
+        return "{friend} ate {number} potatoes!".format(
+            friend=friend, number=number)
+
+    @Command.command(role="subscriber")
+    async def check(self, *items: False):
+        """Check if items are potatoes."""
+
+        if not items:
+            return "You are a potato."
+
+        if len(items) > 3:
+            return "Too many things™!"
+
+        result = []
+        for item in items:
+            if item.lower() == "potato":
+                result.append("Yes")
+            elif item.lower().lstrip('@') == "innectic":
+                result.append("Very")
+            else:
+                result.append("No")
+        return ' '.join(result)
+
+    @Command.command()
+    class Battery(Command):
+        """Potato battery."""
+
+        @Command.command()
+        async def default(self, strength: '[1-9]\d*'=1):
+            """Potato battery."""
+            if strength == 1:
+                return "Potato power!"
+            return "Potato power x {}!".format(strength)
+
+    @Command.command()
+    class Salad(Command):
+        """Potato salad."""
+
+        @Command.command()
+        async def make(self, *ingredients):
+            """Make potato salad."""
+            return "Making potato salad with {}.".format(
+                ', '.join(ingredients))
+
+        @Command.command(hidden=True)
+        async def taco(self):
+            """Taco salad."""
+            return "TACO SALAD!?"
+
+potato = Potato(CactusAPI("test_token", "test_password"))
+
+
+@pytest.mark.asyncio
+async def test_default():
+
+    assert await potato() == "You have 0 potatoes."
+    assert await potato("count") == "You have 0 potatoes."
+
+    assert await potato("battery") == "Potato power!"
+    assert await potato("battery", "high") == "Invalid strength: 'high'."
+    assert await potato("battery", "9001") == "Potato power x 9001!"
+
+    assert await potato("salad") == "Not enough arguments. <make>"
+
+
+@pytest.mark.asyncio
+async def test_args():
+
+    assert await potato("add", "100") == "Added 100 potatoes."
+
+    assert await potato("eat") == "Not enough arguments. <number> [friend]"
+    assert await potato("eat", "8", username="2Cubed") == "2Cubed ate 8 potatoes!"
+    assert await potato(
+        "eat", "2", "innectic", username="2Cubed"
+    ) == "Potato Master Innectic ate 2 potatoes!"
+
+    assert await potato("check") == "You are a potato."
+    assert await potato(
+        "check", packet=MessagePacket(role=4)  # moderator
+    ) == "You are a potato."
+    assert await potato(
+        "check", packet=MessagePacket(role=2)  # subscriber
+    ) == "You are a potato."
+    assert await potato(
+        "check", packet=MessagePacket(role=1)  # user
+    ) == "Role level 'Subscriber' or higher required."
+    assert await potato(
+        "check", "carrot", "potato", "onion"
+    ) == "No Yes No"
+    assert await potato(
+        "check", "Innectic", "@Innectic", "taco"
+    ) == "Very Very No"
+    assert await potato(
+        "check", "way", "too", "many", "things"
+    ) == "Too many things™!"
+
+    assert await potato(
+        "salad", "make"
+    ) == "Not enough arguments. <ingredients...>"
+    assert await potato(
+        "salad", "make", "carrots", "peppers"
+    ) == "Making potato salad with carrots, peppers."
+
+    assert await potato("salad", "taco") == "TACO SALAD!?"

From 7ce6d843d28567be0cc6d5460050234f33986c88 Mon Sep 17 00:00:00 2001
From: 2Cubed <2Cubed@users.noreply.github.com>
Date: Tue, 28 Mar 2017 13:33:14 -0400
Subject: [PATCH 055/122] Add indication that user banning is not necessarily
 working

---
 cactusbot/services/beam/api.py | 3 +++
 1 file changed, 3 insertions(+)

diff --git a/cactusbot/services/beam/api.py b/cactusbot/services/beam/api.py
index a538a36..c5a4327 100644
--- a/cactusbot/services/beam/api.py
+++ b/cactusbot/services/beam/api.py
@@ -43,6 +43,9 @@ async def get_chat(self, chat):
 
     async def update_roles(self, user, add, remove):
         """Update a user's roles."""
+
+        # TODO: Confirm that this works.
+
         response = await self.patch("/channels/{channel}/users/{user}".format(
             channel=self.channel, user=user
         ), data=json.dumps({"add": add, "remove": remove}))

From ecd445fc8f313888ca085c8ad28966a578162b73 Mon Sep 17 00:00:00 2001
From: 2Cubed <2Cubed@users.noreply.github.com>
Date: Tue, 28 Mar 2017 13:35:19 -0400
Subject: [PATCH 056/122] Fix tests

---
 cactusbot/commands/command.py | 4 +++-
 1 file changed, 3 insertions(+), 1 deletion(-)

diff --git a/cactusbot/commands/command.py b/cactusbot/commands/command.py
index 51bb092..fcfa72a 100644
--- a/cactusbot/commands/command.py
+++ b/cactusbot/commands/command.py
@@ -172,6 +172,8 @@ async def __call__(self, *args, **meta):
     @staticmethod
     def _display(arg):
 
+        # pylint: disable=W0212
+
         if arg._kind is arg.VAR_POSITIONAL:
 
             if arg.annotation is False:
@@ -184,7 +186,7 @@ def _display(arg):
 
         else:
             syntax = "[{}]"
-            
+
         return syntax.format(arg.name)
 
     @classmethod

From f71e501ce764e855754dfd4c0c4fe881a2cf8605 Mon Sep 17 00:00:00 2001
From: 2Cubed <2Cubed@users.noreply.github.com>
Date: Tue, 28 Mar 2017 14:01:34 -0400
Subject: [PATCH 057/122] Fix full user bans

---
 cactusbot/services/beam/api.py     | 13 +++++++++++--
 cactusbot/services/beam/handler.py |  5 ++++-
 2 files changed, 15 insertions(+), 3 deletions(-)

diff --git a/cactusbot/services/beam/api.py b/cactusbot/services/beam/api.py
index c5a4327..f6d64ee 100644
--- a/cactusbot/services/beam/api.py
+++ b/cactusbot/services/beam/api.py
@@ -23,6 +23,17 @@ def __init__(self, channel, token):
         self.token = token
         self.headers["Authorization"] = "Bearer {}".format(token)
 
+    async def request(self, method, endpoint, **kwargs):
+        """Send HTTP request to an endpoint."""
+
+        if "headers" in kwargs:
+            headers = self.headers.copy()
+            headers.update(kwargs["headers"])
+            kwargs["headers"] = headers
+        else:
+            kwargs["headers"] = self.headers
+        return await super().request(method, endpoint, **kwargs)
+
     async def get_bot_channel(self, **params):
         """Get the bot's user id."""
         response = await self.get("/users/current", params=params,
@@ -44,8 +55,6 @@ async def get_chat(self, chat):
     async def update_roles(self, user, add, remove):
         """Update a user's roles."""
 
-        # TODO: Confirm that this works.
-
         response = await self.patch("/channels/{channel}/users/{user}".format(
             channel=self.channel, user=user
         ), data=json.dumps({"add": add, "remove": remove}))
diff --git a/cactusbot/services/beam/handler.py b/cactusbot/services/beam/handler.py
index 3e0c6f6..e64bc21 100644
--- a/cactusbot/services/beam/handler.py
+++ b/cactusbot/services/beam/handler.py
@@ -124,7 +124,10 @@ async def handle(self, event, data):
                         method="timeout"
                     )
                 else:
-                    await self.api.update_roles(response.user, ["Banned"], [])
+                    user_id = (await self.api.get_channel(
+                        response.user, fields="userId"
+                    ))["userId"]
+                    await self.api.update_roles(user_id, ["Banned"], [])
 
     async def send(self, *args, **kwargs):
         """Send a packet to Beam."""

From c8a1d8e4c1a0097a47d06abf5a36926abeeebfd1 Mon Sep 17 00:00:00 2001
From: 2Cubed <2Cubed@users.noreply.github.com>
Date: Tue, 28 Mar 2017 14:24:05 -0400
Subject: [PATCH 058/122] Fix default method help message

Solves #264.
---
 cactusbot/commands/command.py  | 8 ++++++--
 tests/handlers/test_command.py | 2 +-
 2 files changed, 7 insertions(+), 3 deletions(-)

diff --git a/cactusbot/commands/command.py b/cactusbot/commands/command.py
index fcfa72a..c45d5c4 100644
--- a/cactusbot/commands/command.py
+++ b/cactusbot/commands/command.py
@@ -160,12 +160,16 @@ async def __call__(self, *args, **meta):
         if self.default is not None:
             try:
                 return await self._run_safe(self.default, *args, **meta)
-            except ArgsError:
-                pass
+            except ArgsError as err:
+                error = err
 
         if args:
             return "Invalid argument: '{0}'.".format(args[0])
 
+        if self.default is not None:
+            return "Not enough arguments. {0}".format(
+                ' '.join(map(self._display, error.args)))
+
         return "Not enough arguments. <{0}>".format(
             '|'.join(self.commands(hidden=False).keys()))
 
diff --git a/tests/handlers/test_command.py b/tests/handlers/test_command.py
index 42beb63..e3c8aeb 100644
--- a/tests/handlers/test_command.py
+++ b/tests/handlers/test_command.py
@@ -207,7 +207,7 @@ class Battery(Command):
         """Potato battery."""
 
         @Command.command()
-        async def default(self, strength: '[1-9]\d*'=1):
+        async def default(self, strength: r'[1-9]\d*'=1):
             """Potato battery."""
             if strength == 1:
                 return "Potato power!"

From 7affb79c785b005d152dcf0335751c4b8286b2c1 Mon Sep 17 00:00:00 2001
From: 2Cubed <2Cubed@users.noreply.github.com>
Date: Tue, 28 Mar 2017 15:23:19 -0400
Subject: [PATCH 059/122] Fix output from nested commands

Thanks, unit tests!
---
 cactusbot/commands/command.py  | 26 +++++++++++++++++---------
 tests/handlers/test_command.py | 16 ++++++++++++++++
 2 files changed, 33 insertions(+), 9 deletions(-)

diff --git a/cactusbot/commands/command.py b/cactusbot/commands/command.py
index c45d5c4..8fbbeef 100644
--- a/cactusbot/commands/command.py
+++ b/cactusbot/commands/command.py
@@ -153,9 +153,17 @@ async def __call__(self, *args, **meta):
                         return "Not enough arguments. {0}".format(
                             ' '.join(map(self._display, error.args)))
 
-            return "Not enough arguments. <{0}>".format('|'.join(
-                commands[command].commands(hidden=False).keys()
-            ))
+            keys = commands[command].commands(hidden=False).keys()
+
+            if keys and list(keys) != ["default"]:
+                return "Not enough arguments. <{0}>".format('|'.join(keys))
+
+            param_args = self._check_safe(
+                commands[command].default, *arguments, error=False)
+
+            return "Not enough arguments. {0}".format(' '.join(map(
+                self._display, param_args
+            )))
 
         if self.default is not None:
             try:
@@ -288,7 +296,7 @@ async def _run_safe(self, function, *args, **meta):
         return await function(*args, **kwargs)
 
     @staticmethod
-    def _check_safe(function, *args):
+    def _check_safe(function, *args, error=True):
 
         params = inspect.signature(function).parameters.values()
 
@@ -304,13 +312,13 @@ def _check_safe(function, *args):
             len(args) if star_arg else len(pos_args)
         )
 
-        if not arg_range[0] <= len(args) <= arg_range[1]:
-
-            if star_arg is not None:
-                pos_args += (star_arg,)
+        if star_arg is not None:
+            pos_args += (star_arg,)
 
+        if error and not arg_range[0] <= len(args) <= arg_range[1]:
             raise ArgsError(len(args) > arg_range[0], pos_args)
-        return True
+
+        return pos_args
 
     @staticmethod
     async def _clean_args(function, *args):
diff --git a/tests/handlers/test_command.py b/tests/handlers/test_command.py
index e3c8aeb..ffd246f 100644
--- a/tests/handlers/test_command.py
+++ b/tests/handlers/test_command.py
@@ -213,6 +213,17 @@ async def default(self, strength: r'[1-9]\d*'=1):
                 return "Potato power!"
             return "Potato power x {}!".format(strength)
 
+    @Command.command(hidden=True)
+    class Wizard(Command):
+        """Potato wizard."""
+
+        @Command.command()
+        async def default(self, *things):
+            """Potato wizard."""
+            return MessagePacket(
+                "waves wand at {} things...".format(len(things)), action=True
+            )
+
     @Command.command()
     class Salad(Command):
         """Potato salad."""
@@ -275,6 +286,11 @@ async def test_args():
         "check", "way", "too", "many", "things"
     ) == "Too many things™!"
 
+    assert await potato("wizard") == "Not enough arguments. <things...>"
+    assert (await potato(
+        "wizard", "taco", "salad"
+    )).text == "waves wand at 2 things..."
+
     assert await potato(
         "salad", "make"
     ) == "Not enough arguments. <ingredients...>"

From 1843ee9f5a643dd77a9d8f67d54ec7f322f7529c Mon Sep 17 00:00:00 2001
From: Innectic <innectic@gmail.com>
Date: Tue, 28 Mar 2017 17:00:52 -0700
Subject: [PATCH 060/122] Sepal tests

---
 tests/sepal/test_sepal_parsing.py | 96 +++++++++++++++++++++++++++++++
 1 file changed, 96 insertions(+)
 create mode 100644 tests/sepal/test_sepal_parsing.py

diff --git a/tests/sepal/test_sepal_parsing.py b/tests/sepal/test_sepal_parsing.py
new file mode 100644
index 0000000..4c865eb
--- /dev/null
+++ b/tests/sepal/test_sepal_parsing.py
@@ -0,0 +1,96 @@
+
+from cactusbot.sepal import SepalParser
+
+import pytest
+
+REPEAT_PACKET = {
+    "type": "event",
+    "event": "config",
+    "channel": "innectic2",
+    "data": {
+        "message": [
+            {
+                "text": "Hello! ",
+                "data": "Hello! ",
+                "type": "text"
+            },
+            {
+                "text": ":D",
+                "data": "😮",
+                "type": "emoji"
+            }
+        ]
+    }
+}
+
+CONFIG_PACKET = {
+    "type": "event",
+    "event": "config",
+    "channel": "innectic2",
+    "data": {
+        "announce": {
+            "follow": {
+                "announce": True,
+                "message": "Thanks for following, %USER%"
+            },
+            "host": {
+                "announce": False,
+                "message": "Thanks for hosting, %USER%"
+            },
+            "join": {
+                "announce": False,
+                "message": "Hello %USER%!"
+            },
+            "leave": {
+                "announce": False,
+                "message": "Thanks for watching %USER%!"
+            },
+            "sub": {
+                "announce": True,
+                "message": "Thanks for subbing, %USER%"
+            }
+        },
+        "spam": {
+            "allowUrls": False,
+            "maxCapsScore": 16,
+            "maxEmoji": 6
+        },
+        "token": "innectic2",
+        "whitelistedUrls": []
+    }
+}
+
+parser = SepalParser()
+
+
+@pytest.mark.asyncio
+async def test_parse_config():
+    packet = await parser.parse_config(CONFIG_PACKET)
+
+    assert len(packet) is 4
+    assert packet[0].json["values"]["follow"]["announce"] is True
+    assert packet[0].json["values"]["follow"]["message"] == "Thanks for following, %USER%"
+
+
+@pytest.mark.asyncio
+async def test_parse_repeat():
+    packet = await parser.parse_repeat(REPEAT_PACKET)
+
+    assert packet.json["message"] == [
+        {
+            "text": "Hello! ",
+            "data": "Hello! ",
+            "type": "text"
+        },
+        {
+            "text": ":D",
+            "data": "😮",
+            "type": "emoji"
+        }
+    ]
+
+    assert packet.json["role"] == 1
+    assert packet.json["action"] is False
+    assert packet.json["target"] is None
+
+    assert packet.text == "Hello! :D"

From 7eba6c8ddaee1942ba9a3c767f808312a5f0435e Mon Sep 17 00:00:00 2001
From: Innectic <innectic@gmail.com>
Date: Tue, 28 Mar 2017 17:08:21 -0700
Subject: [PATCH 061/122] I'm dumb

---
 tests/sepal/test_sepal_parsing.py | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/tests/sepal/test_sepal_parsing.py b/tests/sepal/test_sepal_parsing.py
index 4c865eb..89310dd 100644
--- a/tests/sepal/test_sepal_parsing.py
+++ b/tests/sepal/test_sepal_parsing.py
@@ -68,8 +68,8 @@ async def test_parse_config():
     packet = await parser.parse_config(CONFIG_PACKET)
 
     assert len(packet) is 4
-    assert packet[0].json["values"]["follow"]["announce"] is True
-    assert packet[0].json["values"]["follow"]["message"] == "Thanks for following, %USER%"
+    assert packet.json[0]["values"]["follow"]["announce"] is True
+    assert packet.json[0]["values"]["follow"]["message"] == "Thanks for following, %USER%"
 
 
 @pytest.mark.asyncio

From 922f6f8514b6c041b02b77d3413a21c1c48ce1ac Mon Sep 17 00:00:00 2001
From: Innectic <innectic@gmail.com>
Date: Tue, 28 Mar 2017 17:09:53 -0700
Subject: [PATCH 062/122] Revert "I'm dumb"

This reverts commit 7eba6c8ddaee1942ba9a3c767f808312a5f0435e.
---
 tests/sepal/test_sepal_parsing.py | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/tests/sepal/test_sepal_parsing.py b/tests/sepal/test_sepal_parsing.py
index 89310dd..4c865eb 100644
--- a/tests/sepal/test_sepal_parsing.py
+++ b/tests/sepal/test_sepal_parsing.py
@@ -68,8 +68,8 @@ async def test_parse_config():
     packet = await parser.parse_config(CONFIG_PACKET)
 
     assert len(packet) is 4
-    assert packet.json[0]["values"]["follow"]["announce"] is True
-    assert packet.json[0]["values"]["follow"]["message"] == "Thanks for following, %USER%"
+    assert packet[0].json["values"]["follow"]["announce"] is True
+    assert packet[0].json["values"]["follow"]["message"] == "Thanks for following, %USER%"
 
 
 @pytest.mark.asyncio

From 7a721b6de9a9a3d55e46bf18becf69620ce73f19 Mon Sep 17 00:00:00 2001
From: Innectic <innectic@gmail.com>
Date: Tue, 28 Mar 2017 17:13:27 -0700
Subject: [PATCH 063/122] Pytest is a great thing

---
 tests/sepal/test_sepal_parsing.py | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/tests/sepal/test_sepal_parsing.py b/tests/sepal/test_sepal_parsing.py
index 4c865eb..fa22d7c 100644
--- a/tests/sepal/test_sepal_parsing.py
+++ b/tests/sepal/test_sepal_parsing.py
@@ -68,8 +68,8 @@ async def test_parse_config():
     packet = await parser.parse_config(CONFIG_PACKET)
 
     assert len(packet) is 4
-    assert packet[0].json["values"]["follow"]["announce"] is True
-    assert packet[0].json["values"]["follow"]["message"] == "Thanks for following, %USER%"
+    assert packet[0]["values"]["follow"]["announce"] is True
+    assert packet[0]["values"]["follow"]["message"] == "Thanks for following, %USER%"
 
 
 @pytest.mark.asyncio

From d75dd39e0f43f31f3b51e96078e1f454d669052f Mon Sep 17 00:00:00 2001
From: Innectic <innectic@gmail.com>
Date: Tue, 28 Mar 2017 17:16:21 -0700
Subject: [PATCH 064/122] Pass pls

---
 tests/sepal/test_sepal_parsing.py | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/tests/sepal/test_sepal_parsing.py b/tests/sepal/test_sepal_parsing.py
index fa22d7c..4c865eb 100644
--- a/tests/sepal/test_sepal_parsing.py
+++ b/tests/sepal/test_sepal_parsing.py
@@ -68,8 +68,8 @@ async def test_parse_config():
     packet = await parser.parse_config(CONFIG_PACKET)
 
     assert len(packet) is 4
-    assert packet[0]["values"]["follow"]["announce"] is True
-    assert packet[0]["values"]["follow"]["message"] == "Thanks for following, %USER%"
+    assert packet[0].json["values"]["follow"]["announce"] is True
+    assert packet[0].json["values"]["follow"]["message"] == "Thanks for following, %USER%"
 
 
 @pytest.mark.asyncio

From f2e4b15bf874e059811d3beb2ecdf2ee75e4c136 Mon Sep 17 00:00:00 2001
From: Alkali-Metal <alkali.maps@gmail.com>
Date: Wed, 29 Mar 2017 21:24:41 -0600
Subject: [PATCH 065/122] Add defaults to variable documentation

---
 docs/user/variables.md | 11 +++++++++++
 1 file changed, 11 insertions(+)

diff --git a/docs/user/variables.md b/docs/user/variables.md
index 646c225..761acde 100644
--- a/docs/user/variables.md
+++ b/docs/user/variables.md
@@ -68,6 +68,17 @@ Change the output of variables. To use a modifier, append `|` and the modifier t
 
 Multiple modifiers may be chained, and will be evaluated from left to right.
 
+## `=` (Default)
+Any variable can be assigned a default by adding `=` and then whatever you want to be the default after it
+```
+[Alkali] !command add alkali Alkali is a %ARG1=Metal%
+[CactusBot] Added command !alkali.
+[Alkali] !alkali
+[CactusBot] Alkali is a Metal
+[Innectic] !alkali potato
+[CactusBot] ALkali is a potato
+```
+
 ## `upper`
 
 Replace all lowercase letters with their uppercase equivalents.

From aada82385b9af9070827e2ae05173ca9f882f392 Mon Sep 17 00:00:00 2001
From: Alkali-Metal <alkali.maps@gmail.com>
Date: Wed, 29 Mar 2017 21:33:09 -0600
Subject: [PATCH 066/122] Add period and newline

---
 docs/user/variables.md | 3 ++-
 1 file changed, 2 insertions(+), 1 deletion(-)

diff --git a/docs/user/variables.md b/docs/user/variables.md
index 761acde..34d6ff2 100644
--- a/docs/user/variables.md
+++ b/docs/user/variables.md
@@ -69,7 +69,8 @@ Change the output of variables. To use a modifier, append `|` and the modifier t
 Multiple modifiers may be chained, and will be evaluated from left to right.
 
 ## `=` (Default)
-Any variable can be assigned a default by adding `=` and then whatever you want to be the default after it
+Any variable can be assigned a default by adding `=` and then whatever you want to be the default after it.
+
 ```
 [Alkali] !command add alkali Alkali is a %ARG1=Metal%
 [CactusBot] Added command !alkali.

From fd9686e8b80e85919d1e74c7490faa040e0547fb Mon Sep 17 00:00:00 2001
From: 2Cubed <2Cubed@users.noreply.github.com>
Date: Wed, 29 Mar 2017 23:53:33 -0400
Subject: [PATCH 067/122] Fix Sepal config packet parsing and usage

---
 cactusbot/commands/magic/config.py | 12 ++++++------
 cactusbot/handlers/events.py       | 13 ++++++-------
 cactusbot/handlers/spam.py         |  8 ++++----
 cactusbot/sepal.py                 |  7 +++++--
 4 files changed, 21 insertions(+), 19 deletions(-)

diff --git a/cactusbot/commands/magic/config.py b/cactusbot/commands/magic/config.py
index ce72972..65a3d2c 100644
--- a/cactusbot/commands/magic/config.py
+++ b/cactusbot/commands/magic/config.py
@@ -253,8 +253,8 @@ async def default(self, value=""):
 
                 if not value:
                     urls = await _get_spam_data(self.api, "allowUrls")
-                    return "URLs are {dis}abled.".format(
-                        dis='en' if urls else 'dis')
+                    return "URLs are {dis}allowed.".format(
+                        dis='' if urls else 'dis')
 
                 if value in VALID_TOGGLE_ON_STATES:
                     await _update_config(
@@ -278,13 +278,13 @@ async def default(self, value=""):
 
                 if not value:
                     emoji = await _get_spam_data(self.api, "maxEmoji")
-                    return "Maximum amount of emojis allowed is {}".format(
+                    return "Maximum amount of emoji allowed is {}.".format(
                         emoji)
 
                 response = await _update_config(
                     self.api, "spam", "maxEmoji", value)
                 if response.status == 200:
-                    return "Max emojis updated to {}".format(value)
+                    return "Max emoji updated to {}.".format(value)
                 return "An error occurred."
 
         @Command.command()
@@ -297,10 +297,10 @@ async def default(self, value=""):
 
                 if not value:
                     caps = await _get_spam_data(self.api, "maxCapsScore")
-                    return "Max caps score is {}".format(caps)
+                    return "Max caps score is {}.".format(caps)
 
                 response = await _update_config(
                     self.api, "spam", "maxCapsScore", value)
                 if response.status == 200:
-                    return "Max caps score is now {}".format(value)
+                    return "Max caps score is now {}.".format(value)
                 return "An error occurred."
diff --git a/cactusbot/handlers/events.py b/cactusbot/handlers/events.py
index 9dc9942..4d7e1b7 100644
--- a/cactusbot/handlers/events.py
+++ b/cactusbot/handlers/events.py
@@ -113,14 +113,13 @@ async def on_leave(self, packet):
     async def on_config(self, packet):
         """Handle config update events."""
 
-        values = packet.kwargs["values"]
-        if packet.kwargs["key"] == "announce":
+        if packet.type == "announce":
             self.alert_messages = {
-                "follow": values["follow"],
-                "subscribe": values["sub"],
-                "host": values["host"],
-                "join": values["join"],
-                "leave": values["leave"]
+                "follow": packet.kwargs["follow"],
+                "subscribe": packet.kwargs["sub"],
+                "host": packet.kwargs["host"],
+                "join": packet.kwargs["join"],
+                "leave": packet.kwargs["leave"]
             }
 
     async def _cache(self, packet, event):
diff --git a/cactusbot/handlers/spam.py b/cactusbot/handlers/spam.py
index 2d9d3b7..f68f7d9 100644
--- a/cactusbot/handlers/spam.py
+++ b/cactusbot/handlers/spam.py
@@ -68,10 +68,10 @@ async def on_message(self, packet):
     async def on_config(self, packet):
         """Handle config update events."""
 
-        if packet.kwargs["key"] == "spam":
-            self.config["max_emoji"] = packet.kwargs["values"]["maxEmoji"]
-            self.config["max_score"] = packet.kwargs["values"]["maxCapsScore"]
-            self.config["allow_urls"] = packet.kwargs["values"]["allowUrls"]
+        if packet.type == "spam":
+            self.config["max_emoji"] = packet.kwargs["maxEmoji"]
+            self.config["max_score"] = packet.kwargs["maxCapsScore"]
+            self.config["allow_urls"] = packet.kwargs["allowUrls"]
 
     def check_caps(self, message):
         """Check for excessive capital characters in the message."""
diff --git a/cactusbot/sepal.py b/cactusbot/sepal.py
index b43357f..12e9386 100644
--- a/cactusbot/sepal.py
+++ b/cactusbot/sepal.py
@@ -81,5 +81,8 @@ async def parse_repeat(self, packet):
     async def parse_config(self, packet):
         """Parse the incoming config packets."""
 
-        return [Packet("config", key=key, values=values)
-                for key, values in packet["data"].items()]
+        return [
+            Packet("announce", **packet["data"]["announce"]),
+            Packet("spam", **packet["data"]["spam"]),
+            Packet("whitelistedUrls", urls=packet["data"]["whitelistedUrls"])
+        ]

From 0614851b7a13396beb93311e65a1f2550cdfb40f Mon Sep 17 00:00:00 2001
From: 2Cubed <2Cubed@users.noreply.github.com>
Date: Wed, 29 Mar 2017 23:54:20 -0400
Subject: [PATCH 068/122] Fix Sepal config packet tests

---
 tests/sepal/test_sepal_parsing.py | 194 +++++++++++++++---------------
 1 file changed, 98 insertions(+), 96 deletions(-)

diff --git a/tests/sepal/test_sepal_parsing.py b/tests/sepal/test_sepal_parsing.py
index 4c865eb..b3844a2 100644
--- a/tests/sepal/test_sepal_parsing.py
+++ b/tests/sepal/test_sepal_parsing.py
@@ -1,96 +1,98 @@
-
-from cactusbot.sepal import SepalParser
-
-import pytest
-
-REPEAT_PACKET = {
-    "type": "event",
-    "event": "config",
-    "channel": "innectic2",
-    "data": {
-        "message": [
-            {
-                "text": "Hello! ",
-                "data": "Hello! ",
-                "type": "text"
-            },
-            {
-                "text": ":D",
-                "data": "😮",
-                "type": "emoji"
-            }
-        ]
-    }
-}
-
-CONFIG_PACKET = {
-    "type": "event",
-    "event": "config",
-    "channel": "innectic2",
-    "data": {
-        "announce": {
-            "follow": {
-                "announce": True,
-                "message": "Thanks for following, %USER%"
-            },
-            "host": {
-                "announce": False,
-                "message": "Thanks for hosting, %USER%"
-            },
-            "join": {
-                "announce": False,
-                "message": "Hello %USER%!"
-            },
-            "leave": {
-                "announce": False,
-                "message": "Thanks for watching %USER%!"
-            },
-            "sub": {
-                "announce": True,
-                "message": "Thanks for subbing, %USER%"
-            }
-        },
-        "spam": {
-            "allowUrls": False,
-            "maxCapsScore": 16,
-            "maxEmoji": 6
-        },
-        "token": "innectic2",
-        "whitelistedUrls": []
-    }
-}
-
-parser = SepalParser()
-
-
-@pytest.mark.asyncio
-async def test_parse_config():
-    packet = await parser.parse_config(CONFIG_PACKET)
-
-    assert len(packet) is 4
-    assert packet[0].json["values"]["follow"]["announce"] is True
-    assert packet[0].json["values"]["follow"]["message"] == "Thanks for following, %USER%"
-
-
-@pytest.mark.asyncio
-async def test_parse_repeat():
-    packet = await parser.parse_repeat(REPEAT_PACKET)
-
-    assert packet.json["message"] == [
-        {
-            "text": "Hello! ",
-            "data": "Hello! ",
-            "type": "text"
-        },
-        {
-            "text": ":D",
-            "data": "😮",
-            "type": "emoji"
-        }
-    ]
-
-    assert packet.json["role"] == 1
-    assert packet.json["action"] is False
-    assert packet.json["target"] is None
-
-    assert packet.text == "Hello! :D"
+import pytest
+from cactusbot.sepal import SepalParser
+
+REPEAT_PACKET = {
+    "type": "event",
+    "event": "config",
+    "channel": "innectic2",
+    "data": {
+        "message": [
+            {
+                "text": "Hello! ",
+                "data": "Hello! ",
+                "type": "text"
+            },
+            {
+                "text": ":D",
+                "data": "😮",
+                "type": "emoji"
+            }
+        ]
+    }
+}
+
+CONFIG_PACKET = {
+    "type": "event",
+    "event": "config",
+    "channel": "innectic2",
+    "data": {
+        "announce": {
+            "follow": {
+                "announce": True,
+                "message": "Thanks for following, %USER%!"
+            },
+            "host": {
+                "announce": False,
+                "message": "Thanks for hosting, %USER%!"
+            },
+            "join": {
+                "announce": False,
+                "message": "Hello, %USER%!"
+            },
+            "leave": {
+                "announce": False,
+                "message": "Thanks for watching %USER%!"
+            },
+            "sub": {
+                "announce": True,
+                "message": "Thanks for subbing, %USER%!"
+            }
+        },
+        "spam": {
+            "allowUrls": False,
+            "maxCapsScore": 16,
+            "maxEmoji": 6
+        },
+        "token": "innectic2",
+        "whitelistedUrls": []
+    }
+}
+
+parser = SepalParser()
+
+
+@pytest.mark.asyncio
+async def test_parse_repeat():
+    packet = await parser.parse_repeat(REPEAT_PACKET)
+
+    assert packet.json["message"] == [
+        {
+            "text": "Hello! ",
+            "data": "Hello! ",
+            "type": "text"
+        },
+        {
+            "text": ":D",
+            "data": "😮",
+            "type": "emoji"
+        }
+    ]
+
+    assert packet.json["role"] == 1
+    assert packet.json["action"] is False
+    assert packet.json["target"] is None
+
+    assert packet.text == "Hello! :D"
+
+
+@pytest.mark.asyncio
+async def test_parse_config():
+
+    packets = await parser.parse_config(CONFIG_PACKET)
+
+    assert len(packets) == 3
+    announce, spam, urls = packets
+
+    assert announce.json == CONFIG_PACKET["data"]["announce"]
+    assert spam.json == CONFIG_PACKET["data"]["spam"]
+    assert urls.json == {"urls": CONFIG_PACKET["data"]["whitelistedUrls"]}

From e710096771d869763d1209f57ac661df87712a98 Mon Sep 17 00:00:00 2001
From: 2Cubed <2Cubed@users.noreply.github.com>
Date: Sat, 15 Apr 2017 12:43:46 -0400
Subject: [PATCH 069/122] Add Coveralls

---
 .travis.yml | 13 ++++++++++++-
 README.md   |  2 ++
 2 files changed, 14 insertions(+), 1 deletion(-)

diff --git a/.travis.yml b/.travis.yml
index b1db1c7..65cacb8 100644
--- a/.travis.yml
+++ b/.travis.yml
@@ -1,23 +1,34 @@
 language: python
+
 python:
   - 3.5
+
 branches:
   only:
     - master
     - develop
     - /^release-v(\d+.){0,2}\d+$/
+
 before_install:
     - pip install flake8 pylint
     - pip install pytest pytest-asyncio --upgrade
+    - pip install coveralls
+
 install:
   - pip install -r requirements.txt
+
 env:
   - PYTHONPATH=.
+
 before_script:
   - cp config.template.py config.py
+
 script:
-  - nosetests
+  - nosetests --with-coverage --cover-package=cactusbot
   - flake8 run.py config.template.py cactusbot/
   - pylint cactusbot/
   - pytest tests/
   - pytest cactusbot/ --doctest-modules
+
+after_success:
+  - coveralls
diff --git a/README.md b/README.md
index ce1d175..82e8aa5 100644
--- a/README.md
+++ b/README.md
@@ -1,5 +1,7 @@
 # CactusBot
 
+[![Coverage Status](https://coveralls.io/repos/github/CactusBot/CactusBot/badge.svg?branch=master)](https://coveralls.io/github/CactusBot/CactusBot?branch=master)
+
 CactusBot is a next-generation chat bot for live streams.
 Harnessing the power of open-source, and an extraordinary community to shape its path
 

From 8bf3872a71202c627844ca85c4a103e635e3061a Mon Sep 17 00:00:00 2001
From: 2Cubed <2Cubed@users.noreply.github.com>
Date: Sat, 15 Apr 2017 13:14:29 -0400
Subject: [PATCH 070/122] Satisfy pylint

---
 cactusbot/api.py                    |  4 ++-
 cactusbot/commands/command.py       |  3 +-
 cactusbot/commands/magic/alias.py   |  4 +--
 cactusbot/commands/magic/command.py |  2 +-
 cactusbot/commands/magic/config.py  | 43 ++++++++++++++++-------------
 cactusbot/commands/magic/quote.py   |  2 +-
 cactusbot/commands/magic/repeat.py  |  4 +--
 cactusbot/commands/magic/social.py  | 26 ++++++++---------
 cactusbot/commands/magic/trust.py   |  3 +-
 cactusbot/handlers/command.py       |  8 ++++--
 cactusbot/handlers/spam.py          | 10 ++++---
 cactusbot/packets/message.py        |  2 +-
 cactusbot/sepal.py                  |  8 +++---
 cactusbot/services/api.py           | 27 ++++++++++++------
 cactusbot/services/beam/chat.py     |  4 +--
 cactusbot/services/websocket.py     |  6 ++--
 16 files changed, 88 insertions(+), 68 deletions(-)

diff --git a/cactusbot/api.py b/cactusbot/api.py
index 81ca537..9de3f5c 100644
--- a/cactusbot/api.py
+++ b/cactusbot/api.py
@@ -41,9 +41,11 @@ def __init__(self, token, password, url=URL, auth_token="", **kwargs):
     def __getattr__(self, attr):
         return self.buckets.get(attr)
 
-    async def request(self, method, endpoint, is_json=True, **kwargs):
+    async def request(self, method, endpoint, **kwargs):
         """Send HTTP request to endpoint."""
 
+        is_json = kwargs.get("is_json", True)
+
         headers = {
             "X-Auth-Token": self.token,
             "X-Auth-Key": self.auth_token
diff --git a/cactusbot/commands/command.py b/cactusbot/commands/command.py
index 8fbbeef..7da7345 100644
--- a/cactusbot/commands/command.py
+++ b/cactusbot/commands/command.py
@@ -284,6 +284,7 @@ async def _run_safe(self, function, *args, **meta):
             role = list(ROLES.keys())[list(map(
                 str.lower, ROLES.values())).index(role.lower())]
         if "packet" in meta and meta["packet"].role < role:
+            # pylint: disable=C0201
             return "Role level '{r}' or higher required.".format(
                 r=ROLES[max(k for k in ROLES.keys() if k <= role)])
 
@@ -399,7 +400,7 @@ def commands(self, **meta):
 
         disallowed = ["commands", "api", "__class__"]
 
-        return {
+        return {  # pylint: disable=E1101
             method.COMMAND: method
             for attr in dir(self)
             if attr not in disallowed
diff --git a/cactusbot/commands/magic/alias.py b/cactusbot/commands/magic/alias.py
index d37553f..ddb6ca1 100644
--- a/cactusbot/commands/magic/alias.py
+++ b/cactusbot/commands/magic/alias.py
@@ -32,7 +32,7 @@ async def add(self, alias: "?command", command: "?command", *_: False,
             return "Command !{} does not exist.".format(command)
         elif response.status == 400:
             json = await response.json()
-            if len(json.get("errors", [])) > 0:
+            if json.get("errors", []):
                 return json["errors"][0]
 
     @Command.command(role="moderator")
@@ -59,6 +59,6 @@ async def list_aliases(self):
                     command["attributes"]["commandName"])
                 for command in aliases
             )))
-            if len(aliases) > 0:
+            if aliases:
                 return response
         return "No aliases added!"
diff --git a/cactusbot/commands/magic/command.py b/cactusbot/commands/magic/command.py
index 7b9b5fd..9e3a5d4 100644
--- a/cactusbot/commands/magic/command.py
+++ b/cactusbot/commands/magic/command.py
@@ -73,7 +73,7 @@ async def disable(self, command: "?command"):
 
     @Command.command(role="moderator")
     async def count(self, command: r'?command',
-                    action: r"([=+-]?)(\d+)"=None):
+                    action: r"([=+-]?)(\d+)" = None):
         """Update the count of a command."""
 
         if action is None:
diff --git a/cactusbot/commands/magic/config.py b/cactusbot/commands/magic/config.py
index ce72972..f8d2316 100644
--- a/cactusbot/commands/magic/config.py
+++ b/cactusbot/commands/magic/config.py
@@ -67,12 +67,13 @@ async def default(self, value=""):
                 await _update_deep_config(
                     self.api, "announce", "follow", "announce", True)
                 return "Follow announcements are now enabled."
-            elif value in VALID_TOGGLE_OFF_STATES:
+
+            if value in VALID_TOGGLE_OFF_STATES:
                 await _update_deep_config(
                     self.api, "announce", "follow", "announce", False)
                 return "Follow announcements are now disabled."
-            else:
-                return "Invalid boolean value: `{}`!".format(value)
+
+            return "Invalid boolean value: `{}`!".format(value)
 
         @Command.command(role="moderator")
         async def message(self, *message: False):
@@ -105,12 +106,13 @@ async def default(self, value=""):
                 await _update_deep_config(
                     self.api, "announce", "sub", "announce", True)
                 return "Subscribe announcements are now enabled."
-            elif value in VALID_TOGGLE_OFF_STATES:
+
+            if value in VALID_TOGGLE_OFF_STATES:
                 await _update_deep_config(
                     self.api, "announce", "sub", "announce", False)
                 return "Subscribe announcements are now disabled."
-            else:
-                return "Invalid boolean value: `{}`!".format(value)
+
+            return "Invalid boolean value: `{}`!".format(value)
 
         @Command.command(role="moderator")
         async def message(self, *message: False):
@@ -143,12 +145,13 @@ async def default(self, value=""):
                 await _update_deep_config(
                     self.api, "announce", "host", "announce", True)
                 return "Host announcements are now enabled."
-            elif value in VALID_TOGGLE_OFF_STATES:
+
+            if value in VALID_TOGGLE_OFF_STATES:
                 await _update_deep_config(
                     self.api, "announce", "host", "announce", False)
                 return "Host announcements are now disabled."
-            else:
-                return "Invalid boolean value: `{}`!".format(value)
+
+            return "Invalid boolean value: `{}`!".format(value)
 
         @Command.command(role="moderator")
         async def message(self, *message: False):
@@ -181,12 +184,13 @@ async def default(self, value=""):
                 await _update_deep_config(
                     self.api, "announce", "leave", "announce", True)
                 return "Leave announcements are now enabled."
-            elif value in VALID_TOGGLE_OFF_STATES:
+
+            if value in VALID_TOGGLE_OFF_STATES:
                 await _update_deep_config(
                     self.api, "announce", "leave", "announce", False)
                 return "Leave announcements are now disabled."
-            else:
-                return "Invalid boolean value: `{}`!".format(value)
+
+            return "Invalid boolean value: `{}`!".format(value)
 
         @Command.command(role="moderator")
         async def message(self, *message: False):
@@ -219,12 +223,13 @@ async def default(self, value=""):
                 await _update_deep_config(
                     self.api, "announce", "join", "announce", True)
                 return "Join announcements are now enabled."
-            elif value in VALID_TOGGLE_OFF_STATES:
+
+            if value in VALID_TOGGLE_OFF_STATES:
                 await _update_deep_config(
                     self.api, "announce", "join", "announce", False)
                 return "Join announcements are now disabled."
-            else:
-                return "Invalid boolean value: `{}`!".format(value)
+
+            return "Invalid boolean value: `{}`!".format(value)
 
         @Command.command(role="moderator")
         async def message(self, *message: False):
@@ -260,13 +265,13 @@ async def default(self, value=""):
                     await _update_config(
                         self.api, "spam", "allowUrls", True)
                     return "URLs are now allowed."
-                elif value in VALID_TOGGLE_OFF_STATES:
+
+                if value in VALID_TOGGLE_OFF_STATES:
                     await _update_config(
                         self.api, "spam", "allowUrls", False)
                     return "URLs are now disallowed."
-                else:
-                    return "Invalid boolean value: '{value}'.".format(
-                        value=value)
+
+                return "Invalid boolean value: '{value}'.".format(value=value)
 
         @Command.command()
         class Emoji(Command):
diff --git a/cactusbot/commands/magic/quote.py b/cactusbot/commands/magic/quote.py
index 9faf4d2..6f63376 100644
--- a/cactusbot/commands/magic/quote.py
+++ b/cactusbot/commands/magic/quote.py
@@ -12,7 +12,7 @@ class Quote(Command):
     COMMAND = "quote"
 
     @Command.command(hidden=True)
-    async def default(self, quote: r'[1-9]\d*'=None):
+    async def default(self, quote: r'[1-9]\d*' = None):
         """Get a quote based on ID. If no ID is provided, pick a random one."""
 
         if quote is None:
diff --git a/cactusbot/commands/magic/repeat.py b/cactusbot/commands/magic/repeat.py
index becc7a1..d3194c7 100644
--- a/cactusbot/commands/magic/repeat.py
+++ b/cactusbot/commands/magic/repeat.py
@@ -23,8 +23,8 @@ async def add(self, period: r"[1-9]\d*", command: "?command"):
             )
         elif response.status == 400:
             json = await response.json()
-            if len(json["errors"].get("period", [])) > 0:
-                return json["errors"].get("period")[0]
+            if json["errors"].get("period", []):
+                return json["errors"]["period"][0]
         else:
             return "An error occured."
 
diff --git a/cactusbot/commands/magic/social.py b/cactusbot/commands/magic/social.py
index 8942a14..80773e8 100644
--- a/cactusbot/commands/magic/social.py
+++ b/cactusbot/commands/magic/social.py
@@ -32,20 +32,18 @@ async def default(self, *services: False):
                         service)
 
             return MessagePacket(*response[:-1])
-        else:
-            social = await self.api.social.get()
-            if social.status == 200:
-                data = await social.json()
 
-                for service in data["data"]:
-                    response.append(
-                        service["attributes"]["service"].title() + ': ')
-                    response.append(("url", service["attributes"]["url"]))
-                    response.append(', ')
-                return MessagePacket(*response[:-1])
-            else:
-                return "'{}' not found on the streamer's profile!".format(
-                    service)
+        social = await self.api.social.get()
+        if social.status == 200:
+            data = await social.json()
+
+            for service in data["data"]:
+                response.append(
+                    service["attributes"]["service"].title() + ': ')
+                response.append(("url", service["attributes"]["url"]))
+                response.append(', ')
+            return MessagePacket(*response[:-1])
+        return "'{}' not found on the streamer's profile!".format(service)
 
     @Command.command()
     async def add(self, service, url):
@@ -58,7 +56,7 @@ async def add(self, service, url):
             return "Updated social service {}".format(service)
         elif response.status == 400:
             json = await response.json()
-            if len(json["errors"].get("quote", {}).get("url", [])) > 0:
+            if json["errors"].get("quote", {}).get("url", []):
                 # NOTE: Add detection/hard-coded errors if more errors are
                 #       added in the future
                 return json["errors"]["quote"]["url"][0]
diff --git a/cactusbot/commands/magic/trust.py b/cactusbot/commands/magic/trust.py
index 95b5739..9820a36 100644
--- a/cactusbot/commands/magic/trust.py
+++ b/cactusbot/commands/magic/trust.py
@@ -61,8 +61,7 @@ async def remove(self, username: check_user):
 
         if response.status == 200:
             return MessagePacket("Removed trust for user ", ("tag", user), '.')
-        else:
-            return MessagePacket(("tag", user), " is not a trusted user.")
+        return MessagePacket(("tag", user), " is not a trusted user.")
 
     @Command.command("list")
     async def list_trusts(self):
diff --git a/cactusbot/handlers/command.py b/cactusbot/handlers/command.py
index 39e7440..0ad6686 100644
--- a/cactusbot/handlers/command.py
+++ b/cactusbot/handlers/command.py
@@ -116,10 +116,14 @@ async def custom_response(self, _packet, command, *args, **data):
             return MessagePacket("Command is disabled.", target=_packet.user)
 
         if _packet.role < json["response"]["role"]:
+            # pylint: disable=C0201
             return MessagePacket(
                 "Role level '{role}' or higher required.".format(
-                    role=ROLES[max(k for k in ROLES.keys()
-                                   if k <= json["response"]["role"])]),
+                    role=ROLES[max(
+                        k for k in ROLES.keys()
+                        if k <= json["response"]["role"]
+                    )]
+                ),
                 target=_packet.user if _packet.target else None
             )
 
diff --git a/cactusbot/handlers/spam.py b/cactusbot/handlers/spam.py
index 2d9d3b7..ab3c343 100644
--- a/cactusbot/handlers/spam.py
+++ b/cactusbot/handlers/spam.py
@@ -52,18 +52,20 @@ async def on_message(self, packet):
                                   target=packet.user),
                     BanPacket(packet.user, 1),
                     StopIteration)
-        elif exceeds_emoji:
+
+        if exceeds_emoji:
             return (MessagePacket("Please do not spam emoji.",
                                   target=packet.user),
                     BanPacket(packet.user, 1),
                     StopIteration)
-        elif contains_urls:
+
+        if contains_urls:
             return (MessagePacket("Please do not post URLs.",
                                   target=packet.user),
                     BanPacket(packet.user, 5),
                     StopIteration)
-        else:
-            return None
+
+        return None
 
     async def on_config(self, packet):
         """Handle config update events."""
diff --git a/cactusbot/packets/message.py b/cactusbot/packets/message.py
index 828eec0..bc39df2 100644
--- a/cactusbot/packets/message.py
+++ b/cactusbot/packets/message.py
@@ -98,7 +98,7 @@ def __getitem__(self, key):
         elif isinstance(key, slice):
 
             if key.stop is not None or key.step is not None:
-                raise NotImplementedError  # TODO
+                raise NotImplementedError
 
             count = key.start or 0
             message = self.message.copy()
diff --git a/cactusbot/sepal.py b/cactusbot/sepal.py
index b43357f..a2a2f9a 100644
--- a/cactusbot/sepal.py
+++ b/cactusbot/sepal.py
@@ -32,9 +32,9 @@ async def send(self, packet_type, **kwargs):
         }
 
         packet.update(kwargs)
-        await super().send(json.dumps(packet))
+        await super()._send(json.dumps(packet))
 
-    async def initialize(self):
+    async def initialize(self, *_):
         """Send a subscribe packet."""
 
         await self.send("join")
@@ -63,8 +63,8 @@ async def handle(self, packet):
             return
 
         if isinstance(data, (list, tuple)):
-            for packet in data:
-                await self.service.handle(event, packet)
+            for _packet in data:
+                await self.service.handle(event, _packet)
         else:
             await self.service.handle(event, data)
 
diff --git a/cactusbot/services/api.py b/cactusbot/services/api.py
index bbc7a99..30dde80 100644
--- a/cactusbot/services/api.py
+++ b/cactusbot/services/api.py
@@ -7,7 +7,7 @@
 from aiohttp import ClientHttpProcessingError, ClientSession
 
 
-class API(ClientSession):
+class API:
     """Interact with a REST API."""
 
     URL = None
@@ -17,6 +17,8 @@ def __init__(self, **kwargs):
 
         self.logger = logging.getLogger(__name__)
 
+        self.session = ClientSession()
+
     def _build(self, endpoint):
         return urljoin(self.URL, endpoint.lstrip('/'))
 
@@ -25,7 +27,7 @@ async def request(self, method, endpoint, **kwargs):
 
         url = self._build(endpoint)
 
-        async with super().request(method, url, **kwargs) as response:
+        async with self.session.request(method, url, **kwargs) as response:
             try:
                 text = await response.text()
             except json.decoder.JSONDecodeError:
@@ -40,22 +42,29 @@ async def request(self, method, endpoint, **kwargs):
                 return response
 
     async def get(self, endpoint, **kwargs):
-        return await self.request("GET", endpoint, **kwargs)
+        """HTTP GET request."""
+        return await self.session.request("GET", endpoint, **kwargs)
 
     async def options(self, endpoint, **kwargs):
-        return await self.request("OPTIONS", endpoint, **kwargs)
+        """HTTP OPTIONS request."""
+        return await self.session.request("OPTIONS", endpoint, **kwargs)
 
     async def head(self, endpoint, **kwargs):
-        return await self.request("HEAD", endpoint, **kwargs)
+        """HTTP HEAD request."""
+        return await self.session.request("HEAD", endpoint, **kwargs)
 
     async def post(self, endpoint, **kwargs):
-        return await self.request("POST", endpoint, **kwargs)
+        """HTTP POST request."""
+        return await self.session.request("POST", endpoint, **kwargs)
 
     async def put(self, endpoint, **kwargs):
-        return await self.request("PUT", endpoint, **kwargs)
+        """HTTP PUT request."""
+        return await self.session.request("PUT", endpoint, **kwargs)
 
     async def patch(self, endpoint, **kwargs):
-        return await self.request("PATCH", endpoint, **kwargs)
+        """HTTP PATCH request."""
+        return await self.session.request("PATCH", endpoint, **kwargs)
 
     async def delete(self, endpoint, **kwargs):
-        return await self.request("DELETE", endpoint, **kwargs)
+        """HTTP DELETE request."""
+        return await self.session.request("DELETE", endpoint, **kwargs)
diff --git a/cactusbot/services/beam/chat.py b/cactusbot/services/beam/chat.py
index b1b5590..5f2069f 100644
--- a/cactusbot/services/beam/chat.py
+++ b/cactusbot/services/beam/chat.py
@@ -37,9 +37,9 @@ async def send(self, *args, max_length=360, **kwargs):
             for message in packet.copy()["arguments"]:
                 for index in range(0, len(message), max_length):
                     packet["arguments"] = (message[index:index + max_length],)
-                    await super().send(json.dumps(packet))
+                    await super()._send(json.dumps(packet))
         else:
-            await super().send(json.dumps(packet))
+            await super()._send(json.dumps(packet))
 
     async def initialize(self, *auth):
         """Send an authentication packet."""
diff --git a/cactusbot/services/websocket.py b/cactusbot/services/websocket.py
index ef8d062..427216c 100644
--- a/cactusbot/services/websocket.py
+++ b/cactusbot/services/websocket.py
@@ -20,7 +20,7 @@ def __init__(self, *endpoints):
 
         self.logger = logging.getLogger(__name__)
 
-        assert len(endpoints), "An endpoint is required to connect."
+        assert endpoints, "An endpoint is required to connect."
 
         self.websocket = None
 
@@ -50,7 +50,7 @@ async def connect(self, *args, base=2, maximum=60, **kwargs):
                 self.logger.info("Connection established.")
                 return self.websocket
 
-    async def send(self, packet):
+    async def _send(self, packet):
         """Send a packet to the WebSocket."""
         assert self.websocket is not None, "Must connect to send."
         self.logger.debug(packet)
@@ -76,7 +76,7 @@ async def read(self, handle):
                 self.logger.warning("Connection lost. Reconnecting.")
                 await self.connect(*self._init_args, **self._init_kwargs)
 
-    async def initialize(self):
+    async def initialize(self, *_):
         """Run initialization procedure."""
         pass
 

From 3be0a2bba9e80e16b5542ed528aacb4d01ad78f2 Mon Sep 17 00:00:00 2001
From: 2Cubed <2Cubed@users.noreply.github.com>
Date: Sat, 15 Apr 2017 13:26:30 -0400
Subject: [PATCH 071/122] Switch coverage to pytest

---
 .travis.yml | 8 ++++----
 1 file changed, 4 insertions(+), 4 deletions(-)

diff --git a/.travis.yml b/.travis.yml
index 65cacb8..88dadb8 100644
--- a/.travis.yml
+++ b/.travis.yml
@@ -12,7 +12,7 @@ branches:
 before_install:
     - pip install flake8 pylint
     - pip install pytest pytest-asyncio --upgrade
-    - pip install coveralls
+    - pip install coveralls pytest-cov
 
 install:
   - pip install -r requirements.txt
@@ -24,11 +24,11 @@ before_script:
   - cp config.template.py config.py
 
 script:
-  - nosetests --with-coverage --cover-package=cactusbot
+  - nosetests
   - flake8 run.py config.template.py cactusbot/
   - pylint cactusbot/
-  - pytest tests/
-  - pytest cactusbot/ --doctest-modules
+  - pytest --cov=cactusbot tests/
+  - pytest --cov=cactusbot cactusbot/ --doctest-modules
 
 after_success:
   - coveralls

From 57fef6869371c3bc3933ded3a4e5deae2a9e3168 Mon Sep 17 00:00:00 2001
From: 2Cubed <2Cubed@users.noreply.github.com>
Date: Sat, 15 Apr 2017 13:32:51 -0400
Subject: [PATCH 072/122] Add Travis badge

---
 README.md | 2 ++
 1 file changed, 2 insertions(+)

diff --git a/README.md b/README.md
index 82e8aa5..f537c1f 100644
--- a/README.md
+++ b/README.md
@@ -1,5 +1,7 @@
 # CactusBot
 
+[![Build Status](https://travis-ci.org/CactusDev/CactusBot.svg?branch=master)](https://travis-ci.org/CactusDev/CactusBot)
+
 [![Coverage Status](https://coveralls.io/repos/github/CactusBot/CactusBot/badge.svg?branch=master)](https://coveralls.io/github/CactusBot/CactusBot?branch=master)
 
 CactusBot is a next-generation chat bot for live streams.

From 2808925f7d7ed9bf77eaf5107fd7ff3bed353e62 Mon Sep 17 00:00:00 2001
From: 2Cubed <2Cubed@users.noreply.github.com>
Date: Sat, 15 Apr 2017 14:56:37 -0400
Subject: [PATCH 073/122] Remove coverage testing from doctests

Overwrote main unit test coverage. Should not be part of coverage
anyways.
---
 .travis.yml | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/.travis.yml b/.travis.yml
index 88dadb8..a3fc6fb 100644
--- a/.travis.yml
+++ b/.travis.yml
@@ -28,7 +28,7 @@ script:
   - flake8 run.py config.template.py cactusbot/
   - pylint cactusbot/
   - pytest --cov=cactusbot tests/
-  - pytest --cov=cactusbot cactusbot/ --doctest-modules
+  - pytest cactusbot/ --doctest-modules
 
 after_success:
   - coveralls

From 79ace3be9f4d0d3f25eb55e7922c7b211b0e8e17 Mon Sep 17 00:00:00 2001
From: 2Cubed <2Cubed@users.noreply.github.com>
Date: Mon, 17 Apr 2017 23:51:10 -0400
Subject: [PATCH 074/122] Remove explosions

Dangit `pylint`! Should've tested, and brained, though...
---
 cactusbot/api.py          |  2 +-
 cactusbot/services/api.py | 14 +++++++-------
 2 files changed, 8 insertions(+), 8 deletions(-)

diff --git a/cactusbot/api.py b/cactusbot/api.py
index 9de3f5c..03b8c19 100644
--- a/cactusbot/api.py
+++ b/cactusbot/api.py
@@ -44,7 +44,7 @@ def __getattr__(self, attr):
     async def request(self, method, endpoint, **kwargs):
         """Send HTTP request to endpoint."""
 
-        is_json = kwargs.get("is_json", True)
+        is_json = kwargs.pop("is_json", True)
 
         headers = {
             "X-Auth-Token": self.token,
diff --git a/cactusbot/services/api.py b/cactusbot/services/api.py
index 30dde80..8d5edde 100644
--- a/cactusbot/services/api.py
+++ b/cactusbot/services/api.py
@@ -43,28 +43,28 @@ async def request(self, method, endpoint, **kwargs):
 
     async def get(self, endpoint, **kwargs):
         """HTTP GET request."""
-        return await self.session.request("GET", endpoint, **kwargs)
+        return await self.request("GET", endpoint, **kwargs)
 
     async def options(self, endpoint, **kwargs):
         """HTTP OPTIONS request."""
-        return await self.session.request("OPTIONS", endpoint, **kwargs)
+        return await self.request("OPTIONS", endpoint, **kwargs)
 
     async def head(self, endpoint, **kwargs):
         """HTTP HEAD request."""
-        return await self.session.request("HEAD", endpoint, **kwargs)
+        return await self.request("HEAD", endpoint, **kwargs)
 
     async def post(self, endpoint, **kwargs):
         """HTTP POST request."""
-        return await self.session.request("POST", endpoint, **kwargs)
+        return await self.request("POST", endpoint, **kwargs)
 
     async def put(self, endpoint, **kwargs):
         """HTTP PUT request."""
-        return await self.session.request("PUT", endpoint, **kwargs)
+        return await self.request("PUT", endpoint, **kwargs)
 
     async def patch(self, endpoint, **kwargs):
         """HTTP PATCH request."""
-        return await self.session.request("PATCH", endpoint, **kwargs)
+        return await self.request("PATCH", endpoint, **kwargs)
 
     async def delete(self, endpoint, **kwargs):
         """HTTP DELETE request."""
-        return await self.session.request("DELETE", endpoint, **kwargs)
+        return await self.request("DELETE", endpoint, **kwargs)

From 58a762fbda5d0f08c715d151d49a028140ad4a88 Mon Sep 17 00:00:00 2001
From: Innectic <innectic@gmail.com>
Date: Fri, 5 May 2017 14:52:06 -0700
Subject: [PATCH 075/122] Add command wide roles for execution

---
 cactusbot/commands/command.py       |  7 +++---
 cactusbot/commands/magic/alias.py   |  7 +++---
 cactusbot/commands/magic/command.py | 13 +++++-----
 cactusbot/commands/magic/config.py  | 39 +++++++++++++++--------------
 cactusbot/commands/magic/multi.py   |  1 +
 cactusbot/commands/magic/quote.py   | 11 ++++----
 cactusbot/commands/magic/repeat.py  |  7 +++---
 cactusbot/commands/magic/social.py  |  7 +++---
 cactusbot/commands/magic/trust.py   | 16 +++++++++++-
 cactusbot/handlers/command.py       |  2 +-
 10 files changed, 65 insertions(+), 45 deletions(-)

diff --git a/cactusbot/commands/command.py b/cactusbot/commands/command.py
index 7da7345..13995bd 100644
--- a/cactusbot/commands/command.py
+++ b/cactusbot/commands/command.py
@@ -113,6 +113,8 @@ class Command:
 
     api = None
 
+    ROLE = "user"
+
     def __init__(self, api=None):
 
         if api is not None:
@@ -120,7 +122,6 @@ def __init__(self, api=None):
             Command.api = api
 
     async def __call__(self, *args, **meta):
-
         # pylint: disable=R0911
 
         commands = self.commands()
@@ -135,7 +136,6 @@ async def __call__(self, *args, **meta):
                 to_run.append(commands[command].default)
 
             for index, running in enumerate(to_run):
-
                 try:
                     return await self._run_safe(running, *arguments, **meta)
 
@@ -278,11 +278,12 @@ def decorator(function):
 
     async def _run_safe(self, function, *args, **meta):
 
-        role = function.COMMAND_META.get("role", 1)
+        role = function.COMMAND_META.get("role", self.ROLE)
 
         if isinstance(role, str):
             role = list(ROLES.keys())[list(map(
                 str.lower, ROLES.values())).index(role.lower())]
+
         if "packet" in meta and meta["packet"].role < role:
             # pylint: disable=C0201
             return "Role level '{r}' or higher required.".format(
diff --git a/cactusbot/commands/magic/alias.py b/cactusbot/commands/magic/alias.py
index ddb6ca1..a9f5788 100644
--- a/cactusbot/commands/magic/alias.py
+++ b/cactusbot/commands/magic/alias.py
@@ -8,8 +8,9 @@ class Alias(Command):
     """Alias command."""
 
     COMMAND = "alias"
+    ROLE = "moderator"
 
-    @Command.command(role="moderator")
+    @Command.command()
     async def add(self, alias: "?command", command: "?command", *_: False,
                   raw: "packet"):
         """Add a new command alias."""
@@ -35,7 +36,7 @@ async def add(self, alias: "?command", command: "?command", *_: False,
             if json.get("errors", []):
                 return json["errors"][0]
 
-    @Command.command(role="moderator")
+    @Command.command()
     async def remove(self, alias: "?command"):
         """Remove a command alias."""
 
@@ -45,7 +46,7 @@ async def remove(self, alias: "?command"):
         elif response.status == 404:
             return "Alias !{} doesn't exist!".format(alias)
 
-    @Command.command("list", role="moderator")
+    @Command.command(name="list")
     async def list_aliases(self):
         """List all aliases."""
         response = await self.api.command.get()
diff --git a/cactusbot/commands/magic/command.py b/cactusbot/commands/magic/command.py
index 9e3a5d4..418dbe3 100644
--- a/cactusbot/commands/magic/command.py
+++ b/cactusbot/commands/magic/command.py
@@ -7,13 +7,14 @@ class Meta(Command):
     """Manage commands."""
 
     COMMAND = "command"
+    ROLE = "moderator"
 
     ROLES = {
         '+': 4,
         '$': 2
     }
 
-    @Command.command(role="moderator")
+    @Command.command()
     async def add(self, command: r'!?([+$]?)([\w-]{1,32})', *response,
                   raw: "packet"):
         """Add a command."""
@@ -32,7 +33,7 @@ async def add(self, command: r'!?([+$]?)([\w-]{1,32})', *response,
             return "Added command !{}.".format(name)
         return "Updated command !{}.".format(name)
 
-    @Command.command(role="moderator")
+    @Command.command()
     async def remove(self, name: "?command"):
         """Remove a command."""
         response = await self.api.command.remove(name)
@@ -40,7 +41,7 @@ async def remove(self, name: "?command"):
             return "Removed command !{}.".format(name)
         return "Command !{} does not exist!".format(name)
 
-    @Command.command("list", role="moderator")
+    @Command.command(name="list")
     async def list_commands(self):
         """List all custom commands."""
         response = await self.api.command.get()
@@ -55,7 +56,7 @@ async def list_commands(self):
             )))
         return "No commands added!"
 
-    @Command.command(role="moderator")
+    @Command.command()
     async def enable(self, command: "?command"):
         """Enable a command."""
 
@@ -63,7 +64,7 @@ async def enable(self, command: "?command"):
         if response.status == 200:
             return "Command !{} has been enabled.".format(command)
 
-    @Command.command(role="moderator")
+    @Command.command()
     async def disable(self, command: "?command"):
         """Disable a command."""
 
@@ -71,7 +72,7 @@ async def disable(self, command: "?command"):
         if response.status == 200:
             return "Command !{} has been disabled.".format(command)
 
-    @Command.command(role="moderator")
+    @Command.command()
     async def count(self, command: r'?command',
                     action: r"([=+-]?)(\d+)" = None):
         """Update the count of a command."""
diff --git a/cactusbot/commands/magic/config.py b/cactusbot/commands/magic/config.py
index 24502c0..e88ec1e 100644
--- a/cactusbot/commands/magic/config.py
+++ b/cactusbot/commands/magic/config.py
@@ -48,12 +48,13 @@ class Config(Command):
     """Config Command."""
 
     COMMAND = "config"
+    ROLE = "moderator"
 
-    @Command.command(role="moderator")
+    @Command.command()
     class Follow(Command):
         """Follow subcommand."""
 
-        @Command.command(role="moderator", name="follow")
+        @Command.command()
         async def default(self, value=""):
             """Get status, and message of the follow event, or toggle."""
 
@@ -75,7 +76,7 @@ async def default(self, value=""):
 
             return "Invalid boolean value: `{}`!".format(value)
 
-        @Command.command(role="moderator")
+        @Command.command()
         async def message(self, *message: False):
             """Set the follow message."""
 
@@ -88,11 +89,11 @@ async def message(self, *message: False):
                 self.api, "announce", "follow", "message", ' '.join(message))
             return "Set new follow message response."
 
-    @Command.command(role="moderator")
+    @Command.command()
     class Subscribe(Command):
         """Subcommand subcommand."""
 
-        @Command.command(role="moderator", name="subscribe")
+        @Command.command()
         async def default(self, value=""):
             """Get status, and message of the subscribe event, or toggle."""
 
@@ -114,7 +115,7 @@ async def default(self, value=""):
 
             return "Invalid boolean value: `{}`!".format(value)
 
-        @Command.command(role="moderator")
+        @Command.command()
         async def message(self, *message: False):
             """Set the subscribe message."""
 
@@ -127,11 +128,11 @@ async def message(self, *message: False):
                 self.api, "announce", "sub", "message", ' '.join(message))
             return "Set new subscribe message response."
 
-    @Command.command(role="moderator")
+    @Command.command()
     class Host(Command):
         """Host subcommand."""
 
-        @Command.command(role="moderator", name="host")
+        @Command.command()
         async def default(self, value=""):
             """Get status, and message of the host event, or toggle."""
 
@@ -153,7 +154,7 @@ async def default(self, value=""):
 
             return "Invalid boolean value: `{}`!".format(value)
 
-        @Command.command(role="moderator")
+        @Command.command()
         async def message(self, *message: False):
             """Set the host message."""
 
@@ -166,11 +167,11 @@ async def message(self, *message: False):
                 self.api, "announce", "host", "message", ' '.join(message))
             return "Set new host message response."
 
-    @Command.command(role="moderator")
+    @Command.command()
     class Leave(Command):
         """Leave subcommand."""
 
-        @Command.command(role="moderator", name="leave")
+        @Command.command()
         async def default(self, value=""):
             """Get status, and message of the leave event, or toggle."""
 
@@ -192,7 +193,7 @@ async def default(self, value=""):
 
             return "Invalid boolean value: `{}`!".format(value)
 
-        @Command.command(role="moderator")
+        @Command.command()
         async def message(self, *message: False):
             """Set the leave message."""
 
@@ -205,11 +206,11 @@ async def message(self, *message: False):
                 self.api, "announce", "leave", "message", ' '.join(message))
             return "Set new leave message response."
 
-    @Command.command(role="moderator")
+    @Command.command()
     class Join(Command):
         """Join subcommand."""
 
-        @Command.command(role="moderator", name="join")
+        @Command.command()
         async def default(self, value=""):
             """Get status, and message of the join event, or toggle."""
 
@@ -231,7 +232,7 @@ async def default(self, value=""):
 
             return "Invalid boolean value: `{}`!".format(value)
 
-        @Command.command(role="moderator")
+        @Command.command()
         async def message(self, *message: False):
             """Set the join message."""
 
@@ -244,7 +245,7 @@ async def message(self, *message: False):
                 self.api, "announce", "join", "message", ' '.join(message))
             return "Set new join message response."
 
-    @Command.command(role="moderator")
+    @Command.command()
     class Spam(Command):
         """Spam subcommand."""
 
@@ -252,7 +253,7 @@ class Spam(Command):
         class Urls(Command):
             """Urls subcommand."""
 
-            @Command.command(name="urls")
+            @Command.command()
             async def default(self, value=""):
                 """Urls subcommand."""
 
@@ -277,7 +278,7 @@ async def default(self, value=""):
         class Emoji(Command):
             """Emoji subcommand."""
 
-            @Command.command(name="emoji")
+            @Command.command()
             async def default(self, value=""):
                 """Emoji subcommand."""
 
@@ -296,7 +297,7 @@ async def default(self, value=""):
         class Caps(Command):
             """Caps subcommand."""
 
-            @Command.command(name="caps")
+            @Command.command()
             async def default(self, value=""):
                 """Caps subcommand."""
 
diff --git a/cactusbot/commands/magic/multi.py b/cactusbot/commands/magic/multi.py
index 4b644af..2d1ec66 100644
--- a/cactusbot/commands/magic/multi.py
+++ b/cactusbot/commands/magic/multi.py
@@ -11,6 +11,7 @@ class Multi(Command):
     """Generate a multistream link."""
 
     COMMAND = "multi"
+    ROLE = "moderator"
 
     @Command.command(hidden=True)
     async def default(self, *channels):
diff --git a/cactusbot/commands/magic/quote.py b/cactusbot/commands/magic/quote.py
index 6f63376..0a7b487 100644
--- a/cactusbot/commands/magic/quote.py
+++ b/cactusbot/commands/magic/quote.py
@@ -10,8 +10,9 @@ class Quote(Command):
     """Manage quotes."""
 
     COMMAND = "quote"
+    ROLE = "moderator"
 
-    @Command.command(hidden=True)
+    @Command.command(hidden=True, role="user")
     async def default(self, quote: r'[1-9]\d*' = None):
         """Get a quote based on ID. If no ID is provided, pick a random one."""
 
@@ -27,7 +28,7 @@ async def default(self, quote: r'[1-9]\d*' = None):
                 return "Quote {} does not exist!".format(quote)
             return (await response.json())["data"]["attributes"]["quote"]
 
-    @Command.command(role="moderator")
+    @Command.command()
     async def add(self, *quote):
         """Add a quote."""
         response = await self.api.quote.add(' '.join(quote))
@@ -35,7 +36,7 @@ async def add(self, *quote):
         return "Added quote #{}.".format(
             data["data"]["attributes"]["quoteId"])
 
-    @Command.command(role="moderator")
+    @Command.command()
     async def edit(self, quote_id: r'[1-9]\d*', *quote):
         """Edit a quote based on ID."""
         response = await self.api.edit_quote(quote_id, ' '.join(quote))
@@ -43,7 +44,7 @@ async def edit(self, quote_id: r'[1-9]\d*', *quote):
             return "Added quote #{}.".format(quote_id)
         return "Edited quote #{}.".format(quote_id)
 
-    @Command.command(role="moderator")
+    @Command.command()
     async def remove(self, quote_id: r'[1-9]\d*'):
         """Remove a quote."""
         response = await self.api.remove_quote(quote_id)
@@ -51,7 +52,7 @@ async def remove(self, quote_id: r'[1-9]\d*'):
             return "Quote {} does not exist!".format(quote_id)
         return "Removed quote #{}.".format(quote_id)
 
-    @Command.command(hidden=True, role="subscriber")
+    @Command.command(hidden=True)
     async def inspirational(self):
         """Retrieve an inspirational quote."""
         try:
diff --git a/cactusbot/commands/magic/repeat.py b/cactusbot/commands/magic/repeat.py
index d3194c7..66ad0d9 100644
--- a/cactusbot/commands/magic/repeat.py
+++ b/cactusbot/commands/magic/repeat.py
@@ -7,8 +7,9 @@ class Repeat(Command):
     """Manage repeats."""
 
     COMMAND = "repeat"
+    ROLE = "moderator"
 
-    @Command.command(role="moderator")
+    @Command.command()
     async def add(self, period: r"[1-9]\d*", command: "?command"):
         """Add a repeat."""
 
@@ -28,7 +29,7 @@ async def add(self, period: r"[1-9]\d*", command: "?command"):
         else:
             return "An error occured."
 
-    @Command.command(role="moderator")
+    @Command.command()
     async def remove(self, repeat: "?command"):
         """Remove a repeat"""
 
@@ -39,7 +40,7 @@ async def remove(self, repeat: "?command"):
         elif response.status == 404:
             return "Repeat with ID {} doesn't exist.".format(repeat)
 
-    @Command.command("list", role="moderator")
+    @Command.command(name="list")
     async def list_repeats(self):
         """List all repeats."""
 
diff --git a/cactusbot/commands/magic/social.py b/cactusbot/commands/magic/social.py
index 80773e8..7bc18cb 100644
--- a/cactusbot/commands/magic/social.py
+++ b/cactusbot/commands/magic/social.py
@@ -8,8 +8,9 @@ class Social(Command):
     """Get social data."""
 
     COMMAND = "social"
+    ROLE = "moderator"
 
-    @Command.command(hidden=True)
+    @Command.command(hidden=True, role="user")
     async def default(self, *services: False):
         """Get a social service if it's provived, or give it all."""
 
@@ -57,9 +58,7 @@ async def add(self, service, url):
         elif response.status == 400:
             json = await response.json()
             if json["errors"].get("quote", {}).get("url", []):
-                # NOTE: Add detection/hard-coded errors if more errors are
-                #       added in the future
-                return json["errors"]["quote"]["url"][0]
+                return "An error has occurred."
 
     @Command.command()
     async def remove(self, service):
diff --git a/cactusbot/commands/magic/trust.py b/cactusbot/commands/magic/trust.py
index 9820a36..9331917 100644
--- a/cactusbot/commands/magic/trust.py
+++ b/cactusbot/commands/magic/trust.py
@@ -22,6 +22,7 @@ class Trust(Command):
     """Trust command."""
 
     COMMAND = "trust"
+    ROLE = "moderator"
 
     @Command.command(hidden=True)
     async def default(self, username: check_user):
@@ -63,7 +64,7 @@ async def remove(self, username: check_user):
             return MessagePacket("Removed trust for user ", ("tag", user), '.')
         return MessagePacket(("tag", user), " is not a trusted user.")
 
-    @Command.command("list")
+    @Command.command(name="list")
     async def list_trusts(self):
         """Get the trused users in a channel."""
 
@@ -74,3 +75,16 @@ async def list_trusts(self):
 
         return "Trusted users: {}.".format(', '.join(
             user["attributes"]["userName"] for user in data["data"]))
+
+    @Command.command()
+    async def check(self, username: check_user):
+        """Check if a user is currently trusted in the channel."""
+
+        user, user_id = username
+
+        data = await (await self.api.trust.get(user_id)).json()
+        print(data)
+
+        trusted = " " if data["data"] else " not "
+        return MessagePacket(("tag", user), " is{status}trusted.".format(
+            status=trusted))
diff --git a/cactusbot/handlers/command.py b/cactusbot/handlers/command.py
index 0ad6686..9515c55 100644
--- a/cactusbot/handlers/command.py
+++ b/cactusbot/handlers/command.py
@@ -27,8 +27,8 @@ def __init__(self, channel, api):
         super().__init__()
 
         self.channel = channel
-        self.api = api
 
+        self.api = api
         self.magics = {command.COMMAND: command(api) for command in COMMANDS}
 
     async def on_message(self, packet):

From 763875be0bad3b0adb24b3d3b5546e4fde218e99 Mon Sep 17 00:00:00 2001
From: Innectic <innectic@gmail.com>
Date: Fri, 5 May 2017 15:28:44 -0700
Subject: [PATCH 076/122] Fix %ARGS% defaults.

Fixes #247
---
 cactusbot/handlers/command.py | 8 ++++----
 1 file changed, 4 insertions(+), 4 deletions(-)

diff --git a/cactusbot/handlers/command.py b/cactusbot/handlers/command.py
index 0ad6686..c8971e9 100644
--- a/cactusbot/handlers/command.py
+++ b/cactusbot/handlers/command.py
@@ -184,10 +184,10 @@ def sub_args(match):
 
             return result
 
-        if len(args) < 2 and re.search(self.ARGS_EXPR, _packet.text):
-            return MessagePacket("Not enough arguments!")
-
-        _packet.sub(self.ARGS_EXPR, sub_args)
+        if re.search(self.ARGS_EXPR, _packet.text):
+            _packet.sub(self.ARGS_EXPR, sub_args)
+            if _packet.text is "":
+                return MessagePacket("Not enough arguments!")
 
         username = ""
 

From 99a6ddfaf17c7ee9e316d83198e682489024160e Mon Sep 17 00:00:00 2001
From: Innectic <innectic@gmail.com>
Date: Fri, 5 May 2017 16:40:29 -0700
Subject: [PATCH 077/122] Fix linting

---
 cactusbot/handlers/command.py | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/cactusbot/handlers/command.py b/cactusbot/handlers/command.py
index c8971e9..add4e16 100644
--- a/cactusbot/handlers/command.py
+++ b/cactusbot/handlers/command.py
@@ -186,7 +186,7 @@ def sub_args(match):
 
         if re.search(self.ARGS_EXPR, _packet.text):
             _packet.sub(self.ARGS_EXPR, sub_args)
-            if _packet.text is "":
+            if _packet.text == "":
                 return MessagePacket("Not enough arguments!")
 
         username = ""

From 6554ee60663050bc0bb57b606569604bb005987f Mon Sep 17 00:00:00 2001
From: 2Cubed <2Cubed@users.noreply.github.com>
Date: Fri, 5 May 2017 21:38:56 -0400
Subject: [PATCH 078/122] Clean up ARGS default checking

---
 cactusbot/handlers/command.py | 8 +++++---
 1 file changed, 5 insertions(+), 3 deletions(-)

diff --git a/cactusbot/handlers/command.py b/cactusbot/handlers/command.py
index add4e16..16a2112 100644
--- a/cactusbot/handlers/command.py
+++ b/cactusbot/handlers/command.py
@@ -177,6 +177,8 @@ def sub_args(match):
             if not args[1:] and default is not None:
                 result = default
             else:
+                if not args[1:]:
+                    raise IndexError
                 result = ' '.join(args[1:])
 
             if modifiers is not None:
@@ -184,10 +186,10 @@ def sub_args(match):
 
             return result
 
-        if re.search(self.ARGS_EXPR, _packet.text):
+        try:
             _packet.sub(self.ARGS_EXPR, sub_args)
-            if _packet.text == "":
-                return MessagePacket("Not enough arguments!")
+        except IndexError:
+            return MessagePacket("Not enough arguments!")
 
         username = ""
 

From b46a7cd971e27effa862d10aadbddb1185609094 Mon Sep 17 00:00:00 2001
From: 2Cubed <2Cubed@users.noreply.github.com>
Date: Fri, 5 May 2017 21:43:01 -0400
Subject: [PATCH 079/122] Add tests for ARGS defaults

---
 cactusbot/handlers/command.py  |  1 -
 tests/handlers/test_command.py | 12 ++++++++++++
 2 files changed, 12 insertions(+), 1 deletion(-)

diff --git a/cactusbot/handlers/command.py b/cactusbot/handlers/command.py
index 16a2112..d5bb38b 100644
--- a/cactusbot/handlers/command.py
+++ b/cactusbot/handlers/command.py
@@ -1,7 +1,6 @@
 """Handle commands."""
 
 import random
-import re
 
 from ..commands import COMMANDS
 from ..commands.command import ROLES
diff --git a/tests/handlers/test_command.py b/tests/handlers/test_command.py
index ffd246f..6d55841 100644
--- a/tests/handlers/test_command.py
+++ b/tests/handlers/test_command.py
@@ -84,6 +84,18 @@ def test_inject_args():
         "give"
     )
 
+    verify(
+        "Here, have some %ARGS=taco salad%!",
+        "Here, have some taco salad!",
+        "give"
+    )
+
+    verify(
+        "Here, have some %ARGS=taco salad%!",
+        "Here, have some potato salad!",
+        "give", "potato", "salad"
+    )
+
 
 def test_inject_user():
 

From 7f82ac1468a571f6b68e3438691964a705fb775c Mon Sep 17 00:00:00 2001
From: 2Cubed <2Cubed@users.noreply.github.com>
Date: Sat, 6 May 2017 15:56:41 -0400
Subject: [PATCH 080/122] Begin mock API implementation

---
 tests/commands/api.py        | 101 +++++++++++++++++++++++++++++++++++
 tests/commands/test_multi.py |   1 -
 2 files changed, 101 insertions(+), 1 deletion(-)
 create mode 100644 tests/commands/api.py

diff --git a/tests/commands/api.py b/tests/commands/api.py
new file mode 100644
index 0000000..902c9a6
--- /dev/null
+++ b/tests/commands/api.py
@@ -0,0 +1,101 @@
+from cactusbot.api import CactusAPI, CactusAPIBucket, Social
+
+
+class MockAPI(CactusAPI):
+
+    def __init__(self, token, password):
+
+        self.token = token
+        self.password = password
+
+        self.buckets = {
+            "social": MockSocial(self)
+        }
+
+
+class MockResponse:
+
+    def __init__(self, response, status=200):
+
+        self.response = response
+        self.status = status
+
+    async def json(self):
+        return self.response
+
+
+class MockSocial(Social):
+
+    async def get(self, service=None):
+        if service is None:
+            return MockResponse({
+                'data': [{
+                    'attributes': {
+                        'createdAt': 'Wed May  3 14:17:49 2017',
+                        'service': 'test1',
+                        'token': 'cactusdev',
+                        'url': 'https://example.com/test1'
+                    },
+                    'id': 'e0522d88-62c7-4c5a-b726-899b2894aaec',
+                    'type': 'social'
+                }, {
+                    'attributes': {
+                        'createdAt': 'Wed May  3 14:17:49 2017',
+                        'service': 'test2',
+                        'token': 'cactusdev',
+                        'url': 'https://example.com/test2'
+                    },
+                    'id': 'e0522d88-62c7-4c5a-b726-899b2894aaed',
+                    'type': 'social'
+                }]
+            })
+
+        if service == "invalid":
+            return MockResponse({}, status=404)
+
+        return MockResponse({
+            'data': {
+                'attributes': {
+                    'createdAt': 'Wed May  3 14:17:49 2017',
+                    'service': service,
+                    'token': 'cactusdev',
+                    'url': 'https://example.com/' + service
+                },
+                'id': 'e0522d88-62c7-4c5a-b726-899b2894aaec',
+                'type': 'social'
+            }
+        })
+
+    async def add(self, service, url):
+
+        status = 201
+
+        if service == "existing":
+            status = 200
+
+        return MockResponse({
+            'data': {
+                'attributes': {
+                    'createdAt': 'Wed May  3 14:17:49 2017',
+                    'service': service,
+                    'token': 'cactusdev',
+                    'url': url
+                },
+                'id': 'e0522d88-62c7-4c5a-b726-899b2894aaec',
+                'type': 'social'
+            },
+            'meta': {
+                'created': True
+            }
+        }, status=status)
+
+    async def remove(self, service):
+
+        if service == "nonexistent":
+            return MockResponse(None, status=404)
+
+        return MockResponse({
+            'meta': {
+                'deleted': ['e0522d88-62c7-4c5a-b726-899b2894aaec']
+            }
+        })
diff --git a/tests/commands/test_multi.py b/tests/commands/test_multi.py
index 688318f..0c0b6ec 100644
--- a/tests/commands/test_multi.py
+++ b/tests/commands/test_multi.py
@@ -4,7 +4,6 @@
 
 from cactusbot.api import CactusAPI
 from cactusbot.commands.magic import Multi
-from cactusbot.packets import MessagePacket
 
 multi = Multi(CactusAPI("test_token", "test_password"))
 

From 84c3aa8af5e6050ac0a59630c29fb071e9913952 Mon Sep 17 00:00:00 2001
From: 2Cubed <2Cubed@users.noreply.github.com>
Date: Sat, 6 May 2017 15:57:42 -0400
Subject: [PATCH 081/122] Add tests for Social command

---
 cactusbot/commands/magic/social.py | 18 ++++++---------
 tests/commands/test_social.py      | 35 ++++++++++++++++++++++++++++++
 2 files changed, 42 insertions(+), 11 deletions(-)
 create mode 100644 tests/commands/test_social.py

diff --git a/cactusbot/commands/magic/social.py b/cactusbot/commands/magic/social.py
index 7bc18cb..d7b6dec 100644
--- a/cactusbot/commands/magic/social.py
+++ b/cactusbot/commands/magic/social.py
@@ -14,8 +14,8 @@ class Social(Command):
     async def default(self, *services: False):
         """Get a social service if it's provived, or give it all."""
 
-        if len(services) >= 12:
-            return "Maximum number of requested services (12) exceeded."
+        if len(services) > 8:
+            return "Maximum number of requested services (8) exceeded."
 
         response = []
         if services:
@@ -44,7 +44,7 @@ async def default(self, *services: False):
                 response.append(("url", service["attributes"]["url"]))
                 response.append(', ')
             return MessagePacket(*response[:-1])
-        return "'{}' not found on the streamer's profile!".format(service)
+        return "No services found on the streamer's profile!"
 
     @Command.command()
     async def add(self, service, url):
@@ -52,13 +52,9 @@ async def add(self, service, url):
 
         response = await self.api.social.add(service, url)
         if response.status == 201:
-            return "Added social service {}.".format(service)
+            return "Added social service '{}'.".format(service)
         elif response.status == 200:
-            return "Updated social service {}".format(service)
-        elif response.status == 400:
-            json = await response.json()
-            if json["errors"].get("quote", {}).get("url", []):
-                return "An error has occurred."
+            return "Updated social service '{}'.".format(service)
 
     @Command.command()
     async def remove(self, service):
@@ -66,6 +62,6 @@ async def remove(self, service):
 
         response = await self.api.social.remove(service)
         if response.status == 200:
-            return "Removed social service {}.".format(service)
+            return "Removed social service '{}'.".format(service)
         elif response.status == 404:
-            return "Social service {} doesn't exist!".format(service)
+            return "Social service '{}' doesn't exist!".format(service)
diff --git a/tests/commands/test_social.py b/tests/commands/test_social.py
new file mode 100644
index 0000000..087e057
--- /dev/null
+++ b/tests/commands/test_social.py
@@ -0,0 +1,35 @@
+import pytest
+
+from api import MockAPI
+from cactusbot.commands.magic import Social
+
+social = Social(MockAPI("test_token", "test_password"))
+
+
+@pytest.mark.asyncio
+async def test_default():
+
+    assert (await social("a")).text == "A: https://example.com/a"
+    assert (await social("a", "b")).text == "A: https://example.com/a, B: https://example.com/b"
+    assert await social("invalid") == "'invalid' not found on the streamer's profile!"
+    assert await social("valid", "invalid") == "'invalid' not found on the streamer's profile!"
+
+    assert await social(*["test"] * 9) == "Maximum number of requested services (8) exceeded."
+
+    assert (await social()).text == "Test1: https://example.com/test1, Test2: https://example.com/test2"
+
+
+@pytest.mark.asyncio
+async def test_add():
+
+    assert await social("add", "github", "github.com/CactusDev") == "Added social service 'github'."
+
+    assert await social("add", "existing", "example.com") == "Updated social service 'existing'."
+
+
+@pytest.mark.asyncio
+async def test_remove():
+
+    assert await social("remove", "github") == "Removed social service 'github'."
+
+    assert await social("remove", "nonexistent") == "Social service 'nonexistent' doesn't exist!"

From cb3287032aa042636c47bc3e8c243eda64d84183 Mon Sep 17 00:00:00 2001
From: 2Cubed <2Cubed@users.noreply.github.com>
Date: Sat, 6 May 2017 16:24:20 -0400
Subject: [PATCH 082/122] Add tests for Repeat command

---
 cactusbot/commands/magic/repeat.py | 12 ++---
 tests/commands/api.py              | 75 +++++++++++++++++++++++++++++-
 tests/commands/test_repeat.py      | 28 +++++++++++
 3 files changed, 105 insertions(+), 10 deletions(-)
 create mode 100644 tests/commands/test_repeat.py

diff --git a/cactusbot/commands/magic/repeat.py b/cactusbot/commands/magic/repeat.py
index 66ad0d9..a744c85 100644
--- a/cactusbot/commands/magic/repeat.py
+++ b/cactusbot/commands/magic/repeat.py
@@ -19,15 +19,9 @@ async def add(self, period: r"[1-9]\d*", command: "?command"):
             return "Repeat !{command} added on interval {period}.".format(
                 command=command, period=period)
         elif response.status == 200:
-            return "Repeat !{command} updated with interval {period}".format(
+            return "Repeat !{command} updated with interval {period}.".format(
                 command=command, period=period
             )
-        elif response.status == 400:
-            json = await response.json()
-            if json["errors"].get("period", []):
-                return json["errors"]["period"][0]
-        else:
-            return "An error occured."
 
     @Command.command()
     async def remove(self, repeat: "?command"):
@@ -36,9 +30,9 @@ async def remove(self, repeat: "?command"):
         response = await self.api.repeat.remove(repeat)
 
         if response.status == 200:
-            return "Repeat removed."
+            return "Repeat for !{} removed.".format(repeat)
         elif response.status == 404:
-            return "Repeat with ID {} doesn't exist.".format(repeat)
+            return "Repeat for !{} doesn't exist.".format(repeat)
 
     @Command.command(name="list")
     async def list_repeats(self):
diff --git a/tests/commands/api.py b/tests/commands/api.py
index 902c9a6..a001b22 100644
--- a/tests/commands/api.py
+++ b/tests/commands/api.py
@@ -1,4 +1,4 @@
-from cactusbot.api import CactusAPI, CactusAPIBucket, Social
+from cactusbot.api import CactusAPI, CactusAPIBucket, Repeat, Social
 
 
 class MockAPI(CactusAPI):
@@ -9,6 +9,7 @@ def __init__(self, token, password):
         self.password = password
 
         self.buckets = {
+            "repeat": MockRepeat(self),
             "social": MockSocial(self)
         }
 
@@ -24,6 +25,78 @@ async def json(self):
         return self.response
 
 
+class MockRepeat(Repeat):
+
+    async def get(self):
+
+        return MockResponse({
+            "data": [
+                {
+                    "attributes": {
+                        "command": "67dd51ee-28e7-4622-9c6a-07ddb0dfc6d8",
+                        "commandName": "kittens",
+                        "createdAt": "Wed May  3 14:17:49 2017",
+                        "period": 600,
+                        "repeatName": "kittens",
+                        "token": "cactusdev"
+                    },
+                    "id": "30a57898-bef9-4d4e-a4fc-a276d2f6628c",
+                    "type": "repeat"
+                }
+            ]
+        })
+
+    async def add(self, command, period):
+
+        status = 201
+
+        if command == "existing":
+            status = 200
+
+        return MockResponse({
+            'data': {
+                'attributes': {
+                    'command': {
+                        'count': 6,
+                        'enabled': True,
+                        'id': '0d1fd105-9574-40fd-be24-2edefd080bf8',
+                        'name': command,
+                        'response': {
+                            'action': False,
+                            'message': [{
+                                'data': 'response',
+                                'text': 'response',
+                                'type': 'text'
+                            }],
+                            'role': 1,
+                            'target': None,
+                            'user': '2Cubed'
+                        },
+                        'token': '2cubed'
+                    },
+                    'commandName': command,
+                    'createdAt': 'Wed May  3 14:17:49 2017',
+                    'period': period,
+                    'repeatName': command,
+                    'token': 'cactusdev'
+                },
+                'id': 'f45ab1ff-2030-40e7-afda-64591769e74e',
+                'type': 'repeat'
+            }
+        }, status=status)
+
+    async def remove(self, repeat):
+
+        if repeat == "nonexistent":
+            return MockResponse(None, status=404)
+
+        return MockResponse({
+            'meta': {
+                'deleted': ['f45ab1ff-2030-40e7-afda-64591769e74e']
+            }
+        })
+
+
 class MockSocial(Social):
 
     async def get(self, service=None):
diff --git a/tests/commands/test_repeat.py b/tests/commands/test_repeat.py
new file mode 100644
index 0000000..ba81130
--- /dev/null
+++ b/tests/commands/test_repeat.py
@@ -0,0 +1,28 @@
+import pytest
+
+from api import MockAPI
+from cactusbot.commands.magic import Repeat
+
+repeat = Repeat(MockAPI("test_token", "test_password"))
+
+
+@pytest.mark.asyncio
+async def test_add():
+
+    assert await repeat("add", "600", "kittens") == "Repeat !kittens added on interval 600."
+    assert await repeat("add", "600", "existing") == "Repeat !existing updated with interval 600."
+
+    assert await repeat("add", "twelve", "kittens") == "Invalid period: 'twelve'."
+
+
+@pytest.mark.asyncio
+async def test_remove():
+
+    assert await repeat("remove", "kittens") == "Repeat for !kittens removed."
+    assert await repeat("remove", "nonexistent") == "Repeat for !nonexistent doesn't exist."
+
+
+@pytest.mark.asyncio
+async def test_list():
+
+    assert await repeat("list") == "Active repeats: kittens 600."

From 45d15f8363791a40c570bb9b5da90f680d832ede Mon Sep 17 00:00:00 2001
From: 2Cubed <2Cubed@users.noreply.github.com>
Date: Sat, 6 May 2017 16:51:25 -0400
Subject: [PATCH 083/122] Finish tests for Alias command

---
 cactusbot/api.py                  |   6 +-
 cactusbot/commands/magic/alias.py |   6 +-
 tests/commands/api.py             | 164 ++++++++++++++++++++-
 tests/commands/test_alias.py      | 237 ++++--------------------------
 4 files changed, 194 insertions(+), 219 deletions(-)

diff --git a/cactusbot/api.py b/cactusbot/api.py
index 03b8c19..ab3e424 100644
--- a/cactusbot/api.py
+++ b/cactusbot/api.py
@@ -158,10 +158,10 @@ async def update_count(self, command, action):
 class Alias(CactusAPIBucket):
     """CactusAPI /alias bucket."""
 
-    async def get(self, command):
+    async def get(self, alias):
         """Get a command alias."""
-        return await self.api.get("/user/{token}/alias/{command}".format(
-            token=self.api.token, command=command))
+        return await self.api.get("/user/{token}/alias/{alias}".format(
+            token=self.api.token, alias=alias))
 
     async def add(self, command, alias, args=None):
         """Create a command alias."""
diff --git a/cactusbot/commands/magic/alias.py b/cactusbot/commands/magic/alias.py
index a9f5788..453eb8c 100644
--- a/cactusbot/commands/magic/alias.py
+++ b/cactusbot/commands/magic/alias.py
@@ -28,13 +28,9 @@ async def add(self, alias: "?command", command: "?command", *_: False,
         if response.status == 201:
             return "Alias !{} for !{} created.".format(alias, command)
         elif response.status == 200:
-            return "Alias !{} for command !{} updated.".format(alias, command)
+            return "Alias !{} for !{} updated.".format(alias, command)
         elif response.status == 404:
             return "Command !{} does not exist.".format(command)
-        elif response.status == 400:
-            json = await response.json()
-            if json.get("errors", []):
-                return json["errors"][0]
 
     @Command.command()
     async def remove(self, alias: "?command"):
diff --git a/tests/commands/api.py b/tests/commands/api.py
index a001b22..777e38c 100644
--- a/tests/commands/api.py
+++ b/tests/commands/api.py
@@ -1,4 +1,5 @@
-from cactusbot.api import CactusAPI, CactusAPIBucket, Repeat, Social
+from cactusbot.api import (Alias, CactusAPI, CactusAPIBucket, Command, Repeat,
+                           Social)
 
 
 class MockAPI(CactusAPI):
@@ -9,6 +10,8 @@ def __init__(self, token, password):
         self.password = password
 
         self.buckets = {
+            "alias": MockAlias(self),
+            "command": MockCommand(self),
             "repeat": MockRepeat(self),
             "social": MockSocial(self)
         }
@@ -25,6 +28,161 @@ async def json(self):
         return self.response
 
 
+class MockAlias(Alias):
+
+    async def get(self, alias):
+
+        return MockResponse({
+            "data": {
+                "attributes": {
+                    "command": {
+                        "count": 1,
+                        "enabled": True,
+                        "id": "d23779ce-4522-431d-9095-7bf34718c39d",
+                        "name": "command_name",
+                        "response": {
+                            "action": None,
+                            "message": [
+                                {
+                                    "data": "response",
+                                    "text": "response",
+                                    "type": "text"
+                                }
+                            ],
+                            "role": 1,
+                            "target": None,
+                            "user": "Stanley"
+                        },
+                        "token": "cactusdev"
+                    },
+                    "commandName": "command_name",
+                    "name": alias,
+                    "token": "cactusdev"
+                },
+                "id": "312ab175-fb52-4a7b-865d-4202176f9234",
+                "type": "alias"
+            }
+        })
+
+    async def add(self, command, alias, args=None):
+
+        status = 201
+        if command == "nonexistent":
+            status = 404
+        elif alias == "existing":
+            status = 200
+
+        return MockResponse({
+            "data": {
+                "attributes": {
+                    "command": {
+                        "count": 1,
+                        "enabled": True,
+                        "id": "d23779ce-4522-431d-9095-7bf34718c39d",
+                        "name": command,
+                        "response": {
+                            "action": False,
+                            "message": [
+                                {
+                                    "data": "response",
+                                    "text": "response",
+                                    "type": "text"
+                                }
+                            ],
+                            "role": 1,
+                            "target": None,
+                            "user": "Stanley"
+                        },
+                        "token": "cactusdev"
+                    },
+                    "commandName": command,
+                    "name": alias,
+                    "token": "cactusdev"
+                },
+                "id": "312ab175-fb52-4a7b-865d-4202176f9234",
+                "type": "alias"
+            },
+            "meta": {
+                "edited": True
+            }
+        }, status=status)
+
+    async def remove(self, alias):
+
+        status = 200
+        if alias == "nonexistent":
+            status = 404
+
+        return MockResponse({
+            "meta": {
+                "deleted": [
+                    "312ab175-fb52-4a7b-865d-4202176f9234"
+                ]
+            }
+        }, status=status)
+
+
+class MockCommand(Command):
+
+    async def get(self, name=None):
+
+        if name is not None:
+            raise NotImplementedError
+
+        return MockResponse({
+            "data": [
+                {
+                    "attributes": {
+                        "count": 2,
+                        "enabled": True,
+                        "name": "testing",
+                        "response": {
+                            "action": False,
+                            "message": [
+                                {
+                                    "data": "testing!",
+                                    "text": "testing!",
+                                    "type": "text"
+                                }
+                            ],
+                            "role": 1,
+                            "target": None,
+                            "user": "Stanley"
+                        },
+                        "token": "cactusdev"
+                    },
+                    "id": "d23779ce-4522-431d-9095-7bf34718c39d",
+                    "type": "command"
+                },
+                {
+                    "attributes": {
+                        "commandName": "testing",
+                        "count": 2,
+                        "enabled": True,
+                        "id": "d23779ce-4522-431d-9095-7bf34718c39d",
+                        "name": "test",
+                        "response": {
+                            "action": False,
+                            "message": [
+                                {
+                                    "data": "testing!",
+                                    "text": "testing!",
+                                    "type": "text"
+                                }
+                            ],
+                            "role": 1,
+                            "target": None,
+                            "user": "Stanley"
+                        },
+                        "token": "cactusdev"
+                    },
+                    "id": "312ab175-fb52-4a7b-865d-4202176f9234",
+                    "type": "alias"
+                }
+            ]
+        })
+
+
 class MockRepeat(Repeat):
 
     async def get(self):
@@ -49,7 +207,6 @@ async def get(self):
     async def add(self, command, period):
 
         status = 201
-
         if command == "existing":
             status = 200
 
@@ -72,7 +229,7 @@ async def add(self, command, period):
                             'target': None,
                             'user': '2Cubed'
                         },
-                        'token': '2cubed'
+                        'token': 'cactusdev'
                     },
                     'commandName': command,
                     'createdAt': 'Wed May  3 14:17:49 2017',
@@ -142,7 +299,6 @@ async def get(self, service=None):
     async def add(self, service, url):
 
         status = 201
-
         if service == "existing":
             status = 200
 
diff --git a/tests/commands/test_alias.py b/tests/commands/test_alias.py
index 539b3d4..4ee912a 100644
--- a/tests/commands/test_alias.py
+++ b/tests/commands/test_alias.py
@@ -2,224 +2,47 @@
 
 import pytest
 
+from api import MockAPI
 from cactusbot.commands.magic import Alias
 from cactusbot.packets import MessagePacket
 
-
-class MockAPI:
-    """Fake API."""
-
-    class Alias:
-        async def get(self, command):
-            """Get aliases."""
-
-            class Response:
-                """Fake API response object."""
-
-                @property
-                def status(self):
-                    """Response status."""
-                    return 200
-
-                def json(self):
-                    """Response from the api."""
-
-                    return {
-                        "data": {
-                            "attributes": {
-                                "command": {
-                                    "count": 1,
-                                    "enabled": True,
-                                    "id": "d23779ce-4522-431d-9095-7bf34718c39d",
-                                    "name": "testing",
-                                    "response": {
-                                        "action": None,
-                                        "message": [
-                                            {
-                                                "data": "testing!",
-                                                "text": "testing!",
-                                                "type": "text"
-                                            }
-                                        ],
-                                        "role": 1,
-                                        "target": None,
-                                        "user": "Stanley"
-                                    },
-                                    "token": "Stanley"
-                                },
-                                "commandName": "testing",
-                                "name": "test",
-                                "token": "Stanley"
-                            },
-                            "id": "312ab175-fb52-4a7b-865d-4202176f9234",
-                            "type": "alias"
-                        }
-                    }
-            return Response()
-
-        async def add(self, command, alias, args=None):
-            """Add a new alias."""
-
-            class Response:
-                """Fake API response object."""
-
-                @property
-                def status(self):
-                    """Response status."""
-                    return 200
-
-                def json(self):
-                    """Response from the api."""
-                    return {
-                        "data": {
-                            "attributes": {
-                                "command": {
-                                    "count": 1,
-                                    "enabled": True,
-                                    "id": "d23779ce-4522-431d-9095-7bf34718c39d",
-                                    "name": "testing",
-                                    "response": {
-                                        "action": False,
-                                        "message": [
-                                            {
-                                                "data": "testing!",
-                                                "text": "testing!",
-                                                "type": "text"
-                                            }
-                                        ],
-                                        "role": 1,
-                                        "target": None,
-                                        "user": "Stanley"
-                                    },
-                                    "token": "Stanley"
-                                },
-                                "commandName": "testing",
-                                "name": "test",
-                                "token": "Stanley"
-                            },
-                            "id": "312ab175-fb52-4a7b-865d-4202176f9234",
-                            "type": "alias"
-                        },
-                        "meta": {
-                            "edited": True
-                        }
-                    }
-            return Response()
-
-        async def remove(self, alias):
-            """Remove an alias."""
-
-            class Response:
-                """Fake API response."""
-
-                @property
-                def status(self):
-                    """Response status."""
-                    return 200
-
-                def json(self):
-                    """JSON response."""
-                    return {
-                        "meta": {
-                            "deleted": [
-                                "312ab175-fb52-4a7b-865d-4202176f9234"
-                            ]
-                        }
-                    }
-            return Response()
-    alias = Alias()
-    
-    class Command:
-
-        async def get(self):
-            """Get all the commands."""
-
-            class Response:
-                """Fake API response."""
-
-                @property
-                def status(self):
-                    """Status of the response."""
-                    return 200
-
-                async def json(self):
-                    """JSON response."""
-                    return {
-                        "data": [
-                            {
-                                "attributes": {
-                                    "count": 2,
-                                    "enabled": True,
-                                    "name": "testing",
-                                    "response": {
-                                        "action": False,
-                                        "message": [
-                                            {
-                                                "data": "testing!",
-                                                "text": "testing!",
-                                                "type": "text"
-                                            }
-                                        ],
-                                        "role": 1,
-                                        "target": None,
-                                        "user": "Stanley"
-                                    },
-                                    "token": "Stanley"
-                                },
-                                "id": "d23779ce-4522-431d-9095-7bf34718c39d",
-                                "type": "command"
-                            },
-                            {
-                                "attributes": {
-                                    "commandName": "testing",
-                                    "count": 2,
-                                    "enabled": True,
-                                    "id": "d23779ce-4522-431d-9095-7bf34718c39d",
-                                    "name": "test",
-                                    "response": {
-                                        "action": False,
-                                        "message": [
-                                            {
-                                                "data": "testing!",
-                                                "text": "testing!",
-                                                "type": "text"
-                                            }
-                                        ],
-                                        "role": 1,
-                                        "target": None,
-                                        "user": "Stanley"
-                                    },
-                                    "token": "Stanley"
-                                },
-                                "id": "312ab175-fb52-4a7b-865d-4202176f9234",
-                                "type": "alias"
-                            }
-                        ]
-                    }
-            return Response()
-    command = Command()
-
-
-alias = Alias(MockAPI())
+alias = Alias(MockAPI("test_token", "test_password"))
 
 
 @pytest.mark.asyncio
-async def test_create_alias():
-    """Create an alias."""
+async def test_add():
+
     assert (await alias("add", "test", "testing", packet=MessagePacket(
         "!alias add test testing", role=5))
-    ) == "Alias !test for command !testing updated."
+    ) == "Alias !test for !testing created."
 
+    assert (await alias("add", "existing", "cmd", packet=MessagePacket(
+        "!alias add existing cmd", role=5))
+    ) == "Alias !existing for !cmd updated."
 
-@pytest.mark.asyncio
-async def test_list_alias():
-    """List aliases."""
-    assert (await alias("list", packet=MessagePacket(
-        "!alias list", role=5))) == "Aliases: test (testing)"
+    assert (await alias("add", "existing", "cmd", "arg", packet=MessagePacket(
+        "!alias add existing cmd arg", role=5))
+    ) == "Alias !existing for !cmd updated."
+
+    assert (await alias("add", "thing", "nonexistent", packet=MessagePacket(
+        "!alias add thing nonexistent", role=5))
+    ) == "Command !nonexistent does not exist."
 
 
 @pytest.mark.asyncio
-async def test_remove_alias():
-    """Remove an alias."""
+async def test_remove():
+
     assert (await alias("remove", "test", packet=MessagePacket(
-        "!alias remove test", role=5))) == "Alias !test removed."
+        "!alias remove test", role=5))
+    ) == "Alias !test removed."
+
+    assert (await alias("remove", "nonexistent", packet=MessagePacket(
+        "!alias remove nonexistent", role=5))
+    ) == "Alias !nonexistent doesn't exist!"
+
+
+@pytest.mark.asyncio
+async def test_list():
+
+    assert (await alias("list", packet=MessagePacket(
+        "!alias list", role=5))) == "Aliases: test (testing)"

From 7cabd3cd64edd29de84f182b93b57bfd56aa4d25 Mon Sep 17 00:00:00 2001
From: 2Cubed <2Cubed@users.noreply.github.com>
Date: Sat, 6 May 2017 16:53:25 -0400
Subject: [PATCH 084/122] Add Python 3.6 to Travis tests

---
 .travis.yml | 1 +
 1 file changed, 1 insertion(+)

diff --git a/.travis.yml b/.travis.yml
index a3fc6fb..7c100da 100644
--- a/.travis.yml
+++ b/.travis.yml
@@ -2,6 +2,7 @@ language: python
 
 python:
   - 3.5
+  - 3.6
 
 branches:
   only:

From 8405f8c57bd2cfa42aa6de0f7e4d8c22fef1db27 Mon Sep 17 00:00:00 2001
From: 2Cubed <2Cubed@users.noreply.github.com>
Date: Sat, 6 May 2017 17:32:19 -0400
Subject: [PATCH 085/122] Add tests for Config command

---
 cactusbot/commands/magic/config.py | 41 +++++++++---------
 tests/commands/api.py              | 60 +++++++++++++++++++++++++-
 tests/commands/test_alias.py       | 29 ++++++-------
 tests/commands/test_config.py      | 69 ++++++++++++++++++++++++++++++
 4 files changed, 161 insertions(+), 38 deletions(-)
 create mode 100644 tests/commands/test_config.py

diff --git a/cactusbot/commands/magic/config.py b/cactusbot/commands/magic/config.py
index e88ec1e..135a5de 100644
--- a/cactusbot/commands/magic/config.py
+++ b/cactusbot/commands/magic/config.py
@@ -60,7 +60,7 @@ async def default(self, value=""):
 
             if not value:
                 data = await _get_event_data(self.api, "follow")
-                return "{dis}abled, message: `{message}`".format(
+                return "{dis}abled, message: '{message}'".format(
                     dis='En' if data["announce"] else 'Dis',
                     message=data["message"])
 
@@ -74,7 +74,7 @@ async def default(self, value=""):
                     self.api, "announce", "follow", "announce", False)
                 return "Follow announcements are now disabled."
 
-            return "Invalid boolean value: `{}`!".format(value)
+            return "Invalid boolean value: '{}'.".format(value)
 
         @Command.command()
         async def message(self, *message: False):
@@ -83,7 +83,7 @@ async def message(self, *message: False):
             if not message:
                 data = (await (await self.api.config.get()).json())["data"]
                 message = data["attributes"]["announce"]["follow"]["message"]
-                return "Current response: `{}`".format(message)
+                return "Current response: '{}'".format(message)
 
             await _update_deep_config(
                 self.api, "announce", "follow", "message", ' '.join(message))
@@ -99,7 +99,7 @@ async def default(self, value=""):
 
             if not value:
                 data = await _get_event_data(self.api, "sub")
-                return "{dis}abled, message: `{message}`".format(
+                return "{dis}abled, message: '{message}'".format(
                     dis='En' if data["announce"] else 'Dis',
                     message=data["message"])
 
@@ -113,7 +113,7 @@ async def default(self, value=""):
                     self.api, "announce", "sub", "announce", False)
                 return "Subscribe announcements are now disabled."
 
-            return "Invalid boolean value: `{}`!".format(value)
+            return "Invalid boolean value: '{}'.".format(value)
 
         @Command.command()
         async def message(self, *message: False):
@@ -122,7 +122,7 @@ async def message(self, *message: False):
             if not message:
                 data = (await (await self.api.config.get()).json())["data"]
                 message = data["attributes"]["announce"]["sub"]["message"]
-                return "Current response: `{}`".format(message)
+                return "Current response: '{}'".format(message)
 
             await _update_deep_config(
                 self.api, "announce", "sub", "message", ' '.join(message))
@@ -138,7 +138,7 @@ async def default(self, value=""):
 
             if not value:
                 data = await _get_event_data(self.api, "host")
-                return "{dis}abled, message: `{message}`".format(
+                return "{dis}abled, message: '{message}'".format(
                     dis='En' if data["announce"] else 'Dis',
                     message=data["message"])
 
@@ -152,7 +152,7 @@ async def default(self, value=""):
                     self.api, "announce", "host", "announce", False)
                 return "Host announcements are now disabled."
 
-            return "Invalid boolean value: `{}`!".format(value)
+            return "Invalid boolean value: '{}'.".format(value)
 
         @Command.command()
         async def message(self, *message: False):
@@ -161,7 +161,7 @@ async def message(self, *message: False):
             if not message:
                 data = (await (await self.api.config.get()).json())["data"]
                 message = data["attributes"]["announce"]["host"]["message"]
-                return "Current response: `{}`".format(message)
+                return "Current response: '{}'".format(message)
 
             await _update_deep_config(
                 self.api, "announce", "host", "message", ' '.join(message))
@@ -177,7 +177,7 @@ async def default(self, value=""):
 
             if not value:
                 data = await _get_event_data(self.api, "leave")
-                return "{dis}abled, message: `{message}`".format(
+                return "{dis}abled, message: '{message}'".format(
                     dis='En' if data["announce"] else 'Dis',
                     message=data["message"])
 
@@ -191,7 +191,7 @@ async def default(self, value=""):
                     self.api, "announce", "leave", "announce", False)
                 return "Leave announcements are now disabled."
 
-            return "Invalid boolean value: `{}`!".format(value)
+            return "Invalid boolean value: '{}'.".format(value)
 
         @Command.command()
         async def message(self, *message: False):
@@ -200,7 +200,7 @@ async def message(self, *message: False):
             if not message:
                 data = (await (await self.api.config.get()).json())["data"]
                 message = data["attributes"]["announce"]["leave"]["message"]
-                return "Current response: `{}`".format(message)
+                return "Current response: '{}'".format(message)
 
             await _update_deep_config(
                 self.api, "announce", "leave", "message", ' '.join(message))
@@ -216,7 +216,7 @@ async def default(self, value=""):
 
             if not value:
                 data = await _get_event_data(self.api, "join")
-                return "{dis}abled, message: `{message}`".format(
+                return "{dis}abled, message: '{message}'".format(
                     dis='En' if data["announce"] else 'Dis',
                     message=data["message"])
 
@@ -230,7 +230,7 @@ async def default(self, value=""):
                     self.api, "announce", "join", "announce", False)
                 return "Join announcements are now disabled."
 
-            return "Invalid boolean value: `{}`!".format(value)
+            return "Invalid boolean value: '{}'.".format(value)
 
         @Command.command()
         async def message(self, *message: False):
@@ -239,7 +239,7 @@ async def message(self, *message: False):
             if not message:
                 data = (await (await self.api.config.get()).json())["data"]
                 message = data["attributes"]["announce"]["join"]["message"]
-                return "Current response: `{}`".format(message)
+                return "Current response: '{}'".format(message)
 
             await _update_deep_config(
                 self.api, "announce", "join", "message", ' '.join(message))
@@ -272,7 +272,7 @@ async def default(self, value=""):
                         self.api, "spam", "allowUrls", False)
                     return "URLs are now disallowed."
 
-                return "Invalid boolean value: '{value}'.".format(value=value)
+                return "Invalid boolean value: '{}'.".format(value)
 
         @Command.command()
         class Emoji(Command):
@@ -290,8 +290,8 @@ async def default(self, value=""):
                 response = await _update_config(
                     self.api, "spam", "maxEmoji", value)
                 if response.status == 200:
-                    return "Max emoji updated to {}.".format(value)
-                return "An error occurred."
+                    return "Maximum amount of emoji updated to {}.".format(
+                        value)
 
         @Command.command()
         class Caps(Command):
@@ -303,10 +303,9 @@ async def default(self, value=""):
 
                 if not value:
                     caps = await _get_spam_data(self.api, "maxCapsScore")
-                    return "Max caps score is {}.".format(caps)
+                    return "Maximum capitals score is {}.".format(caps)
 
                 response = await _update_config(
                     self.api, "spam", "maxCapsScore", value)
                 if response.status == 200:
-                    return "Max caps score is now {}.".format(value)
-                return "An error occurred."
+                    return "Maximum capitals score is now {}.".format(value)
diff --git a/tests/commands/api.py b/tests/commands/api.py
index 777e38c..a8f6bff 100644
--- a/tests/commands/api.py
+++ b/tests/commands/api.py
@@ -1,5 +1,5 @@
-from cactusbot.api import (Alias, CactusAPI, CactusAPIBucket, Command, Repeat,
-                           Social)
+from cactusbot.api import (Alias, CactusAPI, CactusAPIBucket, Command, Config,
+                           Repeat, Social)
 
 
 class MockAPI(CactusAPI):
@@ -12,6 +12,7 @@ def __init__(self, token, password):
         self.buckets = {
             "alias": MockAlias(self),
             "command": MockCommand(self),
+            "config": MockConfig(self),
             "repeat": MockRepeat(self),
             "social": MockSocial(self)
         }
@@ -183,6 +184,61 @@ async def get(self, name=None):
         })
 
 
+class MockConfig(Config):
+
+    async def get(self, *keys):
+
+        if keys:
+            raise NotImplementedError
+
+        return MockResponse({
+            'data': {
+                'attributes': {
+                    'announce': {
+                        'follow': {
+                            'announce': True,
+                            'message': 'Thanks for the follow, %USER%!'
+                        },
+                        'host': {
+                            'announce': True,
+                            'message': 'Thanks for the host, %USER%!'
+                        },
+                        'join': {
+                            'announce': False,
+                            'message': 'Welcome, %USER%!'
+                        },
+                        'leave': {
+                            'announce': False,
+                            'message': 'Thanks for watching, %USER%!'
+                        },
+                        'sub': {
+                            'announce': True,
+                            'message': 'Thanks for the subscription, %USER%!'
+                        }
+                    },
+                    'services': [{
+                        'isOAuth': False,
+                        'name': 'beam',
+                        'permissions': ['chat:connect', 'chat:chat'],
+                        'username': 'CactusBot'
+                    }],
+                    'spam': {
+                        'allowUrls': True,
+                        'maxCapsScore': 16,
+                        'maxEmoji': 6
+                    },
+                    'token': 'cactusdev',
+                    'whitelistedUrls': []
+                },
+                'id': '2d1976ca-d2fe-4b95-a9fa-e9bac4fe9cfa',
+                'type': 'config'
+            }
+        })
+
+    async def update(self, value):
+        return MockResponse(value)
+
+
 class MockRepeat(Repeat):
 
     async def get(self):
diff --git a/tests/commands/test_alias.py b/tests/commands/test_alias.py
index 4ee912a..13d1e85 100644
--- a/tests/commands/test_alias.py
+++ b/tests/commands/test_alias.py
@@ -1,5 +1,3 @@
-"""Test the alias command."""
-
 import pytest
 
 from api import MockAPI
@@ -13,36 +11,37 @@
 async def test_add():
 
     assert (await alias("add", "test", "testing", packet=MessagePacket(
-        "!alias add test testing", role=5))
-    ) == "Alias !test for !testing created."
+        "!alias add test testing", role=5
+    ))) == "Alias !test for !testing created."
 
     assert (await alias("add", "existing", "cmd", packet=MessagePacket(
-        "!alias add existing cmd", role=5))
-    ) == "Alias !existing for !cmd updated."
+        "!alias add existing cmd", role=5
+    ))) == "Alias !existing for !cmd updated."
 
     assert (await alias("add", "existing", "cmd", "arg", packet=MessagePacket(
-        "!alias add existing cmd arg", role=5))
-    ) == "Alias !existing for !cmd updated."
+        "!alias add existing cmd arg", role=5
+    ))) == "Alias !existing for !cmd updated."
 
     assert (await alias("add", "thing", "nonexistent", packet=MessagePacket(
-        "!alias add thing nonexistent", role=5))
-    ) == "Command !nonexistent does not exist."
+        "!alias add thing nonexistent", role=5
+    ))) == "Command !nonexistent does not exist."
 
 
 @pytest.mark.asyncio
 async def test_remove():
 
     assert (await alias("remove", "test", packet=MessagePacket(
-        "!alias remove test", role=5))
-    ) == "Alias !test removed."
+        "!alias remove test", role=5
+    ))) == "Alias !test removed."
 
     assert (await alias("remove", "nonexistent", packet=MessagePacket(
-        "!alias remove nonexistent", role=5))
-    ) == "Alias !nonexistent doesn't exist!"
+        "!alias remove nonexistent", role=5
+    ))) == "Alias !nonexistent doesn't exist!"
 
 
 @pytest.mark.asyncio
 async def test_list():
 
     assert (await alias("list", packet=MessagePacket(
-        "!alias list", role=5))) == "Aliases: test (testing)"
+        "!alias list", role=5
+    ))) == "Aliases: test (testing)"
diff --git a/tests/commands/test_config.py b/tests/commands/test_config.py
new file mode 100644
index 0000000..1938697
--- /dev/null
+++ b/tests/commands/test_config.py
@@ -0,0 +1,69 @@
+import pytest
+
+from api import MockAPI
+from cactusbot.commands.magic import Config
+
+config = Config(MockAPI("test_token", "test_password"))
+
+
+@pytest.mark.asyncio
+async def test_announce_default():
+
+    assert await config("follow") == "Enabled, message: 'Thanks for the follow, %USER%!'"
+    assert await config("follow", "enable") == "Follow announcements are now enabled."
+    assert await config("follow", "disable") == "Follow announcements are now disabled."
+    assert await config("follow", "potato") == "Invalid boolean value: 'potato'."
+
+    assert await config("subscribe") == "Enabled, message: 'Thanks for the subscription, %USER%!'"
+    assert await config("subscribe", "enable") == "Subscribe announcements are now enabled."
+    assert await config("subscribe", "disable") == "Subscribe announcements are now disabled."
+    assert await config("subscribe", "potato") == "Invalid boolean value: 'potato'."
+
+    assert await config("host") == "Enabled, message: 'Thanks for the host, %USER%!'"
+    assert await config("host", "enable") == "Host announcements are now enabled."
+    assert await config("host", "disable") == "Host announcements are now disabled."
+    assert await config("host", "potato") == "Invalid boolean value: 'potato'."
+
+    assert await config("leave") == "Disabled, message: 'Thanks for watching, %USER%!'"
+    assert await config("leave", "enable") == "Leave announcements are now enabled."
+    assert await config("leave", "disable") == "Leave announcements are now disabled."
+    assert await config("leave", "potato") == "Invalid boolean value: 'potato'."
+
+    assert await config("join") == "Disabled, message: 'Welcome, %USER%!'"
+    assert await config("join", "enable") == "Join announcements are now enabled."
+    assert await config("join", "disable") == "Join announcements are now disabled."
+    assert await config("join", "potato") == "Invalid boolean value: 'potato'."
+
+
+@pytest.mark.asyncio
+async def test_announce_message():
+
+    assert await config("follow", "message") == "Current response: 'Thanks for the follow, %USER%!'"
+    assert await config("follow", "message", "Thanks!") == "Set new follow message response."
+
+    assert await config("subscribe", "message") == "Current response: 'Thanks for the subscription, %USER%!'"
+    assert await config("subscribe", "message", "Thanks!") == "Set new subscribe message response."
+
+    assert await config("host", "message") == "Current response: 'Thanks for the host, %USER%!'"
+    assert await config("host", "message", "Thanks!") == "Set new host message response."
+
+    assert await config("leave", "message") == "Current response: 'Thanks for watching, %USER%!'"
+    assert await config("leave", "message", "Thanks!") == "Set new leave message response."
+
+    assert await config("join", "message") == "Current response: 'Welcome, %USER%!'"
+    assert await config("join", "message", "Thanks!") == "Set new join message response."
+
+
+@pytest.mark.asyncio
+async def test_spam_default():
+
+    assert await config("spam", "urls") == "URLs are allowed."
+    assert await config("spam", "urls", "enable") == "URLs are now allowed."
+    assert await config("spam", "urls", "disable") == "URLs are now disallowed."
+    assert await config("spam", "urls", "potato") == "Invalid boolean value: 'potato'."
+
+    assert await config("spam", "emoji") == "Maximum amount of emoji allowed is 6."
+    assert await config("spam", "emoji", "8") == "Maximum amount of emoji updated to 8."
+
+    assert await config("spam", "caps") == "Maximum capitals score is 16."
+    assert await config("spam", "caps", "17") == "Maximum capitals score is now 17."

From 240ae9fe352b7871c5d4394ad178feb0a4a4b2fd Mon Sep 17 00:00:00 2001
From: 2Cubed <2Cubed@users.noreply.github.com>
Date: Sat, 6 May 2017 17:43:18 -0400
Subject: [PATCH 086/122] Remove use of CactusAPI in tests

Replaced with MockAPI.
---
 tests/commands/test_cactus.py  | 4 ++--
 tests/commands/test_cube.py    | 6 +++---
 tests/commands/test_multi.py   | 4 ++--
 tests/handlers/test_command.py | 6 +++---
 4 files changed, 10 insertions(+), 10 deletions(-)

diff --git a/tests/commands/test_cactus.py b/tests/commands/test_cactus.py
index d67feaf..021c4bf 100644
--- a/tests/commands/test_cactus.py
+++ b/tests/commands/test_cactus.py
@@ -1,11 +1,11 @@
 import pytest
 
-from cactusbot.api import CactusAPI
+from api import MockAPI
 from cactusbot.commands.magic import Cactus
 
 from cactusbot.cactus import __version__
 
-cactus = Cactus(CactusAPI("test_token", "test_password"))
+cactus = Cactus(MockAPI("test_token", "test_password"))
 
 
 @pytest.mark.asyncio
diff --git a/tests/commands/test_cube.py b/tests/commands/test_cube.py
index d90ad3d..a9a2a21 100644
--- a/tests/commands/test_cube.py
+++ b/tests/commands/test_cube.py
@@ -1,10 +1,10 @@
 import pytest
 
-from cactusbot.api import CactusAPI
+from api import MockAPI
 from cactusbot.commands.magic import Cube, Temmie
 from cactusbot.packets import MessagePacket
 
-cube = Cube(CactusAPI("test_token", "test_password"))
+cube = Cube(MockAPI("test_token", "test_password"))
 
 async def verify_cube(packet, expected):
     _, *args = packet[1:].text.split()
@@ -50,7 +50,7 @@ async def test_cube():
         "lots³ · of³ · taco³ · salad³ · 😃³"
     )
 
-temmie = Temmie(CactusAPI("test_token", "test_password"))
+temmie = Temmie(MockAPI("test_token", "test_password"))
 
 
 @pytest.mark.asyncio
diff --git a/tests/commands/test_multi.py b/tests/commands/test_multi.py
index 0c0b6ec..30190bb 100644
--- a/tests/commands/test_multi.py
+++ b/tests/commands/test_multi.py
@@ -2,10 +2,10 @@
 
 import pytest
 
-from cactusbot.api import CactusAPI
+from api import MockAPI
 from cactusbot.commands.magic import Multi
 
-multi = Multi(CactusAPI("test_token", "test_password"))
+multi = Multi(MockAPI("test_token", "test_password"))
 
 
 @pytest.mark.asyncio
diff --git a/tests/handlers/test_command.py b/tests/handlers/test_command.py
index 6d55841..81c0163 100644
--- a/tests/handlers/test_command.py
+++ b/tests/handlers/test_command.py
@@ -1,11 +1,11 @@
 import pytest
-from cactusbot.api import CactusAPI
+from api import MockAPI
 from cactusbot.commands.command import Command
 from cactusbot.handlers import CommandHandler
 from cactusbot.packets import MessagePacket
 
 command_handler = CommandHandler(
-    "TestChannel", CactusAPI("test_token", "test_password"))
+    "TestChannel", MockAPI("test_token", "test_password"))
 
 
 def verify(message, expected, *args, **kwargs):
@@ -251,7 +251,7 @@ async def taco(self):
             """Taco salad."""
             return "TACO SALAD!?"
 
-potato = Potato(CactusAPI("test_token", "test_password"))
+potato = Potato(MockAPI("test_token", "test_password"))
 
 
 @pytest.mark.asyncio

From 28259681b83f69e45e08b6b27e6ffa4b865c3138 Mon Sep 17 00:00:00 2001
From: 2Cubed <2Cubed@users.noreply.github.com>
Date: Sun, 7 May 2017 01:15:19 -0400
Subject: [PATCH 087/122] Add tests for Quote command

---
 cactusbot/commands/magic/quote.py | 16 +++----
 tests/commands/api.py             | 76 ++++++++++++++++++++++++++++++-
 tests/commands/test_quote.py      | 32 +++++++++++++
 3 files changed, 114 insertions(+), 10 deletions(-)
 create mode 100644 tests/commands/test_quote.py

diff --git a/cactusbot/commands/magic/quote.py b/cactusbot/commands/magic/quote.py
index 0a7b487..0a60846 100644
--- a/cactusbot/commands/magic/quote.py
+++ b/cactusbot/commands/magic/quote.py
@@ -22,11 +22,11 @@ async def default(self, quote: r'[1-9]\d*' = None):
             if not data:
                 return "No quotes have been added!"
             return data[0]["attributes"]["quote"]
-        else:
-            response = await self.api.quote.get(quote)
-            if response.status == 404:
-                return "Quote {} does not exist!".format(quote)
-            return (await response.json())["data"]["attributes"]["quote"]
+
+        response = await self.api.quote.get(quote)
+        if response.status == 404:
+            return "Quote {} does not exist!".format(quote)
+        return (await response.json())["data"]["attributes"]["quote"]
 
     @Command.command()
     async def add(self, *quote):
@@ -39,7 +39,7 @@ async def add(self, *quote):
     @Command.command()
     async def edit(self, quote_id: r'[1-9]\d*', *quote):
         """Edit a quote based on ID."""
-        response = await self.api.edit_quote(quote_id, ' '.join(quote))
+        response = await self.api.quote.edit(quote_id, ' '.join(quote))
         if response.status == 201:
             return "Added quote #{}.".format(quote_id)
         return "Edited quote #{}.".format(quote_id)
@@ -47,9 +47,9 @@ async def edit(self, quote_id: r'[1-9]\d*', *quote):
     @Command.command()
     async def remove(self, quote_id: r'[1-9]\d*'):
         """Remove a quote."""
-        response = await self.api.remove_quote(quote_id)
+        response = await self.api.quote.remove(quote_id)
         if response.status == 404:
-            return "Quote {} does not exist!".format(quote_id)
+            return "Quote #{} does not exist!".format(quote_id)
         return "Removed quote #{}.".format(quote_id)
 
     @Command.command(hidden=True)
diff --git a/tests/commands/api.py b/tests/commands/api.py
index a8f6bff..65c742f 100644
--- a/tests/commands/api.py
+++ b/tests/commands/api.py
@@ -1,5 +1,5 @@
-from cactusbot.api import (Alias, CactusAPI, CactusAPIBucket, Command, Config,
-                           Repeat, Social)
+from cactusbot.api import (Alias, CactusAPI, Command, Config, Quote, Repeat,
+                           Social)
 
 
 class MockAPI(CactusAPI):
@@ -13,6 +13,7 @@ def __init__(self, token, password):
             "alias": MockAlias(self),
             "command": MockCommand(self),
             "config": MockConfig(self),
+            "quote": MockQuote(self),
             "repeat": MockRepeat(self),
             "social": MockSocial(self)
         }
@@ -239,6 +240,77 @@ async def update(self, value):
         return MockResponse(value)
 
 
+class MockQuote(Quote):
+
+    async def get(self, quote_id=None):
+
+        if quote_id == "123":
+            return MockResponse({'data': {}}, status=404)
+
+        response = {
+            'data': {
+                'attributes': {
+                    'createdAt': 'Wed May  3 14:17:49 2017',
+                    'quote': '"Quote!" -Someone',
+                    'quoteId': quote_id if quote_id is not None else 8,
+                    'token': 'cactusdev'
+                },
+                'id': '9f8421c7-8e54-4ca7-9a68-b2cb6e8626e5',
+                'type': 'quote'
+            }
+        }
+
+        if quote_id is None:
+            response["data"] = [response["data"]]
+
+        return MockResponse(response)
+
+    async def add(self, quote):
+        return MockResponse({
+            'data': {
+                'attributes': {
+                    'createdAt': 'Wed May  3 14:17:49 2017',
+                    'quote': quote,
+                    'quoteId': 8,
+                    'token': 'cactusdev'
+                },
+                'id': '9f8421c7-8e54-4ca7-9a68-b2cb6e8626e5',
+                'type': 'quote'
+            }
+        })
+
+    async def edit(self, quote_id, quote):
+
+        status = 200
+        if quote_id == "8":
+            status = 201
+
+        return MockResponse({
+            'data': {
+                'attributes': {
+                    'createdAt': 'Wed May  3 14:17:49 2017',
+                    'quote': quote,
+                    'quoteId': quote_id,
+                    'token': 'cactusdev'
+                },
+                'id': '9f8421c7-8e54-4ca7-9a68-b2cb6e8626e5',
+                'type': 'quote'
+            }
+        }, status=status)
+
+    async def remove(self, quote_id):
+
+        status = 200
+        if quote_id == "8":
+            status = 404
+
+        return MockResponse({
+            'meta': {
+                'deleted': ['50983973-cd75-442e-ad71-1a9e194b51c4']
+            }
+        }, status=status)
+
+
 class MockRepeat(Repeat):
 
     async def get(self):
diff --git a/tests/commands/test_quote.py b/tests/commands/test_quote.py
new file mode 100644
index 0000000..9b6c910
--- /dev/null
+++ b/tests/commands/test_quote.py
@@ -0,0 +1,32 @@
+import pytest
+
+from api import MockAPI
+from cactusbot.commands.magic import Quote
+
+quote = Quote(MockAPI("test_token", "test_password"))
+
+
+@pytest.mark.asyncio
+async def test_get():
+    
+    assert await quote() == '"Quote!" -Someone'
+
+    assert await quote("8") == '"Quote!" -Someone'
+    assert await quote("123") == "Quote 123 does not exist!"
+
+
+@pytest.mark.asyncio
+async def test_add():
+    assert await quote("add", "Hello!") == "Added quote #8."
+
+
+@pytest.mark.asyncio
+async def test_edit():
+    assert await quote("edit", "7", "Hi!") == "Edited quote #7."
+    assert await quote("edit", "8", "Hi!") == "Added quote #8."
+
+
+@pytest.mark.asyncio
+async def test_remove():
+    assert await quote("remove", "7") == "Removed quote #7."
+    assert await quote("remove", "8") == "Quote #8 does not exist!"

From 125a8cbbb3c786e1fbefe5a2ee89c4fadd920567 Mon Sep 17 00:00:00 2001
From: Innectic <innectic@gmail.com>
Date: Mon, 8 May 2017 11:45:40 -0700
Subject: [PATCH 088/122] Fix regex-checked argument names

---
 cactusbot/commands/command.py | 6 ++++--
 1 file changed, 4 insertions(+), 2 deletions(-)

diff --git a/cactusbot/commands/command.py b/cactusbot/commands/command.py
index 13995bd..83e22f7 100644
--- a/cactusbot/commands/command.py
+++ b/cactusbot/commands/command.py
@@ -172,6 +172,7 @@ async def __call__(self, *args, **meta):
                 error = err
 
         if args:
+            print("stuff things")
             return "Invalid argument: '{0}'.".format(args[0])
 
         if self.default is not None:
@@ -334,8 +335,9 @@ async def _clean_args(function, *args):
 
         for index, arg in enumerate(pos_args[:len(args)]):
             if arg.annotation is not arg.empty:
-                error_response = "Invalid {type}: '{value}'.".format(
-                    type=arg.name, value=args[index])
+                argument_name = arg.name.replace('_', ' ')
+                error_response = "Invalid '{type}': '{value}'.".format(
+                    type=argument_name, value=args[index])
                 if isinstance(arg.annotation, str):
                     annotation = arg.annotation
                     if annotation.startswith('?'):

From 9122c64ad08837a5d5f61e05513d5b3774413b4a Mon Sep 17 00:00:00 2001
From: Innectic <innectic@gmail.com>
Date: Mon, 8 May 2017 12:01:53 -0700
Subject: [PATCH 089/122] Turn '_' into 'arguments'

---
 cactusbot/commands/command.py | 6 ++++--
 1 file changed, 4 insertions(+), 2 deletions(-)

diff --git a/cactusbot/commands/command.py b/cactusbot/commands/command.py
index 83e22f7..db4e484 100644
--- a/cactusbot/commands/command.py
+++ b/cactusbot/commands/command.py
@@ -172,7 +172,6 @@ async def __call__(self, *args, **meta):
                 error = err
 
         if args:
-            print("stuff things")
             return "Invalid argument: '{0}'.".format(args[0])
 
         if self.default is not None:
@@ -200,7 +199,10 @@ def _display(arg):
         else:
             syntax = "[{}]"
 
-        return syntax.format(arg.name)
+        argument_name = arg.name
+        if argument_name == "_":
+            argument_name = "arguments"
+        return syntax.format(argument_name)
 
     @classmethod
     def command(cls, name=None, **meta):

From bd948cb750bc311a89784dbab40e4ba3109390e3 Mon Sep 17 00:00:00 2001
From: Innectic <innectic@gmail.com>
Date: Mon, 8 May 2017 12:10:41 -0700
Subject: [PATCH 090/122] Fix tests

---
 tests/handlers/test_command.py | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/tests/handlers/test_command.py b/tests/handlers/test_command.py
index 6d55841..45fa55d 100644
--- a/tests/handlers/test_command.py
+++ b/tests/handlers/test_command.py
@@ -261,7 +261,7 @@ async def test_default():
     assert await potato("count") == "You have 0 potatoes."
 
     assert await potato("battery") == "Potato power!"
-    assert await potato("battery", "high") == "Invalid strength: 'high'."
+    assert await potato("battery", "high") == "Invalid 'strength': 'high'."
     assert await potato("battery", "9001") == "Potato power x 9001!"
 
     assert await potato("salad") == "Not enough arguments. <make>"

From 482e380507f3aef8d4062cc641c9f76911e625e3 Mon Sep 17 00:00:00 2001
From: Innectic <innectic@gmail.com>
Date: Mon, 8 May 2017 12:46:24 -0700
Subject: [PATCH 091/122] Add tests for command list

---
 tests/handlers/test_command.py | 10 ++++++++++
 1 file changed, 10 insertions(+)

diff --git a/tests/handlers/test_command.py b/tests/handlers/test_command.py
index 45fa55d..55dd38e 100644
--- a/tests/handlers/test_command.py
+++ b/tests/handlers/test_command.py
@@ -311,3 +311,13 @@ async def test_args():
     ) == "Making potato salad with carrots, peppers."
 
     assert await potato("salad", "taco") == "TACO SALAD!?"
+
+@pytest.mark.asyncio
+async def test_list():
+    command_list = potato.commands()
+
+    assert "check" in command_list
+    assert "add" in command_list
+    assert "eat" in command_list
+    assert "wizard" in command_list
+    assert "salad" in command_list

From 9dd27992c7b683883d2e74c05c05acc6c9e490ce Mon Sep 17 00:00:00 2001
From: 2Cubed <2Cubed@users.noreply.github.com>
Date: Tue, 9 May 2017 16:56:58 -0400
Subject: [PATCH 092/122] Finish tests for Trust command

---
 cactusbot/commands/magic/trust.py | 105 ++++++++++++-----------
 tests/commands/api.py             |  66 ++++++++++++++-
 tests/commands/test_quote.py      |   2 +-
 tests/commands/test_trust.py      | 136 +++++++-----------------------
 4 files changed, 153 insertions(+), 156 deletions(-)

diff --git a/cactusbot/commands/magic/trust.py b/cactusbot/commands/magic/trust.py
index 9331917..48f607d 100644
--- a/cactusbot/commands/magic/trust.py
+++ b/cactusbot/commands/magic/trust.py
@@ -8,7 +8,7 @@
 BASE_URL = "https://beam.pro/api/v1/channels/{username}"
 
 
-async def check_user(username):
+async def check_beam_user(username):
     """Check if a Beam username exists."""
     if username.startswith('@'):
         username = username[1:]
@@ -18,73 +18,80 @@ async def check_user(username):
         return (username, (await response.json())["id"])
 
 
-class Trust(Command):
-    """Trust command."""
+def _trust(check_user=check_beam_user):
 
-    COMMAND = "trust"
-    ROLE = "moderator"
+    class Trust(Command):  # pylint: disable=W0621
+        """Trust command."""
 
-    @Command.command(hidden=True)
-    async def default(self, username: check_user):
-        """Toggle a trust."""
+        COMMAND = "trust"
+        ROLE = "moderator"
 
-        user, user_id = username
+        @Command.command(hidden=True)
+        async def default(self, username: check_user):
+            """Toggle a trust."""
 
-        is_trusted = (await self.api.trust.get(user_id)).status == 200
+            user, user_id = username
 
-        if is_trusted:
-            await self.api.trust.remove(user_id)
-        else:
-            await self.api.trust.add(user_id, user)
+            is_trusted = (await self.api.trust.get(user_id)).status == 200
 
-        return MessagePacket(
-            ("tag", user), " is {modifier} trusted.".format(
-                modifier=("now", "no longer")[is_trusted]))
+            if is_trusted:
+                await self.api.trust.remove(user_id)
+            else:
+                await self.api.trust.add(user_id, user)
 
-    @Command.command()
-    async def add(self, username: check_user):
-        """Add a trusted user."""
+            return MessagePacket(
+                ("tag", user), " is {modifier} trusted.".format(
+                    modifier=("now", "no longer")[is_trusted]))
 
-        user, user_id = username
+        @Command.command()
+        async def add(self, username: check_user):
+            """Add a trusted user."""
 
-        response = await self.api.trust.add(user_id, user)
+            user, user_id = username
 
-        if response.status in (201, 200):
-            return MessagePacket("User ", ("tag", user), " has been trusted.")
+            response = await self.api.trust.add(user_id, user)
 
-    @Command.command()
-    async def remove(self, username: check_user):
-        """Remove a trusted user."""
+            if response.status in (201, 200):
+                return MessagePacket(
+                    "User ", ("tag", user), " has been trusted.")
 
-        user, user_id = username
+        @Command.command()
+        async def remove(self, username: check_user):
+            """Remove a trusted user."""
 
-        response = await self.api.trust.remove(user_id)
+            user, user_id = username
 
-        if response.status == 200:
-            return MessagePacket("Removed trust for user ", ("tag", user), '.')
-        return MessagePacket(("tag", user), " is not a trusted user.")
+            response = await self.api.trust.remove(user_id)
 
-    @Command.command(name="list")
-    async def list_trusts(self):
-        """Get the trused users in a channel."""
+            if response.status == 200:
+                return MessagePacket(
+                    "Removed trust for user ", ("tag", user), '.')
+            return MessagePacket(("tag", user), " is not a trusted user.")
 
-        data = await (await self.api.trust.get()).json()
+        @Command.command(name="list")
+        async def list_trusts(self):
+            """Get the trused users in a channel."""
 
-        if not data["data"]:
-            return "No trusted users."
+            data = await (await self.api.trust.get()).json()
 
-        return "Trusted users: {}.".format(', '.join(
-            user["attributes"]["userName"] for user in data["data"]))
+            if not data["data"]:
+                return "No trusted users."
 
-    @Command.command()
-    async def check(self, username: check_user):
-        """Check if a user is currently trusted in the channel."""
+            return "Trusted users: {}.".format(', '.join(
+                user["attributes"]["userName"] for user in data["data"]))
 
-        user, user_id = username
+        @Command.command()
+        async def check(self, username: check_user):
+            """Check if a user is currently trusted in the channel."""
 
-        data = await (await self.api.trust.get(user_id)).json()
-        print(data)
+            user, user_id = username
 
-        trusted = " " if data["data"] else " not "
-        return MessagePacket(("tag", user), " is{status}trusted.".format(
-            status=trusted))
+            data = await (await self.api.trust.get(user_id)).json()
+
+            return MessagePacket(("tag", user), " is {status}trusted.".format(
+                status="" if data["data"] else "not "))
+
+    return Trust
+
+
+Trust = _trust()
diff --git a/tests/commands/api.py b/tests/commands/api.py
index 65c742f..f582567 100644
--- a/tests/commands/api.py
+++ b/tests/commands/api.py
@@ -1,5 +1,5 @@
 from cactusbot.api import (Alias, CactusAPI, Command, Config, Quote, Repeat,
-                           Social)
+                           Social, Trust)
 
 
 class MockAPI(CactusAPI):
@@ -15,7 +15,8 @@ def __init__(self, token, password):
             "config": MockConfig(self),
             "quote": MockQuote(self),
             "repeat": MockRepeat(self),
-            "social": MockSocial(self)
+            "social": MockSocial(self),
+            "trust": MockTrust(self)
         }
 
 
@@ -456,3 +457,64 @@ async def remove(self, service):
                 'deleted': ['e0522d88-62c7-4c5a-b726-899b2894aaec']
             }
         })
+
+
+class MockTrust(Trust):
+
+    async def get(self, user_id=None):
+
+        if user_id is not None:
+
+            if user_id == "untrusted":
+                return MockResponse({"data": {}}, status=404)
+
+            return MockResponse({
+                "data": {
+                    "attributes": {
+                        "token": "cactusdev",
+                        "userId": user_id,
+                        "userName": "Stanley"
+                    }
+                }
+            })
+
+        return MockResponse({
+            "data": [{
+                "attributes": {
+                    "token": "cactusdev",
+                    "userId": "95845",
+                    "userName": "Stanley"
+                }
+            }]
+        })
+
+    async def add(self, user_id, username):
+
+        return MockResponse({
+            "attributes": {
+                "attributes": {
+                    "token": "cactusdev",
+                    "userId": user_id,
+                    "userName": username
+                },
+                "id": "7875b898-fbb3-426f-aca3-7375d97326b0",
+                "type": "trust"
+            },
+            "meta": {
+                "created": True
+            }
+        })
+
+    async def remove(self, user_id):
+
+        status = 200
+        if user_id == "untrusted":
+            status = 404
+
+        return MockResponse({
+            "meta": {
+                "deleted": [
+                    "7875b898-fbb3-426f-aca3-7375d97326b0"
+                ]
+            }
+        }, status=status)
diff --git a/tests/commands/test_quote.py b/tests/commands/test_quote.py
index 9b6c910..f42227a 100644
--- a/tests/commands/test_quote.py
+++ b/tests/commands/test_quote.py
@@ -8,7 +8,7 @@
 
 @pytest.mark.asyncio
 async def test_get():
-    
+
     assert await quote() == '"Quote!" -Someone'
 
     assert await quote("8") == '"Quote!" -Someone'
diff --git a/tests/commands/test_trust.py b/tests/commands/test_trust.py
index c41589c..b73b146 100644
--- a/tests/commands/test_trust.py
+++ b/tests/commands/test_trust.py
@@ -2,120 +2,48 @@
 
 import pytest
 
-from cactusbot.commands.magic import Trust
+from api import MockAPI
+from cactusbot.commands.magic.trust import _trust
 from cactusbot.packets import MessagePacket
 
+async def check_user(username):
+    if username.startswith('@'):
+        username = username[1:]
+    if username == "invalid":
+        raise NameError
+    return (username, username)
+
+trust = _trust(check_user)(MockAPI("test_token", "test_password"))
 
-class MockAPI:
-    """Fake API."""
-
-    class Trust:
-
-        async def get(self, user_id=None):
-            """Get trusts."""
-
-            class Response:
-                """Fake API response object."""
-                @property
-                def status(self):
-                    """Response status."""
-                    return 200
-
-                async def json(self):
-                    """JSON version of the response."""
-
-                    if user_id:
-                        return {
-                            "data": {
-                                {
-                                    "attributes": {
-                                        "token": "TestChannel",
-                                        "userId": "95845",
-                                        "userName": "Stanley"
-                                    }
-                                }
-                            }
-                        }
-                    else:
-                        return {
-                            "data": [
-                                {
-                                    "attributes": {
-                                        "token": "TestChannel",
-                                        "userId": "95845",
-                                        "userName": "Stanley"
-                                    }
-                                }
-                            ]
-                        }
-            return Response()
-
-        async def add(self, user_id, username):
-            """Add a new trust."""
-            class Response:
-                """Fake API response object."""
-                @property
-                def status(self):
-                    """Response status."""
-                    return 200
-
-                async def json(self):
-                    """JSON response."""
-                    return {
-                        "attributes": {
-                            "attributes": {
-                                "token": "TestChannel",
-                                "userId": "95845",
-                                "userName": "Stanley"
-                            },
-                            "id": "7875b898-fbb3-426f-aca3-7375d97326b0",
-                            "type": "trust"
-                        },
-                        "meta": {
-                            "created": True
-                        }
-                    }
-            return Response()
-
-        async def remove(self, user_id):
-            """Remove a trust."""
-            class Response:
-                """Fake API response."""
-                @property
-                def status(self):
-                    """Response status."""
-                    return 200
-
-                async def json(self):
-                    """JSON response."""
-                    return {
-                        "meta": {
-                            "deleted": [
-                                "7875b898-fbb3-426f-aca3-7375d97326b0"
-                            ]
-                        }
-                    }
-            return Response()
-    trust = Trust()
-
-trust = Trust(MockAPI())
 
 @pytest.mark.asyncio
-async def test_trust_list():
-    """Get a list of trusts."""
-    assert (await trust("list")) == "Trusted users: Stanley."
+async def test_toggle():
+
+    assert (await trust("Stanley")).text == "Stanley is no longer trusted."
+    assert (await trust("untrusted")).text == "untrusted is now trusted."
+
 
 @pytest.mark.asyncio
-async def test_trust_add():
-    """Add a new trust."""
+async def test_add():
+
     assert (await trust("add", "Stanley")).text == "User Stanley has been trusted."
 
+
 @pytest.mark.asyncio
-async def test_trust_remove():
-    """Remove a trust."""
+async def test_remove():
+
     assert (await trust("remove", "Stanley")).text == "Removed trust for user Stanley."
+    assert (await trust("remove", "untrusted")).text == "untrusted is not a trusted user."
+
 
 @pytest.mark.asyncio
-async def test_trust_toggle():
-    """Toggle a trust."""
-    assert (await trust("Stanley")).text == "Stanley is no longer trusted."
+async def test_list():
+
+    assert await trust("list") == "Trusted users: Stanley."
+
+
+@pytest.mark.asyncio
+async def test_check():
+
+    assert (await trust("check", "Stanley")).text == "Stanley is trusted."
+    assert (await trust("check", "untrusted")).text == "untrusted is not trusted."

From 987f23b92f3b947efcd613d9824369947962342a Mon Sep 17 00:00:00 2001
From: 2Cubed <2Cubed@users.noreply.github.com>
Date: Tue, 9 May 2017 17:20:19 -0400
Subject: [PATCH 093/122] Finish tests for Command command

---
 cactusbot/commands/magic/command.py    |   4 +-
 tests/commands/api.py                  |  99 ++++++++++-
 tests/commands/test_command_command.py | 233 ++++---------------------
 3 files changed, 136 insertions(+), 200 deletions(-)

diff --git a/cactusbot/commands/magic/command.py b/cactusbot/commands/magic/command.py
index 418dbe3..ebc52de 100644
--- a/cactusbot/commands/magic/command.py
+++ b/cactusbot/commands/magic/command.py
@@ -23,8 +23,8 @@ async def add(self, command: r'!?([+$]?)([\w-]{1,32})', *response,
 
         user_level = self.ROLES.get(symbol, 1)
 
-        raw.role = user_level  # HACK
-        raw.target = None
+        raw = raw.copy(role=user_level, target=None)
+
         response = await self.api.command.add(
             name, raw.split(maximum=3)[-1].json, user_level=user_level)
         data = await response.json()
diff --git a/tests/commands/api.py b/tests/commands/api.py
index f582567..11e592a 100644
--- a/tests/commands/api.py
+++ b/tests/commands/api.py
@@ -130,7 +130,43 @@ class MockCommand(Command):
     async def get(self, name=None):
 
         if name is not None:
-            raise NotImplementedError
+
+            if name == "nonexistent":
+                return MockResponse({}, status=404)
+
+            return MockResponse({
+                "data": {
+                    "attributes": {
+                        "count": 12,
+                        "enabled": True,
+                        "name": name,
+                        "response": {
+                            "action": False,
+                            "message": [
+                                {
+                                    "data": "testing!",
+                                    "text": "testing!",
+                                    "type": "text"
+                                },
+                                {
+                                    "data": ":smile:",
+                                    "text": ":)",
+                                    "type": "emoji"
+                                }
+                            ],
+                            "target": None,
+                            "user": "Stanley"
+                        },
+                        "role": 0,
+                        "token": "cactusdev"
+                    },
+                    "id": "3f51fc4d-d012-41c0-b98e-ff6257394f75",
+                    "type": "command"
+                },
+                "meta": {
+                    "created": True
+                }
+            })
 
         return MockResponse({
             "data": [
@@ -185,6 +221,67 @@ async def get(self, name=None):
             ]
         })
 
+    async def add(self, name, response, *, user_level=1):
+
+        meta = {"created": True}
+        if name == "existing":
+            meta = {"edited": True}
+
+        return MockResponse({
+            "data": {
+                "attributes": {
+                    "count": 0,
+                    "enabled": True,
+                    "name": name,
+                    "response": {
+                        "action": False,
+                        "message": [
+                            {
+                                "data": "lol!",
+                                "text": "lol!",
+                                "type": "text"
+                            },
+                            {
+                                "data": ":smile:",
+                                "text": ":)",
+                                "type": "emoji"
+                            }
+                        ],
+                        "role": user_level,
+                        "target": None,
+                        "user": ""
+                    },
+                    "token": "cactusdev"
+                },
+                "id": "d23779ce-4522-431d-9095-7bf34718c39d",
+                "type": "command"
+            },
+            "meta": meta
+        })
+
+    async def remove(self, name):
+
+        if name == "nonexistent":
+            return MockResponse({}, status=404)
+
+        return MockResponse({
+            "meta": {
+                "deleted": {
+                    "aliases": None,
+                    "command": [
+                        "d23779ce-4522-431d-9095-7bf34718c39d"
+                    ],
+                    "repeats": None
+                }
+            }
+        })
+
+    async def toggle(self, command, state):
+        return MockResponse({})
+
+    async def update_count(self, command, action):
+        return MockResponse({})
+
 
 class MockConfig(Config):
 
diff --git a/tests/commands/test_command_command.py b/tests/commands/test_command_command.py
index ce972db..a8bab23 100644
--- a/tests/commands/test_command_command.py
+++ b/tests/commands/test_command_command.py
@@ -2,212 +2,51 @@
 
 import pytest
 
+from api import MockAPI
 from cactusbot.commands.magic import Meta
 from cactusbot.packets import MessagePacket
 
-class MockAPI:
-    """Fake API."""
-
-    class Command:
-
-        async def get(self, command=None):
-            """Get commands."""
-
-            class Response:
-                """API response."""
-
-                @property
-                def status(self):
-                    """Status of the response."""
-                    return 200
-
-                async def json(self):
-                    """JSON response."""
-
-                    if command:
-                        return {
-                            "data": {
-                                "attributes": {
-                                    "count": 0,
-                                    "enabled": True,
-                                    "name": "testing",
-                                    "response": {
-                                        "action": False,
-                                        "message": [
-                                            {
-                                                "data": "testing!",
-                                                "text": "testing!",
-                                                "type": "text"
-                                            },
-                                            {
-                                                "data": ":smile:",
-                                                "text": ":)",
-                                                "type": "emoji"
-                                            }
-                                        ],
-                                        "target": None,
-                                        "user": "Stanley"
-                                    },
-                                    "role": 0,
-                                    "token": "Stanley"
-                                },
-                                "id": "3f51fc4d-d012-41c0-b98e-ff6257394f75",
-                                "type": "command"
-                            },
-                            "meta": {
-                                "created": True
-                            }
-                        }
-                    else:
-                        return {
-                            "data": [
-                                {
-                                    "attributes": {
-                                        "count": 2,
-                                        "enabled": True,
-                                        "name": "testing",
-                                        "response": {
-                                            "action": False,
-                                            "message": [
-                                                {
-                                                    "data": "testing!",
-                                                    "text": "testing!",
-                                                    "type": "text"
-                                                }
-                                            ],
-                                            "role": 1,
-                                            "target": None,
-                                            "user": "Stanley"
-                                        },
-                                        "token": "Stanley"
-                                    },
-                                    "id": "d23779ce-4522-431d-9095-7bf34718c39d",
-                                    "type": "command"
-                                },
-                                {
-                                    "attributes": {
-                                        "commandName": "testing",
-                                        "count": 2,
-                                        "enabled": True,
-                                        "id": "d23779ce-4522-431d-9095-7bf34718c39d",
-                                        "name": "test",
-                                        "response": {
-                                            "action": False,
-                                            "message": [
-                                                {
-                                                    "data": "testing!",
-                                                    "text": "testing!",
-                                                    "type": "text"
-                                                }
-                                            ],
-                                            "role": 1,
-                                            "target": None,
-                                            "user": "Stanley"
-                                        },
-                                        "token": "Stanley"
-                                    },
-                                    "id": "312ab175-fb52-4a7b-865d-4202176f9234",
-                                    "type": "aliases"
-                                }
-                            ]
-                        }
-            return Response()
-
-        async def add(self, name, response, *, user_level=1):
-            """Add a command."""
-
-            class Response:
-                """API response."""
-
-                @property
-                def status(self):
-                    """Status of the request."""
-                    return 200
-
-                async def json(self):
-                    """JSON response."""
-
-                    return {
-                        "data": {
-                            "attributes": {
-                                "count": 0,
-                                "enabled": True,
-                                "name": "testing",
-                                "response": {
-                                    "action": False,
-                                    "message": [
-                                        {
-                                            "data": "lol!",
-                                            "text": "lol!",
-                                            "type": "text"
-                                        },
-                                        {
-                                            "data": ":smile:",
-                                            "text": ":)",
-                                            "type": "emoji"
-                                        }
-                                    ],
-                                    "role": 0,
-                                    "target": None,
-                                    "user": ""
-                                },
-                                "token": "innectic2"
-                            },
-                            "id": "d23779ce-4522-431d-9095-7bf34718c39d",
-                            "type": "command"
-                        },
-                        "meta": {
-                            "edited": True
-                        }
-                    }
-            return Response()
-
-        async def remove(self, name):
-            """Remove a command."""
-
-            class Response:
-                """API response."""
-
-                @property
-                def status(self):
-                    """Status of the request."""
-                    return 200
-
-                async def json(self):
-                    """JSON response."""
-                    return {
-                        "meta": {
-                            "deleted": {
-                                "aliases": None,
-                                "command": [
-                                    "d23779ce-4522-431d-9095-7bf34718c39d"
-                                ],
-                                "repeats": None
-                            }
-                        }
-                    }
-            return Response()
-    command = Command()
-
-command = Meta(MockAPI())
+command = Meta(MockAPI("test_token", "test_password"))
+
 
 @pytest.mark.asyncio
-async def test_command_add():
-    """Add a command."""
+async def test_add():
+
     packet = MessagePacket(("text", "lol"), ("emoji", "😃"), role=5)
-    assert (await command("add", "testing", packet, packet=packet)) == "Updated command !testing."
+    assert await command("add", "testing", packet, packet=packet) == "Added command !testing."
+
+    packet = MessagePacket("Existing.", role=5)
+    assert await command("add", "existing", packet, packet=packet) == "Updated command !existing."
+
 
 @pytest.mark.asyncio
-async def test_command_remove():
-    """Remove a command."""
+async def test_remove():
+
+    assert await command("remove", "testing") == "Removed command !testing."
+
+    assert await command("remove", "nonexistent") == "Command !nonexistent does not exist!"
 
-    packet = MessagePacket("!command remove testing", role=5)
-    assert (await command("remove", "testing", packet=packet)
-           ) == "Removed command !testing."
 
 @pytest.mark.asyncio
-async def test_command_list():
-    """List commands."""
+async def test_list():
+
+    assert await command("list") == "Commands: test, testing"
+
+
+@pytest.mark.asyncio
+async def test_toggle():
+
+    assert await command("enable", "test") == "Command !test has been enabled."
+    assert await command("disable", "test") == "Command !test has been disabled."
+
+
+@pytest.mark.asyncio
+async def test_count():
+
+    assert await command("count", "test") == "!test's count is 12."
+    assert await command("count", "nonexistent") == "Command !nonexistent does not exist."
 
-    packet = MessagePacket("!command list", role=5)
-    assert (await command("list", packet=packet)) == "Commands: testing"
+    assert await command("count", "test", "50") == "Count updated."
+    assert await command("count", "test", "=50") == "Count updated."
+    assert await command("count", "test", "+3") == "Count updated."
+    assert await command("count", "test", "-1") == "Count updated."

From 21290522fa253a2d57f4e9c99a2ca8992d6a735a Mon Sep 17 00:00:00 2001
From: 2Cubed <2Cubed@users.noreply.github.com>
Date: Tue, 9 May 2017 17:24:13 -0400
Subject: [PATCH 094/122] Fix tests after branch update

---
 tests/commands/test_repeat.py | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/tests/commands/test_repeat.py b/tests/commands/test_repeat.py
index ba81130..65ec357 100644
--- a/tests/commands/test_repeat.py
+++ b/tests/commands/test_repeat.py
@@ -12,7 +12,7 @@ async def test_add():
     assert await repeat("add", "600", "kittens") == "Repeat !kittens added on interval 600."
     assert await repeat("add", "600", "existing") == "Repeat !existing updated with interval 600."
 
-    assert await repeat("add", "twelve", "kittens") == "Invalid period: 'twelve'."
+    assert await repeat("add", "twelve", "kittens") == "Invalid 'period': 'twelve'."
 
 
 @pytest.mark.asyncio

From 2fdf493c0429a582329ea03773ca4d2ce2fbaee0 Mon Sep 17 00:00:00 2001
From: 2Cubed <2Cubed@users.noreply.github.com>
Date: Tue, 9 May 2017 17:35:31 -0400
Subject: [PATCH 095/122] Important business logic

Hopefully to unbreak Coveralls build...?
---
 cactusbot/commands/magic/__init__.py | 5 ++---
 1 file changed, 2 insertions(+), 3 deletions(-)

diff --git a/cactusbot/commands/magic/__init__.py b/cactusbot/commands/magic/__init__.py
index 2f30530..a5b15b7 100644
--- a/cactusbot/commands/magic/__init__.py
+++ b/cactusbot/commands/magic/__init__.py
@@ -6,16 +6,15 @@
 from .command import Meta
 from .config import Config
 from .cube import Cube, Temmie
+from .multi import Multi
 from .quote import Quote
 from .repeat import Repeat
 from .social import Social
 from .trust import Trust
 from .uptime import Uptime
-from .multi import Multi
 
 COMMANDS = (Alias, Cactus, Meta, Config, Cube, Temmie,
             Quote, Repeat, Social, Trust, Uptime, Multi)
 
 __all__ = ("Alias", "Command", "Cactus", "Meta", "Config", "Cube",
-           "Temmie", "Quote", "Repeat", "Social", "Trust", "Uptime",
-           "Multi")
+           "Temmie", "Quote", "Repeat", "Social", "Trust", "Uptime", "Multi")

From aff47f3b60ba71ca55534858df92a2791056ad41 Mon Sep 17 00:00:00 2001
From: 2Cubed <2Cubed@users.noreply.github.com>
Date: Tue, 9 May 2017 23:16:51 -0400
Subject: [PATCH 096/122] Write BeamHandler tests

---
 tests/services/test_beam.py | 236 +++++++++++++++++++++++++++++++-----
 1 file changed, 206 insertions(+), 30 deletions(-)

diff --git a/tests/services/test_beam.py b/tests/services/test_beam.py
index 6802a34..52d3135 100644
--- a/tests/services/test_beam.py
+++ b/tests/services/test_beam.py
@@ -1,4 +1,8 @@
-from cactusbot.packets import MessagePacket
+import pytest
+
+from cactusbot.handler import Handler, Handlers
+from cactusbot.packets import BanPacket, MessagePacket
+from cactusbot.services.beam import BeamHandler
 from cactusbot.services.beam.parser import BeamParser
 
 
@@ -71,35 +75,37 @@ def test_parse_follow():
         'user': {
             'avatarUrl': 'https://uploads.beam.pro/avatar/l0icubxz-95845.jpg',
             'bio': None,
-            'channel': {'audience': 'teen',
-                        'badgeId': None,
-                        'coverId': None,
-                        'createdAt': '2016-03-05T20:41:21.000Z',
-                        'deletedAt': None,
-                        'description': None,
-                        'featured': False,
-                        'ftl': 0,
-                        'hasTranscodes': True,
-                        'hasVod': False,
-                        'hosteeId': None,
-                        'id': 68762,
-                        'interactive': False,
-                        'interactiveGameId': None,
-                        'languageId': None,
-                        'name': "Stanley's Channel",
-                        'numFollowers': 0,
-                        'online': False,
-                        'partnered': False,
-                        'suspended': False,
-                        'thumbnailId': None,
-                        'token': 'Stanley',
-                        'transcodingProfileId': None,
-                        'typeId': None,
-                        'updatedAt': '2016-08-16T02:53:01.000Z',
-                        'userId': 95845,
-                        'viewersCurrent': 0,
-                        'viewersTotal': 0,
-                        'vodsEnabled': True},
+            'channel': {
+                'audience': 'teen',
+                'badgeId': None,
+                'coverId': None,
+                'createdAt': '2016-03-05T20:41:21.000Z',
+                'deletedAt': None,
+                'description': None,
+                'featured': False,
+                'ftl': 0,
+                'hasTranscodes': True,
+                'hasVod': False,
+                'hosteeId': None,
+                'id': 68762,
+                'interactive': False,
+                'interactiveGameId': None,
+                'languageId': None,
+                'name': "Stanley's Channel",
+                'numFollowers': 0,
+                'online': False,
+                'partnered': False,
+                'suspended': False,
+                'thumbnailId': None,
+                'token': 'Stanley',
+                'transcodingProfileId': None,
+                'typeId': None,
+                'updatedAt': '2016-08-16T02:53:01.000Z',
+                'userId': 95845,
+                'viewersCurrent': 0,
+                'viewersTotal': 0,
+                'vodsEnabled': True
+            },
             'createdAt': '2016-03-05T20:41:21.000Z',
             'deletedAt': None,
             'experience': 401,
@@ -205,6 +211,7 @@ def test_parse_subscribe():
         "streak": 1
     }
 
+
 def test_parse_resubscribe():
 
     assert BeamParser.parse_resubscribe({
@@ -299,3 +306,172 @@ def test_synthesize():
     assert BeamParser.synthesize(MessagePacket(
         "Hello!", target="Stanley"
     )) == (("Stanley", "Hello!",), {"method": "whisper"})
+
+
+class BeamHandlerWrapper(BeamHandler):
+
+    def __init__(self, handlers):
+        self._queue = []
+        super().__init__("channel", "token", handlers)
+
+    async def send(self, *args, **kwargs):
+        self._queue.append((args, kwargs))
+
+    @property
+    def queue(self):
+        queue = self._queue
+        self._queue = []
+        return queue
+
+
+class PingHandler(Handler):
+    async def on_message(self, packet):
+        if packet.text == "Ping!":
+            return "Pong!"
+
+
+class SpamHandler(Handler):
+    async def on_message(self, packet):
+        if "spam" in packet.text:
+            return ("No spamming!", BanPacket(packet.user, duration=5))
+        if "SPAM" in packet.text:
+            return BanPacket(packet.user)
+
+
+class FollowHandler(Handler):
+    async def on_follow(self, packet):
+        return "Thanks for the follow, {}!".format(packet.user)
+
+
+handlers = Handlers(PingHandler(), SpamHandler(), FollowHandler())
+beam_handler = BeamHandlerWrapper(handlers)
+
+
+@pytest.mark.asyncio
+async def test_handle():
+
+    await beam_handler.handle("message", "Hello!")
+    assert not beam_handler.queue
+
+    await beam_handler.handle("message", MessagePacket("Ping!"))
+    assert beam_handler.queue == [(("Pong!",), {})]
+
+    await beam_handler.handle(
+        "message", MessagePacket("spam eggs foo bar", user="Stanley")
+    )
+    assert beam_handler.queue == [
+        (("No spamming!",), {}),
+        (("Stanley", 5), {"method": "timeout"})
+    ]
+
+
+@pytest.mark.asyncio
+async def test_handle_chat():
+
+    await beam_handler.handle_chat({
+        'event': 'ChatMessage',
+        'data': {
+            'id': '688d66e0-352c-11e7-bd11-993537334664',
+            'user_level': 111,
+            'user_roles': ['Mod', 'Pro', 'User'],
+            'message': {
+                'message': [{
+                    'data': 'Ping!', 'text': 'Ping!', 'type': 'text'
+                }],
+                'meta': {}
+            },
+            'channel': 3016,
+            'user_id': 2547,
+            'user_name': '2Cubed'
+        },
+        'type': 'event'
+    })
+    assert beam_handler.queue == [(("Pong!",), {})]
+
+    await beam_handler.handle_chat({
+        'event': 'ChatMessage',
+        'data': {
+            'id': '688d66e0-352c-11e7-bd11-993537334664',
+            'user_level': 111,
+            'user_roles': ['User'],
+            'message': {
+                'message': [{
+                    'data': 'Such spam!', 'text': 'Such spam!', 'type': 'text'
+                }],
+                'meta': {}
+            },
+            'channel': 3016,
+            'user_id': 2547,
+            'user_name': 'Stanley'
+        },
+        'type': 'event'
+    })
+    assert beam_handler.queue == [
+        (("No spamming!",), {}),
+        (("Stanley", 5), {"method": "timeout"})
+    ]
+
+
+@pytest.mark.asyncio
+async def test_handle_constellation():
+
+    await beam_handler.handle_constellation({
+        'event': 'live',
+        'data': {
+            'payload': {
+                'user': {
+                    'id': 95845,
+                    'avatarUrl': 'https://uploads.beam.pro/avatar/l0icubxz-95845.jpg',
+                    'sparks': 2550,
+                    'experience': 715,
+                    'username': 'Stanley',
+                    'verified': True,
+                    'deletedAt': None,
+                    'channel': {
+                        'viewersCurrent': 0,
+                        'vodsEnabled': True,
+                        'featureLevel': 0,
+                        'partnered': False,
+                        'interactive': False,
+                        'description': None,
+                        'name': "Stanley's Channel",
+                        'typeId': None,
+                        'interactiveGameId': None,
+                        'createdAt': '2016-03-05T20:41:21.000Z',
+                        'userId': 95845,
+                        'bannerUrl': None,
+                        'hasTranscodes': True,
+                        'hosteeId': None,
+                        'suspended': False,
+                        'badgeId': None,
+                        'numFollowers': 0,
+                        'id': 68762,
+                        'deletedAt': None,
+                        'costreamId': None,
+                        'online': False,
+                        'languageId': None,
+                        'thumbnailId': None,
+                        'hasVod': False,
+                        'featured': False,
+                        'coverId': None,
+                        'viewersTotal': 0,
+                        'token': 'Stanley',
+                        'transcodingProfileId': 1,
+                        'updatedAt': '2016-11-13T20:22:01.000Z',
+                        'audience': 'teen',
+                        'ftl': 0
+                    },
+                    'createdAt': '2016-03-05T20:41:21.000Z',
+                    'bio': None,
+                    'updatedAt': '2016-08-20T04:35:25.000Z',
+                    'level': 17,
+                    'social': {'verified': []},
+                    'primaryTeam': None
+                },
+                'following': True
+            },
+            'channel': 'channel:3016:followed'
+        }, 'type': 'event'})
+    assert beam_handler.queue == [
+        (("Thanks for the follow, Stanley!",), {})
+    ]

From 040d9b71f275710716b117b5a94c0c0f9a905830 Mon Sep 17 00:00:00 2001
From: 2Cubed <2Cubed@users.noreply.github.com>
Date: Tue, 9 May 2017 23:23:49 -0400
Subject: [PATCH 097/122] Move MockAPI to accessible location in tests

---
 tests/__init__.py                      | 0
 tests/{commands => }/api.py            | 0
 tests/commands/test_alias.py           | 2 +-
 tests/commands/test_cactus.py          | 2 +-
 tests/commands/test_command_command.py | 2 +-
 tests/commands/test_config.py          | 2 +-
 tests/commands/test_cube.py            | 2 +-
 tests/commands/test_multi.py           | 2 +-
 tests/commands/test_quote.py           | 2 +-
 tests/commands/test_repeat.py          | 2 +-
 tests/commands/test_social.py          | 2 +-
 tests/commands/test_trust.py           | 2 +-
 tests/handlers/test_command.py         | 5 ++++-
 13 files changed, 14 insertions(+), 11 deletions(-)
 create mode 100644 tests/__init__.py
 rename tests/{commands => }/api.py (100%)

diff --git a/tests/__init__.py b/tests/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/tests/commands/api.py b/tests/api.py
similarity index 100%
rename from tests/commands/api.py
rename to tests/api.py
diff --git a/tests/commands/test_alias.py b/tests/commands/test_alias.py
index 13d1e85..fe4492b 100644
--- a/tests/commands/test_alias.py
+++ b/tests/commands/test_alias.py
@@ -1,6 +1,6 @@
 import pytest
 
-from api import MockAPI
+from tests.api import MockAPI
 from cactusbot.commands.magic import Alias
 from cactusbot.packets import MessagePacket
 
diff --git a/tests/commands/test_cactus.py b/tests/commands/test_cactus.py
index 021c4bf..e5b3d58 100644
--- a/tests/commands/test_cactus.py
+++ b/tests/commands/test_cactus.py
@@ -1,6 +1,6 @@
 import pytest
 
-from api import MockAPI
+from tests.api import MockAPI
 from cactusbot.commands.magic import Cactus
 
 from cactusbot.cactus import __version__
diff --git a/tests/commands/test_command_command.py b/tests/commands/test_command_command.py
index a8bab23..e38d74c 100644
--- a/tests/commands/test_command_command.py
+++ b/tests/commands/test_command_command.py
@@ -2,7 +2,7 @@
 
 import pytest
 
-from api import MockAPI
+from tests.api import MockAPI
 from cactusbot.commands.magic import Meta
 from cactusbot.packets import MessagePacket
 
diff --git a/tests/commands/test_config.py b/tests/commands/test_config.py
index 1938697..d2ac206 100644
--- a/tests/commands/test_config.py
+++ b/tests/commands/test_config.py
@@ -1,6 +1,6 @@
 import pytest
 
-from api import MockAPI
+from tests.api import MockAPI
 from cactusbot.commands.magic import Config
 
 config = Config(MockAPI("test_token", "test_password"))
diff --git a/tests/commands/test_cube.py b/tests/commands/test_cube.py
index a9a2a21..aec771b 100644
--- a/tests/commands/test_cube.py
+++ b/tests/commands/test_cube.py
@@ -1,6 +1,6 @@
 import pytest
 
-from api import MockAPI
+from tests.api import MockAPI
 from cactusbot.commands.magic import Cube, Temmie
 from cactusbot.packets import MessagePacket
 
diff --git a/tests/commands/test_multi.py b/tests/commands/test_multi.py
index 30190bb..187da7b 100644
--- a/tests/commands/test_multi.py
+++ b/tests/commands/test_multi.py
@@ -2,7 +2,7 @@
 
 import pytest
 
-from api import MockAPI
+from tests.api import MockAPI
 from cactusbot.commands.magic import Multi
 
 multi = Multi(MockAPI("test_token", "test_password"))
diff --git a/tests/commands/test_quote.py b/tests/commands/test_quote.py
index f42227a..159c14b 100644
--- a/tests/commands/test_quote.py
+++ b/tests/commands/test_quote.py
@@ -1,6 +1,6 @@
 import pytest
 
-from api import MockAPI
+from tests.api import MockAPI
 from cactusbot.commands.magic import Quote
 
 quote = Quote(MockAPI("test_token", "test_password"))
diff --git a/tests/commands/test_repeat.py b/tests/commands/test_repeat.py
index 65ec357..d02fc03 100644
--- a/tests/commands/test_repeat.py
+++ b/tests/commands/test_repeat.py
@@ -1,6 +1,6 @@
 import pytest
 
-from api import MockAPI
+from tests.api import MockAPI
 from cactusbot.commands.magic import Repeat
 
 repeat = Repeat(MockAPI("test_token", "test_password"))
diff --git a/tests/commands/test_social.py b/tests/commands/test_social.py
index 087e057..7e230b3 100644
--- a/tests/commands/test_social.py
+++ b/tests/commands/test_social.py
@@ -1,6 +1,6 @@
 import pytest
 
-from api import MockAPI
+from tests.api import MockAPI
 from cactusbot.commands.magic import Social
 
 social = Social(MockAPI("test_token", "test_password"))
diff --git a/tests/commands/test_trust.py b/tests/commands/test_trust.py
index b73b146..a983720 100644
--- a/tests/commands/test_trust.py
+++ b/tests/commands/test_trust.py
@@ -2,7 +2,7 @@
 
 import pytest
 
-from api import MockAPI
+from tests.api import MockAPI
 from cactusbot.commands.magic.trust import _trust
 from cactusbot.packets import MessagePacket
 
diff --git a/tests/handlers/test_command.py b/tests/handlers/test_command.py
index bd432cf..db9dbe5 100644
--- a/tests/handlers/test_command.py
+++ b/tests/handlers/test_command.py
@@ -1,5 +1,6 @@
 import pytest
-from api import MockAPI
+
+from tests.api import MockAPI
 from cactusbot.commands.command import Command
 from cactusbot.handlers import CommandHandler
 from cactusbot.packets import MessagePacket
@@ -251,6 +252,7 @@ async def taco(self):
             """Taco salad."""
             return "TACO SALAD!?"
 
+
 potato = Potato(MockAPI("test_token", "test_password"))
 
 
@@ -312,6 +314,7 @@ async def test_args():
 
     assert await potato("salad", "taco") == "TACO SALAD!?"
 
+
 @pytest.mark.asyncio
 async def test_list():
     command_list = potato.commands()

From 48ee2658b36e97a1c17672eacd0d0b2845934022 Mon Sep 17 00:00:00 2001
From: 2Cubed <2Cubed@users.noreply.github.com>
Date: Tue, 9 May 2017 23:51:36 -0400
Subject: [PATCH 098/122] Finish CommandHandler tests

---
 cactusbot/handlers/command.py  | 14 +++-----
 tests/api.py                   | 51 +++++++++++++++++++++++++----
 tests/handlers/test_command.py | 59 +++++++++++++++++++++++++++++++++-
 3 files changed, 106 insertions(+), 18 deletions(-)

diff --git a/cactusbot/handlers/command.py b/cactusbot/handlers/command.py
index 975037c..d00ac34 100644
--- a/cactusbot/handlers/command.py
+++ b/cactusbot/handlers/command.py
@@ -103,11 +103,11 @@ async def custom_response(self, _packet, command, *args, **data):
                 ).text.split()), *args[1:])
         cmd = await self.api.command.get(name=command)
         if cmd.status != 200:
-            return MessagePacket("Command does not exist for that alias",
+            return MessagePacket("Command does not exist for that alias.",
                                  target=_packet.user)
         cmd_response = await cmd.json()
-        cmd_response = cmd_response["data"]["attributes"]["response"]
-        json["data"]["attributes"]["response"] = cmd_response
+        json["data"]["attributes"]["response"] = cmd_response[
+            "data"]["attributes"]["response"]
 
         json = json["data"]["attributes"]
 
@@ -133,13 +133,7 @@ async def custom_response(self, _packet, command, *args, **data):
         if not is_alias and "count" not in data:
             data["count"] = str(json["count"] + 1)
         elif is_alias:
-            response = await self.api.command.get(
-                name=command)
-            if response.status == 200:
-                command_data = (await (response.json()))["data"]["attributes"]
-                data["count"] = str(command_data["count"])
-            else:
-                return MessagePacket("An error has occured.")
+            data["count"] = str(cmd_response["data"]["attributes"]["count"])
 
         return self._inject(MessagePacket.from_json(json["response"]),
                             *args, **data)
diff --git a/tests/api.py b/tests/api.py
index 11e592a..9b75d45 100644
--- a/tests/api.py
+++ b/tests/api.py
@@ -131,21 +131,58 @@ async def get(self, name=None):
 
         if name is not None:
 
-            if name == "nonexistent":
+            if "nonexistent" in name:
                 return MockResponse({}, status=404)
 
+            if name == "aliased":
+                return MockResponse({
+                    "data": {
+                        "attributes": {
+                            "arguments": [
+                                {
+                                    "data": "arg1",
+                                    "text": "arg1",
+                                    "type": "text"
+                                }
+                            ],
+                            "command": "67dd51ee-28e7-4622-9c6a-07ddb0dfc6d8",
+                            "commandName": "nonaliased",
+                            "createdAt": "Wed May  3 14:17:49 2017",
+                            "name": name,
+                            "token": "cactusdev"
+                        },
+                        "id": "afd63f19-dece-4c2c-98c5-23446e3a49e9",
+                        "type": "alias"
+                    }
+                })
+
+            if name == "fakealias":
+                return MockResponse({
+                    "data": {
+                        "attributes": {
+                            "command": "67dd51ee-28e7-4622-9c6a-07ddb0dfc6d8",
+                            "commandName": "nonexistent",
+                            "createdAt": "Wed May  3 14:17:49 2017",
+                            "name": name,
+                            "token": "cactusdev"
+                        },
+                        "id": "afd63f19-dece-4c2c-98c5-23446e3a49e9",
+                        "type": "alias"
+                    }
+                })
+
             return MockResponse({
                 "data": {
                     "attributes": {
                         "count": 12,
-                        "enabled": True,
+                        "enabled": name != "disabled",
                         "name": name,
                         "response": {
                             "action": False,
                             "message": [
                                 {
-                                    "data": "testing!",
-                                    "text": "testing!",
+                                    "data": "Testing {}! ".format(name),
+                                    "text": "Testing {}! ".format(name),
                                     "type": "text"
                                 },
                                 {
@@ -154,10 +191,10 @@ async def get(self, name=None):
                                     "type": "emoji"
                                 }
                             ],
+                            "role": 4 if name == "modonly" else 1,
                             "target": None,
                             "user": "Stanley"
                         },
-                        "role": 0,
                         "token": "cactusdev"
                     },
                     "id": "3f51fc4d-d012-41c0-b98e-ff6257394f75",
@@ -179,8 +216,8 @@ async def get(self, name=None):
                             "action": False,
                             "message": [
                                 {
-                                    "data": "testing!",
-                                    "text": "testing!",
+                                    "data": "Testing!",
+                                    "text": "Testing!",
                                     "type": "text"
                                 }
                             ],
diff --git a/tests/handlers/test_command.py b/tests/handlers/test_command.py
index db9dbe5..9eaedbc 100644
--- a/tests/handlers/test_command.py
+++ b/tests/handlers/test_command.py
@@ -1,6 +1,6 @@
 import pytest
-
 from tests.api import MockAPI
+
 from cactusbot.commands.command import Command
 from cactusbot.handlers import CommandHandler
 from cactusbot.packets import MessagePacket
@@ -25,6 +25,40 @@ async def test_on_message():
         MessagePacket("!cactus")
     )).text == "Ohai! I'm CactusBot! 🌵"
 
+    assert (await command_handler.on_message(
+        MessagePacket("!test")
+    )).text == "Testing test! :)"
+
+    assert (await command_handler.on_message(
+        MessagePacket("!a b nonexistent c")
+    )).text == "Testing a-b! :)"
+
+    assert (await command_handler.on_message(
+        MessagePacket("!nonexistent")
+    )).text == "Command not found."
+
+    assert (await command_handler.on_message(
+        MessagePacket("!aliased")
+    )).text == "Testing nonaliased! :)"
+
+    assert (await command_handler.on_message(
+        MessagePacket("!fakealias")
+    )).text == "Command does not exist for that alias."
+
+    assert (await command_handler.on_message(
+        MessagePacket("!disabled")
+    )).text == "Command is disabled."
+
+    targeted = await command_handler.on_message(
+        MessagePacket("!targeted", target="CactusBot", user="Stanley")
+    )
+    assert targeted.text == "Testing targeted! :)"
+    assert targeted.user == "Stanley"
+
+    assert (await command_handler.on_message(
+        MessagePacket("!modonly", role=3)
+    )).text == "Role level 'Moderator' or higher required."
+
 
 def test_inject_argn():
 
@@ -70,6 +104,18 @@ def test_inject_argn():
         "raid", "@Streamer"
     )
 
+    verify(
+        "Would you like a %ARG1=potato%?",
+        "Would you like a potato?",
+        "offer"
+    )
+
+    verify(
+        "Would you like a %ARG1=potato%?",
+        "Would you like a carrot?",
+        "offer", "carrot"
+    )
+
 
 def test_inject_args():
 
@@ -85,6 +131,12 @@ def test_inject_args():
         "give"
     )
 
+    verify(
+        "WAAA %ARGS|upper%!",
+        "WAAA STUFF AND THINGS!",
+        "waaa", *"stuff and things".split()
+    )
+
     verify(
         "Here, have some %ARGS=taco salad%!",
         "Here, have some taco salad!",
@@ -153,8 +205,13 @@ def test_modify():
     assert command_handler.modify("Jello", "reverse", "title") == "Ollej"
 
 
+@pytest.mark.asyncio
+async def test_repeat():
+    assert await command_handler.on_repeat("Hi!") == "Hi!"
+
 ###
 
+
 async def add_title(name):
     """Add 'Potato Master' title to a name."""
     return "Potato Master " + name.title()

From d8dd84b9f7785fbae6a55fe32073fd23a6b7cb1a Mon Sep 17 00:00:00 2001
From: 2Cubed <2Cubed@users.noreply.github.com>
Date: Wed, 10 May 2017 00:09:25 -0400
Subject: [PATCH 099/122] Finish EventHandler tests

---
 tests/handlers/test_events.py | 71 ++++++++++++++---------------------
 1 file changed, 28 insertions(+), 43 deletions(-)

diff --git a/tests/handlers/test_events.py b/tests/handlers/test_events.py
index df35d90..d3699b0 100644
--- a/tests/handlers/test_events.py
+++ b/tests/handlers/test_events.py
@@ -1,53 +1,16 @@
 import pytest
+from tests.api import MockAPI
 
 from cactusbot.handlers import EventHandler
 from cactusbot.packets import EventPacket
 
-
-class MockAPI:
-
-    class Config:
-        async def get(self):
-
-            class Response:
-
-                async def json(self):
-
-                    return {
-                        "data": {"attributes": {"announce": {
-                            "follow": {
-                                "announce": True,
-                                "message": "Thanks for following, %USER%!"
-                            },
-                            "sub": {
-                                "announce": True,
-                                "message": "Thanks for subscribing, %USER%!"
-                            },
-                            "host": {
-                                "announce": True,
-                                "message": "Thanks for hosting, %USER%!"
-                            },
-                            "join": {
-                                "announce": True,
-                                "message": "Welcome to the channel, %USER%!"
-                            },
-                            "leave": {
-                                "announce": True,
-                                "message": "Thanks for watching, %USER%!"
-                            }
-                        }}}
-                    }
-
-            return Response()
-    config = Config()
-
 event_handler = EventHandler({
     "cache_follow": True,
     "cache_host": True,
     "cache_join": True,
     "cache_leave": True,
     "cache_time": 1200
-}, MockAPI())
+}, MockAPI("test_token", "test_password"))
 
 
 @pytest.mark.asyncio
@@ -63,19 +26,27 @@ async def test_on_follow():
 
     assert (await event_handler.on_follow(EventPacket(
         "follow", "TestUser"
-    ))).text == "Thanks for following, TestUser!"
+    ))).text == "Thanks for the follow, TestUser!"
 
     assert (await event_handler.on_follow(EventPacket(
         "follow", "TestUser", success=False
     ))) is None
 
+    event_handler.alert_messages["follow"]["announce"] = False
+
+    assert await event_handler.on_follow(EventPacket("follow", "TestUser")) is None
+
 
 @pytest.mark.asyncio
 async def test_on_subscribe():
 
     assert (await event_handler.on_subscribe(EventPacket(
         "subscribe", "TestUser"
-    ))).text == "Thanks for subscribing, TestUser!"
+    ))).text == "Thanks for the subscription, TestUser!"
+
+    event_handler.alert_messages["subscribe"]["announce"] = False
+
+    assert await event_handler.on_subscribe(EventPacket("subscribe", "TestUser")) is None
 
 
 @pytest.mark.asyncio
@@ -83,18 +54,32 @@ async def test_on_host():
 
     assert (await event_handler.on_host(EventPacket(
         "host", "TestUser"
-    ))).text == "Thanks for hosting, TestUser!"
+    ))).text == "Thanks for the host, TestUser!"
+
+    event_handler.alert_messages["host"]["announce"] = False
+
+    assert await event_handler.on_host(EventPacket("leave", "TestUser")) is None
+
 
 @pytest.mark.asyncio
 async def test_on_join():
 
+    assert await event_handler.on_join(EventPacket("join", "TestUser")) is None
+
+    event_handler.alert_messages["join"]["announce"] = True
+
     assert (await event_handler.on_join(EventPacket(
         "join", "TestUser"
-    ))).text == "Welcome to the channel, TestUser!"
+    ))).text == "Welcome, TestUser!"
+
 
 @pytest.mark.asyncio
 async def test_on_leave():
 
+    assert await event_handler.on_leave(EventPacket("leave", "TestUser")) is None
+
+    event_handler.alert_messages["leave"]["announce"] = True
+
     assert (await event_handler.on_leave(EventPacket(
         "leave", "TestUser"
     ))).text == "Thanks for watching, TestUser!"

From 420799ebab803cac772fedfc0ad6899dd179fc56 Mon Sep 17 00:00:00 2001
From: 2Cubed <2Cubed@users.noreply.github.com>
Date: Wed, 10 May 2017 00:09:43 -0400
Subject: [PATCH 100/122] Finish SpamHandler tests

---
 cactusbot/handlers/spam.py  | 20 +++++++++-----------
 tests/handlers/test_spam.py | 34 +++++++++++++++-------------------
 2 files changed, 24 insertions(+), 30 deletions(-)

diff --git a/cactusbot/handlers/spam.py b/cactusbot/handlers/spam.py
index 486d481..8a9c4d8 100644
--- a/cactusbot/handlers/spam.py
+++ b/cactusbot/handlers/spam.py
@@ -8,14 +8,6 @@
 BASE_URL = "https://beam.pro/api/v1/channels/{username}"
 
 
-async def get_user_id(username):
-    """Retrieve Beam user ID from username."""
-    async with aiohttp.get(BASE_URL.format(username=username)) as response:
-        if response.status == 404:
-            return 0
-        return (await response.json())["id"]
-
-
 class SpamHandler(Handler):
     """Spam handler."""
 
@@ -30,13 +22,21 @@ def __init__(self, api):
             "allow_urls": False
         }
 
+    @staticmethod
+    async def get_user_id(username):
+        """Retrieve Beam user ID from username."""
+        async with aiohttp.get(BASE_URL.format(username=username)) as response:
+            if response.status == 404:
+                return 0
+            return (await response.json())["id"]
+
     async def on_message(self, packet):
         """Handle message events."""
 
         if packet.role >= 4:
             return
 
-        user_id = await get_user_id(packet.user)
+        user_id = await self.get_user_id(packet.user)
         if (await self.api.trust.get(user_id)).status == 200:
             return
 
@@ -65,8 +65,6 @@ async def on_message(self, packet):
                     BanPacket(packet.user, 5),
                     StopIteration)
 
-        return None
-
     async def on_config(self, packet):
         """Handle config update events."""
 
diff --git a/tests/handlers/test_spam.py b/tests/handlers/test_spam.py
index 3588cce..30c6128 100644
--- a/tests/handlers/test_spam.py
+++ b/tests/handlers/test_spam.py
@@ -1,47 +1,43 @@
 import pytest
+from tests.api import MockAPI
 
 from cactusbot.handlers import SpamHandler
 from cactusbot.packets import MessagePacket
 
-async def get_user_id(_):
-    return 0
+spam_handler = SpamHandler(MockAPI("test_token", "test_password"))
 
 
-class MockAPI:
-
-    class Trust:
-
-        async def get(self, _):
-
-            class Response:
-                status = 404
-
-            return Response()
-    trust = Trust()
-
-spam_handler = SpamHandler(MockAPI())
+async def get_user_id(username):
+    return username
+spam_handler.get_user_id = get_user_id
 
 
 @pytest.mark.asyncio
 async def test_on_message():
 
     assert (await spam_handler.on_message(
-        MessagePacket("THIS CONTAINS EXCESSIVE CAPITAL LETTERS.")
+        MessagePacket("THIS CONTAINS EXCESSIVE CAPITALS.", user="untrusted")
     ))[0].text == "Please do not spam capital letters."
 
     assert (await spam_handler.on_message(MessagePacket(
         "This is what one hundred emoji looks like!",
-        *(("emoji", "😮"),) * 100
+        *(("emoji", "😮"),) * 100,
+        user="untrusted"
     )))[0].text == "Please do not spam emoji."
 
     assert (await spam_handler.on_message(MessagePacket(
         "Check out my amazing Twitter!",
         ("url", "twitter.com/CactusDevTeam",
-         "https://twitter.com/CactusDevTeam")
+         "https://twitter.com/CactusDevTeam"),
+        user="untrusted"
     )))[0].text == "Please do not post URLs."
 
     assert await spam_handler.on_message(
-        MessagePacket("PLEASE STOP SPAMMING CAPITAL LETTERS.", role=50)
+        MessagePacket("PLEASE STOP SPAMMING CAPITAL LETTERS.", role=5)
+    ) is None
+
+    assert await spam_handler.on_message(
+        MessagePacket("THIS IS ALMOST LIKE SPAM!", user="trusted")
     ) is None
 
 

From 3854cb88a1d38e35c91527f424b1e11407d58c26 Mon Sep 17 00:00:00 2001
From: 2Cubed <2Cubed@users.noreply.github.com>
Date: Wed, 10 May 2017 00:26:58 -0400
Subject: [PATCH 101/122] Add tests for BeamChat

Move Beam tests into separate files in beam/ directory.
---
 cactusbot/services/beam/chat.py               |   4 +-
 tests/services/beam/test_beam_chat.py         |  76 ++++++++
 tests/services/beam/test_beam_handler.py      | 174 +++++++++++++++++
 .../test_beam_parser.py}                      | 175 +-----------------
 4 files changed, 253 insertions(+), 176 deletions(-)
 create mode 100644 tests/services/beam/test_beam_chat.py
 create mode 100644 tests/services/beam/test_beam_handler.py
 rename tests/services/{test_beam.py => beam/test_beam_parser.py} (63%)

diff --git a/cactusbot/services/beam/chat.py b/cactusbot/services/beam/chat.py
index 5f2069f..586be1b 100644
--- a/cactusbot/services/beam/chat.py
+++ b/cactusbot/services/beam/chat.py
@@ -37,9 +37,9 @@ async def send(self, *args, max_length=360, **kwargs):
             for message in packet.copy()["arguments"]:
                 for index in range(0, len(message), max_length):
                     packet["arguments"] = (message[index:index + max_length],)
-                    await super()._send(json.dumps(packet))
+                    await self._send(json.dumps(packet))
         else:
-            await super()._send(json.dumps(packet))
+            await self._send(json.dumps(packet))
 
     async def initialize(self, *auth):
         """Send an authentication packet."""
diff --git a/tests/services/beam/test_beam_chat.py b/tests/services/beam/test_beam_chat.py
new file mode 100644
index 0000000..52e0b28
--- /dev/null
+++ b/tests/services/beam/test_beam_chat.py
@@ -0,0 +1,76 @@
+import json
+
+import pytest
+
+from cactusbot.packets import BanPacket, MessagePacket
+from cactusbot.services.beam import BeamChat
+
+
+class BeamChatWrapper(BeamChat):
+
+    def __init__(self, channel):
+        super().__init__(channel, "endpoint")
+        self._queue = []
+
+    async def _send(self, data):
+        self._queue.append(json.loads(data))
+
+    @property
+    def queue(self):
+        queue = self._queue
+        self._queue = []
+        for item in queue:
+            item.pop("id")
+        return queue
+
+
+chat = BeamChatWrapper(238)
+
+
+async def test_send():
+
+    await chat.send("Hello, world!")
+    assert chat.queue == [{
+        'arguments': ['Hello, world!'],
+        'method': 'msg',
+        'type': 'method'
+    }]
+
+    await chat.send("This is a reasonably long message.", max_length=10)
+    assert chat.queue == [{
+        'arguments': ['This is a '], 'method': 'msg', 'type': 'method'
+    }, {
+        'arguments': ['reasonably'], 'method': 'msg', 'type': 'method'
+    }, {
+        'arguments': [' long mess'], 'method': 'msg', 'type': 'method'
+    }, {
+        'arguments': ['age.'], 'method': 'msg', 'type': 'method'
+    }]
+
+    await chat.send(238, 123, "authkey", method="auth")
+    assert chat.queue == [{
+        'arguments': [238, 123, 'authkey'],
+        'method': 'auth',
+        'type': 'method'
+    }]
+
+
+@pytest.mark.asyncio
+async def test_initialize():
+
+    async def get_chat():
+        return {"authkey": "AUTHK3Y"}
+
+    await chat.initialize(123, get_chat)
+    assert chat.queue == [{
+        'arguments': [238, 123, 'AUTHK3Y'],
+        'method': 'auth',
+        'type': 'method'
+    }]
+
+    await chat.initialize()
+    assert chat.queue == [{
+        "arguments": [238],
+        "method": "auth",
+        "type": "method"
+    }]
diff --git a/tests/services/beam/test_beam_handler.py b/tests/services/beam/test_beam_handler.py
new file mode 100644
index 0000000..340e953
--- /dev/null
+++ b/tests/services/beam/test_beam_handler.py
@@ -0,0 +1,174 @@
+import pytest
+
+from cactusbot.handler import Handler, Handlers
+from cactusbot.packets import BanPacket, MessagePacket
+from cactusbot.services.beam import BeamHandler
+
+
+class BeamHandlerWrapper(BeamHandler):
+
+    def __init__(self, handlers):
+        self._queue = []
+        super().__init__("channel", "token", handlers)
+
+    async def send(self, *args, **kwargs):
+        self._queue.append((args, kwargs))
+
+    @property
+    def queue(self):
+        queue = self._queue
+        self._queue = []
+        return queue
+
+
+class PingHandler(Handler):
+    async def on_message(self, packet):
+        if packet.text == "Ping!":
+            return "Pong!"
+
+
+class SpamHandler(Handler):
+    async def on_message(self, packet):
+        if "spam" in packet.text:
+            return ("No spamming!", BanPacket(packet.user, duration=5))
+        if "SPAM" in packet.text:
+            return BanPacket(packet.user)
+
+
+class FollowHandler(Handler):
+    async def on_follow(self, packet):
+        return "Thanks for the follow, {}!".format(packet.user)
+
+
+handlers = Handlers(PingHandler(), SpamHandler(), FollowHandler())
+beam_handler = BeamHandlerWrapper(handlers)
+
+
+@pytest.mark.asyncio
+async def test_handle():
+
+    await beam_handler.handle("message", "Hello!")
+    assert not beam_handler.queue
+
+    await beam_handler.handle("message", MessagePacket("Ping!"))
+    assert beam_handler.queue == [(("Pong!",), {})]
+
+    await beam_handler.handle(
+        "message", MessagePacket("spam eggs foo bar", user="Stanley")
+    )
+    assert beam_handler.queue == [
+        (("No spamming!",), {}),
+        (("Stanley", 5), {"method": "timeout"})
+    ]
+
+
+@pytest.mark.asyncio
+async def test_handle_chat():
+
+    await beam_handler.handle_chat({
+        'event': 'ChatMessage',
+        'data': {
+            'id': '688d66e0-352c-11e7-bd11-993537334664',
+            'user_level': 111,
+            'user_roles': ['Mod', 'Pro', 'User'],
+            'message': {
+                'message': [{
+                    'data': 'Ping!', 'text': 'Ping!', 'type': 'text'
+                }],
+                'meta': {}
+            },
+            'channel': 3016,
+            'user_id': 2547,
+            'user_name': '2Cubed'
+        },
+        'type': 'event'
+    })
+    assert beam_handler.queue == [(("Pong!",), {})]
+
+    await beam_handler.handle_chat({
+        'event': 'ChatMessage',
+        'data': {
+            'id': '688d66e0-352c-11e7-bd11-993537334664',
+            'user_level': 111,
+            'user_roles': ['User'],
+            'message': {
+                'message': [{
+                    'data': 'Such spam!', 'text': 'Such spam!', 'type': 'text'
+                }],
+                'meta': {}
+            },
+            'channel': 3016,
+            'user_id': 2547,
+            'user_name': 'Stanley'
+        },
+        'type': 'event'
+    })
+    assert beam_handler.queue == [
+        (("No spamming!",), {}),
+        (("Stanley", 5), {"method": "timeout"})
+    ]
+
+
+@pytest.mark.asyncio
+async def test_handle_constellation():
+
+    await beam_handler.handle_constellation({
+        'event': 'live',
+        'data': {
+            'payload': {
+                'user': {
+                    'id': 95845,
+                    'avatarUrl': 'https://uploads.beam.pro/avatar/l0icubxz-95845.jpg',
+                    'sparks': 2550,
+                    'experience': 715,
+                    'username': 'Stanley',
+                    'verified': True,
+                    'deletedAt': None,
+                    'channel': {
+                        'viewersCurrent': 0,
+                        'vodsEnabled': True,
+                        'featureLevel': 0,
+                        'partnered': False,
+                        'interactive': False,
+                        'description': None,
+                        'name': "Stanley's Channel",
+                        'typeId': None,
+                        'interactiveGameId': None,
+                        'createdAt': '2016-03-05T20:41:21.000Z',
+                        'userId': 95845,
+                        'bannerUrl': None,
+                        'hasTranscodes': True,
+                        'hosteeId': None,
+                        'suspended': False,
+                        'badgeId': None,
+                        'numFollowers': 0,
+                        'id': 68762,
+                        'deletedAt': None,
+                        'costreamId': None,
+                        'online': False,
+                        'languageId': None,
+                        'thumbnailId': None,
+                        'hasVod': False,
+                        'featured': False,
+                        'coverId': None,
+                        'viewersTotal': 0,
+                        'token': 'Stanley',
+                        'transcodingProfileId': 1,
+                        'updatedAt': '2016-11-13T20:22:01.000Z',
+                        'audience': 'teen',
+                        'ftl': 0
+                    },
+                    'createdAt': '2016-03-05T20:41:21.000Z',
+                    'bio': None,
+                    'updatedAt': '2016-08-20T04:35:25.000Z',
+                    'level': 17,
+                    'social': {'verified': []},
+                    'primaryTeam': None
+                },
+                'following': True
+            },
+            'channel': 'channel:3016:followed'
+        }, 'type': 'event'})
+    assert beam_handler.queue == [
+        (("Thanks for the follow, Stanley!",), {})
+    ]
diff --git a/tests/services/test_beam.py b/tests/services/beam/test_beam_parser.py
similarity index 63%
rename from tests/services/test_beam.py
rename to tests/services/beam/test_beam_parser.py
index 52d3135..cf147bb 100644
--- a/tests/services/test_beam.py
+++ b/tests/services/beam/test_beam_parser.py
@@ -1,9 +1,5 @@
-import pytest
-
-from cactusbot.handler import Handler, Handlers
-from cactusbot.packets import BanPacket, MessagePacket
-from cactusbot.services.beam import BeamHandler
 from cactusbot.services.beam.parser import BeamParser
+from cactusbot.packets import MessagePacket
 
 
 def test_parse_message():
@@ -306,172 +302,3 @@ def test_synthesize():
     assert BeamParser.synthesize(MessagePacket(
         "Hello!", target="Stanley"
     )) == (("Stanley", "Hello!",), {"method": "whisper"})
-
-
-class BeamHandlerWrapper(BeamHandler):
-
-    def __init__(self, handlers):
-        self._queue = []
-        super().__init__("channel", "token", handlers)
-
-    async def send(self, *args, **kwargs):
-        self._queue.append((args, kwargs))
-
-    @property
-    def queue(self):
-        queue = self._queue
-        self._queue = []
-        return queue
-
-
-class PingHandler(Handler):
-    async def on_message(self, packet):
-        if packet.text == "Ping!":
-            return "Pong!"
-
-
-class SpamHandler(Handler):
-    async def on_message(self, packet):
-        if "spam" in packet.text:
-            return ("No spamming!", BanPacket(packet.user, duration=5))
-        if "SPAM" in packet.text:
-            return BanPacket(packet.user)
-
-
-class FollowHandler(Handler):
-    async def on_follow(self, packet):
-        return "Thanks for the follow, {}!".format(packet.user)
-
-
-handlers = Handlers(PingHandler(), SpamHandler(), FollowHandler())
-beam_handler = BeamHandlerWrapper(handlers)
-
-
-@pytest.mark.asyncio
-async def test_handle():
-
-    await beam_handler.handle("message", "Hello!")
-    assert not beam_handler.queue
-
-    await beam_handler.handle("message", MessagePacket("Ping!"))
-    assert beam_handler.queue == [(("Pong!",), {})]
-
-    await beam_handler.handle(
-        "message", MessagePacket("spam eggs foo bar", user="Stanley")
-    )
-    assert beam_handler.queue == [
-        (("No spamming!",), {}),
-        (("Stanley", 5), {"method": "timeout"})
-    ]
-
-
-@pytest.mark.asyncio
-async def test_handle_chat():
-
-    await beam_handler.handle_chat({
-        'event': 'ChatMessage',
-        'data': {
-            'id': '688d66e0-352c-11e7-bd11-993537334664',
-            'user_level': 111,
-            'user_roles': ['Mod', 'Pro', 'User'],
-            'message': {
-                'message': [{
-                    'data': 'Ping!', 'text': 'Ping!', 'type': 'text'
-                }],
-                'meta': {}
-            },
-            'channel': 3016,
-            'user_id': 2547,
-            'user_name': '2Cubed'
-        },
-        'type': 'event'
-    })
-    assert beam_handler.queue == [(("Pong!",), {})]
-
-    await beam_handler.handle_chat({
-        'event': 'ChatMessage',
-        'data': {
-            'id': '688d66e0-352c-11e7-bd11-993537334664',
-            'user_level': 111,
-            'user_roles': ['User'],
-            'message': {
-                'message': [{
-                    'data': 'Such spam!', 'text': 'Such spam!', 'type': 'text'
-                }],
-                'meta': {}
-            },
-            'channel': 3016,
-            'user_id': 2547,
-            'user_name': 'Stanley'
-        },
-        'type': 'event'
-    })
-    assert beam_handler.queue == [
-        (("No spamming!",), {}),
-        (("Stanley", 5), {"method": "timeout"})
-    ]
-
-
-@pytest.mark.asyncio
-async def test_handle_constellation():
-
-    await beam_handler.handle_constellation({
-        'event': 'live',
-        'data': {
-            'payload': {
-                'user': {
-                    'id': 95845,
-                    'avatarUrl': 'https://uploads.beam.pro/avatar/l0icubxz-95845.jpg',
-                    'sparks': 2550,
-                    'experience': 715,
-                    'username': 'Stanley',
-                    'verified': True,
-                    'deletedAt': None,
-                    'channel': {
-                        'viewersCurrent': 0,
-                        'vodsEnabled': True,
-                        'featureLevel': 0,
-                        'partnered': False,
-                        'interactive': False,
-                        'description': None,
-                        'name': "Stanley's Channel",
-                        'typeId': None,
-                        'interactiveGameId': None,
-                        'createdAt': '2016-03-05T20:41:21.000Z',
-                        'userId': 95845,
-                        'bannerUrl': None,
-                        'hasTranscodes': True,
-                        'hosteeId': None,
-                        'suspended': False,
-                        'badgeId': None,
-                        'numFollowers': 0,
-                        'id': 68762,
-                        'deletedAt': None,
-                        'costreamId': None,
-                        'online': False,
-                        'languageId': None,
-                        'thumbnailId': None,
-                        'hasVod': False,
-                        'featured': False,
-                        'coverId': None,
-                        'viewersTotal': 0,
-                        'token': 'Stanley',
-                        'transcodingProfileId': 1,
-                        'updatedAt': '2016-11-13T20:22:01.000Z',
-                        'audience': 'teen',
-                        'ftl': 0
-                    },
-                    'createdAt': '2016-03-05T20:41:21.000Z',
-                    'bio': None,
-                    'updatedAt': '2016-08-20T04:35:25.000Z',
-                    'level': 17,
-                    'social': {'verified': []},
-                    'primaryTeam': None
-                },
-                'following': True
-            },
-            'channel': 'channel:3016:followed'
-        }, 'type': 'event'})
-    assert beam_handler.queue == [
-        (("Thanks for the follow, Stanley!",), {})
-    ]

From 3357e5c1f3bde6e7e0e786476ad76c4109cda2ca Mon Sep 17 00:00:00 2001
From: 2Cubed <2Cubed@users.noreply.github.com>
Date: Sat, 13 May 2017 08:52:42 -0400
Subject: [PATCH 102/122] Add tests for CactusAPI

---
 cactusbot/api.py                        | 136 +++++-----
 tests/services/cactus/test_cactusapi.py | 317 ++++++++++++++++++++++++
 2 files changed, 385 insertions(+), 68 deletions(-)
 create mode 100644 tests/services/cactus/test_cactusapi.py

diff --git a/cactusbot/api.py b/cactusbot/api.py
index ab3e424..e27ff49 100644
--- a/cactusbot/api.py
+++ b/cactusbot/api.py
@@ -29,10 +29,10 @@ def __init__(self, token, password, url=URL, auth_token="", **kwargs):
         self.url = url
 
         self.buckets = {
-            "command": Command(self),
             "alias": Alias(self),
-            "quote": Quote(self),
+            "command": Command(self),
             "config": Config(self),
+            "quote": Quote(self),
             "repeat": Repeat(self),
             "social": Social(self),
             "trust": Trust(self)
@@ -70,7 +70,7 @@ async def request(self, method, endpoint, **kwargs):
 
     async def get(self, endpoint, **kwargs):
         """Perform a GET request without requesting a JSON response."""
-        return await self.request("GET", endpoint, is_json=False, **kwargs)
+        return await super().get(endpoint, is_json=False, **kwargs)
 
     async def login(self, *scopes, password=None):
         """Authenticate."""
@@ -104,6 +104,34 @@ def __init__(self, api):
         self.api = api
 
 
+class Alias(CactusAPIBucket):
+    """CactusAPI /alias bucket."""
+
+    async def get(self, alias):
+        """Get a command alias."""
+        return await self.api.get("/user/{token}/alias/{alias}".format(
+            token=self.api.token, alias=alias))
+
+    async def add(self, command, alias, args=None):
+        """Create a command alias."""
+
+        data = {
+            "commandName": command,
+        }
+
+        if args is not None:
+            data["arguments"] = args
+
+        return await self.api.patch("/user/{token}/alias/{alias}".format(
+            token=self.api.token, alias=alias), data=json.dumps(data))
+
+    async def remove(self, alias):
+        """Remove a command alias."""
+
+        return await self.api.delete("/user/{token}/alias/{alias}".format(
+            token=self.api.token, alias=alias))
+
+
 class Command(CactusAPIBucket):
     """CactusAPI /command bucket."""
 
@@ -145,42 +173,34 @@ async def toggle(self, command, state):
         return await self.api.patch("/user/{token}/command/{command}".format(
             token=self.api.token, command=command), data=json.dumps(data))
 
-    async def update_count(self, command, action):
+    async def update_count(self, command, value):
         """Set the count of a command."""
 
-        data = {"count": action}
+        data = {"count": value}
 
         return await(
             self.api.patch("/user/{token}/command/{command}/count".format(
                 token=self.api.token, command=command), data=json.dumps(data)))
 
 
-class Alias(CactusAPIBucket):
-    """CactusAPI /alias bucket."""
-
-    async def get(self, alias):
-        """Get a command alias."""
-        return await self.api.get("/user/{token}/alias/{alias}".format(
-            token=self.api.token, alias=alias))
-
-    async def add(self, command, alias, args=None):
-        """Create a command alias."""
+class Config(CactusAPIBucket):
+    """CactusAPI /config bucket."""
 
-        data = {
-            "commandName": command,
-        }
+    async def get(self, *keys):
+        """Get the token config."""
 
-        if args is not None:
-            data["arguments"] = args
+        if keys:
+            return await self.api.get("/user/{token}/config".format(
+                token=self.api.token), data=json.dumps({"keys": keys}))
 
-        return await self.api.patch("/user/{user}/alias/{alias}".format(
-            user=self.api.token, alias=alias), data=json.dumps(data))
+        return await self.api.get("/user/{token}/config".format(
+            token=self.api.token))
 
-    async def remove(self, alias):
-        """Remove a command alias."""
+    async def update(self, value):
+        """Update config attributes."""
 
-        return await self.api.delete("/user/{user}/alias/{alias}".format(
-            user=self.api.token, alias=alias))
+        return await self.api.patch("/user/{token}/config".format(
+            token=self.api.token), data=json.dumps(value))
 
 
 class Quote(CactusAPIBucket):
@@ -223,33 +243,13 @@ async def remove(self, quote_id):
             token=self.api.token, id=quote_id))
 
 
-class Config(CactusAPIBucket):
-    """CactusAPI /config bucket."""
-
-    async def get(self, *keys):
-        """Get the token config."""
-
-        if keys:
-            return await self.api.get("/user/{token}/config".format(
-                token=self.api.token), data=json.dumps({"keys": keys}))
-
-        return await self.api.get("/user/{token}/config".format(
-            token=self.api.token))
-
-    async def update(self, value):
-        """Update config attributes."""
-
-        return await self.api.patch("/user/{user}/config".format(
-            user=self.api.token), data=json.dumps(value))
-
-
 class Repeat(CactusAPIBucket):
     """CactusAPI /repeat bucket."""
 
     async def get(self):
         """Get all repeats."""
-        return await self.api.get("/user/{user}/repeat".format(
-            user=self.api.token))
+        return await self.api.get("/user/{token}/repeat".format(
+            token=self.api.token))
 
     async def add(self, command, period):
         """Add a repeat."""
@@ -259,14 +259,14 @@ async def add(self, command, period):
             "period": period
         }
 
-        return await self.api.patch("/user/{user}/repeat/{command}".format(
-            user=self.api.token, command=command), data=json.dumps(data))
+        return await self.api.patch("/user/{token}/repeat/{command}".format(
+            token=self.api.token, command=command), data=json.dumps(data))
 
     async def remove(self, repeat):
         """Remove a repeat."""
 
-        return await self.api.delete("/user/{user}/repeat/{repeat}".format(
-            user=self.api.token, repeat=repeat))
+        return await self.api.delete("/user/{token}/repeat/{repeat}".format(
+            token=self.api.token, repeat=repeat))
 
 
 class Social(CactusAPIBucket):
@@ -276,24 +276,24 @@ async def get(self, service=None):
         """Get social service."""
 
         if service is None:
-            return await self.api.get("/user/{user}/social".format(
-                user=self.api.token))
-        return await self.api.get("/user/{user}/social/{service}".format(
-            user=self.api.token, service=service))
+            return await self.api.get("/user/{token}/social".format(
+                token=self.api.token))
+        return await self.api.get("/user/{token}/social/{service}".format(
+            token=self.api.token, service=service))
 
     async def add(self, service, url):
         """Add a social service."""
 
         data = {"url": url}
 
-        return await self.api.patch("/user/{user}/social/{service}".format(
-            user=self.api.token, service=service), data=json.dumps(data))
+        return await self.api.patch("/user/{token}/social/{service}".format(
+            token=self.api.token, service=service), data=json.dumps(data))
 
     async def remove(self, service):
         """Remove a social service."""
 
-        return await self.api.delete("/user/{user}/social/{service}".format(
-            user=self.api.token, service=service))
+        return await self.api.delete("/user/{token}/social/{service}".format(
+            token=self.api.token, service=service))
 
 
 class Trust(CactusAPIBucket):
@@ -303,22 +303,22 @@ async def get(self, user_id=None):
         """Get trusted users."""
 
         if user_id is None:
-            return await self.api.get("/user/{user}/trust".format(
-                user=self.api.token))
+            return await self.api.get("/user/{token}/trust".format(
+                token=self.api.token))
 
-        return await self.api.get("/user/{user}/trust/{user_id}".format(
-            user=self.api.token, user_id=user_id))
+        return await self.api.get("/user/{token}/trust/{user_id}".format(
+            token=self.api.token, user_id=user_id))
 
     async def add(self, user_id, username):
         """Trust new user."""
 
         data = {"userName": username}
 
-        return await self.api.patch("/user/{user}/trust/{user_id}".format(
-            user=self.api.token, user_id=user_id), data=json.dumps(data))
+        return await self.api.patch("/user/{token}/trust/{user_id}".format(
+            token=self.api.token, user_id=user_id), data=json.dumps(data))
 
     async def remove(self, user_id):
         """Remove user trust."""
 
-        return await self.api.delete("/user/{user}/trust/{user_id}".format(
-            user=self.api.token, user_id=user_id))
+        return await self.api.delete("/user/{token}/trust/{user_id}".format(
+            token=self.api.token, user_id=user_id))
diff --git a/tests/services/cactus/test_cactusapi.py b/tests/services/cactus/test_cactusapi.py
new file mode 100644
index 0000000..4ee5ef3
--- /dev/null
+++ b/tests/services/cactus/test_cactusapi.py
@@ -0,0 +1,317 @@
+import json
+
+import pytest
+
+from cactusbot.api import CactusAPI
+
+
+@pytest.fixture(autouse=True)
+def fake_web_requests(monkeypatch):
+
+    async def request(self, method, endpoint, **kwargs):
+
+        if "data" in kwargs:
+            kwargs["data"] = json.loads(kwargs["data"])
+
+        if method.upper() == "GET" and kwargs.get("is_json") is False:
+            kwargs.pop("is_json")
+        elif kwargs.get("is_json") is True:
+            kwargs.pop("is_json")
+
+        return method.upper(), endpoint, kwargs
+    monkeypatch.setattr(CactusAPI, "request", request)
+
+
+api = CactusAPI("token", "password")
+
+
+class TestAlias:
+
+    @pytest.mark.asyncio
+    async def test_get(self):
+
+        assert await api.alias.get("tato") == (
+            "GET",
+            "/user/token/alias/tato",
+            {}
+        )
+
+    @pytest.mark.asyncio
+    async def test_add(self):
+
+        assert await api.alias.add("potato", "tato") == (
+            "PATCH",
+            "/user/token/alias/tato",
+            {"data": {
+                "commandName": "potato"
+            }}
+        )
+
+        assert await api.alias.add("potato", "tato", ["arg"]) == (
+            "PATCH",
+            "/user/token/alias/tato",
+            {"data": {
+                "commandName": "potato",
+                "arguments": ["arg"]
+            }}
+        )
+
+    @pytest.mark.asyncio
+    async def test_remove(self):
+
+        assert await api.alias.remove("tato") == (
+            "DELETE",
+            "/user/token/alias/tato",
+            {}
+        )
+
+
+class TestCommand:
+
+    @pytest.mark.asyncio
+    async def test_get(self):
+
+        assert await api.command.get() == (
+            "GET",
+            "/user/token/command",
+            {}
+        )
+
+        assert await api.command.get("hoi") == (
+            "GET",
+            "/user/token/command/hoi",
+            {}
+        )
+
+    @pytest.mark.asyncio
+    async def test_add(self):
+
+        assert await api.command.add("hoi", ["i'm", "temmie"], user_level=4) == (
+            "PATCH",
+            "/user/token/command/hoi",
+            {"data": {
+                "response": ["i'm", "temmie"],
+                "userLevel": 4
+            }}
+        )
+
+    @pytest.mark.asyncio
+    async def test_remove(self):
+
+        assert await api.command.remove("hoi") == (
+            "DELETE",
+            "/user/token/command/hoi",
+            {}
+        )
+
+    @pytest.mark.asyncio
+    async def test_toggle(self):
+
+        assert await api.command.toggle("hoi", False) == (
+            "PATCH",
+            "/user/token/command/hoi",
+            {"data": {
+                "enabled": False
+            }}
+        )
+
+    @pytest.mark.asyncio
+    async def test_update_count(self):
+
+        assert await api.command.update_count("hoi", 123) == (
+            "PATCH",
+            "/user/token/command/hoi/count",
+            {"data": {
+                "count": 123
+            }}
+        )
+
+
+class TestConfig:
+
+    @pytest.mark.asyncio
+    async def test_get(self):
+
+        assert await api.config.get() == (
+            "GET",
+            "/user/token/config",
+            {}
+        )
+
+        assert await api.config.get("key1", "key2") == (
+            "GET",
+            "/user/token/config",
+            {"data": {
+                "keys": ["key1", "key2"]
+            }}
+        )
+
+    @pytest.mark.asyncio
+    async def test_update(self):
+
+        assert await api.config.update({"key": "value"}) == (
+            "PATCH",
+            "/user/token/config",
+            {"data": {
+                "key": "value"
+            }}
+        )
+
+
+class TestQuote:
+
+    @pytest.mark.asyncio
+    async def test_get(self):
+
+        assert await api.quote.get() == (
+            "GET",
+            "/user/token/quote",
+            {"params": {
+                "random": "true"
+            }}
+        )
+
+        assert await api.quote.get(8) == (
+            "GET",
+            "/user/token/quote/8",
+            {}
+        )
+
+    @pytest.mark.asyncio
+    async def test_add(self):
+
+        assert await api.quote.add("This is a quote!") == (
+            "POST",
+            "/user/token/quote",
+            {"data": {
+                "quote": "This is a quote!"
+            }}
+        )
+
+    @pytest.mark.asyncio
+    async def test_edit(self):
+
+        assert await api.quote.edit(8, "This is a great quote!") == (
+            "PATCH",
+            "/user/token/quote/8",
+            {"data": {
+                "quote": "This is a great quote!"
+            }}
+        )
+
+    @pytest.mark.asyncio
+    async def test_remove(self):
+
+        assert await api.quote.remove(8) == (
+            "DELETE",
+            "/user/token/quote/8",
+            {}
+        )
+
+
+class TestRepeat:
+
+    @pytest.mark.asyncio
+    async def test_get(self):
+
+        assert await api.repeat.get() == (
+            "GET",
+            "/user/token/repeat",
+            {}
+        )
+
+    @pytest.mark.asyncio
+    async def test_add(self):
+
+        assert await api.repeat.add("hoi", 60) == (
+            "PATCH",
+            "/user/token/repeat/hoi",
+            {"data": {
+                "commandName": "hoi",
+                "period": 60
+            }}
+        )
+
+    @pytest.mark.asyncio
+    async def test_remove(self):
+        assert await api.repeat.remove("hoi") == (
+            "DELETE",
+            "/user/token/repeat/hoi",
+            {}
+        )
+
+
+class TestSocial:
+
+    @pytest.mark.asyncio
+    async def test_get(self):
+
+        assert await api.social.get() == (
+            "GET",
+            "/user/token/social",
+            {}
+        )
+
+        assert await api.social.get("github") == (
+            "GET",
+            "/user/token/social/github",
+            {}
+        )
+
+    @pytest.mark.asyncio
+    async def test_add(self):
+
+        assert await api.social.add("github", "github.com/CactusDev") == (
+            "PATCH",
+            "/user/token/social/github",
+            {"data": {
+                "url": "github.com/CactusDev"
+            }}
+        )
+
+    @pytest.mark.asyncio
+    async def test_remove(self):
+
+        assert await api.social.remove("github") == (
+            "DELETE",
+            "/user/token/social/github",
+            {}
+        )
+
+
+class TestTrust:
+
+    @pytest.mark.asyncio
+    async def test_get(self):
+
+        assert await api.trust.get() == (
+            "GET",
+            "/user/token/trust",
+            {}
+        )
+
+        assert await api.trust.get(12345) == (
+            "GET",
+            "/user/token/trust/12345",
+            {}
+        )
+
+    @pytest.mark.asyncio
+    async def test_add(self):
+
+        assert await api.trust.add(95845, "Stanley") == (
+            "PATCH",
+            "/user/token/trust/95845",
+            {"data": {
+                "userName": "Stanley"
+            }}
+        )
+
+    @pytest.mark.asyncio
+    async def test_remove(self):
+
+        assert await api.trust.remove(12345) == (
+            "DELETE",
+            "/user/token/trust/12345",
+            {}
+        )

From 45b5c64b37735e8da1cd68400d79ded45eb3949e Mon Sep 17 00:00:00 2001
From: 2Cubed <2Cubed@users.noreply.github.com>
Date: Sat, 13 May 2017 10:19:09 -0400
Subject: [PATCH 103/122] Finish Sepal tests

---
 cactusbot/sepal.py                            | 41 ++++----
 tests/{services => }/cactus/test_cactusapi.py |  0
 tests/cactus/test_sepal.py                    | 94 +++++++++++++++++++
 tests/{sepal => cactus}/test_sepal_parsing.py | 15 ++-
 4 files changed, 121 insertions(+), 29 deletions(-)
 rename tests/{services => }/cactus/test_cactusapi.py (100%)
 create mode 100644 tests/cactus/test_sepal.py
 rename tests/{sepal => cactus}/test_sepal_parsing.py (89%)

diff --git a/cactusbot/sepal.py b/cactusbot/sepal.py
index 531f63c..0272961 100644
--- a/cactusbot/sepal.py
+++ b/cactusbot/sepal.py
@@ -19,7 +19,6 @@ def __init__(self, channel, service, url=URL):
 
         self.channel = channel
         self.service = service
-        self.parser = SepalParser()
 
     async def send(self, packet_type, **kwargs):
         """Send a packet to Sepal."""
@@ -32,7 +31,7 @@ async def send(self, packet_type, **kwargs):
         }
 
         packet.update(kwargs)
-        await super()._send(json.dumps(packet))
+        await self._send(json.dumps(packet))
 
     async def initialize(self, *_):
         """Send a subscribe packet."""
@@ -49,15 +48,12 @@ async def handle(self, packet):
 
         assert self.service is not None, "Must have a service to handle"
 
-        if "event" not in packet:
-            return
-
-        event = packet["event"]
+        event = packet.get("event")
 
-        if not hasattr(self.parser, "parse_" + event.lower()):
+        if event not in PARSERS:
             return
 
-        data = await getattr(self.parser, "parse_" + event)(packet)
+        data = await PARSERS[event](packet)
 
         if data is None:
             return
@@ -69,20 +65,23 @@ async def handle(self, packet):
             await self.service.handle(event, data)
 
 
-class SepalParser:
-    """Parse Sepal packets."""
+async def parse_repeat(packet):
+    """Parse the incoming repeat packets."""
+
+    if "message" in packet["data"]:
+        return MessagePacket.from_json(packet["data"])
 
-    async def parse_repeat(self, packet):
-        """Parse the incoming repeat packets."""
 
-        if "message" in packet["data"]:
-            return MessagePacket.from_json(packet["data"])
+async def parse_config(packet):
+    """Parse the incoming config packets."""
 
-    async def parse_config(self, packet):
-        """Parse the incoming config packets."""
+    return [
+        Packet("announce", **packet["data"]["announce"]),
+        Packet("spam", **packet["data"]["spam"]),
+        Packet("whitelistedUrls", urls=packet["data"]["whitelistedUrls"])
+    ]
 
-        return [
-            Packet("announce", **packet["data"]["announce"]),
-            Packet("spam", **packet["data"]["spam"]),
-            Packet("whitelistedUrls", urls=packet["data"]["whitelistedUrls"])
-        ]
+PARSERS = {
+    "repeat": parse_repeat,
+    "config": parse_config
+}
diff --git a/tests/services/cactus/test_cactusapi.py b/tests/cactus/test_cactusapi.py
similarity index 100%
rename from tests/services/cactus/test_cactusapi.py
rename to tests/cactus/test_cactusapi.py
diff --git a/tests/cactus/test_sepal.py b/tests/cactus/test_sepal.py
new file mode 100644
index 0000000..56b9f11
--- /dev/null
+++ b/tests/cactus/test_sepal.py
@@ -0,0 +1,94 @@
+import copy
+import json
+
+import pytest
+
+from cactusbot.sepal import Sepal
+from test_sepal_parsing import CONFIG_PACKET, REPEAT_PACKET
+
+CONFIG_PACKET = copy.deepcopy(CONFIG_PACKET)
+REPEAT_PACKET = copy.deepcopy(REPEAT_PACKET)
+
+
+class SepalWrapper(Sepal):
+
+    def __init__(self, channel, service):
+        super().__init__(channel, service, "endpoint")
+        self._queue = []
+
+    async def _send(self, data):
+        self._queue.append(json.loads(data))
+
+    @property
+    def queue(self):
+        queue = self._queue
+        self._queue = []
+        return queue
+
+
+class FakeService:
+
+    def __init__(self):
+        self._queue = []
+
+    async def handle(self, event, packet):
+        self._queue.append(packet)
+
+    @property
+    def queue(self):
+        queue = self._queue
+        self._queue = []
+        return queue
+
+
+sepal = SepalWrapper("channel", FakeService())
+
+
+@pytest.mark.asyncio
+async def test_send():
+
+    await sepal.send("packet_type", key="value")
+    assert sepal.queue == [{
+        "type": "packet_type",
+        "data": {
+            "channel": "channel"
+        },
+        "key": "value"
+    }]
+
+
+@pytest.mark.asyncio
+async def test_initialize():
+
+    await sepal.initialize()
+    assert sepal.queue == [{
+        "type": "join",
+        "data": {
+            "channel": "channel"
+        }
+    }]
+
+
+@pytest.mark.asyncio
+async def test_parse():
+
+    assert await sepal._success_function("packet") == "packet"
+
+    assert await sepal.parse('{"key": "value"}') == {"key": "value"}
+    assert await sepal.parse("invalid json") is None
+
+
+@pytest.mark.asyncio
+async def test_handle():
+
+    await sepal.handle(REPEAT_PACKET)
+    assert len(sepal.service.queue) == 1
+
+    await sepal.handle(CONFIG_PACKET)
+    assert len(sepal.service.queue) == 3
+
+    await sepal.handle({"event": "fake"})
+    assert not sepal.service.queue
+
+    await sepal.handle({"event": "repeat", "data": {}})
+    assert not sepal.service.queue
diff --git a/tests/sepal/test_sepal_parsing.py b/tests/cactus/test_sepal_parsing.py
similarity index 89%
rename from tests/sepal/test_sepal_parsing.py
rename to tests/cactus/test_sepal_parsing.py
index b3844a2..4c335bc 100644
--- a/tests/sepal/test_sepal_parsing.py
+++ b/tests/cactus/test_sepal_parsing.py
@@ -1,10 +1,11 @@
 import pytest
-from cactusbot.sepal import SepalParser
+
+from cactusbot.sepal import PARSERS
 
 REPEAT_PACKET = {
     "type": "event",
-    "event": "config",
-    "channel": "innectic2",
+    "event": "repeat",
+    "channel": "cactusdev",
     "data": {
         "message": [
             {
@@ -24,7 +25,7 @@
 CONFIG_PACKET = {
     "type": "event",
     "event": "config",
-    "channel": "innectic2",
+    "channel": "cactusdev",
     "data": {
         "announce": {
             "follow": {
@@ -58,12 +59,10 @@
     }
 }
 
-parser = SepalParser()
-
 
 @pytest.mark.asyncio
 async def test_parse_repeat():
-    packet = await parser.parse_repeat(REPEAT_PACKET)
+    packet = await PARSERS["repeat"](REPEAT_PACKET)
 
     assert packet.json["message"] == [
         {
@@ -88,7 +87,7 @@ async def test_parse_repeat():
 @pytest.mark.asyncio
 async def test_parse_config():
 
-    packets = await parser.parse_config(CONFIG_PACKET)
+    packets = await PARSERS["config"](CONFIG_PACKET)
 
     assert len(packets) == 3
     announce, spam, urls = packets

From c81428078af23d253923ffcae7dc8325c6449f94 Mon Sep 17 00:00:00 2001
From: 2Cubed <2Cubed@users.noreply.github.com>
Date: Sat, 13 May 2017 10:40:34 -0400
Subject: [PATCH 104/122] Add Constellation tests

---
 cactusbot/services/beam/constellation.py      | 10 ++---
 .../services/beam/test_beam_constellation.py  | 45 +++++++++++++++++++
 2 files changed, 50 insertions(+), 5 deletions(-)
 create mode 100644 tests/services/beam/test_beam_constellation.py

diff --git a/cactusbot/services/beam/constellation.py b/cactusbot/services/beam/constellation.py
index 5ba8127..a7670c0 100644
--- a/cactusbot/services/beam/constellation.py
+++ b/cactusbot/services/beam/constellation.py
@@ -39,10 +39,10 @@ async def initialize(self, *interfaces):
                 "user:{user}:achievement"
             )
 
-            interfaces = [
-                interface.format(channel=self.channel, user=self.user)
-                for interface in interfaces
-            ]
+        interfaces = [
+            interface.format(channel=self.channel, user=self.user)
+            for interface in interfaces
+        ]
 
         packet = {
             "type": "method",
@@ -53,7 +53,7 @@ async def initialize(self, *interfaces):
             "id": 1
         }
 
-        self.websocket.send_str(json.dumps(packet))
+        await self._send(json.dumps(packet))
         await self.receive()
 
         self.logger.info(
diff --git a/tests/services/beam/test_beam_constellation.py b/tests/services/beam/test_beam_constellation.py
new file mode 100644
index 0000000..768bf44
--- /dev/null
+++ b/tests/services/beam/test_beam_constellation.py
@@ -0,0 +1,45 @@
+import json
+
+import pytest
+
+from cactusbot.services.beam import BeamConstellation
+
+
+class ConstellationWrapper(BeamConstellation):
+
+    def __init__(self, channel, user):
+        super().__init__(channel, user)
+        self._queue = []
+
+    async def _send(self, data):
+        self._queue.append(json.loads(data))
+
+    @property
+    def queue(self):
+        queue = self._queue
+        self._queue = []
+        for item in queue:
+            item.pop("id")
+        return queue
+
+    async def receive(self):
+        return {}
+
+
+constellation = ConstellationWrapper(123, 456)
+
+
+@pytest.mark.asyncio
+async def test_initialize():
+
+    await constellation.initialize("channel:{channel}:update", "user:{user}:followed")
+    assert constellation.queue == [{
+        "type": "method",
+        "method": "livesubscribe",
+        "params": {
+            "events": ["channel:123:update", "user:456:followed"]
+        }
+    }]
+
+    await constellation.initialize()
+    assert constellation.queue

From 85ad5146b90f8d2dbfc773d4f8734f3c9234c42d Mon Sep 17 00:00:00 2001
From: 2Cubed <2Cubed@users.noreply.github.com>
Date: Sat, 13 May 2017 11:07:20 -0400
Subject: [PATCH 105/122] Finish Beam parser tests

---
 cactusbot/services/beam/parser.py       |  2 +-
 tests/services/beam/test_beam_parser.py | 83 ++++++++++++++++++++++++-
 2 files changed, 82 insertions(+), 3 deletions(-)

diff --git a/cactusbot/services/beam/parser.py b/cactusbot/services/beam/parser.py
index fc4b2ed..bf524d4 100644
--- a/cactusbot/services/beam/parser.py
+++ b/cactusbot/services/beam/parser.py
@@ -42,7 +42,7 @@ def parse_message(cls, packet):
                 message.append(chunk)
             elif component["type"] == "inaspacesuit":
                 chunk["type"] = "emoji"
-                chunk["data"] = ""
+                chunk["data"] = "👨‍🚀"
                 message.append(chunk)
             elif component["type"] == "link":
                 chunk["type"] = "url"
diff --git a/tests/services/beam/test_beam_parser.py b/tests/services/beam/test_beam_parser.py
index cf147bb..704eaa8 100644
--- a/tests/services/beam/test_beam_parser.py
+++ b/tests/services/beam/test_beam_parser.py
@@ -1,5 +1,5 @@
-from cactusbot.services.beam.parser import BeamParser
 from cactusbot.packets import MessagePacket
+from cactusbot.services.beam.parser import BeamParser
 
 
 def test_parse_message():
@@ -43,7 +43,8 @@ def test_parse_message():
                  'source': 'builtin',
                  'text': ':D',
                  'type': 'emoticon'}],
-            'meta': {'me': True}},
+            'meta': {'me': True}
+        },
         'user_id': 95845,
         'user_name': 'Stanley',
         'user_roles': ['User']
@@ -63,6 +64,50 @@ def test_parse_message():
         "target": None
     }
 
+    assert BeamParser.parse_message({
+        'channel': 2151,
+        'id': '8ef6a160-a9c8-11e6-9c8f-6bd6b629c2eb',
+        'message': {
+            'message': [{
+                'userId': 95845,
+                'text': ':Stanleyinaspacesuit',
+                'username': 'Stanley',
+                'type': 'inaspacesuit'
+            }, {
+                'text': 'github.com/CactusDev',
+                'url': 'http://github.com/CactusDev',
+                'type': 'link'
+            }, {
+                'id': 95845,
+                'text': '@Stanley',
+                'username': 'Stanley',
+                'type': 'tag'
+            }],
+            'meta': {}
+        },
+        'user_id': 95845,
+        'user_name': 'Stanley',
+        'user_roles': ['User']
+    }).json == {
+        "message": [{
+            "type": "emoji",
+            "data": "👨‍🚀",
+            "text": ":Stanleyinaspacesuit"
+        }, {
+            "type": "url",
+            "data": "http://github.com/CactusDev",
+            "text": "github.com/CactusDev"
+        }, {
+            "type": "tag",
+            "data": "Stanley",
+            "text": "@Stanley"
+        }],
+        "user": "Stanley",
+        "role": 1,
+        "action": False,
+        "target": None
+    }
+
 
 def test_parse_follow():
 
@@ -283,6 +328,36 @@ def test_parse_host():
     }
 
 
+def test_parse_join():
+
+    assert BeamParser.parse_join({
+        'id': 95845,
+        'originatingChannel': 2151,
+        'username': 'Stanley',
+        'roles': ['Mod', 'User']
+    }).json == {
+        "event": "join",
+        "streak": 1,
+        "success": True,
+        "user": "Stanley"
+    }
+
+
+def test_parse_leave():
+
+    assert BeamParser.parse_leave({
+        'id': 95845,
+        'originatingChannel': 2151,
+        'username': 'Stanley',
+        'roles': ['Mod', 'User']
+    }).json == {
+        "event": "leave",
+        "streak": 1,
+        "success": True,
+        "user": "Stanley"
+    }
+
+
 def test_synthesize():
 
     assert BeamParser.synthesize(MessagePacket(
@@ -302,3 +377,7 @@ def test_synthesize():
     assert BeamParser.synthesize(MessagePacket(
         "Hello!", target="Stanley"
     )) == (("Stanley", "Hello!",), {"method": "whisper"})
+
+    assert BeamParser.synthesize(MessagePacket(
+        "Hello! ", ("emoji", "🌵"), "How are you?"
+    )) == (("Hello! :cactus How are you?",), {})

From 9661db692c957a2d9ec9a64319e938bc50958017 Mon Sep 17 00:00:00 2001
From: 2Cubed <2Cubed@users.noreply.github.com>
Date: Sat, 13 May 2017 11:31:33 -0400
Subject: [PATCH 106/122] Add tests for BeamAPI

---
 cactusbot/services/beam/api.py       |   8 +-
 tests/services/beam/test_beam_api.py | 110 +++++++++++++++++++++++++++
 2 files changed, 113 insertions(+), 5 deletions(-)
 create mode 100644 tests/services/beam/test_beam_api.py

diff --git a/cactusbot/services/beam/api.py b/cactusbot/services/beam/api.py
index f6d64ee..cc66abd 100644
--- a/cactusbot/services/beam/api.py
+++ b/cactusbot/services/beam/api.py
@@ -36,20 +36,18 @@ async def request(self, method, endpoint, **kwargs):
 
     async def get_bot_channel(self, **params):
         """Get the bot's user id."""
-        response = await self.get("/users/current", params=params,
-                                  headers=self.headers)
+        response = await self.get("/users/current", params=params)
         return await response.json()
 
     async def get_channel(self, channel, **params):
         """Get channel data by username or ID."""
         response = await self.get("/channels/{channel}".format(
-            channel=channel), params=params, headers=self.headers)
+            channel=channel), params=params)
         return await response.json()
 
     async def get_chat(self, chat):
         """Get required data for connecting to a chat server by channel ID."""
-        response = await self.get("/chats/{chat}".format(chat=chat),
-                                  headers=self.headers)
+        response = await self.get("/chats/{chat}".format(chat=chat))
         return await response.json()
 
     async def update_roles(self, user, add, remove):
diff --git a/tests/services/beam/test_beam_api.py b/tests/services/beam/test_beam_api.py
new file mode 100644
index 0000000..81f41f6
--- /dev/null
+++ b/tests/services/beam/test_beam_api.py
@@ -0,0 +1,110 @@
+import json
+
+import pytest
+
+from cactusbot.services.beam.api import API, BeamAPI
+
+
+class FakeResponse:
+
+    def __init__(self, method, endpoint, kwargs):
+
+        self.method = method
+        self.endpoint = endpoint
+        self.kwargs = kwargs
+
+    async def json(self):
+
+        return self.method, self.endpoint, self.kwargs
+
+
+@pytest.fixture(autouse=True)
+def fake_web_requests(monkeypatch):
+
+    async def request(self, method, endpoint, **kwargs):
+
+        if kwargs.get("raw") is True:
+            kwargs.pop("raw")
+
+        else:
+
+            if "data" in kwargs:
+                kwargs["data"] = json.loads(kwargs["data"])
+
+            if "headers" in kwargs:
+                for key, value in list(kwargs["headers"].items()):
+                    if key in self.headers and self.headers[key] == value:
+                        kwargs["headers"].pop(key)
+                if not kwargs["headers"]:
+                    kwargs.pop("headers")
+
+        return FakeResponse(method.upper(), endpoint, kwargs)
+    monkeypatch.setattr(API, "request", request)
+
+
+api = BeamAPI("channel", "token")
+
+
+@pytest.mark.asyncio
+async def test_request():
+
+    assert await (
+        await api.request("GET", "/test", headers={"X-Key": "value"}, raw=True)
+    ).json() == (
+        "GET",
+        "/test",
+        {
+            "headers": {
+                "Content-Type": "application/json",
+                "Authorization": "Bearer token",
+                "X-Key": "value"
+            }
+        }
+    )
+
+
+@pytest.mark.asyncio
+async def test_get_bot_channel():
+
+    assert await api.get_bot_channel(fields="id") == (
+        "GET",
+        "/users/current",
+        {"params": {
+            "fields": "id"
+        }}
+    )
+
+
+@pytest.mark.asyncio
+async def test_get_channel():
+
+    assert await api.get_channel("Stanley", fields="userId") == (
+        "GET",
+        "/channels/Stanley",
+        {"params": {
+            "fields": "userId"
+        }}
+    )
+
+
+@pytest.mark.asyncio
+async def test_get_chat():
+
+    assert await api.get_chat("Stanley") == (
+        "GET",
+        "/chats/Stanley",
+        {}
+    )
+
+
+@pytest.mark.asyncio
+async def test_update_roles():
+
+    assert await api.update_roles("Potato", ["Banned"], ["Mod"]) == (
+        "PATCH",
+        "/channels/channel/users/Potato",
+        {"data": {
+            "add": ["Banned"],
+            "remove": ["Mod"]
+        }}
+    )

From f2af7f74ab006ab2d401daa0eed51d31f3544223 Mon Sep 17 00:00:00 2001
From: 2Cubed <2Cubed@users.noreply.github.com>
Date: Sat, 13 May 2017 12:03:09 -0400
Subject: [PATCH 107/122] Finsh CactusAPI tests

---
 cactusbot/api.py               |  2 +
 tests/cactus/test_cactusapi.py | 96 ++++++++++++++++++++++++++++++----
 2 files changed, 89 insertions(+), 9 deletions(-)

diff --git a/cactusbot/api.py b/cactusbot/api.py
index e27ff49..be27fb8 100644
--- a/cactusbot/api.py
+++ b/cactusbot/api.py
@@ -65,6 +65,8 @@ async def request(self, method, endpoint, **kwargs):
             reauth = await self.login(self.password, *self.SCOPES)
             if reauth.status == 200:
                 self.auth_token = (await reauth.json()).get("token")
+                response = await super().request(method, endpoint, **kwargs)
+                
 
         return response
 
diff --git a/tests/cactus/test_cactusapi.py b/tests/cactus/test_cactusapi.py
index 4ee5ef3..1028ddd 100644
--- a/tests/cactus/test_cactusapi.py
+++ b/tests/cactus/test_cactusapi.py
@@ -2,7 +2,38 @@
 
 import pytest
 
-from cactusbot.api import CactusAPI
+from cactusbot.api import API, CactusAPI
+
+
+class FakeResponse:
+
+    def __init__(self, method, endpoint, kwargs, status=200):
+
+        self.method = method
+        self.endpoint = endpoint
+        self.kwargs = kwargs
+
+        self.status = status
+        
+        if "data" in kwargs:
+            if kwargs["data"].get("password") == "fake":
+                self.status = 404
+
+    def __eq__(self, other):
+        return other == self.data
+
+    def __repr__(self):
+        return "<FakeResponse: {}>".format(repr(self.data))
+
+    @property
+    def data(self):
+        return self.method, self.endpoint, self.kwargs
+
+    async def json(self):
+        return {
+            "token": "authtoken",
+            "errors": ["Such error."]
+        }
 
 
 @pytest.fixture(autouse=True)
@@ -10,21 +41,68 @@ def fake_web_requests(monkeypatch):
 
     async def request(self, method, endpoint, **kwargs):
 
-        if "data" in kwargs:
-            kwargs["data"] = json.loads(kwargs["data"])
+        if kwargs.get("raw") is True:
+            kwargs.pop("raw")
+
+        else:
+
+            if "data" in kwargs:
+                kwargs["data"] = json.loads(kwargs["data"])
 
-        if method.upper() == "GET" and kwargs.get("is_json") is False:
-            kwargs.pop("is_json")
-        elif kwargs.get("is_json") is True:
-            kwargs.pop("is_json")
+            if "headers" in kwargs:
+                for header in list(kwargs["headers"].keys()):
+                    if header in ["Content-Type", "X-Auth-Token", "X-Auth-Key"]:
+                        kwargs["headers"].pop(header)
+                if not kwargs["headers"]:
+                    kwargs.pop("headers")
 
-        return method.upper(), endpoint, kwargs
-    monkeypatch.setattr(CactusAPI, "request", request)
+            if method.upper() == "GET" and kwargs.get("is_json") is False:
+                kwargs.pop("is_json")
+            elif kwargs.get("is_json") is True:
+                kwargs.pop("is_json")
+
+        return FakeResponse(
+            method.upper(), endpoint, kwargs, status=kwargs.get("status", 200)
+        )
+    monkeypatch.setattr(API, "request", request)
 
 
 api = CactusAPI("token", "password")
 
 
+@pytest.mark.asyncio
+async def test_request():
+    
+    assert (await api.request("GET", "/test", headers={"X-Key": "value"}, raw=True)).data == (
+        "GET",
+        "/test",
+        {
+            "headers": {
+                "Content-Type": "application/json",
+                "X-Auth-Token": api.token,
+                "X-Auth-Key": api.auth_token,
+                "X-Key": "value"
+            }
+        }
+    )
+    
+    api.auth_token = ""
+    await api.request("GET", "/test", status=401)
+    assert api.auth_token == "authtoken"
+
+@pytest.mark.asyncio
+async def test_login():
+    
+    api.auth_token = ""
+    await api.login()
+    assert api.auth_token == "authtoken"
+    
+    api.auth_token = ""
+    with pytest.raises(ValueError):
+        await api.login(password="fake")
+    
+
+
 class TestAlias:
 
     @pytest.mark.asyncio

From 44555c6c536875d9572ac92dcfb86c9a66317453 Mon Sep 17 00:00:00 2001
From: 2Cubed <2Cubed@users.noreply.github.com>
Date: Sat, 13 May 2017 13:14:34 -0400
Subject: [PATCH 108/122] Finish command.py tests

---
 cactusbot/commands/command.py  |  2 +-
 tests/handlers/test_command.py | 75 ++++++++++++++++++++++++++++++++--
 2 files changed, 72 insertions(+), 5 deletions(-)

diff --git a/cactusbot/commands/command.py b/cactusbot/commands/command.py
index db4e484..740349c 100644
--- a/cactusbot/commands/command.py
+++ b/cactusbot/commands/command.py
@@ -201,7 +201,7 @@ def _display(arg):
 
         argument_name = arg.name
         if argument_name == "_":
-            argument_name = "arguments"
+            argument_name = "argument"
         return syntax.format(argument_name)
 
     @classmethod
diff --git a/tests/handlers/test_command.py b/tests/handlers/test_command.py
index 9eaedbc..d3206c5 100644
--- a/tests/handlers/test_command.py
+++ b/tests/handlers/test_command.py
@@ -1,3 +1,5 @@
+import inspect
+
 import pytest
 from tests.api import MockAPI
 
@@ -284,9 +286,11 @@ async def default(self, strength: r'[1-9]\d*'=1):
             return "Potato power x {}!".format(strength)
 
     @Command.command(hidden=True)
-    class Wizard(Command):
+    class Wizardry(Command):
         """Potato wizard."""
 
+        COMMAND = "wizard"
+
         @Command.command()
         async def default(self, *things):
             """Potato wizard."""
@@ -310,7 +314,42 @@ async def taco(self):
             return "TACO SALAD!?"
 
 
-potato = Potato(MockAPI("test_token", "test_password"))
+class Cat(Command):
+
+    @Command.command()
+    async def default(self, a, b):
+        return a + b
+
+
+class Echo(Command):
+
+    @Command.command()
+    async def f(self, value, _):
+        return value
+
+
+def fail(_):
+    raise Exception
+
+
+class Broken(Command):
+
+    @Command.command()
+    async def f(self, arg: 12):  # invalid annotation
+        return arg
+
+    @Command.command()
+    async def g(self, bad_arg: fail):
+        return bad_arg
+
+
+api = MockAPI("test_token", "test_password")
+
+potato = Potato(api)
+
+cat = Cat(api)
+echo = Echo(api)
+broken = Broken(api)
 
 
 @pytest.mark.asyncio
@@ -322,14 +361,19 @@ async def test_default():
     assert await potato("battery") == "Potato power!"
     assert await potato("battery", "high") == "Invalid 'strength': 'high'."
     assert await potato("battery", "9001") == "Potato power x 9001!"
+    assert await potato("battery", "1", "2") == "Invalid argument: '1'."
 
     assert await potato("salad") == "Not enough arguments. <make>"
+    assert await cat() == "Not enough arguments. <a> <b>"
+    assert await echo() == "Not enough arguments. <f>"
+    assert await echo("f") == "Not enough arguments. <value> <argument>"
 
 
 @pytest.mark.asyncio
 async def test_args():
 
     assert await potato("add", "100") == "Added 100 potatoes."
+    assert await potato("add", "1", "2") == "Too many arguments."
 
     assert await potato("eat") == "Not enough arguments. <number> [friend]"
     assert await potato("eat", "8", username="2Cubed") == "2Cubed ate 8 potatoes!"
@@ -371,13 +415,36 @@ async def test_args():
 
     assert await potato("salad", "taco") == "TACO SALAD!?"
 
+    with pytest.raises(TypeError):
+        await broken("f", "x")
+    assert await broken("g", "x") == "Invalid 'bad arg': 'x'."
+
+    with pytest.raises(NameError):
+
+        class Invalid(Command):
+
+            @Command.command(name="a")
+            class B(Command):
+
+                COMMAND = "b"
+
 
 @pytest.mark.asyncio
-async def test_list():
-    command_list = potato.commands()
+async def test_helpers():
 
+    command_list = potato.commands()
     assert "check" in command_list
     assert "add" in command_list
     assert "eat" in command_list
     assert "wizard" in command_list
     assert "salad" in command_list
+
+    def f(a, b=0, *c):
+        pass
+    params = inspect.signature(f).parameters.values()
+    assert list(map(potato._display, params)) == ["<a>", "[b]", "<c...>"]
+
+    def f(a, b=0, *c: False):
+        pass
+    params = inspect.signature(f).parameters.values()
+    assert list(map(potato._display, params)) == ["<a>", "[b]", "[c...]"]

From b3065b3b83ab455bb64a4acb99f24790f08e3699 Mon Sep 17 00:00:00 2001
From: 2Cubed <2Cubed@users.noreply.github.com>
Date: Sat, 13 May 2017 13:30:05 -0400
Subject: [PATCH 109/122] Finish MessagePacket tests

---
 cactusbot/packets/message.py  |  5 ++++-
 tests/packets/test_message.py | 27 +++++++++++++++++++++++----
 2 files changed, 27 insertions(+), 5 deletions(-)

diff --git a/cactusbot/packets/message.py b/cactusbot/packets/message.py
index bc39df2..66a7900 100644
--- a/cactusbot/packets/message.py
+++ b/cactusbot/packets/message.py
@@ -102,20 +102,23 @@ def __getitem__(self, key):
 
             count = key.start or 0
             message = self.message.copy()
+            delta = 0
 
             for index, component in enumerate(message.copy()):
                 if component.type == "text":
                     if len(component.text) <= count:
                         count -= len(component.text)
                         message.pop(0)
+                        delta += 1
                     else:
                         while count > 0:
                             new_text = component.text[1:]
-                            component = message[index] = component._replace(
+                            component = message[index - delta] = component._replace(
                                 text=new_text, data=new_text)
                             count -= 1
                 else:
                     message.pop(0)
+                    delta += 1
                 if count == 0:
                     return self.copy(*message)
             return self.copy(*message)
diff --git a/tests/packets/test_message.py b/tests/packets/test_message.py
index 85fd2cd..f703bd6 100644
--- a/tests/packets/test_message.py
+++ b/tests/packets/test_message.py
@@ -1,6 +1,11 @@
+import pytest
+
 from cactusbot.packets import MessagePacket
 
 
+def test_str():
+    assert str(MessagePacket("Hi!", user="Stanley")) == '<Message: Stanley - "Hi!">'
+
 def test_copy():
 
     initial = MessagePacket("Test message.", user="TestUser")
@@ -36,14 +41,12 @@ def test_sub():
 
 def _split(text, *args, **kwargs):
     return [
-        component.text
-        for component in
+        component.text for component in
         MessagePacket(text).split(*args, **kwargs)
     ]
 
 
 def test_split():
-    """Test splitting message packets."""
 
     assert _split("0 1 2 3") == ['0', '1', '2', '3']
     assert _split("0 1 2 3", "2") == ['0 1 ', ' 3']
@@ -53,8 +56,24 @@ def test_split():
     assert _split(" 0 1 2 3 ") == ['0', '1', '2', '3']
 
 
+def test_contains():
+
+    assert "Hello" in MessagePacket("Hello")
+    assert "Hello" in MessagePacket("Hello, world!")
+    assert "Hello" not in MessagePacket("Hi, world!")
+
+
+def test_getitem():
+
+    assert MessagePacket("Hello!")[4:].text == "o!"
+    assert MessagePacket()[:].text == ""
+    assert MessagePacket("Hello, ", ("emoji", "🌵"), "world!")[10:].text == "ld!"
+
+    with pytest.raises(TypeError):
+        MessagePacket("Hello!")["two"]
+
+
 def test_join():
-    """Test joining message packets."""
 
     assert MessagePacket.join(
         MessagePacket(("text", "I like "), ("emoji", "😃")),

From 7667d2ee31d720d07fde2541339445fb9fcab000 Mon Sep 17 00:00:00 2001
From: 2Cubed <2Cubed@users.noreply.github.com>
Date: Sat, 13 May 2017 14:10:17 -0400
Subject: [PATCH 110/122] Add tests for Handler

---
 cactusbot/handler.py                     |  7 ++---
 cactusbot/handlers/spam.py               | 12 ++++----
 cactusbot/services/api.py                |  1 +
 tests/handlers/test_handler.py           | 36 ++++++++++++++++++++++++
 tests/handlers/test_logging.py           | 18 ++++++++++++
 tests/packets/test_ban.py                | 15 ++++++++++
 tests/services/beam/test_beam_handler.py |  2 +-
 7 files changed, 79 insertions(+), 12 deletions(-)
 create mode 100644 tests/handlers/test_handler.py
 create mode 100644 tests/handlers/test_logging.py

diff --git a/cactusbot/handler.py b/cactusbot/handler.py
index 2fa913a..81045c0 100644
--- a/cactusbot/handler.py
+++ b/cactusbot/handler.py
@@ -132,12 +132,9 @@ def translate(self, packet, handler):
 
         if isinstance(packet, Packet):
             yield packet
-        elif isinstance(packet, (tuple, list)):
+        elif isinstance(packet, list):
             for component in packet:
-                for item in self.translate(component, handler):
-                    if item is StopIteration:
-                        return item
-                    yield item
+                yield from self.translate(component, handler)
         elif isinstance(packet, str):
             yield MessagePacket(packet)
         elif packet is StopIteration:
diff --git a/cactusbot/handlers/spam.py b/cactusbot/handlers/spam.py
index 8a9c4d8..f001085 100644
--- a/cactusbot/handlers/spam.py
+++ b/cactusbot/handlers/spam.py
@@ -48,22 +48,22 @@ async def on_message(self, packet):
         contains_urls = self.contains_urls(packet)
 
         if exceeds_caps:
-            return (MessagePacket("Please do not spam capital letters.",
+            return [MessagePacket("Please do not spam capital letters.",
                                   target=packet.user),
                     BanPacket(packet.user, 1),
-                    StopIteration)
+                    StopIteration]
 
         if exceeds_emoji:
-            return (MessagePacket("Please do not spam emoji.",
+            return [MessagePacket("Please do not spam emoji.",
                                   target=packet.user),
                     BanPacket(packet.user, 1),
-                    StopIteration)
+                    StopIteration]
 
         if contains_urls:
-            return (MessagePacket("Please do not post URLs.",
+            return [MessagePacket("Please do not post URLs.",
                                   target=packet.user),
                     BanPacket(packet.user, 5),
-                    StopIteration)
+                    StopIteration]
 
     async def on_config(self, packet):
         """Handle config update events."""
diff --git a/cactusbot/services/api.py b/cactusbot/services/api.py
index 8d5edde..91b17ad 100644
--- a/cactusbot/services/api.py
+++ b/cactusbot/services/api.py
@@ -14,6 +14,7 @@ class API:
 
     def __init__(self, **kwargs):
         super().__init__(**kwargs)
+        assert self.URL is not None
 
         self.logger = logging.getLogger(__name__)
 
diff --git a/tests/handlers/test_handler.py b/tests/handlers/test_handler.py
new file mode 100644
index 0000000..5fc3bb1
--- /dev/null
+++ b/tests/handlers/test_handler.py
@@ -0,0 +1,36 @@
+import pytest
+
+from cactusbot.handler import Handler, Handlers
+from cactusbot.packets import BanPacket, MessagePacket
+
+
+class SpamHandler(Handler):
+    async def on_message(self, packet):
+        if "spam" in packet:
+            return [
+                MessagePacket("No spamming!", target=packet.user),
+                BanPacket(packet.user, duration=1),
+                StopIteration
+            ]
+
+
+class EchoHandler(Handler):
+    async def on_message(self, packet):
+        if packet.text == "break":
+            return [12, "working"]
+        return packet.text
+
+
+handlers = Handlers(SpamHandler(), EchoHandler())
+
+
+@pytest.mark.asyncio
+async def test_handlers():
+
+    result = await handlers.handle("message", MessagePacket("spam"))
+    assert len(result) == 2
+    assert isinstance(result[0], MessagePacket)
+    assert isinstance(result[1], BanPacket)
+
+    result = await handlers.handle("message", MessagePacket("break"))
+    assert len(result) == 1  # due to invalid packet return type, int
diff --git a/tests/handlers/test_logging.py b/tests/handlers/test_logging.py
new file mode 100644
index 0000000..7934ad5
--- /dev/null
+++ b/tests/handlers/test_logging.py
@@ -0,0 +1,18 @@
+import pytest
+
+from cactusbot.handlers import LoggingHandler
+from cactusbot.packets import EventPacket, MessagePacket
+
+logging_handler = LoggingHandler()
+
+
+@pytest.mark.asyncio
+async def test_logging():
+
+    await logging_handler.on_message(MessagePacket("Hello!", user="Stanley"))
+    await logging_handler.on_join(EventPacket("join", "Stanley"))
+    await logging_handler.on_leave(EventPacket("leave", "Stanley"))
+    await logging_handler.on_follow(EventPacket("follow", "Stanley"))
+    await logging_handler.on_subscribe(EventPacket("subscribe", "Stanley"))
+    await logging_handler.on_resubscribe(EventPacket("subscribe", "Stanley"))
+    await logging_handler.on_host(EventPacket("subscribe", "Stanley"))
diff --git a/tests/packets/test_ban.py b/tests/packets/test_ban.py
index eabfde0..44af3e0 100644
--- a/tests/packets/test_ban.py
+++ b/tests/packets/test_ban.py
@@ -2,8 +2,23 @@
 
 from cactusbot.packets import BanPacket
 
+
 def test_ban_packet():
+
     packet = BanPacket("TestUser")
 
     assert packet.duration == 0
     assert packet.user == "TestUser"
+
+
+def test_str():
+    assert str(BanPacket("Stanley")) == "<Ban: Stanley>"
+    assert str(BanPacket("Stanley", 5)) == "<Ban: Stanley, 5 seconds>"
+
+
+def test_json():
+
+    assert BanPacket("Stanley", 5).json == {
+        "user": "Stanley",
+        "duration": 5
+    }
diff --git a/tests/services/beam/test_beam_handler.py b/tests/services/beam/test_beam_handler.py
index 340e953..0e5160e 100644
--- a/tests/services/beam/test_beam_handler.py
+++ b/tests/services/beam/test_beam_handler.py
@@ -30,7 +30,7 @@ async def on_message(self, packet):
 class SpamHandler(Handler):
     async def on_message(self, packet):
         if "spam" in packet.text:
-            return ("No spamming!", BanPacket(packet.user, duration=5))
+            return ["No spamming!", BanPacket(packet.user, duration=5)]
         if "SPAM" in packet.text:
             return BanPacket(packet.user)
 

From 4904a5df251de1595055f44e9be6736479d1c468 Mon Sep 17 00:00:00 2001
From: 2Cubed <2Cubed@users.noreply.github.com>
Date: Sat, 13 May 2017 14:15:45 -0400
Subject: [PATCH 111/122] Fix important business logic

I swear, I can type.
---
 cactusbot/api.py             | 1 -
 cactusbot/packets/message.py | 5 +++--
 2 files changed, 3 insertions(+), 3 deletions(-)

diff --git a/cactusbot/api.py b/cactusbot/api.py
index be27fb8..775e3f4 100644
--- a/cactusbot/api.py
+++ b/cactusbot/api.py
@@ -66,7 +66,6 @@ async def request(self, method, endpoint, **kwargs):
             if reauth.status == 200:
                 self.auth_token = (await reauth.json()).get("token")
                 response = await super().request(method, endpoint, **kwargs)
-                
 
         return response
 
diff --git a/cactusbot/packets/message.py b/cactusbot/packets/message.py
index 66a7900..31590ba 100644
--- a/cactusbot/packets/message.py
+++ b/cactusbot/packets/message.py
@@ -113,8 +113,9 @@ def __getitem__(self, key):
                     else:
                         while count > 0:
                             new_text = component.text[1:]
-                            component = message[index - delta] = component._replace(
-                                text=new_text, data=new_text)
+                            component = message[index - delta] = \
+                                component._replace(
+                                    text=new_text, data=new_text)
                             count -= 1
                 else:
                     message.pop(0)

From d73c76457fe5119cf0c52d4cd89ed79474fd013f Mon Sep 17 00:00:00 2001
From: 2Cubed <2Cubed@users.noreply.github.com>
Date: Sat, 13 May 2017 14:19:30 -0400
Subject: [PATCH 112/122] Fix Coveralls README URL

---
 README.md | 7 +++----
 1 file changed, 3 insertions(+), 4 deletions(-)

diff --git a/README.md b/README.md
index f537c1f..4405595 100644
--- a/README.md
+++ b/README.md
@@ -2,10 +2,9 @@
 
 [![Build Status](https://travis-ci.org/CactusDev/CactusBot.svg?branch=master)](https://travis-ci.org/CactusDev/CactusBot)
 
-[![Coverage Status](https://coveralls.io/repos/github/CactusBot/CactusBot/badge.svg?branch=master)](https://coveralls.io/github/CactusBot/CactusBot?branch=master)
+[![Coverage Status](https://coveralls.io/repos/github/CactusDev/CactusBot/badge.svg?branch=master)](https://coveralls.io/github/CactusDev/CactusBot?branch=master)
 
-CactusBot is a next-generation chat bot for live streams.
-Harnessing the power of open-source, and an extraordinary community to shape its path
+CactusBot is a next-generation chat bot for live streams. Harnessing the power of open-source, and an extraordinary community to shape its path
 
 ![screenshot of the bot running in a terminal](./assets/terminal.png)
 
@@ -15,7 +14,7 @@ Harnessing the power of open-source, and an extraordinary community to shape its
 
 # Installation
 
-*PYTHON 3.5 OR GREATER IS REQUIRED. ANY VERSION LOWER IS NOT SUPPORTED.*
+_PYTHON 3.5 OR GREATER IS REQUIRED. ANY VERSION LOWER IS NOT SUPPORTED._
 
 [Sepal Setup](https://github.com/CactusDev/Sepal)
 

From c5dd2dea6cbe3010ae65a14adcc7a712036a4bc5 Mon Sep 17 00:00:00 2001
From: 2Cubed <2Cubed@users.noreply.github.com>
Date: Sat, 13 May 2017 14:30:14 -0400
Subject: [PATCH 113/122] Fix doctests

Broke because StopIteration is now yielded in order to fix a bug.
---
 cactusbot/handler.py | 15 ++++++++-------
 1 file changed, 8 insertions(+), 7 deletions(-)

diff --git a/cactusbot/handler.py b/cactusbot/handler.py
index 81045c0..b5a1170 100644
--- a/cactusbot/handler.py
+++ b/cactusbot/handler.py
@@ -104,7 +104,8 @@ def translate(self, packet, handler):
              - :obj:`tuple` or :obj:`list` is iterated over, passing each
                item through :meth:`translate` again.
              - :exc:`StopIteration` signifies that no future packets should be
-               yielded, stopping the chain.
+               yielded. Note that :exc:`StopIteration` will be yielded and
+               should be dealt with externally.
              - :obj:`None` is ignored, and is never yielded.
         handler : :obj:`Handler`
             The handler response to turn into a packet
@@ -113,21 +114,21 @@ def translate(self, packet, handler):
         --------
         >>> handlers = Handlers()
         >>> translated = handlers.translate("Hello!", Handler())
-        >>> [(item.__class__.__name__, item.text) for item in translated]
-        [('MessagePacket', 'Hello!')]
+        >>> [item.__class__.__name__ for item in translated]
+        ['MessagePacket']
 
         >>> handlers = Handlers()
         >>> translated = handlers.translate(["Potato?", "Potato!"], Handler())
-        >>> [(item.__class__.__name__, item.text) for item in translated]
-        [('MessagePacket', 'Potato?'), ('MessagePacket', 'Potato!')]
+        >>> [item.__class__.__name__ for item in translated]
+        ['MessagePacket', 'MessagePacket']
 
         >>> handlers = Handlers()
         >>> translated = handlers.translate(
         ...     ["Stop spamming.", StopIteration, "Nice message!"],
         ...     Handler()
         ... )
-        >>> [(item.__class__.__name__, item.text) for item in translated]
-        [('MessagePacket', 'Stop spamming.')]
+        >>> [item.__class__.__name__ for item in translated]
+        ['MessagePacket', 'type', 'MessagePacket']
         """
 
         if isinstance(packet, Packet):

From dc9f0f0b3945b2f029dfd5b68f3477ab5cea8814 Mon Sep 17 00:00:00 2001
From: 2Cubed <2Cubed@users.noreply.github.com>
Date: Sat, 27 May 2017 01:36:02 -0400
Subject: [PATCH 114/122] Fix maximum whisper length

---
 cactusbot/services/beam/chat.py | 17 +++++++++++------
 1 file changed, 11 insertions(+), 6 deletions(-)

diff --git a/cactusbot/services/beam/chat.py b/cactusbot/services/beam/chat.py
index 586be1b..e5a35c3 100644
--- a/cactusbot/services/beam/chat.py
+++ b/cactusbot/services/beam/chat.py
@@ -1,6 +1,5 @@
 """Interact with Beam chat."""
 
-
 import itertools
 import json
 import logging
@@ -33,11 +32,17 @@ async def send(self, *args, max_length=360, **kwargs):
 
         packet.update(kwargs)
 
-        if packet["method"] == "msg":
-            for message in packet.copy()["arguments"]:
-                for index in range(0, len(message), max_length):
-                    packet["arguments"] = (message[index:index + max_length],)
-                    await self._send(json.dumps(packet))
+        if packet["method"] in ("msg", "whisper"):
+
+            message = packet.copy()["arguments"][-1]
+
+            for index in range(0, len(message), max_length):
+
+                chunk = message[index:index + max_length]
+                packet["arguments"] = (*packet["arguments"][:-1], chunk)
+
+                await self._send(json.dumps(packet))
+
         else:
             await self._send(json.dumps(packet))
 

From 222ad7407616cc2b66a6356a0be57adb8cd7a363 Mon Sep 17 00:00:00 2001
From: Innectic <innectic@gmail.com>
Date: Fri, 14 Jul 2017 22:36:23 -0700
Subject: [PATCH 115/122] Convert to Mixer

---
 CHANGELOG.md                                  |  2 +-
 INSTALL.md                                    |  2 +-
 README.md                                     |  2 +-
 cactusbot/commands/magic/trust.py             |  8 +++---
 cactusbot/commands/magic/uptime.py            |  8 +++---
 cactusbot/handlers/spam.py                    |  4 +--
 cactusbot/services/beam/__init__.py           |  8 ------
 cactusbot/services/mixer/__init__.py          |  8 ++++++
 cactusbot/services/{beam => mixer}/api.py     |  8 +++---
 cactusbot/services/{beam => mixer}/chat.py    |  6 ++--
 .../services/{beam => mixer}/constellation.py |  8 +++---
 cactusbot/services/{beam => mixer}/emoji.json |  0
 cactusbot/services/{beam => mixer}/handler.py | 28 +++++++++----------
 cactusbot/services/{beam => mixer}/parser.py  | 10 +++----
 config.template.py                            |  4 +--
 docs/user/multi.md                            |  2 +-
 docs/user/variables.md                        |  6 ++--
 tests/handlers/test_command.py                |  4 +--
 .../test_mixer_api.py}                        |  4 +--
 .../test_mixer_chat.py}                       |  6 ++--
 .../test_mixer_constellation.py}              |  4 +--
 .../test_mixer_handler.py}                    |  0
 .../test_mixer_parser.py}                     |  0
 23 files changed, 66 insertions(+), 66 deletions(-)
 delete mode 100644 cactusbot/services/beam/__init__.py
 create mode 100644 cactusbot/services/mixer/__init__.py
 rename cactusbot/services/{beam => mixer}/api.py (92%)
 rename cactusbot/services/{beam => mixer}/chat.py (94%)
 rename cactusbot/services/{beam => mixer}/constellation.py (90%)
 rename cactusbot/services/{beam => mixer}/emoji.json (100%)
 rename cactusbot/services/{beam => mixer}/handler.py (85%)
 rename cactusbot/services/{beam => mixer}/parser.py (95%)
 rename tests/services/{beam/test_beam_api.py => mixer/test_mixer_api.py} (96%)
 rename tests/services/{beam/test_beam_chat.py => mixer/test_mixer_chat.py} (93%)
 rename tests/services/{beam/test_beam_constellation.py => mixer/test_mixer_constellation.py} (89%)
 rename tests/services/{beam/test_beam_handler.py => mixer/test_mixer_handler.py} (100%)
 rename tests/services/{beam/test_beam_parser.py => mixer/test_mixer_parser.py} (100%)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index 1860ed3..7149e6e 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -53,7 +53,7 @@
 #### Released: July 31st, 2016 
 
 ### Fixed
- - Beam CSRF token usage.
+ - Beam (now Mixer) CSRF token usage.
  - Message removal
  - `!repeat` command
 
diff --git a/INSTALL.md b/INSTALL.md
index c0f2c23..8203ef7 100644
--- a/INSTALL.md
+++ b/INSTALL.md
@@ -8,7 +8,7 @@ cp config.template.py config.py
 ```
 
 Next, open `config.py` with your favorite text editor, and set 
-`TOKEN` to the bot's OAuth token, which can be obtained from [Beam's Documentation](https://dev.beam.pro/tutorials/chatbot.html). Then, set `CHANNEL` to your channel's name.
+`TOKEN` to the bot's OAuth token, which can be obtained from [Mixer's Documentation](https://dev.mixer.pro/tutorials/chatbot.html). Then, set `CHANNEL` to your channel's name.
 
 # Usage
 
diff --git a/README.md b/README.md
index 4405595..4b13d0f 100644
--- a/README.md
+++ b/README.md
@@ -10,7 +10,7 @@ CactusBot is a next-generation chat bot for live streams. Harnessing the power o
 
 ## CactusBot in action
 
-![gif of CactusBot running in Beam chat](./assets/action.gif)
+![gif of CactusBot running in Mixer chat](./assets/action.gif)
 
 # Installation
 
diff --git a/cactusbot/commands/magic/trust.py b/cactusbot/commands/magic/trust.py
index 48f607d..6cdd2fb 100644
--- a/cactusbot/commands/magic/trust.py
+++ b/cactusbot/commands/magic/trust.py
@@ -5,11 +5,11 @@
 from ...packets import MessagePacket
 from ..command import Command
 
-BASE_URL = "https://beam.pro/api/v1/channels/{username}"
+BASE_URL = "https://mixer.com/api/v1/channels/{username}"
 
 
-async def check_beam_user(username):
-    """Check if a Beam username exists."""
+async def check_mixer_user(username):
+    """Check if a Mixer username exists."""
     if username.startswith('@'):
         username = username[1:]
     async with aiohttp.get(BASE_URL.format(username=username)) as response:
@@ -18,7 +18,7 @@ async def check_beam_user(username):
         return (username, (await response.json())["id"])
 
 
-def _trust(check_user=check_beam_user):
+def _trust(check_user=check_mixer_user):
 
     class Trust(Command):  # pylint: disable=W0621
         """Trust command."""
diff --git a/cactusbot/commands/magic/uptime.py b/cactusbot/commands/magic/uptime.py
index ffde0ef..5d744ae 100644
--- a/cactusbot/commands/magic/uptime.py
+++ b/cactusbot/commands/magic/uptime.py
@@ -12,20 +12,20 @@ class Uptime(Command):
 
     COMMAND = "uptime"
 
-    BEAM_MANIFEST_URL = ("https://beam.pro/api/v1/channels/{channel}"
-                         "/manifest.light2")
+    MIXER_MANIFEST_URL = ("https://mixer.com/api/v1/channels/{channel}"
+                          "/manifest.light2")
 
     @Command.command(hidden=True)
     async def default(self, *, channel: "channel"):
         """Default response."""
 
         response = await (await aiohttp.get(
-            "https://beam.pro/api/v1/channels/{}".format(channel)
+            "https://mixer.com/api/v1/channels/{}".format(channel)
         )).json()
 
         if "id" in response:
             data = await (await aiohttp.get(
-                self.BEAM_MANIFEST_URL.format(channel=response["id"])
+                self.MIXER_MANIFEST_URL.format(channel=response["id"])
             )).json()
 
             if "startedAt" in data:
diff --git a/cactusbot/handlers/spam.py b/cactusbot/handlers/spam.py
index f001085..67364e3 100644
--- a/cactusbot/handlers/spam.py
+++ b/cactusbot/handlers/spam.py
@@ -5,7 +5,7 @@
 from ..handler import Handler
 from ..packets import BanPacket, MessagePacket
 
-BASE_URL = "https://beam.pro/api/v1/channels/{username}"
+BASE_URL = "https://mixer.com/api/v1/channels/{username}"
 
 
 class SpamHandler(Handler):
@@ -24,7 +24,7 @@ def __init__(self, api):
 
     @staticmethod
     async def get_user_id(username):
-        """Retrieve Beam user ID from username."""
+        """Retrieve Mixer user ID from username."""
         async with aiohttp.get(BASE_URL.format(username=username)) as response:
             if response.status == 404:
                 return 0
diff --git a/cactusbot/services/beam/__init__.py b/cactusbot/services/beam/__init__.py
deleted file mode 100644
index a7f54c8..0000000
--- a/cactusbot/services/beam/__init__.py
+++ /dev/null
@@ -1,8 +0,0 @@
-"""Interact with Beam."""
-
-from .api import BeamAPI
-from .chat import BeamChat
-from .handler import BeamHandler
-from .constellation import BeamConstellation
-
-__all__ = ["BeamHandler", "BeamAPI", "BeamChat", "BeamConstellation"]
diff --git a/cactusbot/services/mixer/__init__.py b/cactusbot/services/mixer/__init__.py
new file mode 100644
index 0000000..497e027
--- /dev/null
+++ b/cactusbot/services/mixer/__init__.py
@@ -0,0 +1,8 @@
+"""Interact with Mixer."""
+
+from .api import MixerAPI
+from .chat import MixerChat
+from .handler import MixerHandler
+from .constellation import MixerConstellation
+
+__all__ = ["MixerHandler", "MixerAPI", "MixerChat", "MixerConstellation"]
diff --git a/cactusbot/services/beam/api.py b/cactusbot/services/mixer/api.py
similarity index 92%
rename from cactusbot/services/beam/api.py
rename to cactusbot/services/mixer/api.py
index cc66abd..1853505 100644
--- a/cactusbot/services/beam/api.py
+++ b/cactusbot/services/mixer/api.py
@@ -1,14 +1,14 @@
-"""Interact with the Beam API."""
+"""Interact with the Mixer API."""
 
 import json
 
 from ..api import API
 
 
-class BeamAPI(API):
-    """Interact with the Beam API."""
+class MixerAPI(API):
+    """Interact with the Mixer API."""
 
-    URL = "https://beam.pro/api/v1/"
+    URL = "https://mixer.com/api/v1/"
 
     headers = {
         "Content-Type": "application/json"
diff --git a/cactusbot/services/beam/chat.py b/cactusbot/services/mixer/chat.py
similarity index 94%
rename from cactusbot/services/beam/chat.py
rename to cactusbot/services/mixer/chat.py
index e5a35c3..767e058 100644
--- a/cactusbot/services/beam/chat.py
+++ b/cactusbot/services/mixer/chat.py
@@ -1,4 +1,4 @@
-"""Interact with Beam chat."""
+"""Interact with Mixer chat."""
 
 import itertools
 import json
@@ -7,8 +7,8 @@
 from .. import WebSocket
 
 
-class BeamChat(WebSocket):
-    """Interact with Beam chat."""
+class MixerChat(WebSocket):
+    """Interact with Mixer chat."""
 
     def __init__(self, channel, *endpoints):
         super().__init__(*endpoints)
diff --git a/cactusbot/services/beam/constellation.py b/cactusbot/services/mixer/constellation.py
similarity index 90%
rename from cactusbot/services/beam/constellation.py
rename to cactusbot/services/mixer/constellation.py
index a7670c0..84da992 100644
--- a/cactusbot/services/beam/constellation.py
+++ b/cactusbot/services/mixer/constellation.py
@@ -1,4 +1,4 @@
-"""Interact with Beam Constellation."""
+"""Interact with Mixer Constellation."""
 
 import re
 import json
@@ -6,10 +6,10 @@
 from .. import WebSocket
 
 
-class BeamConstellation(WebSocket):
-    """Interact with Beam Constellation."""
+class MixerConstellation(WebSocket):
+    """Interact with Mixer Constellation."""
 
-    URL = "wss://constellation.beam.pro"
+    URL = "wss://constellation.mixer.com"
 
     RESPONSE_EXPR = re.compile(r'^(\d+)(.+)?$')
     INTERFACE_EXPR = re.compile(r'^([a-z]+):\d+:([a-z]+)')
diff --git a/cactusbot/services/beam/emoji.json b/cactusbot/services/mixer/emoji.json
similarity index 100%
rename from cactusbot/services/beam/emoji.json
rename to cactusbot/services/mixer/emoji.json
diff --git a/cactusbot/services/beam/handler.py b/cactusbot/services/mixer/handler.py
similarity index 85%
rename from cactusbot/services/beam/handler.py
rename to cactusbot/services/mixer/handler.py
index e64bc21..e41cd81 100644
--- a/cactusbot/services/beam/handler.py
+++ b/cactusbot/services/mixer/handler.py
@@ -1,14 +1,14 @@
-"""Handle data from Beam."""
+"""Handle data from Mixer."""
 
 import asyncio
 import logging
 from functools import partial
 
 from ...packets import BanPacket, MessagePacket, Packet
-from .api import BeamAPI
-from .chat import BeamChat
-from .constellation import BeamConstellation
-from .parser import BeamParser
+from .api import MixerAPI
+from .chat import MixerChat
+from .constellation import MixerConstellation
+from .parser import MixerParser
 
 CHAT_EVENTS = {
     "ChatMessage": "message",
@@ -24,16 +24,16 @@
 }
 
 
-class BeamHandler:
-    """Handle data from Beam services."""
+class MixerHandler:
+    """Handle data from Mixer services."""
 
     def __init__(self, channel, token, handlers):
 
         self.logger = logging.getLogger(__name__)
 
-        self.api = BeamAPI(channel, token)
+        self.api = MixerAPI(channel, token)
 
-        self.parser = BeamParser()
+        self.parser = MixerParser()
         self.handlers = handlers  # HACK, potentially
 
         self.channel = channel
@@ -42,7 +42,7 @@ def __init__(self, channel, token, handlers):
         self.constellation = None
 
     async def run(self):
-        """Connect to Beam chat and handle incoming packets."""
+        """Connect to Mixer chat and handle incoming packets."""
 
         channel = await self.api.get_channel(self.channel)
         self.api.channel = str(channel["id"])
@@ -57,14 +57,14 @@ async def run(self):
                           Packet(username=bot_channel["channel"]["token"]))
 
         if "authkey" not in chat:
-            self.logger.error("Failed to authenticate with Beam!")
+            self.logger.error("Failed to authenticate with Mixer!")
 
-        self.chat = BeamChat(channel["id"], *chat["endpoints"])
+        self.chat = MixerChat(channel["id"], *chat["endpoints"])
         await self.chat.connect(
             bot_id, partial(self.api.get_chat, channel["id"]))
         asyncio.ensure_future(self.chat.read(self.handle_chat))
 
-        self.constellation = BeamConstellation(channel["id"], user_id)
+        self.constellation = MixerConstellation(channel["id"], user_id)
         await self.constellation.connect()
         asyncio.ensure_future(
             self.constellation.read(self.handle_constellation))
@@ -130,7 +130,7 @@ async def handle(self, event, data):
                     await self.api.update_roles(user_id, ["Banned"], [])
 
     async def send(self, *args, **kwargs):
-        """Send a packet to Beam."""
+        """Send a packet to Mixer."""
 
         if self.chat is None:
             raise ConnectionError("Chat not initialized.")
diff --git a/cactusbot/services/beam/parser.py b/cactusbot/services/mixer/parser.py
similarity index 95%
rename from cactusbot/services/beam/parser.py
rename to cactusbot/services/mixer/parser.py
index bf524d4..bda904a 100644
--- a/cactusbot/services/beam/parser.py
+++ b/cactusbot/services/mixer/parser.py
@@ -1,4 +1,4 @@
-"""Parse Beam packets."""
+"""Parse Mixer packets."""
 
 import json
 from os import path
@@ -6,8 +6,8 @@
 from ...packets import EventPacket, MessagePacket
 
 
-class BeamParser:
-    """Parse Beam packets."""
+class MixerParser:
+    """Parse Mixer packets."""
 
     ROLES = {
         "Owner": 5,
@@ -27,7 +27,7 @@ class BeamParser:
 
     @classmethod
     def parse_message(cls, packet):
-        """Parse a Beam message packet."""
+        """Parse a Mixer message packet."""
 
         message = []
         for component in packet["message"]["message"]:
@@ -107,7 +107,7 @@ def parse_leave(cls, packet):
 
     @classmethod
     def synthesize(cls, packet):
-        """Create a Beam packet from a :obj:`MessagePacket`."""
+        """Create a Mixer packet from a :obj:`MessagePacket`."""
 
         message = ""
         emoji = dict(zip(cls.EMOJI.values(), cls.EMOJI.keys()))
diff --git a/config.template.py b/config.template.py
index 48c7992..e46c809 100644
--- a/config.template.py
+++ b/config.template.py
@@ -4,7 +4,7 @@
 from cactusbot.handler import Handlers
 from cactusbot.handlers import (CommandHandler, EventHandler, LoggingHandler,
                                 ResponseHandler, SpamHandler)
-from cactusbot.services.beam.handler import BeamHandler
+from cactusbot.services.mixer.handler import MixerHandler
 
 TOKEN = "OAuth_Token"
 CHANNEL = "ChannelName"
@@ -36,4 +36,4 @@
     CommandHandler(CHANNEL, api)
 )
 
-SERVICE = BeamHandler(CHANNEL, TOKEN, handlers)
+SERVICE = MixerHandler(CHANNEL, TOKEN, handlers)
diff --git a/docs/user/multi.md b/docs/user/multi.md
index 4eefe7d..e3e7ba6 100644
--- a/docs/user/multi.md
+++ b/docs/user/multi.md
@@ -5,7 +5,7 @@ Generate a multistream link.
 ## `!multi [service]:[channel]`
 
 `service` can be one of the following:
- - `b` (Beam)
+ - `m` (Mixer)
  - `t` (Twitch)
  - `h` (Hitbox)
  - `y` (Youtube)
diff --git a/docs/user/variables.md b/docs/user/variables.md
index 34d6ff2..06a4b9e 100644
--- a/docs/user/variables.md
+++ b/docs/user/variables.md
@@ -131,14 +131,14 @@ Remove the initial `@`, if it exists.
 ```
 
 ```
-[artdude543] !command add +raid Let's go raid @%ARG1|tag%! beam.pro/%ARG1|tag%
+[artdude543] !command add +raid Let's go raid @%ARG1|tag%! mixer.com/%ARG1|tag%
 [CactusBot] Added command !raid.
 
 [Chikachi] !raid @Innectic
-[CactusBot] Let's go raid @Innectic! beam.pro/Innectic
+[CactusBot] Let's go raid @Innectic! mixer.com/Innectic
 
 [alfw] !raid TransportLayer
-[CactusBot] Let's go raid @TransportLayer! beam.pro/TransportLayer
+[CactusBot] Let's go raid @TransportLayer! mixer.com/TransportLayer
 ```
 
 ## `shuffle`
diff --git a/tests/handlers/test_command.py b/tests/handlers/test_command.py
index d3206c5..45bf76f 100644
--- a/tests/handlers/test_command.py
+++ b/tests/handlers/test_command.py
@@ -101,8 +101,8 @@ def test_inject_argn():
     )
 
     verify(
-        ["Let's raid %ARG1%! ", ("url", "beam.pro/%ARG1|tag%")],
-        "Let's raid @Streamer! beam.pro/Streamer",
+        ["Let's raid %ARG1%! ", ("url", "mixer.pro/%ARG1|tag%")],
+        "Let's raid @Streamer! mixer.pro/Streamer",
         "raid", "@Streamer"
     )
 
diff --git a/tests/services/beam/test_beam_api.py b/tests/services/mixer/test_mixer_api.py
similarity index 96%
rename from tests/services/beam/test_beam_api.py
rename to tests/services/mixer/test_mixer_api.py
index 81f41f6..f1817e1 100644
--- a/tests/services/beam/test_beam_api.py
+++ b/tests/services/mixer/test_mixer_api.py
@@ -2,7 +2,7 @@
 
 import pytest
 
-from cactusbot.services.beam.api import API, BeamAPI
+from cactusbot.services.mixer.api import API, MixerAPi
 
 
 class FakeResponse:
@@ -42,7 +42,7 @@ async def request(self, method, endpoint, **kwargs):
     monkeypatch.setattr(API, "request", request)
 
 
-api = BeamAPI("channel", "token")
+api = MixerAPI("channel", "token")
 
 
 @pytest.mark.asyncio
diff --git a/tests/services/beam/test_beam_chat.py b/tests/services/mixer/test_mixer_chat.py
similarity index 93%
rename from tests/services/beam/test_beam_chat.py
rename to tests/services/mixer/test_mixer_chat.py
index 52e0b28..84ac62f 100644
--- a/tests/services/beam/test_beam_chat.py
+++ b/tests/services/mixer/test_mixer_chat.py
@@ -3,10 +3,10 @@
 import pytest
 
 from cactusbot.packets import BanPacket, MessagePacket
-from cactusbot.services.beam import BeamChat
+from cactusbot.services.mixer import MixerChat
 
 
-class BeamChatWrapper(BeamChat):
+class MixerChatWrapper(MixerChat):
 
     def __init__(self, channel):
         super().__init__(channel, "endpoint")
@@ -24,7 +24,7 @@ def queue(self):
         return queue
 
 
-chat = BeamChatWrapper(238)
+chat = MixerChatWrapper(238)
 
 
 async def test_send():
diff --git a/tests/services/beam/test_beam_constellation.py b/tests/services/mixer/test_mixer_constellation.py
similarity index 89%
rename from tests/services/beam/test_beam_constellation.py
rename to tests/services/mixer/test_mixer_constellation.py
index 768bf44..0d1fd8e 100644
--- a/tests/services/beam/test_beam_constellation.py
+++ b/tests/services/mixer/test_mixer_constellation.py
@@ -2,10 +2,10 @@
 
 import pytest
 
-from cactusbot.services.beam import BeamConstellation
+from cactusbot.services.mixer import MixerConstellation
 
 
-class ConstellationWrapper(BeamConstellation):
+class ConstellationWrapper(MixerConstellation):
 
     def __init__(self, channel, user):
         super().__init__(channel, user)
diff --git a/tests/services/beam/test_beam_handler.py b/tests/services/mixer/test_mixer_handler.py
similarity index 100%
rename from tests/services/beam/test_beam_handler.py
rename to tests/services/mixer/test_mixer_handler.py
diff --git a/tests/services/beam/test_beam_parser.py b/tests/services/mixer/test_mixer_parser.py
similarity index 100%
rename from tests/services/beam/test_beam_parser.py
rename to tests/services/mixer/test_mixer_parser.py

From f0ba42bf70b349af1f9679dd4e93a11120712f82 Mon Sep 17 00:00:00 2001
From: Innectic <innectic@gmail.com>
Date: Fri, 14 Jul 2017 22:47:46 -0700
Subject: [PATCH 116/122] Final fixes

---
 tests/services/mixer/test_mixer_api.py     |  2 +-
 tests/services/mixer/test_mixer_handler.py | 30 +++++++++++-----------
 tests/services/mixer/test_mixer_parser.py  | 30 +++++++++++-----------
 3 files changed, 31 insertions(+), 31 deletions(-)

diff --git a/tests/services/mixer/test_mixer_api.py b/tests/services/mixer/test_mixer_api.py
index f1817e1..86bce07 100644
--- a/tests/services/mixer/test_mixer_api.py
+++ b/tests/services/mixer/test_mixer_api.py
@@ -2,7 +2,7 @@
 
 import pytest
 
-from cactusbot.services.mixer.api import API, MixerAPi
+from cactusbot.services.mixer.api import API, MixerAPI
 
 
 class FakeResponse:
diff --git a/tests/services/mixer/test_mixer_handler.py b/tests/services/mixer/test_mixer_handler.py
index 0e5160e..1ff0b0c 100644
--- a/tests/services/mixer/test_mixer_handler.py
+++ b/tests/services/mixer/test_mixer_handler.py
@@ -2,10 +2,10 @@
 
 from cactusbot.handler import Handler, Handlers
 from cactusbot.packets import BanPacket, MessagePacket
-from cactusbot.services.beam import BeamHandler
+from cactusbot.services.mixer import MixerHandler
 
 
-class BeamHandlerWrapper(BeamHandler):
+class MixerHandlerWrapper(MixerHandler):
 
     def __init__(self, handlers):
         self._queue = []
@@ -41,22 +41,22 @@ async def on_follow(self, packet):
 
 
 handlers = Handlers(PingHandler(), SpamHandler(), FollowHandler())
-beam_handler = BeamHandlerWrapper(handlers)
+mixer_handler = MixerHandlerWrapper(handlers)
 
 
 @pytest.mark.asyncio
 async def test_handle():
 
-    await beam_handler.handle("message", "Hello!")
-    assert not beam_handler.queue
+    await mixer_handler.handle("message", "Hello!")
+    assert not mixer_handler.queue
 
-    await beam_handler.handle("message", MessagePacket("Ping!"))
-    assert beam_handler.queue == [(("Pong!",), {})]
+    await mixer_handler.handle("message", MessagePacket("Ping!"))
+    assert mixer_handler.queue == [(("Pong!",), {})]
 
-    await beam_handler.handle(
+    await mixer_handler.handle(
         "message", MessagePacket("spam eggs foo bar", user="Stanley")
     )
-    assert beam_handler.queue == [
+    assert mixer_handler.queue == [
         (("No spamming!",), {}),
         (("Stanley", 5), {"method": "timeout"})
     ]
@@ -65,7 +65,7 @@ async def test_handle():
 @pytest.mark.asyncio
 async def test_handle_chat():
 
-    await beam_handler.handle_chat({
+    await mixer_handler.handle_chat({
         'event': 'ChatMessage',
         'data': {
             'id': '688d66e0-352c-11e7-bd11-993537334664',
@@ -83,9 +83,9 @@ async def test_handle_chat():
         },
         'type': 'event'
     })
-    assert beam_handler.queue == [(("Pong!",), {})]
+    assert mixer_handler.queue == [(("Pong!",), {})]
 
-    await beam_handler.handle_chat({
+    await mixer_handler.handle_chat({
         'event': 'ChatMessage',
         'data': {
             'id': '688d66e0-352c-11e7-bd11-993537334664',
@@ -103,7 +103,7 @@ async def test_handle_chat():
         },
         'type': 'event'
     })
-    assert beam_handler.queue == [
+    assert mixer_handler.queue == [
         (("No spamming!",), {}),
         (("Stanley", 5), {"method": "timeout"})
     ]
@@ -112,7 +112,7 @@ async def test_handle_chat():
 @pytest.mark.asyncio
 async def test_handle_constellation():
 
-    await beam_handler.handle_constellation({
+    await mixer_handler.handle_constellation({
         'event': 'live',
         'data': {
             'payload': {
@@ -169,6 +169,6 @@ async def test_handle_constellation():
             },
             'channel': 'channel:3016:followed'
         }, 'type': 'event'})
-    assert beam_handler.queue == [
+    assert mixer_handler.queue == [
         (("Thanks for the follow, Stanley!",), {})
     ]
diff --git a/tests/services/mixer/test_mixer_parser.py b/tests/services/mixer/test_mixer_parser.py
index 704eaa8..2922af6 100644
--- a/tests/services/mixer/test_mixer_parser.py
+++ b/tests/services/mixer/test_mixer_parser.py
@@ -1,10 +1,10 @@
 from cactusbot.packets import MessagePacket
-from cactusbot.services.beam.parser import BeamParser
+from cactusbot.services.mixer.parser import MixerParser
 
 
 def test_parse_message():
 
-    assert BeamParser.parse_message({
+    assert MixerParser.parse_message({
         'channel': 2151,
         'id': '7f43cca0-a9c5-11e6-9c8f-6bd6b629c2eb',
         'message': {
@@ -30,7 +30,7 @@ def test_parse_message():
         "target": None
     }
 
-    assert BeamParser.parse_message({
+    assert MixerParser.parse_message({
         'channel': 2151,
         'id': '8ef6a160-a9c8-11e6-9c8f-6bd6b629c2eb',
         'message': {
@@ -64,7 +64,7 @@ def test_parse_message():
         "target": None
     }
 
-    assert BeamParser.parse_message({
+    assert MixerParser.parse_message({
         'channel': 2151,
         'id': '8ef6a160-a9c8-11e6-9c8f-6bd6b629c2eb',
         'message': {
@@ -111,7 +111,7 @@ def test_parse_message():
 
 def test_parse_follow():
 
-    assert BeamParser.parse_follow({
+    assert MixerParser.parse_follow({
         'following': True,
         'user': {
             'avatarUrl': 'https://uploads.beam.pro/avatar/l0icubxz-95845.jpg',
@@ -167,7 +167,7 @@ def test_parse_follow():
         "streak": 1
     }
 
-    assert BeamParser.parse_follow({
+    assert MixerParser.parse_follow({
         'following': False,
         'user': {
             'avatarUrl': 'https://uploads.beam.pro/avatar/l0icubxz-95845.jpg',
@@ -224,7 +224,7 @@ def test_parse_follow():
 
 def test_parse_subscribe():
 
-    assert BeamParser.parse_subscribe({
+    assert MixerParser.parse_subscribe({
         'user': {
             'avatarUrl': 'https://uploads.beam.pro/avatar/20621.jpg',
             'bio': 'Broadcasting Daily at 10 AM PST. Join in on fun with mostly Minecraft.',
@@ -255,7 +255,7 @@ def test_parse_subscribe():
 
 def test_parse_resubscribe():
 
-    assert BeamParser.parse_resubscribe({
+    assert MixerParser.parse_resubscribe({
         "totalMonths": 3,
         "user": {
             "level": 88,
@@ -288,7 +288,7 @@ def test_parse_resubscribe():
 
 def test_parse_host():
 
-    assert BeamParser.parse_host({
+    assert MixerParser.parse_host({
         'hoster': {
             'audience': 'teen',
             'badgeId': None,
@@ -330,7 +330,7 @@ def test_parse_host():
 
 def test_parse_join():
 
-    assert BeamParser.parse_join({
+    assert MixerParser.parse_join({
         'id': 95845,
         'originatingChannel': 2151,
         'username': 'Stanley',
@@ -345,7 +345,7 @@ def test_parse_join():
 
 def test_parse_leave():
 
-    assert BeamParser.parse_leave({
+    assert MixerParser.parse_leave({
         'id': 95845,
         'originatingChannel': 2151,
         'username': 'Stanley',
@@ -360,7 +360,7 @@ def test_parse_leave():
 
 def test_synthesize():
 
-    assert BeamParser.synthesize(MessagePacket(
+    assert MixerParser.synthesize(MessagePacket(
         "Hey, ",
         ("tag", "Stanley"),
         "! ",
@@ -370,14 +370,14 @@ def test_synthesize():
         "!"
     )) == (("Hey, @Stanley! :cactus Check out cactusbot.rtfd.org!",), {})
 
-    assert BeamParser.synthesize(MessagePacket(
+    assert MixerParser.synthesize(MessagePacket(
         "waves", action=True
     )) == (("/me waves",), {})
 
-    assert BeamParser.synthesize(MessagePacket(
+    assert MixerParser.synthesize(MessagePacket(
         "Hello!", target="Stanley"
     )) == (("Stanley", "Hello!",), {"method": "whisper"})
 
-    assert BeamParser.synthesize(MessagePacket(
+    assert MixerParser.synthesize(MessagePacket(
         "Hello! ", ("emoji", "🌵"), "How are you?"
     )) == (("Hello! :cactus How are you?",), {})

From 3165222702d2c8ddf88f48b9d61c0c86c059fa55 Mon Sep 17 00:00:00 2001
From: Innectic <innectic@gmail.com>
Date: Fri, 14 Jul 2017 22:53:37 -0700
Subject: [PATCH 117/122] Put the changelog back

---
 CHANGELOG.md | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index 7149e6e..1860ed3 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -53,7 +53,7 @@
 #### Released: July 31st, 2016 
 
 ### Fixed
- - Beam (now Mixer) CSRF token usage.
+ - Beam CSRF token usage.
  - Message removal
  - `!repeat` command
 

From 7a4a79a474dd38093fed0017237af58eebdcfb91 Mon Sep 17 00:00:00 2001
From: Innectic <innectic@gmail.com>
Date: Mon, 29 Jan 2018 14:18:08 -0800
Subject: [PATCH 118/122] Fix user roles not being able to be editors

---
 cactusbot/commands/command.py      | 1 +
 cactusbot/services/mixer/parser.py | 1 +
 2 files changed, 2 insertions(+)

diff --git a/cactusbot/commands/command.py b/cactusbot/commands/command.py
index 740349c..7488147 100644
--- a/cactusbot/commands/command.py
+++ b/cactusbot/commands/command.py
@@ -5,6 +5,7 @@
 
 ROLES = {
     5: "Owner",
+    4: "ChannelEditor",
     4: "Moderator",
     2: "Subscriber",
     1: "User",
diff --git a/cactusbot/services/mixer/parser.py b/cactusbot/services/mixer/parser.py
index bda904a..329ca0f 100644
--- a/cactusbot/services/mixer/parser.py
+++ b/cactusbot/services/mixer/parser.py
@@ -14,6 +14,7 @@ class MixerParser:
         "Staff": 4,  # Not necessarily bot staff.
         "Global Mod": 4,
         "Mod": 4,
+        "ChannelEditor": 4,
         "Subscriber": 2,
         "Pro": 1,
         "User": 1,

From e79e0f819f7a05e22fc040ab6ddc00ce41a6ab35 Mon Sep 17 00:00:00 2001
From: Innectic <innectic@gmail.com>
Date: Mon, 29 Jan 2018 14:24:58 -0800
Subject: [PATCH 119/122] Add editor role tests

---
 tests/services/mixer/test_mixer_parser.py | 34 +++++++++++++++++++++++
 1 file changed, 34 insertions(+)

diff --git a/tests/services/mixer/test_mixer_parser.py b/tests/services/mixer/test_mixer_parser.py
index 2922af6..4cf91a9 100644
--- a/tests/services/mixer/test_mixer_parser.py
+++ b/tests/services/mixer/test_mixer_parser.py
@@ -108,6 +108,40 @@ def test_parse_message():
         "target": None
     }
 
+    assert MixerParser.parse_message({
+        'channel': 2151,
+        'id': '8ef6a160-a9c8-11e6-9c8f-6bd6b629c2eb',
+        'message': {
+            'message': [
+                {'data': 'waves ',
+                 'text': 'waves ',
+                 'type': 'text'},
+                {'coords': {'height': 24, 'width': 24, 'x': 72, 'y': 0},
+                 'pack': 'default',
+                 'source': 'builtin',
+                 'text': ':D',
+                 'type': 'emoticon'}],
+            'meta': {'me': True}
+        },
+        'user_id': 95845,
+        'user_name': 'Stanley',
+        'user_roles': ['ChannelEditor']
+    }).json == {
+        "message": [{
+            "type": "text",
+            "data": "waves ",
+            "text": "waves "
+        }, {
+            "type": "emoji",
+            "data": "😃",
+            "text": ":D"
+        }],
+        "user": "Stanley",
+        "role": 4,
+        "action": True,
+        "target": None
+    }
+
 
 def test_parse_follow():
 

From d4e460e74a6fb618880dec0568dee6a7d281fe8e Mon Sep 17 00:00:00 2001
From: Innectic <innectic@gmail.com>
Date: Mon, 29 Jan 2018 14:34:01 -0800
Subject: [PATCH 120/122] Fix tests I think

---
 cactusbot/commands/command.py | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/cactusbot/commands/command.py b/cactusbot/commands/command.py
index 7488147..9234858 100644
--- a/cactusbot/commands/command.py
+++ b/cactusbot/commands/command.py
@@ -4,8 +4,8 @@
 import re
 
 ROLES = {
-    5: "Owner",
-    4: "ChannelEditor",
+    6: "Owner",
+    5: "ChannelEditor",
     4: "Moderator",
     2: "Subscriber",
     1: "User",

From 0b83516652c9a1147559d95217674070baa0a854 Mon Sep 17 00:00:00 2001
From: Innectic <innectic@gmail.com>
Date: Mon, 29 Jan 2018 17:06:23 -0800
Subject: [PATCH 121/122] Don't use channel editor in commands

---
 cactusbot/commands/command.py      | 3 +--
 cactusbot/services/mixer/parser.py | 2 +-
 2 files changed, 2 insertions(+), 3 deletions(-)

diff --git a/cactusbot/commands/command.py b/cactusbot/commands/command.py
index 9234858..740349c 100644
--- a/cactusbot/commands/command.py
+++ b/cactusbot/commands/command.py
@@ -4,8 +4,7 @@
 import re
 
 ROLES = {
-    6: "Owner",
-    5: "ChannelEditor",
+    5: "Owner",
     4: "Moderator",
     2: "Subscriber",
     1: "User",
diff --git a/cactusbot/services/mixer/parser.py b/cactusbot/services/mixer/parser.py
index 329ca0f..bfe422d 100644
--- a/cactusbot/services/mixer/parser.py
+++ b/cactusbot/services/mixer/parser.py
@@ -13,8 +13,8 @@ class MixerParser:
         "Owner": 5,
         "Staff": 4,  # Not necessarily bot staff.
         "Global Mod": 4,
-        "Mod": 4,
         "ChannelEditor": 4,
+        "Mod": 4,
         "Subscriber": 2,
         "Pro": 1,
         "User": 1,

From 785e7b3cc971e1c71ce1746ea0eb24127472d236 Mon Sep 17 00:00:00 2001
From: 2Cubed <2Cubed@users.noreply.github.com>
Date: Mon, 29 Jan 2018 20:23:57 -0500
Subject: [PATCH 122/122] Fix pylint too-many-locals

---
 cactusbot/commands/command.py | 6 +++---
 1 file changed, 3 insertions(+), 3 deletions(-)

diff --git a/cactusbot/commands/command.py b/cactusbot/commands/command.py
index 9234858..c625a8f 100644
--- a/cactusbot/commands/command.py
+++ b/cactusbot/commands/command.py
@@ -148,9 +148,9 @@ async def __call__(self, *args, **meta):
                     if error.direction:
                         return "Too many arguments."
 
-                    has_default = hasattr(running, "default")
-                    has_commands = hasattr(running, "commands")
-                    if not (has_default or has_commands):
+                    enough_args = (hasattr(running, "default") or
+                                   hasattr(running, "commands"))
+                    if not enough_args:
                         return "Not enough arguments. {0}".format(
                             ' '.join(map(self._display, error.args)))