Repository: Skelmis/Discord-Anti-Spam (language: python)

Issue:
Feature request: Plugin whitelist/blacklists
Given guilds should have the option to not use certain loaded plugins if they don't want them
Add the ability to send all guild messages to a 'log' channel

Background cleanup tasks
**Is your feature request related to a problem? Please describe.**
The longer a bot remains up, the more the internal storage for this package will grow. Currently there is no way to clear the cache without simply restarting the bot.

**Describe the solution you'd like**
The package should have an entry point that can be called periodically to prune the internal cache and remove un-needed data.

This solution should also extend to plugins.

**Describe alternatives you've considered**
Not doing this. I haven't profiled it so I'm not sure how big of an issue it is, although I assume it is indeed an issue.

**Additional context**
This will be worked on post rewrite



Discussion / hints:

Fix patch:
diff --git a/.coveragerc b/.coveragerc
new file mode 100644
index 0000000..5843666
--- /dev/null
+++ b/.coveragerc
@@ -0,0 +1,12 @@
+[run]
+branch = True
+omit =
+    antispam/abc.py
+    antispam/__init__.py
+    antispam/caches/__init__.py
+    antispam/caches/memory/__init__.py
+    antispam/caches/redis/__init__.py
+    antispam/dataclasses/__init__.py
+    antispam/enums/__init__.py
+    antispam/plugins/__init__.py
+    antispam/libs/hikari.py
\ No newline at end of file
diff --git a/.gitignore b/.gitignore
index b30ff07..3564d6a 100644
--- a/.gitignore
+++ b/.gitignore
@@ -4,11 +4,12 @@
 
 notes.txt
 *.json
-!/testing/unittests.json
+!/tests/raw.json
 !/testing/tracker_tests.json
-examples/dev_test.py
-
+examples/dev.py
+.tox/
 /assets
+docs/_build
 
 # IntelliJ project files
 .idea
@@ -88,7 +89,7 @@ instance/
 .scrapy
 
 # Sphinx documentation
-docs/_build/
+new_docs/_build/
 
 # PyBuilder
 .pybuilder/
diff --git a/.readthedocs.yaml b/.readthedocs.yaml
index 008adb1..50c69e3 100644
--- a/.readthedocs.yaml
+++ b/.readthedocs.yaml
@@ -1,12 +1,12 @@
 version: 2
 
+formats:
+  - pdf
+
 sphinx:
   configuration: docs/conf.py
 
 python:
   version: 3.8
   install:
-    - method: pip
-      path: .
-      extra_requirements:
-        - docs
\ No newline at end of file
+    - requirements: docs/requirements.txt
\ No newline at end of file
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 9036f10..f54aaf4 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -1,8 +1,9 @@
 # Contributing to DPY Anti-Spam
 I love all input, whether its issues or pull requests!
 
-### This project is solely ran through Github
+### Project management
 Github is used to host code, to track issues and feature requests, as well as accept pull requests.
+Our discord is used for further discussion where required and general team work side of things.
 
 ### Any contributions you make will be under the MIT Software License
 In short, when you submit code changes, your submissions are understood to be under the same [MIT License](http://choosealicense.com/licenses/mit/) that covers the project. Feel free to contact the maintainers if that's a concern.
@@ -30,14 +31,15 @@ I love new code for this project, I love seeing it grow. However, there is a pro
 ## Use a Consistent Coding Style
 - Tabs should be 4 spaces for indentation
 - Where applicable, code should be documented. For implementation see the below Documentaion sections
+  - Applicable means any public methods
+- All code should be unittested to a level which ensures good future regression tests
 - Where code gets excessive, functional programming ideas should come into play for readability
-- `return` statements are prefered within conditionals when checking whether to continue with processing or not
+- `return` statements are preferred within conditionals when checking whether to continue with processing or not
 For example:
 ```python
 if not condtion:
     # This means we should not process the rest of the command
-    await ctx.send("You are missing perms, an argument, etc")
-    return
+    return await ctx.send("You are missing perms, an argument, etc")
 
 # We should process the rest of the command here
 ```
diff --git a/MANIFEST.in b/MANIFEST.in
new file mode 100644
index 0000000..84c71df
--- /dev/null
+++ b/MANIFEST.in
@@ -0,0 +1,1 @@
+include readme.md
\ No newline at end of file
diff --git a/antispam/__init__.py b/antispam/__init__.py
index 8dafaef..92ebe1c 100644
--- a/antispam/__init__.py
+++ b/antispam/__init__.py
@@ -20,15 +20,17 @@
 FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
 DEALINGS IN THE SOFTWARE.
 """
-__version__ = "0.8.0"
+__version__ = "1.0.0a"
+
+from .anti_spam_handler import AntiSpamHandler
+from .exceptions import *
+from .dataclasses import Options, CorePayload
+from .plugin_cache import PluginCache
 
-from antispam.anti_spam_handler import AntiSpamHandler
-from antispam.exceptions import *
-from antispam.base_extension import BaseExtension
 
 import logging
 from collections import namedtuple
 
 logging.getLogger(__name__).addHandler(logging.NullHandler())
 VersionInfo = namedtuple("VersionInfo", "major minor micro releaselevel serial")
-version_info = VersionInfo(major=0, minor=8, micro=0, releaselevel="beta", serial=0)
+version_info = VersionInfo(major=1, minor=0, micro=0, releaselevel="beta", serial=0)
diff --git a/antispam/abc.py b/antispam/abc.py
new file mode 100644
index 0000000..fac416c
--- /dev/null
+++ b/antispam/abc.py
@@ -0,0 +1,524 @@
+from typing import Protocol, runtime_checkable, Union, Optional, AsyncIterable
+
+from .dataclasses import Guild, Member, Message
+from .dataclasses.propagate_data import PropagateData
+from .enums import ResetType
+
+
+@runtime_checkable
+class Cache(Protocol):
+    """A generic Protocol for any Cache to implement"""
+
+    def __init__(self, handler) -> None:
+        """Stores the handler for later option usage"""
+        self.handler = handler
+
+    async def initialize(self, *args, **kwargs) -> None:
+        """
+        This method gets called once when the AntiSpamHandler
+        init() method gets called to allow for setting up
+        connections, etc
+
+        Notes
+        -----
+        This is not required.
+        """
+        pass
+
+    async def get_guild(self, guild_id: int) -> Guild:
+        """Fetch a Guild dataclass populated with members
+
+        Parameters
+        ----------
+        guild_id : int
+            The id of the Guild to retrieve from cache
+
+        Raises
+        ------
+        GuildNotFound
+            A Guild could not be found in the cache
+            with the given id
+        """
+        raise NotImplementedError
+
+    async def set_guild(self, guild: Guild) -> None:
+        """
+        Stores a Guild in the cache
+
+        This is essentially a UPSERT operation
+
+        Parameters
+        ----------
+        guild : Guild
+            The Guild that needs to be stored
+        """
+        raise NotImplementedError
+
+    async def delete_guild(self, guild_id: int) -> None:
+        """
+        Removes a guild from the cache.
+
+        Parameters
+        ----------
+        guild_id : int
+            The id of the guild we wish to remove
+
+        Notes
+        -----
+        This fails silently.
+        """
+        raise NotImplementedError
+
+    async def get_member(self, member_id: int, guild_id: int) -> Member:
+        """Fetch a Member dataclass populated with messages
+
+        Parameters
+        ----------
+        member_id : int
+            The id of the member to fetch from cache
+        guild_id : int
+            The id of the guild this member is associated with
+
+        Raises
+        ------
+        MemberNotFound
+            This Member could not be found on the associated
+            Guild within the internal cache
+        GuildNotFound
+            The relevant guild could not be found
+        """
+        raise NotImplementedError
+
+    async def set_member(self, member: Member) -> None:
+        """
+        Stores a Member internally and attaches them
+        to a Guild, creating the Guild silently if required
+
+        Essentially an UPSERT operation
+
+        Parameters
+        ----------
+        member : Member
+            The Member we want to cache
+        """
+        raise NotImplementedError
+
+    async def delete_member(self, member_id: int, guild_id: int) -> None:
+        """
+        Removes a member from the cache.
+
+        Parameters
+        ----------
+        member_id : int
+            The id of the member we wish to remove
+        guild_id : int
+            The guild this member is in
+
+        Notes
+        -----
+        This fails silently.
+        """
+        raise NotImplementedError
+
+    async def add_message(self, message: Message) -> None:
+        """
+        Adds a Message to the relevant Member,
+        creating the Guild/Member if they don't exist
+
+        Parameters
+        ----------
+        message : Message
+            The Message to add to the internal cache
+
+        Notes
+        -----
+        This should silently create any Guild's/Member's
+        required to fulfil this transaction
+        """
+        raise NotImplementedError
+
+    async def reset_member_count(
+        self, member_id: int, guild_id: int, reset_type: ResetType
+    ) -> None:
+        """
+        Reset the chosen enum type back to the default value
+
+        Parameters
+        ----------
+        member_id : int
+            The Member to reset
+        guild_id : int
+            The guild this member is in
+        reset_type : ResetType
+            An enum denoting the type of reset
+
+        """
+        raise NotImplementedError
+
+    async def get_all_guilds(self) -> AsyncIterable[Guild]:
+        """
+        Returns a generator containing all cached guilds
+
+        Yields
+        ------
+        Guild
+            A generator of all stored guilds
+        """
+
+    async def get_all_members(self, guild_id: int) -> AsyncIterable[Member]:
+        """
+        Fetches all members within a guild and returns
+        them within a generator
+
+        Parameters
+        ----------
+        guild_id : int
+            The guild we want members in
+
+        Yields
+        ------
+        Member
+            All members in the given guild
+
+        Raises
+        ------
+        GuildNotFound
+            The given guild was not found
+        """
+
+    async def drop(self) -> None:
+        """
+        Drops the entire cache,
+        deleting everything contained within.
+        """
+        raise NotImplementedError
+
+
+@runtime_checkable
+class Lib(Protocol):
+    """
+    A protocol to extend and implement for any libs that wish
+    to hook into this package and work natively.
+
+    Notes
+    -----
+    Not public api. For internal usage only.
+    """
+
+    async def check_message_can_be_propagated(self, message) -> PropagateData:
+        """
+        Given a message from the relevant package,
+        run all checks to check if this message should be
+        propagated.
+
+        Parameters
+        ----------
+        message : Union[discord.Message, hikari.messages.Message]
+            The message to check
+
+        Returns
+        -------
+        PropagateData
+            The data required within propagate
+
+        Raises
+        ------
+        PropagateFailure
+            This raises an error with the `.data` attribute set.
+            `.data` is what get returned from within propagate
+        """
+        raise NotImplementedError
+
+    def substitute_args(
+        self, message: str, original_message, warn_count: int, kick_count: int
+    ) -> str:
+        """
+        Given a message, substitute in relevant arguments
+        and return a valid string
+
+        Parameters
+        ----------
+        message : str
+            The message to substitute args into
+        original_message : Union[discord.Message, hikari.messages.Message]
+            The message to extract data from
+        warn_count : int
+            How many warns this person has
+        kick_count : int
+            How many kicks this person has
+
+
+        Returns
+        -------
+        str
+            The message with substituted args
+        """
+        raise NotImplementedError
+
+    def embed_to_string(self, embed) -> str:
+        """
+        Given an embed, return a string representation
+
+        Parameters
+        ----------
+        embed : Union[discord.Embed, hikari.embeds.Embed]
+            The embed to cast to string
+
+        Returns
+        -------
+        str
+            The embed as a string
+        """
+        raise NotImplementedError
+
+    def dict_to_embed(self, data: dict, message, warn_count: int, kick_count: int):
+        """
+
+        Parameters
+        ----------
+        data : dict
+            The data to build an embed from
+        message : Union[discord.Message, hikari.messages.Message]
+            The message to extract data from
+        warn_count : int
+            How many warns this person has
+        kick_count : int
+            How many kicks this person has
+
+        Returns
+        -------
+        Union[discord.Embed, hikari.embeds.Embed]
+        """
+        raise NotImplementedError
+
+    def transform_message(
+        self, item: Union[str, dict], message, warn_count: int, kick_count: int
+    ):
+        """
+
+        Parameters
+        ----------
+        item : Union[str, dict]
+            The data to substitute
+        message : Union[discord.Message, hikari.messages.Message]
+            The message to extract data from
+        warn_count : int
+            How many warns this person has
+        kick_count : int
+            How many kicks this person has
+
+        Returns
+        -------
+        Union[str, discord.Embed, hikari.embeds.Embed]
+        A template complete message ready for sending
+
+        """
+        raise NotImplementedError
+
+    def visualizer(
+        self,
+        content: str,
+        message,
+        warn_count: int = 1,
+        kick_count: int = 2,
+    ):
+        """
+        Returns a message transformed as if the handler did it
+
+        Parameters
+        ----------
+        content : Union[str, discord.Embed, hikari.embeds.Embed]
+            What to transform
+        message : Union[discord.Message, hikari.messages.Message]
+            Where to extract our values from
+        warn_count : int
+            The warns to visualize with
+        kick_count : int
+            The kicks to visualize with
+
+        Returns
+        -------
+        Union[str, discord.Embed]
+            The transformed content
+        """
+        raise NotImplementedError
+
+    def create_message(self, message) -> Message:
+        """
+        Given a message to extract data from, create
+        and return a Message class
+
+        Parameters
+        ----------
+        message : Union[discord.Message, hikari.messages.Message]
+            The message to extract data from
+
+        Returns
+        -------
+        Message
+            The flushed out message
+        """
+        raise NotImplementedError
+
+    async def send_guild_log(
+        self,
+        guild,
+        message,
+        delete_after_time: Optional[int],
+        original_channel,
+        file=None,
+    ) -> None:
+        """
+        Sends a message to the guilds log channel
+
+        Notes
+        -----
+        If no log channel, send in ctx.channel
+
+        Parameters
+        ----------
+        guild : Guild
+            The guild we wish to send this too
+        message : Union[str, discord.Embed, hikari.embeds.Embed]
+            What to send to the guilds log channel
+        delete_after_time : Optional[int]
+            How long to delete these messages after
+        original_channel : Union[discord.abc.GuildChannel, discord.abc.PrivateChannel, hikari.GuildTextChannel]
+            Where to send the message assuming this guild has no guild log
+            channel already set.
+        file
+            A file to send
+
+        Notes
+        -----
+        This should catch any sending errors, log them
+        and then proceed to return None
+        """
+        raise NotImplementedError
+
+    async def punish_member(
+        self,
+        original_message,
+        member: Member,
+        internal_guild: Guild,
+        user_message,
+        guild_message,
+        is_kick: bool,
+        user_delete_after: int = None,
+        channel_delete_after: int = None,
+    ):
+        """
+        A generic method to handle multiple methods of punishment for a user.
+        Supports: kicking, banning
+
+        Parameters
+        ----------
+        member : Member
+            A reference to the member we wish to punish
+        internal_guild : Guild
+            A reference to the guild this member is in
+        original_message : Union[discord.Message, hikari.messages.Message]
+            Where we get everything from :)
+        user_message : Union[str, discord.Embed, hikari.embeds.Embed]
+            A message to send to the user who is being punished
+        guild_message : Union[str, discord.Embed, hikari.embeds.Embed]
+            A message to send in the guild for whoever is being punished
+        is_kick : bool
+            Is it a kick? Else ban
+        user_delete_after : int, Optional
+            An int value denoting the time to
+            delete user sent messages after
+        channel_delete_after : int, Optional
+            An int value denoting the time to
+            delete channel sent messages after
+
+        Raises
+        ======
+        MissingGuildPermissions
+            I lack perms to carry out this punishment
+        """
+        raise NotImplementedError
+
+    async def delete_member_messages(self, member: Member) -> None:
+        """
+        Given a member, traverse all duplicate messages
+        and delete them.
+
+        Parameters
+        ----------
+        member : Member
+            The member whose messages should be deleted
+
+        Notes
+        -----
+        Just call delete_message on each message
+        """
+        raise NotImplementedError
+
+    async def delete_message(self, message) -> None:
+        """
+        Given a message, call and handle the relevant
+        deletion contexts.
+
+        Parameters
+        ----------
+        message : Union[discord.Message, hikari.messages.Message]
+            The message to delete
+
+        Notes
+        -----
+        This should handle given errors silently.
+        """
+        raise NotImplementedError
+
+    async def send_message_to_(
+        self, target, message, mention: str, delete_after_time: Optional[int] = None
+    ) -> None:
+        """
+        Given a message and target, send
+        Parameters
+        ----------
+        target : Union[discord.abc.Messageable, hikari TODO doc this]
+            Where to send the message
+        message : Union[str, discord.Embed, hikari.embeds.Embed]
+            The message to send
+        mention : str
+            A string denoting a raw mention of the punished user
+        delete_after_time : Optional[int]
+            When to delete the message after
+
+        Notes
+        -----
+        This should implement Options.mention_on_embed
+        """
+        raise NotImplementedError
+
+    def get_guild_id(self, message) -> int:
+        """
+        Returns the guild id of this message
+        """
+        raise NotImplementedError
+
+    def get_channel_id(self, message) -> int:
+        """
+        Returns the channel id of this message
+        """
+        raise NotImplementedError
+
+    def get_message_mentions(self, message):
+        """
+        Returns all the mentions from a message
+        """
+        raise NotImplementedError
+
+    async def get_channel_from_message(self, message):
+        """Returns the channel for a message"""
+        raise NotImplementedError
+
+    async def get_channel_by_id(self, channel_id: int):
+        """Returns the given channel for the id"""
+        raise NotImplementedError
+
+    def get_file(self, path: str):
+        """Returns a discord file object for the given path"""
+        return NotImplementedError
diff --git a/antispam/anti_spam_handler.py b/antispam/anti_spam_handler.py
index c61c830..e5007aa 100644
--- a/antispam/anti_spam_handler.py
+++ b/antispam/anti_spam_handler.py
@@ -22,26 +22,29 @@
 DEALINGS IN THE SOFTWARE.
 LICENSE
 """
-import inspect
+import functools
 import logging
 from copy import deepcopy
 from typing import Optional, Union
-from unittest.mock import AsyncMock
 
-import discord
-from discord.ext import commands
+from attr import asdict
 
-from antispam.guild import Guild
-from antispam.exceptions import (
-    DuplicateObject,
-    BaseASHException,
+
+from .abc import Cache
+from .core import Core
+from .dataclasses import Guild, Options, CorePayload
+from .caches import MemoryCache
+from .enums import IgnoreType, ResetType
+from .exceptions import (
     MissingGuildPermissions,
-    LogicError,
-    ExtensionError,
+    PluginError,
+    GuildNotFound,
+    PropagateFailure,
+    InvocationCancelled,
 )
-from antispam.base_extension import BaseExtension
-from antispam.user import User
-from antispam.static import Static
+from .factory import FactoryBuilder
+from .base_plugin import BasePlugin
+from .util import get_aware_time
 
 log = logging.getLogger(__name__)
 
@@ -51,6 +54,22 @@
 """
 
 
+def ensure_init(func):
+    """Ensures all cache related operations
+    are done on an initialized cache.
+    """
+
+    @functools.wraps(func)
+    async def wrapper(*args, **kwargs):
+        handler = args[0]
+        if handler.needs_init:
+            await handler.init()
+
+        return await func(*args, **kwargs)
+
+    return wrapper
+
+
 class AntiSpamHandler:
     """
     The overall handler for the DPY Anti-spam package
@@ -77,16 +96,16 @@ class AntiSpamHandler:
         guild_ban_message: "$USERNAME was banned for spamming/sending duplicate messages."
             The message to be sent in the guild upon ban_threshold being reached
 
-        user_kick_message : "Hey $MENTIONUSER, you are being kicked from $GUILDNAME for spamming/sending duplicate messages."
+        member_kick_message : "Hey $MENTIONUSER, you are being kicked from $GUILDNAME for spamming/sending duplicate messages."
             The message to be sent to the user who is being warned
 
-        user_ban_message : "Hey $MENTIONUSER, you are being banned from $GUILDNAME for spamming/sending duplicate messages."
+        member_ban_message : "Hey $MENTIONUSER, you are being banned from $GUILDNAME for spamming/sending duplicate messages."
             The message to be sent to the user who is being banned
 
-        user_failed_kick_message : "I failed to punish you because I lack permissions, but still you shouldn't spam"
+        member_failed_kick_message : "I failed to punish you because I lack permissions, but still you shouldn't spam"
             The message to be sent to the user if the bot fails to kick them
 
-        user_failed_ban_message : "I failed to punish you because I lack permissions, but still you shouldn't spam"
+        member_failed_ban_message : "I failed to punish you because I lack permissions, but still you shouldn't spam"
             The message to be sent to the user if the bot fails to ban them
 
         message_duplicate_count: 5
@@ -95,26 +114,28 @@ class AntiSpamHandler:
         message_duplicate_accuracy: 90
             How 'close' messages need to be to be registered as duplicates (Out of 100)
 
-        delete_spam: True
+        delete_spam: False
             Whether or not to delete messages marked as spam
 
             *Won't delete messages if* ``no_punish`` *is* ``True``
 
-        ignore_perms: [8]
-            The perms (ID Form), that bypass anti-spam
+            Note, this method is expensive. It will all messages marked
+            as spam, and this means an api call per message.
 
-            **Not Implemented**
+        mention_on_embed: True
+            If the message your trying to send is an embed,
+            also send some content to mention the person being punished.
 
-        ignore_users: []
+        ignored_members: []
             The users (ID Form), that bypass anti-spam
 
-        ignore_channels: []
+        ignored_channels: []
             Channels (ID Form), that bypass anti-spam
 
-        ignore_roles: []
+        ignored_roles: []
             The roles (ID Form), that bypass anti-spam
 
-        ignore_guilds: []
+        ignored_guilds: []
             Guilds (ID Form), that bypass anti-spam
 
         ignore_bots: True
@@ -144,13 +165,13 @@ class AntiSpamHandler:
             The time to delete the ``guild_warn_message`` message
 
         user_kick_message_delete_after: None
-            The time to delete the ``user_kick_message`` message
+            The time to delete the ``member_kick_message`` message
 
         guild_kick_message_delete_after: None
             The time to delete the ``guild_kick_message`` message
 
         user_ban_message_delete_after: None
-            The time to delete the ``user_ban_message`` message
+            The time to delete the ``member_ban_message`` message
 
         guild_ban_message_delete_after: None
             The time to delete the ``guild_ban_message`` message
@@ -158,116 +179,49 @@ class AntiSpamHandler:
         delete_zero_width_chars: True
             Should zero width characters be removed from messages
 
+        is_using_hikari: False
+            Set this to True if you are using the package with hikari
+            rather then discord.py
+
 
     """
 
-    # TODO Add options for group spamming, rather then just per member.
-    #      This could possibly be implemented at a Guild() level
     def __init__(
         self,
-        bot: Union[
-            commands.Bot,
-            commands.AutoShardedBot,
-            discord.Client,
-            discord.AutoShardedClient,
-        ],
-        **kwargs,
+        bot,
+        *,
+        is_using_hikari: bool = False,
+        options: Options = None,
+        cache: Cache = None,
     ):
         """
-        This is the first initialization of the entire spam handler,
-        this is also where the initial options are set
+        AntiSpamHandler entry point.
 
         Parameters
         ----------
-        bot : commands.Bot or commands.AutoShardedBot or discord.Client or discord.AutoShardedClient
-            The commands.Bot or commands.AutoSharedBot or discord.Client or discord.AutoShardedClient instance
-        warn_threshold : int, optional
-            This is the amount of messages in a row that result in a warning within the message_interval
-        kick_threshold : int, optional
-            The amount of 'warns' before a kick occurs
-        ban_threshold : int, optional
-            The amount of 'kicks' that occur before a ban occurs
-        message_interval : int, optional
-            Amount of time a message is kept before being discarded.
-            Essentially the amount of time (In milliseconds) a message can count towards spam
-        guild_warn_message : Union[str, dict], optional
-            The message to be sent in the guild upon warn_threshold being reached
-        guild_kick_message : Union[str, dict], optional
-            The message to be sent in the guild upon kick_threshold being reached
-        guild_ban_message : Union[str, dict], optional
-            The message to be sent in the guild upon ban_threshold being reached
-        user_kick_message : Union[str, dict], optional
-            The message to be sent to the user who is being warned
-        user_ban_message : Union[str, dict], optional
-            The message to be sent to the user who is being banned
-        user_failed_kick_message : Union[str, dict], optional
-            The message to be sent to the user if the bot fails to kick them
-        user_failed_ban_message : Union[str, dict], optional
-            The message to be sent to the user if the bot fails to ban them
-        message_duplicate_count : int, optional
-            Amount of duplicate messages needed to trip a punishment
-        message_duplicate_accuracy : float, optional
-            How 'close' messages need to be to be registered as duplicates (Out of 100)
-        delete_spam : bool, optional
-            Whether or not to delete any messages marked as spam
-        ignore_perms : list, optional
-            The perms (ID Form), that bypass anti-spam
-        ignore_channels : list, optional
-            The channels (ID Form) that are ignored
-        ignore_roles : list, optional
-            The roles (ID, Name) that are ignored
-        ignore_guilds : list, optional
-            The guilds (ID) that are ignored
-        ignore_users : list, optional
-            The users (ID Form), that bypass anti-spam
-        ignore_bots : bool, optional
-            Should bots bypass anti-spam?
-        warn_only : bool, optional
-            Only warn users?
-        no_punish : bool, optional
-            Dont punish users?
-            Return if they should be punished or
-            not without actually punishing them
-        per_channel_spam : bool, optional
-            Track spam as per channel,
-            rather then per guild
-        guild_warn_message_delete_after : int, optional
-            The time to delete the ``guild_warn_message`` message
-        user_kick_message_delete_after : int, optional
-            The time to delete the ``user_kick_message`` message
-        guild_kick_message_delete_after : int, optional
-            The time to delete the ``guild_kick_message`` message
-        user_ban_message_delete_after : int, optional
-            The time to delete the ``user_ban_message`` message
-        guild_ban_message_delete_after : int, optional
-            The time to delete the ``guild_ban_message`` message
-        delete_zero_width_chars : bool
-            Should zero width characters be removed from messages
+        bot
+            A reference to your discord bot object.
+        is_using_hikari : bool, Optional
+            Set this to True if you are using this package
+            within hikari rather then discord.py
+        options : Options, Optional
+            An instance of your custom Options
+            the handler should use
+        cache : Cache, Optional
+            Your choice of backend caching
         """
-        # Just gotta casually ignore_type check everything.
-        if (
-            not isinstance(
-                bot,
-                (
-                    commands.Bot,
-                    commands.AutoShardedBot,
-                    discord.Client,
-                    discord.AutoShardedClient,
-                ),
-            )
-            and not isinstance(bot, AsyncMock)
-        ):
-            raise ValueError(
-                "Expected bot of type commands.Bot, commands.AutoShardedBot, "
-                "discord.Client or discord.AutoShardedClient"
-            )
+        # TODO Implement an async cache initialization somehow
+
+        options = options or Options()
+        if not isinstance(options, Options):
+            raise ValueError("Expected `option`s of type `Option`")
 
-        self.options = self._ensure_options(**kwargs)
+        self.options = options
 
-        if self.options.get("no_punish") and (
-            self.options.get("delete_spam"),
-            self.options.get("warn_only"),
-            self.options.get("per_channel_spam"),
+        if self.options.no_punish and (
+            self.options.delete_spam
+            or self.options.warn_only
+            or self.options.per_channel_spam
         ):
             log.warning(
                 "You are attempting to create an AntiSpamHandler with options that are mutually exclusive. "
@@ -275,15 +229,53 @@ def __init__(
                 "mutually exclusive."
             )
 
+        cache = cache or MemoryCache(self)
+        if not issubclass(type(cache), Cache):
+            raise ValueError("Expected `cache` that inherits from the `Cache` Protocol")
+
         self.bot = bot
-        self._guilds = []
+        self.cache = cache
+        self.core = Core(self)
+
+        self.needs_init = True
 
         self.pre_invoke_extensions = {}
         self.after_invoke_extensions = {}
 
-        log.info("Package initialized successfully")
+        # Import these here to avoid errors when not having the
+        # other lib installed, I think
+        if is_using_hikari:
+            from antispam.libs.hikari import Hikari
+
+            self.lib_handler = Hikari(self)
+
+        else:
+            from antispam.libs.dpy import DPY
+
+            self.lib_handler = DPY(self)
+
+        log.info("Package instance created")
+
+    async def init(self) -> None:
+        """
+        This method provides a means to initialize any
+        async calls cleanly and without asyncio madness.
+
+        Notes
+        -----
+        This method is guaranteed to be called before the
+        first time propagate runs. However, it will not
+        be run when the class is initialized.
+
+        """
+        await self.cache.initialize()
+
+        self.needs_init = False
 
-    async def propagate(self, message: discord.Message) -> Optional[dict]:
+        log.info("Init has been called, everything is now definitely setup.")
+
+    @ensure_init
+    async def propagate(self, message) -> Optional[Union[CorePayload, dict]]:
         """
         This method is the base level intake for messages, then
         propagating it out to the relevant guild or creating one
@@ -293,161 +285,137 @@ async def propagate(self, message: discord.Message) -> Optional[dict]:
 
         Parameters
         ==========
-        message : discord.Message
+        message : Union[discord.Message, hikari.messages.Message]
             The message that needs to be propagated out
 
         Returns
         =======
         dict
-            A dictionary of useful information about the user in question
+            A dictionary of useful information about the Member in question
         """
-        if not isinstance(message, discord.Message) and not isinstance(
-            message, AsyncMock
-        ):
-            log.debug("Invalid value given to propagate")
-            raise ValueError("Expected message of ignore_type: discord.Message")
-
-        # Ensure we only moderate actual guild messages
-        if not message.guild:
-            log.debug("Message was not in a guild")
-            return {"status": "Ignoring messages from dm's"}
-
-        # The bot is immune to spam
-        if message.author.id == self.bot.user.id:
-            log.debug("Message was from myself")
-            return {"status": "Ignoring messages from myself (the bot)"}
-
-        if isinstance(message.author, discord.User):
-            log.warning(f"Given message with an author of type User")
-
-        # Return if ignored bot
-        if self.options["ignore_bots"] and message.author.bot:
-            log.debug(f"I ignore bots, and this is a bot message: {message.author.id}")
-            return {"status": "Ignoring messages from bots"}
-
-        # Return if ignored member
-        if message.author.id in self.options["ignore_users"]:
-            log.debug(f"The user who sent this message is ignored: {message.author.id}")
-            return {"status": f"Ignoring this user: {message.author.id}"}
-
-        # Return if ignored channel
-        if (
-            message.channel.id in self.options["ignore_channels"]
-            or message.channel.name in self.options["ignore_channels"]
-        ):
-            log.debug(f"{message.channel} is ignored")
-            return {"status": f"Ignoring this channel: {message.channel.id}"}
-
-        # Return if member has an ignored role
         try:
-            user_roles = [role.id for role in message.author.roles]
-            user_roles.extend([role.name for role in message.author.roles])
-            for item in user_roles:
-                if item in self.options.get("ignore_roles"):
-                    log.debug(f"{item} is a part of ignored roles")
-                    return {"status": f"Ignoring this role: {item}"}
-        except AttributeError:
-            log.warning(
-                f"Could not compute ignore_roles for {message.author.name}({message.author.id})"
+            propagate_data = await self.lib_handler.check_message_can_be_propagated(
+                message=message
             )
-
-        # Return if ignored guild
-        if message.guild.id in self.options.get("ignore_guilds"):
-            log.debug(f"{message.guild.id} is an ignored guild")
-            return {"status": f"Ignoring this guild: {message.guild.id}"}
-
-        log.debug(
-            f"Propagating message for: {message.author.name}({message.author.id})"
+        except PropagateFailure as e:
+            return e.data
+
+        log.info(
+            "Propagating message for %s(%s) in guild(%s)",
+            propagate_data.member_name,
+            propagate_data.member_id,
+            propagate_data.guild_id,
         )
 
-        guild = Guild(self.bot, message.guild.id, self.options)
         try:
-            guild = next(iter(g for g in self.guilds if g == guild))
-        except StopIteration:
+            guild = await self.cache.get_guild(guild_id=propagate_data.guild_id)
+        except GuildNotFound:
             # Check we have perms to actually create this guild object
             # and punish based upon our guild wide permissions
-            perms = message.guild.me.guild_permissions
-            if not perms.kick_members or not perms.ban_members:
+            if not propagate_data.has_perms_to_make_guild:
                 raise MissingGuildPermissions
 
-            self.guilds = guild
-            log.info(f"Created Guild: {guild.id}")
+            guild = Guild(id=propagate_data.guild_id, options=self.options)
+            await self.cache.set_guild(guild)
+            log.info("Created Guild(id=%s)", guild.id)
 
-        data = {"pre_invoke_extensions": {}, "after_invoke_extensions": {}}
+        pre_invoke_extensions = {}
 
         for pre_invoke_ext in self.pre_invoke_extensions.values():
-            pre_invoke_return = await pre_invoke_ext.propagate(message)
-            data["pre_invoke_extensions"][
-                pre_invoke_ext.__class__.__name__
-            ] = pre_invoke_return
+            if guild.id in pre_invoke_ext.blacklisted_guilds:
+                # https://github.com/Skelmis/DPY-Anti-Spam/issues/65
+                continue
 
-        main_return = await guild.propagate(message)
+            pre_invoke_return = await pre_invoke_ext.propagate(message)
+            pre_invoke_extensions[pre_invoke_ext.__class__.__name__] = pre_invoke_return
+
+            try:
+                if pre_invoke_return.get("cancel_next_invocation"):
+                    stats = self.after_invoke_extensions.get("stats")
+                    if stats:
+                        if hasattr(stats, "injectable_nonce"):
+                            # Increment stats for invocation call stats
+                            try:
+                                stats.data["pre_invoke_calls"][
+                                    pre_invoke_ext.__class__.__name__
+                                ]["cancel_next_invocation_calls"] += 1
+                            except KeyError:
+                                if (
+                                    pre_invoke_ext.__class__.__name__
+                                    not in stats.data["pre_invoke_calls"]
+                                ):
+                                    stats.data["pre_invoke_calls"][
+                                        pre_invoke_ext.__class__.__name__
+                                    ] = {}
+
+                                stats.data["pre_invoke_calls"][
+                                    pre_invoke_ext.__class__.__name__
+                                ]["cancel_next_invocation_calls"] = 1
+
+                    raise InvocationCancelled
+            except InvocationCancelled as e:
+                # Propagate this on further
+                raise e from None
+            except:
+                pass
+
+        main_return = await self.core.propagate(message, guild=guild)
+        main_return.pre_invoke_extensions = pre_invoke_extensions
 
         for after_invoke_ext in self.after_invoke_extensions.values():
+            if guild.id in after_invoke_ext.blacklisted_guilds:
+                # https://github.com/Skelmis/DPY-Anti-Spam/issues/65
+                continue
+
             after_invoke_return = await after_invoke_ext.propagate(message, main_return)
-            data["after_invoke_extensions"][
+            main_return.after_invoke_extensions[
                 after_invoke_ext.__class__.__name__
             ] = after_invoke_return
 
-        return {**main_return, **data}
+        return main_return
 
-    def add_ignored_item(self, item: int, ignore_type: str) -> None:
+    def add_ignored_item(self, item: int, ignore_type: IgnoreType) -> None:
         """
-        TODO Document this better with ignore_type notations
         Add an item to the relevant ignore list
 
         Parameters
         ----------
         item : int
             The id of the thing to ignore
-        ignore_type : str
-            A string representation of the ignored
-            items overall container
+        ignore_type : IgnoreType
+            An enum representing the item to ignore
 
         Raises
         ======
-        BaseASHException
-            Invalid ignore ignore_type
         ValueError
-            item is not of ignore_type int or int convertible
+            item is not of type int or int convertible
 
         Notes
         =====
         This will silently ignore any attempts
         to add an item already added.
         """
-        try:
-            ignore_type = ignore_type.lower()
-        except Exception:
-            raise ValueError("Expected ignore_type of type: str")
+        if not isinstance(ignore_type, IgnoreType):
+            raise ValueError("Expected `ignore_type` to be of type IgnoreType")
 
         try:
-            if not isinstance(item, int):
-                item = int(item)
-        except ValueError:
+            item = int(item)
+        except (ValueError, TypeError):
             raise ValueError("Expected item of type: int")
 
-        if ignore_type == "member":
-            if item not in self.options["ignore_users"]:
-                self.options["ignore_users"].append(item)
-        elif ignore_type == "channel":
-            if item not in self.options["ignore_channels"]:
-                self.options["ignore_channels"].append(item)
-        elif ignore_type == "perm":
-            if item not in self.options["ignore_perms"]:
-                self.options["ignore_perms"].append(item)
-        elif ignore_type == "guild":
-            if item not in self.options["ignore_guilds"]:
-                self.options["ignore_guilds"].append(item)
-        elif ignore_type == "role":
-            if item not in self.options["ignore_roles"]:
-                self.options["ignore_roles"].append(item)
+        if ignore_type == IgnoreType.MEMBER:
+            self.options.ignored_members.add(item)
+        elif ignore_type == IgnoreType.CHANNEL:
+            self.options.ignored_channels.add(item)
+        elif ignore_type == IgnoreType.GUILD:
+            self.options.ignored_guilds.add(item)
+        # elif ignore_type == IgnoreType.ROLE:
         else:
-            raise BaseASHException("Invalid ignore ignore_type")
+            self.options.ignored_roles.add(item)
 
-        log.debug(f"Ignored {ignore_type}: {item}")
+        log.info("Added %s as an ignored item under bucket %s", item, ignore_type.name)
 
-    def remove_ignored_item(self, item: int, ignore_type: str) -> None:
+    def remove_ignored_item(self, item: int, ignore_type: IgnoreType) -> None:
         """
         Remove an item from the relevant ignore list
 
@@ -455,135 +423,55 @@ def remove_ignored_item(self, item: int, ignore_type: str) -> None:
         ----------
         item : int
             The id of the thing to un-ignore
-        ignore_type : str
-            A string representation of the ignored
-            items overall container
+        ignore_type : IgnoreType
+            An enum representing the item to ignore
 
         Raises
         ======
-        BaseASHException
-            Invalid ignore ignore_type
         ValueError
-            item is not of ignore_type int or int convertible
+            item is not of type int or int convertible
 
         Notes
         =====
         This will silently ignore any attempts
         to remove an item not ignored.
         """
-        try:
-            ignore_type = ignore_type.lower()
-        except Exception:
-            raise ValueError("Expected ignore_type of type: str")
+        if not isinstance(ignore_type, IgnoreType):
+            raise ValueError("Expected `ignore_type` to be of type IgnoreType")
 
         try:
-            # TODO Handle more then just ints, take relevant objs as well
-            if not isinstance(item, int):
-                item = int(item)
-        except ValueError:
+            item = int(item)
+        except (ValueError, TypeError):
             raise ValueError("Expected item of type: int")
 
-        if ignore_type == "member":
-            if item in self.options["ignore_users"]:
-                index = self.options["ignore_users"].index(item)
-                self.options["ignore_users"].pop(index)
-        elif ignore_type == "channel":
-            if item in self.options["ignore_channels"]:
-                index = self.options["ignore_channels"].index(item)
-                self.options["ignore_channels"].pop(index)
-        elif ignore_type == "perm":
-            if item in self.options["ignore_perms"]:
-                index = self.options["ignore_perms"].index(item)
-                self.options["ignore_perms"].pop(index)
-        elif ignore_type == "guild":
-            if item in self.options["ignore_guilds"]:
-                index = self.options["ignore_guilds"].index(item)
-                self.options["ignore_guilds"].pop(index)
-        elif ignore_type == "role":
-            if item in self.options["ignore_roles"]:
-                index = self.options["ignore_roles"].index(item)
-                self.options["ignore_roles"].pop(index)
+        if ignore_type == IgnoreType.MEMBER:
+            self.options.ignored_members.discard(item)
+        elif ignore_type == IgnoreType.CHANNEL:
+            self.options.ignored_channels.discard(item)
+        elif ignore_type == IgnoreType.GUILD:
+            self.options.ignored_guilds.discard(item)
+        # elif ignore_type == IgnoreType.ROLE:
         else:
-            raise BaseASHException("Invalid ignore ignore_type")
+            self.options.ignored_roles.discard(item)
 
-        log.debug(f"Un-Ignored {ignore_type}: {item}")
+        log.info(
+            "Removed %s as an ignored item under bucket %s", item, ignore_type.name
+        )
 
-    def add_custom_guild_options(self, guild_id: int, **kwargs):
+    @ensure_init
+    async def add_guild_options(self, guild_id: int, options: Options) -> None:
         """
         Set a guild's options to a custom set, rather then the base level
         set used and defined in ASH initialization
 
-        Other Parameters
-        ----------------
-        guild_id : int
-            The id of the guild to create
-        warn_threshold : int, optional
-            This is the amount of messages in a row that result in a warning within the message_interval
-        kick_threshold : int, optional
-            The amount of 'warns' before a kick occurs
-        ban_threshold : int, optional
-            The amount of 'kicks' that occur before a ban occurs
-        message_interval : int, optional
-            Amount of time a message is kept before being discarded.
-            Essentially the amount of time (In milliseconds) a message can count towards spam
-        guild_warn_message : Union[str, dict], optional
-            The message to be sent in the guild upon warn_threshold being reached
-        guild_kick_message : Union[str, dict], optional
-            The message to be sent in the guild upon kick_threshold being reached
-        guild_ban_message : Union[str, dict], optional
-            The message to be sent in the guild upon ban_threshold being reached
-        user_kick_message : Union[str, dict], optional
-            The message to be sent to the user who is being warned
-        user_ban_message : Union[str, dict], optional
-            The message to be sent to the user who is being banned
-        user_failed_kick_message : Union[str, dict], optional
-            The message to be sent to the user if the bot fails to kick them
-        user_failed_ban_message : Union[str, dict], optional
-            The message to be sent to the user if the bot fails to ban them
-        message_duplicate_count : int, optional
-            Amount of duplicate messages needed to trip a punishment
-        message_duplicate_accuracy : float, optional
-            How 'close' messages need to be to be registered as duplicates (Out of 100)
-        delete_spam : bool, optional
-            Whether or not to delete any messages marked as spam
-        ignore_perms : list, optional
-            The perms (ID Form), that bypass anti-spam
-        ignore_channels : list, optional
-            The channels (ID Form) that are ignored
-        ignore_roles : list, optional
-            The roles (ID, Name) that are ignored
-        ignore_guilds : list, optional
-            The guilds (ID) that are ignored
-        ignore_users : list, optional
-            The users (ID Form), that bypass anti-spam
-        ignore_bots : bool, optional
-            Should bots bypass anti-spam?
-        warn_only : bool, optional
-            Only warn users?
-        no_punish : bool, optional
-            Dont punish users?
-            Return if they should be punished or
-            not without actually punishing them
-        per_channel_spam : bool, optional
-            Track spam as per channel,
-            rather then per guild
-        guild_warn_message_delete_after : int, optional
-            The time to delete the ``guild_warn_message`` message
-        user_kick_message_delete_after : int, optional
-            The time to delete the ``user_kick_message`` message
-        guild_kick_message_delete_after : int, optional
-            The time to delete the ``guild_kick_message`` message
-        user_ban_message_delete_after : int, optional
-            The time to delete the ``user_ban_message`` message
-        guild_ban_message_delete_after : int, optional
-            The time to delete the ``guild_ban_message`` message
-        delete_zero_width_chars : bool
-            Should zero width characters be removed from messages
-
         Warnings
         --------
-        If using ``AntiSpamTracker``, please call this
-        method on that class instance. Not this one.
+        If using/modifying ``AntiSpamHandler.options`` to give to
+        this method you will **also** be modifying the overall options.
+
+        To get an options item you can modify freely call ``AntiSpamHandler.get_options()``,
+        this method will give you an instance of the current options you are
+        free to modify however you like.
 
         Notes
         =====
@@ -591,31 +479,29 @@ def add_custom_guild_options(self, guild_id: int, **kwargs):
         to continue using existing settings and merely change some
         I suggest using the get_options method first and then giving
         those values back to this method with the changed arguments
-
-        This is also a somewhat expensive operation at ``O(n)``
-        where ``n`` is the total number of users cached for the guild
         """
-        options = self._ensure_options(**kwargs)
+        if not isinstance(options, Options):
+            raise ValueError("Expected options of type Options")
 
-        guild = Guild(self.bot, guild_id, options, custom_options=True)
         try:
-            guild = next(iter(g for g in self.guilds if g == guild))
-        except StopIteration:
+            guild = await self.cache.get_guild(guild_id=guild_id)
+        except GuildNotFound:
             log.warning(
-                f"I cannot ensure I have permissions to kick/ban ban people in guild: {guild_id}"
+                "I cannot ensure I have permissions to kick/ban ban people in Guild(id=%s)",
+                guild_id,
             )
-
-            self.guilds = guild
-            log.info(f"Created Guild: {guild.id}")
+            guild = Guild(id=guild_id, options=options)
+            await self.cache.set_guild(guild)
+            log.info("Created Guild(id=%s)", guild.id)
         else:
             guild.options = options
-            guild.has_custom_options = True
 
-        log.info(f"Set custom options for guild: {guild_id}")
+        log.info("Set custom options for guild(%s)", guild_id)
 
-    def get_guild_options(self, guild_id: int) -> tuple:
+    @ensure_init
+    async def get_guild_options(self, guild_id: int) -> Options:
         """
-        Get the options dictionary for a given guild,
+        Get the options dataclass for a given guild,
         if the guild doesnt exist raise an exception
 
         Parameters
@@ -625,43 +511,25 @@ def get_guild_options(self, guild_id: int) -> tuple:
 
         Returns
         -------
-        tuple
-            The options for this guild as tuple[0] and tuple[1] is a bool
-            which is used to say if the guild has custom options or not
-
-            Be wary of the return value. It is in the format,
-            (dict, boolean),
-            where dict is the options and boolean is whether
-            or not these options are custom
+        Options
+            The options for this guild
 
         Raises
         ------
-        BaseASHException
+        GuildNotFound
             This guild does not exist
 
         Notes
         -----
-        The value for tuple[1] is not checked/ensured at runtime.
-        Be wary of this if you access guild and manually change
-        options rather then using this libraries methods.
-
-        Another thing to note is this returns a deepcopy of the
-        options dictionary. This is to encourage usage of this
-        libraries methods for changing options, rather then
-        playing around with them yourself and potentially
-        doing damage.
+        This returns a copy of the options, if you wish to change
+        the options on the guild you should use the package methods.
 
         """
-        guild = Guild(self.bot, guild_id, self.options)
-        try:
-            guild = next(iter(g for g in self.guilds if g == guild))
-        except StopIteration:
-            raise BaseASHException("This guild does not exist")
-        else:
-            log.debug(f"Returned guild options for {guild_id}")
-            return deepcopy(guild.options), guild.has_custom_options
+        guild = await self.cache.get_guild(guild_id=guild_id)
+        return deepcopy(guild.options)
 
-    def remove_custom_guild_options(self, guild_id: int) -> None:
+    @ensure_init
+    async def remove_guild_options(self, guild_id: int) -> None:
         """
         Reset a guilds options to the ASH options
 
@@ -670,53 +538,38 @@ def remove_custom_guild_options(self, guild_id: int) -> None:
         guild_id : int
             The guild to reset
 
-        Warnings
-        --------
-        If using ``AntiSpamTracker``, please call this
-        method on that class instance. Not this one.
-
         Notes
         -----
         This method will silently ignore guilds that
         do not exist, as it is considered to have
         'removed' custom options due to how Guild's
         are created
-
-        This is also a somewhat expensive operation at ``O(n)``
-        where ``n`` is the total number of users cached for the guild
         """
-        guild = Guild(self.bot, guild_id, self.options)
         try:
-            guild = next(iter(g for g in self.guilds if g == guild))
-        except StopIteration:
+            guild = await self.cache.get_guild(guild_id=guild_id)
+        except GuildNotFound:
             pass
         else:
             guild.options = self.options
-            guild.has_custom_options = False
+            log.debug("Reset options for Guild(id=%s)", guild_id)
 
-            log.debug(f"Reset guild options for {guild_id}")
-
-    def reset_user_count(self, user_id: int, guild_id: int, counter: str) -> None:
+    @ensure_init
+    async def reset_member_count(
+        self, member_id: int, guild_id: int, reset_type: ResetType
+    ) -> None:
         """
         Reset an internal counter attached
         to a User object
 
         Parameters
         ----------
-        user_id : int
+        member_id : int
             The user to reset
         guild_id : int
             The guild they are attached to
-        counter : str
-            A str denoting which count
-            to reset, Options are:\n
-            ``warn_counter`` -> Reset the warn count\n
-            ``kick_counter`` -> Reset the kick count
-
-        Raises
-        ======
-        LogicError
-            Invalid count to reset
+        reset_type : ResetType
+            An enum representing the
+            counter to reset
 
         Notes
         =====
@@ -727,31 +580,65 @@ def reset_user_count(self, user_id: int, guild_id: int, counter: str) -> None:
         the reset value.
 
         """
-        guild = Guild(self.bot, guild_id, self.options)
+        if not isinstance(reset_type, ResetType):
+            raise ValueError("Expected reset_type of type ResetType")
+
+        await self.cache.reset_member_count(member_id, guild_id, reset_type)
+
+    @ensure_init
+    async def add_guild_log_channel(self, log_channel: int, guild_id: int) -> None:
+        """
+        Registers a log channel on a guild internally
+
+        Parameters
+        ----------
+        log_channel : int
+            The channel id you wish to use for logging
+        guild_id : int
+            The id of the guild to store this on
+
+        Notes
+        -----
+        Not setting a log channel means it will
+        not send any punishment messages
+        """
+        if not isinstance(log_channel, int):
+            raise ValueError("Expected log_channel with correct type")
+
         try:
-            guild = next(iter(g for g in self.guilds if g == guild))
-        except StopIteration:
-            return
+            guild = await self.cache.get_guild(guild_id=guild_id)
+            guild.log_channel_id = log_channel
+        except GuildNotFound:
+            guild = Guild(
+                id=guild_id,
+                options=self.options,
+                log_channel_id=log_channel,
+            )
+            await self.cache.set_guild(guild)
+
+    @ensure_init
+    async def remove_guild_log_channel(self, guild_id: int) -> None:
+        """
+        Removes a registered guild log channel
 
-        user = User(self.bot, user_id, guild_id=guild_id, options=guild.options)
+        Parameters
+        ----------
+        guild_id : int
+            The guild to remove it from
+
+        Notes
+        -----
+        Silently ignores guilds which don't exist
+        """
         try:
-            user = next(iter(u for u in guild.users if u == user))
-        except StopIteration:
-            return
-
-        if counter.lower() == Static.WARNCOUNTER:
-            user.warn_count = 0
-            log.debug(f"Reset the warn count for user: {user_id}")
-        elif counter.lower() == Static.KICKCOUNTER:
-            user.kick_count = 0
-            log.debug(f"Reset the kick count for user: {user_id}")
-        else:
-            raise LogicError("Invalid counter argument, please select a valid counter.")
+            guild = await self.cache.get_guild(guild_id)
+            guild.log_channel_id = None
+            await self.cache.set_guild(guild)
+        except GuildNotFound:
+            pass
 
     @staticmethod
-    def load_from_dict(
-        bot, data: dict, *, raise_on_exception: bool = True
-    ):  # TODO typehint this correct
+    async def load_from_dict(bot, data: dict, *, raise_on_exception: bool = True):
         """
         Can be used as an entry point when starting your bot
         to reload a previous state so you don't lose all of
@@ -759,7 +646,7 @@ def load_from_dict(
 
         Parameters
         ----------
-        bot : commands.Bot
+        bot
             The bot instance
         data : dict
             The data to load AntiSpamHandler from
@@ -792,20 +679,20 @@ def load_from_dict(
         the following occurs
 
         If you set ``raise_on_exception`` to ``False``, and an exception occurs during the
-        build process. This method will return an ``AntiSpamhandler`` instance
+        build process. This method will return an ``AntiSpamHandler`` instance
         **without** any of the saved state and is equivalent to simply
         doing ``AntiSpamHandler(bot)``
 
-        -----
-
-        This is fairly computationally expensive. It deepcopy's
-        nearly everything lol.
-
         """
+        # TODO Add redis cache here
+        caches = {"MemoryCache": MemoryCache}
+
         try:
-            ash = AntiSpamHandler(bot=bot, **data["options"])
+            ash = AntiSpamHandler(bot=bot, options=Options(**data["options"]))
+            cache_type = data["cache"]
+            ash.cache = caches[cache_type](ash)
             for guild in data["guilds"]:
-                ash.guilds = Guild.load_from_dict(bot, guild)
+                await ash.cache.set_guild(FactoryBuilder.create_guild_from_dict(guild))
 
             log.info("Loaded AntiSpamHandler from state")
         except Exception as e:
@@ -820,6 +707,7 @@ def load_from_dict(
 
         return ash
 
+    @ensure_init
     async def save_to_dict(self) -> dict:
         """
         Creates a 'save point' of the current
@@ -839,8 +727,6 @@ async def save_to_dict(self) -> dict:
         will be outdated, however, they are included
         as it is technically part of the current state.
 
-        -----
-
         Note that is method is expensive in both time and memory.
         It has to iterate over every single stored class
         instance within the library and store it in a dictionary.
@@ -856,22 +742,26 @@ async def save_to_dict(self) -> dict:
         to begin using that modified copy.
 
         """
-        data = {"options": self.options, "guilds": []}
-        for guild in self._guilds:
-            data["guilds"].append(await guild.save_to_dict())
+        data = {
+            "options": asdict(self.options),
+            "cache": self.cache.__class__.__name__,
+            "guilds": [],
+        }
+        async for guild in self.cache.get_all_guilds():  # pragma: no cover
+            data["guilds"].append(asdict(guild, recurse=True))
 
         log.info("Saved AntiSpamHandler state")
 
         return data
 
-    def register_extension(self, extension, force_overwrite=False) -> None:
+    def register_plugin(self, plugin, force_overwrite=False) -> None:
         """
-        Registers an extension for usage for within the package
+        Registers a plugin for usage for within the package
 
         Parameters
         ----------
-        extension
-            The extension to register
+        plugin
+            The plugin to register
         force_overwrite : bool
             Whether to overwrite any duplicates currently stored.
 
@@ -880,27 +770,21 @@ def register_extension(self, extension, force_overwrite=False) -> None:
 
         Raises
         ------
-        ExtensionError
-            An extension with this name is already loaded
+        PluginError
+            A plugin with this name is already loaded
 
         Notes
         -----
         This must be a class instance, and must
-        subclass ``BaseExtension``
+        subclass ``BasePlugin``
         """
-        if not issubclass(type(extension), BaseExtension):
+        if not issubclass(type(plugin), BasePlugin):
             log.debug("Failed to load extension due to class type issues")
-            raise ExtensionError(
-                "Expected extension that subclassed BaseExtension and was a class instance not class reference"
+            raise PluginError(
+                "Expected extension that subclassed BasePlugin and was a class instance not class reference"
             )
 
-        # TODO Try explicitly check its actually a class instance rather then inferring it?
-
-        if not inspect.iscoroutinefunction(getattr(extension, "propagate")):
-            log.debug("Failed to load extension due to a failed propagate inspect")
-            raise ExtensionError("Expected coro method for propagate")
-
-        is_pre_invoke = getattr(extension, "is_pre_invoke", True)
+        is_pre_invoke = getattr(plugin, "is_pre_invoke", True)
 
         # propagate_signature = inspect.signature(getattr(extension, "propagate"))
 
@@ -908,358 +792,148 @@ def register_extension(self, extension, force_overwrite=False) -> None:
         # everything and its ultimately on the end developer
         # to get this correct rather then server-side validation
 
-        cls_name = extension.__class__.__name__.lower()
+        cls_name = plugin.__class__.__name__.lower()
 
         if (
             self.pre_invoke_extensions.get(cls_name)
             or self.after_invoke_extensions.get(cls_name)
         ) and not force_overwrite:
             log.debug("Duplicate extension load attempt")
-            raise ExtensionError(
+            raise PluginError(
                 "Error loading extension, an extension with this name already exists!"
             )
 
         if is_pre_invoke:
-            log.info(f"Loading pre-invoke extension: {cls_name}")
-            self.pre_invoke_extensions[cls_name] = extension
-        elif not is_pre_invoke:
-            log.info(f"Loading after-invoke extension: {cls_name}")
-            self.after_invoke_extensions[cls_name] = extension
+            log.info("Loading pre-invoke extension: %s", cls_name)
+            self.pre_invoke_extensions[cls_name] = plugin
+        else:
+            log.info("Loading after-invoke extension: %s", cls_name)
+            self.after_invoke_extensions[cls_name] = plugin
 
-    def unregister_extension(self, extension_name: str) -> None:
+    def unregister_plugin(self, plugin_name: str) -> None:
         """
-        Used to unregister or remove an extension that is
+        Used to unregister or remove a plugin that is
         currently loaded into AntiSpamHandler
 
         Parameters
         ----------
-        extension_name : str
+        plugin_name : str
             The name of the class you want to unregister
 
         Raises
         ------
-        ExtensionError
+        PluginError
             This extension isn't loaded
 
         """
         has_popped_pre_invoke = False
         try:
-            self.pre_invoke_extensions.pop(extension_name.lower())
+            self.pre_invoke_extensions.pop(plugin_name.lower())
             has_popped_pre_invoke = True
         except KeyError:
             pass
 
         try:
-            self.after_invoke_extensions.pop(extension_name.lower())
+            self.after_invoke_extensions.pop(plugin_name.lower())
         except KeyError:
             if not has_popped_pre_invoke:
                 log.debug(
-                    f"Failed to unload extension {extension_name} as it isn't loaded"
+                    "Failed to unload extension %s as it isn't currently loaded",
+                    plugin_name,
                 )
-                raise ExtensionError("An extension matching this name doesn't exist!")
+                raise PluginError("An extension matching this name doesn't exist!")
 
-        log.info(f"Unregistered extension {extension_name}")
+        log.info("Unregistered extension: %s", plugin_name)
 
-    @staticmethod
-    def _ensure_options(
-        warn_threshold=None,
-        kick_threshold=None,
-        ban_threshold=None,
-        message_interval=None,
-        guild_warn_message=None,
-        guild_kick_message=None,
-        guild_ban_message=None,
-        user_kick_message=None,
-        user_ban_message=None,
-        user_failed_kick_message=None,
-        user_failed_ban_message=None,
-        message_duplicate_count=None,
-        message_duplicate_accuracy=None,
-        delete_spam=None,
-        ignore_perms=None,
-        ignore_users=None,
-        ignore_channels=None,
-        ignore_roles=None,
-        ignore_guilds=None,
-        ignore_bots=None,
-        warn_only=None,
-        no_punish=None,
-        per_channel_spam=None,
-        guild_warn_message_delete_after=None,
-        user_kick_message_delete_after=None,
-        guild_kick_message_delete_after=None,
-        user_ban_message_delete_after=None,
-        guild_ban_message_delete_after=None,
-        delete_zero_width_chars=None,
-    ):
+    async def clean_cache(self, strict=False) -> None:
         """
-        Given the relevant arguments,
-        validate and return the options dict
-
-        Notes
-        =====
-        For args, view this class's __init__ docstring
-        """
-        if not isinstance(warn_threshold, int) and warn_threshold is not None:
-            raise ValueError("Expected warn_threshold of type int")
-
-        if not isinstance(kick_threshold, int) and kick_threshold is not None:
-            raise ValueError("Expected kick_threshold of type int")
-
-        if not isinstance(ban_threshold, int) and ban_threshold is not None:
-            raise ValueError("Expected ban_threshold of type int")
-
-        if not isinstance(message_interval, int) and message_interval is not None:
-            raise ValueError("Expected message_interval of type int")
-
-        if message_interval is not None and message_interval < 1000:
-            raise BaseASHException("Minimum message_interval is 1 seconds (1000 ms)")
-
-        if (
-            not isinstance(guild_warn_message, (str, dict))
-            and guild_warn_message is not None
-        ):
-            raise ValueError("Expected guild_warn_message of type str or dict")
+        Cleans the internal cache, pruning
+        any old/un-needed entries.
+
+
+        TODO Test these modes
+        Non Strict mode:
+         - Member deletion criteria:
+            - warn_count == default
+            - kick_count == default
+            - duplicate_counter == default
+            - duplicate_channel_counter_dict == default
+            - addons dict == default
+            - Also must have no active messages after cleaning.
+
+         - Guild deletion criteria:
+            - options are not custom
+            - log_channel_id is not set
+            - addons dict == default
+            - Also must have no members stored
+
+        Strict mode:
+         - Member deletion criteria
+            - Has no active messages
+
+         - Guild deletion criteria
+            - Does not have custom options
+            - log_channel_id is not set
+            - Has no active members
 
-        if (
-            not isinstance(guild_kick_message, (str, dict))
-            and guild_kick_message is not None
-        ):
-            raise ValueError("Expected guild_kick_message of type str or dict")
-
-        if (
-            not isinstance(guild_ban_message, (str, dict))
-            and guild_ban_message is not None
-        ):
-            raise ValueError("Expected guild_ban_message of type str or dict")
-
-        if (
-            not isinstance(user_kick_message, (str, dict))
-            and user_kick_message is not None
-        ):
-            raise ValueError("Expected user_kick_message of type str or dict")
-
-        if (
-            not isinstance(user_ban_message, (str, dict))
-            and user_ban_message is not None
-        ):
-            raise ValueError("Expected user_ban_message of type str or dict")
-
-        if (
-            not isinstance(user_failed_kick_message, (str, dict))
-            and user_failed_kick_message is not None
-        ):
-            raise ValueError("Expected user_failed_kick_message of type str or dict")
+        Parameters
+        ----------
+        strict : bool
+            Toggles the above
 
-        if (
-            not isinstance(user_failed_ban_message, (str, dict))
-            and user_failed_ban_message is not None
-        ):
-            raise ValueError("Expected user_failed_ban_message of type str or dict")
 
-        if (
-            not isinstance(message_duplicate_count, int)
-            and message_duplicate_count is not None
-        ):
-            raise ValueError("Expected message_duplicate_count of type int")
-
-        # Convert message_duplicate_accuracy from int to float if exists
-        if isinstance(message_duplicate_accuracy, int):
-            message_duplicate_accuracy = float(message_duplicate_accuracy)
-        if (
-            not isinstance(message_duplicate_accuracy, float)
-            and message_duplicate_accuracy is not None
-        ):
-            raise ValueError("Expected message_duplicate_accuracy of type float")
-        if message_duplicate_accuracy is not None:
-            if 1.0 > message_duplicate_accuracy or message_duplicate_accuracy > 100.0:
-                # Only accept values between 1 and 100
-                raise ValueError(
-                    "Expected message_duplicate_accuracy between 1 and 100"
+        Notes
+        -----
+        This is expensive, and likely
+        only required to be run every so often
+        depending on how high traffic your bot is.
+        """
+        # In a nutshell,
+        # Get entire cache and loop over
+        # Build a list of 'still valid' cache entries
+        # Drop the previous cache
+        # Insert the new list of 'still valid' entries, this is now the cache
+
+        # Ideally I don't want to load this cache into memory
+        cache = []
+        async for guild in self.cache.get_all_guilds():
+            new_guild = Guild(guild.id)
+            for member in guild.members.values():
+                FactoryBuilder.clean_old_messages(
+                    member, get_aware_time(), self.options
                 )
 
-        if not isinstance(delete_spam, bool) and delete_spam is not None:
-            raise ValueError("Expected delete_spam of type bool")
-
-        if not isinstance(ignore_perms, list) and ignore_perms is not None:
-            raise ValueError("Expected ignore_perms of type list")
-
-        if not isinstance(ignore_users, list) and ignore_users is not None:
-            raise ValueError("Expected ignore_users of type list")
-
-        if not isinstance(ignore_channels, list) and ignore_channels is not None:
-            raise ValueError("Expected ignore_channels of type list")
-
-        if not isinstance(ignore_roles, list) and ignore_roles is not None:
-            raise ValueError("Expected ignore_roles of type list")
-
-        if not isinstance(ignore_guilds, list) and ignore_guilds is not None:
-            raise ValueError("Expected ignore_guilds of type list")
-
-        if not isinstance(ignore_bots, bool) and ignore_bots is not None:
-            raise ValueError("Expected ignore_bots of type bool")
-
-        if not isinstance(warn_only, bool) and warn_only is not None:
-            raise ValueError("Expected warn_only of type bool")
-
-        if not isinstance(no_punish, bool) and no_punish is not None:
-            raise ValueError("Expected no_punish of type bool")
-
-        if not isinstance(per_channel_spam, bool) and per_channel_spam is not None:
-            raise ValueError("Expected per_channel_spam of type bool")
-
-        if (
-            not isinstance(guild_warn_message_delete_after, int)
-            and guild_warn_message_delete_after is not None
-        ):
-            raise ValueError("Expected guild_warn_message_delete_after of type int")
-
-        if (
-            not isinstance(user_kick_message_delete_after, int)
-            and user_kick_message_delete_after is not None
-        ):
-            raise ValueError("Expected user_kick_message_delete_after of type int")
-
-        if (
-            not isinstance(guild_kick_message_delete_after, int)
-            and guild_kick_message_delete_after is not None
-        ):
-            raise ValueError("Expected guild_kick_message_delete_after of type int")
-
-        if (
-            not isinstance(user_ban_message_delete_after, int)
-            and user_ban_message_delete_after is not None
-        ):
-            raise ValueError("Expected user_ban_message_delete_after of type int")
-
-        if (
-            not isinstance(guild_ban_message_delete_after, int)
-            and guild_ban_message_delete_after is not None
-        ):
-            raise ValueError("Expected guild_ban_message_delete_after of type int")
-
-        if warn_only and no_punish:
-            raise BaseASHException(
-                "Cannot do BOTH warn_only and no_punish. Pick one and try again"
+                if strict and len(member.messages) != 0:
+                    new_guild.members[member.id] = member
+                elif (
+                    len(member.messages) != 0
+                    or member.kick_count != 0
+                    or member.warn_count != 0
+                    or member.duplicate_counter != 1
+                    or bool(member.duplicate_channel_counter_dict)
+                    or bool(member.addons)
+                ):
+                    new_guild.members[member.id] = member
+
+            # Clean guild
+            predicate = (
+                guild.options != Options()
+                or guild.log_channel_id is not None
+                or bool(new_guild.members)
             )
-
-        if (
-            not isinstance(delete_zero_width_chars, bool)
-            and delete_zero_width_chars is not None
-        ):
-            raise ValueError("Expected `delete_zero_width_chars` of type bool")
-
-        # Now we have ignore_type checked everything, lets do some logic
-        if ignore_bots is None:
-            ignore_bots = Static.DEFAULTS.get("ignore_bots")
-
-        if ignore_roles is not None:
-            placeholder_ignore_roles = []
-            for item in ignore_roles:
-                if isinstance(item, discord.Role):
-                    placeholder_ignore_roles.append(item.id)
-                elif isinstance(item, int):
-                    placeholder_ignore_roles.append(item)
-                elif isinstance(item, str):
-                    placeholder_ignore_roles.append(item)
-                else:
-                    raise ValueError(
-                        "Expected discord.Role or int or str for ignore_roles"
-                    )
-            ignore_roles = placeholder_ignore_roles
-
-        if ignore_channels is not None:
-            placeholder_ignore_channels = []
-            for item in ignore_channels:
-                if isinstance(item, discord.TextChannel):
-                    placeholder_ignore_channels.extend([item.id])
-                else:
-                    placeholder_ignore_channels.append(item)
-            ignore_channels = placeholder_ignore_channels
-
-        if ignore_users is not None:
-            placeholder_ignore_users = []
-            for item in ignore_users:
-                if isinstance(item, discord.User) or isinstance(item, discord.Member):
-                    placeholder_ignore_users.append(item.id)
-                else:
-                    placeholder_ignore_users.append(item)
-            ignore_users = placeholder_ignore_users
-
-        return {
-            "warn_threshold": warn_threshold or Static.DEFAULTS.get("warn_threshold"),
-            "kick_threshold": kick_threshold or Static.DEFAULTS.get("kick_threshold"),
-            "ban_threshold": ban_threshold or Static.DEFAULTS.get("ban_threshold"),
-            "message_interval": message_interval
-            or Static.DEFAULTS.get("message_interval"),
-            "guild_warn_message": guild_warn_message
-            or Static.DEFAULTS.get("guild_warn_message"),
-            "guild_kick_message": guild_kick_message
-            or Static.DEFAULTS.get("guild_kick_message"),
-            "guild_ban_message": guild_ban_message
-            or Static.DEFAULTS.get("guild_ban_message"),
-            "user_kick_message": user_kick_message
-            or Static.DEFAULTS.get("user_kick_message"),
-            "user_ban_message": user_ban_message
-            or Static.DEFAULTS.get("user_ban_message"),
-            "user_failed_kick_message": user_failed_kick_message
-            or Static.DEFAULTS.get("user_failed_kick_message"),
-            "user_failed_ban_message": user_failed_ban_message
-            or Static.DEFAULTS.get("user_failed_ban_message"),
-            "message_duplicate_count": message_duplicate_count
-            or Static.DEFAULTS.get("message_duplicate_count"),
-            "message_duplicate_accuracy": message_duplicate_accuracy
-            or Static.DEFAULTS.get("message_duplicate_accuracy"),
-            "delete_spam": delete_spam or Static.DEFAULTS.get("delete_spam"),
-            "ignore_perms": ignore_perms or Static.DEFAULTS.get("ignore_perms"),
-            "ignore_users": ignore_users or Static.DEFAULTS.get("ignore_users"),
-            "ignore_channels": ignore_channels
-            or Static.DEFAULTS.get("ignore_channels"),
-            "ignore_roles": ignore_roles or Static.DEFAULTS.get("ignore_roles"),
-            "ignore_guilds": ignore_guilds or Static.DEFAULTS.get("ignore_guilds"),
-            "ignore_bots": ignore_bots,
-            "warn_only": warn_only or Static.DEFAULTS.get("warn_only"),
-            "no_punish": no_punish or Static.DEFAULTS.get("no_punish"),
-            "per_channel_spam": per_channel_spam
-            or Static.DEFAULTS.get("per_channel_spam"),
-            "guild_warn_message_delete_after": guild_warn_message_delete_after
-            or Static.DEFAULTS.get("guild_warn_message_delete_after"),
-            "user_kick_message_delete_after": user_kick_message_delete_after
-            or Static.DEFAULTS.get("user_kick_message_delete_after"),
-            "guild_kick_message_delete_after": guild_kick_message_delete_after
-            or Static.DEFAULTS.get("guild_kick_message_delete_after"),
-            "user_ban_message_delete_after": user_ban_message_delete_after
-            or Static.DEFAULTS.get("user_ban_message_delete_after"),
-            "guild_ban_message_delete_after": guild_ban_message_delete_after
-            or Static.DEFAULTS.get("guild_ban_message_delete_after"),
-            "delete_zero_width_chars": delete_zero_width_chars
-            or Static.DEFAULTS.get("delete_zero_width_chars"),
-        }
-
-    # <-- Getter & Setters -->
-    @property
-    def guilds(self):
-        return self._guilds
-
-    @guilds.setter
-    def guilds(self, value):
-        """
-        Raises
-        ======
-        ValueError
-            value must be a Guild object
-        DuplicateObject
-            It won't maintain two guild objects with the same
-            id's, and it will complain about it haha
-        ObjectMismatch
-            Raised if `value` wasn't made by this person, so they
-            shouldn't be the ones maintaining the reference
-        """
-        if not isinstance(value, Guild):
-            raise ValueError("Expected Guild object")
-
-        if value in self._guilds:
-            raise DuplicateObject
-
-        log.debug(f"Added guild: {value}")
-        self._guilds.append(value)
+            if strict and predicate:
+                new_guild.options = guild.options
+                new_guild.log_channel_id = guild.log_channel_id
+                cache.append(new_guild)
+            elif predicate or bool(guild.addons):
+                new_guild.addons = guild.addons
+                new_guild.options = guild.options
+                new_guild.log_channel_id = guild.log_channel_id
+                cache.append(new_guild)
+
+        await self.cache.drop()
+
+        for guild in cache:
+            await self.cache.set_guild(guild)
+
+        log.info("Cleaned the internal cache")
diff --git a/antispam/base_extension.py b/antispam/base_extension.py
deleted file mode 100644
index aef2c41..0000000
--- a/antispam/base_extension.py
+++ /dev/null
@@ -1,24 +0,0 @@
-import discord
-import typing
-
-
-class BaseExtension:
-    def __init__(self, is_pre_invoke=True) -> None:
-        self.is_pre_invoke = is_pre_invoke
-
-    async def propagate(
-        self, message: discord.Message, data: typing.Optional[dict] = None
-    ) -> dict:
-        """
-        This method is called whenever the base ``antispam.propagate`` is called,
-        adhering to ``self.is_pre_invoke``
-        Parameters
-        ----------
-        message
-        data
-
-        Returns
-        -------
-
-        """
-        pass
diff --git a/antispam/base_plugin.py b/antispam/base_plugin.py
new file mode 100644
index 0000000..dd0758b
--- /dev/null
+++ b/antispam/base_plugin.py
@@ -0,0 +1,33 @@
+from typing import Optional, Any, Set
+
+from .dataclasses import CorePayload
+
+
+class BasePlugin:
+    def __init__(self, is_pre_invoke=True) -> None:
+        self.is_pre_invoke = is_pre_invoke
+
+        # A set of blacklisted guilds,
+        # If a guilds in this, the plugin wont run
+        self.blacklisted_guilds: Set[int] = set()
+
+    async def propagate(self, message, data: Optional[CorePayload] = None) -> Any:
+        """
+        This method is called whenever the base ``antispam.propagate`` is called,
+        adhering to ``self.is_pre_invoke``
+
+        Parameters
+        ----------
+        message : Union[discord.Message, hikari.messages.Message]
+            The message to run propagation on
+        data : Optional[CorePayload]
+            Optional input given to after invoke plugins
+            which is the return value from the main `propagate()`
+
+        Returns
+        -------
+        dict
+            A dictionary of useful data to the end user
+
+        """
+        raise NotImplementedError
diff --git a/antispam/caches/__init__.py b/antispam/caches/__init__.py
new file mode 100644
index 0000000..3bfc0ef
--- /dev/null
+++ b/antispam/caches/__init__.py
@@ -0,0 +1,2 @@
+from .memory import MemoryCache
+from .redis import RedisCache
diff --git a/antispam/caches/memory/__init__.py b/antispam/caches/memory/__init__.py
new file mode 100644
index 0000000..4d8a63f
--- /dev/null
+++ b/antispam/caches/memory/__init__.py
@@ -0,0 +1,1 @@
+from .memory import MemoryCache
diff --git a/antispam/caches/memory/memory.py b/antispam/caches/memory/memory.py
new file mode 100644
index 0000000..a486e8d
--- /dev/null
+++ b/antispam/caches/memory/memory.py
@@ -0,0 +1,145 @@
+import logging
+from typing import AsyncIterable
+
+from ...abc import Cache
+from ...dataclasses import Message, Member, Guild
+from ...enums import ResetType
+from ...exceptions import GuildNotFound, MemberNotFound
+
+
+log = logging.getLogger(__name__)
+
+
+class MemoryCache(Cache):
+    def __init__(self, handler):
+        self.handler = handler
+        self.cache = {}
+        log.info("Cache instance ready to roll.")
+
+    async def initialize(self, *args, **kwargs) -> None:
+        return await super().initialize(*args, **kwargs)
+
+    async def get_guild(self, guild_id: int) -> Guild:
+        log.debug("Attempting to return cached Guild(id=%s)", guild_id)
+        try:
+            return self.cache[guild_id]
+        except KeyError:
+            raise GuildNotFound from None
+
+    async def set_guild(self, guild: Guild) -> None:
+        log.debug("Attempting to set Guild(id=%s)", guild.id)
+        self.cache[guild.id] = guild
+
+    async def delete_guild(self, guild_id: int) -> None:
+        log.debug("Attempting to delete Guild(id=%s)", guild_id)
+        self.cache.pop(guild_id, None)
+
+    async def get_member(self, member_id: int, guild_id: int) -> Member:
+        log.debug(
+            "Attempting to return a cached Member(id=%s) for Guild(id=%s)",
+            member_id,
+            guild_id,
+        )
+        guild = await self.get_guild(guild_id)
+
+        try:
+            return guild.members[member_id]
+        except KeyError:
+            raise MemberNotFound from None
+
+    async def set_member(self, member: Member) -> None:
+        log.debug(
+            "Attempting to cache Member(id=%s) for Guild(id=%s)",
+            member.id,
+            member.guild_id,
+        )
+        try:
+            guild = await self.get_guild(member.guild_id)
+        except GuildNotFound:
+            guild = Guild(id=member.guild_id, options=self.handler.options)
+
+        guild.members[member.id] = member
+        await self.set_guild(guild)
+
+    async def delete_member(self, member_id: int, guild_id: int) -> None:
+        log.debug(
+            "Attempting to delete Member(id=%s) in Guild(id=%s)", member_id, guild_id
+        )
+        try:
+            guild = await self.get_guild(guild_id)
+        except GuildNotFound:
+            return
+
+        try:
+            guild.members.pop(member_id)
+        except KeyError:
+            return
+
+        await self.set_guild(guild)
+
+    async def add_message(self, message: Message) -> None:
+        log.debug(
+            "Attempting to add a Message(id=%s) to Member(id=%s) in Guild(id=%s)",
+            message.id,
+            message.author_id,
+            message.guild_id,
+        )
+        try:
+            member = await self.get_member(
+                member_id=message.author_id, guild_id=message.guild_id
+            )
+        except GuildNotFound:
+            member = Member(id=message.author_id, guild_id=message.guild_id)
+            guild = Guild(id=message.guild_id, options=self.handler.options)
+            guild.members[member.id] = member
+
+            await self.set_guild(guild)
+
+        except MemberNotFound:
+            guild = await self.get_guild(message.guild_id)
+
+            member = Member(id=message.author_id, guild_id=message.guild_id)
+            guild.members[member.id] = member
+
+            await self.set_guild(guild)
+
+        member.messages.append(message)
+        await self.set_member(member)
+
+    async def reset_member_count(
+        self, member_id: int, guild_id: int, reset_type: ResetType
+    ) -> None:
+        log.debug(
+            "Attempting to reset counts on Member(id=%s) in Guild(id=%s) with type %s",
+            member_id,
+            guild_id,
+            reset_type.name,
+        )
+        try:
+            member = await self.get_member(member_id, guild_id)
+            if reset_type == ResetType.KICK_COUNTER:
+                member.kick_count = 0
+            # elif reset_type == ResetType.WARN_COUNTER:
+            else:
+                member.warn_count = 0
+
+            await self.set_member(member)
+
+        except (MemberNotFound, GuildNotFound):
+            # This is fine
+            return
+
+    async def get_all_members(self, guild_id: int) -> AsyncIterable[Member]:  # noqa
+        log.debug("Yielding all cached members for Guild(id=%s)", guild_id)
+        guilds = await self.get_guild(guild_id=guild_id)
+        for member in guilds.members.values():
+            yield member
+
+    async def get_all_guilds(self) -> AsyncIterable[Guild]:  # noqa
+        log.debug("Yielding all cached guilds")
+        for guild in self.cache.values():
+            yield guild
+
+    async def drop(self) -> None:
+        log.warning("Cache was just dropped")
+        self.cache = {}
diff --git a/antispam/caches/redis/__init__.py b/antispam/caches/redis/__init__.py
new file mode 100644
index 0000000..86b8c5e
--- /dev/null
+++ b/antispam/caches/redis/__init__.py
@@ -0,0 +1,1 @@
+from .redis import RedisCache
diff --git a/antispam/caches/redis/redis.py b/antispam/caches/redis/redis.py
new file mode 100644
index 0000000..6fdb513
--- /dev/null
+++ b/antispam/caches/redis/redis.py
@@ -0,0 +1,10 @@
+from antispam.abc import Cache
+
+
+class RedisCache(Cache):
+    """Not implemented lol"""
+
+    def __init__(self, handler):  # pragma: no cover
+        self.handler = handler
+
+        raise NotImplementedError
diff --git a/antispam/core.py b/antispam/core.py
new file mode 100644
index 0000000..8cf2b4c
--- /dev/null
+++ b/antispam/core.py
@@ -0,0 +1,442 @@
+import datetime
+import logging
+
+from fuzzywuzzy import fuzz
+
+from .exceptions import (
+    MemberNotFound,
+    LogicError,
+    DuplicateObject,
+)
+from .dataclasses import Member, Message, CorePayload, Guild
+from .util import get_aware_time
+
+log = logging.getLogger(__name__)
+
+
+# noinspection PyProtectedMember
+class Core:
+    """An abstract way to handle spam tracking on different levels"""
+
+    __slots__ = ("handler", "cache", "options")
+
+    def __init__(self, handler):
+        self.handler = handler
+        self.options = handler.options
+        self.cache = handler.cache  # Shorthand
+
+    async def propagate(self, message, guild: Guild) -> CorePayload:
+        """
+        The internal representation of core functionality.
+
+        TODO Test this links
+        Please see and use :meth:`antispam.AntiSpamHandler`
+        """
+
+        # To get here it must have passed checks so simply run the relevant methods
+        guild_r = await self.propagate_user(message, guild)
+
+        if self.options.is_per_channel_per_guild:
+            guild_r = await self.propagate_per_channel_per_guild(message, guild_r)
+
+        return guild_r
+
+    async def propagate_user(self, original_message, guild: Guild) -> CorePayload:
+        """
+        The internal representation of core functionality.
+
+        Please see and use :meth:`discord.ext.antispam.AntiSpamHandler`
+        """
+        try:
+            if original_message.author.id in guild.members:
+                member = guild.members[original_message.author.id]
+            else:
+                member: Member = await self.cache.get_member(
+                    member_id=original_message.author.id,
+                    guild_id=self.handler.lib_handler.get_guild_id(original_message),
+                )
+
+            if not member._in_guild:
+                return CorePayload(
+                    member_status="Bypassing message check since the member isn't seen to be in a guild"
+                )
+
+        except MemberNotFound:
+            # Create a useable member
+            member = Member(
+                id=original_message.author.id,
+                guild_id=self.handler.lib_handler.get_guild_id(original_message),
+            )
+            guild.members[member.id] = member
+            await self.cache.set_guild(guild=guild)
+
+        await self.clean_up(
+            member=member,
+            current_time=get_aware_time(),
+            channel_id=self.handler.lib_handler.get_channel_id(original_message),
+        )
+        message: Message = self.handler.lib_handler.create_message(original_message)
+        self._calculate_ratios(message, member)
+
+        # Check again since in theory the above could take awhile
+        if not member._in_guild:
+            return CorePayload(
+                member_status="Bypassing message check since the member isn't seen to be in a guild"
+            )
+
+        member.messages.append(message)
+        log.info(
+            "Created Message(%s) on Member(id=%s) in Guild(id=%s)",
+            message.id,
+            member.id,
+            member.guild_id,
+        )
+
+        if (
+            self._get_duplicate_count(member, channel_id=message.channel_id)
+            < self.options.message_duplicate_count
+        ):
+            return CorePayload()
+
+        # We need to punish the member with something
+        log.debug(
+            "Message(%s) on Member(id=%s) in Guild(id=%s) requires some form of punishment",
+            message.id,
+            member.id,
+            member.guild_id,
+        )
+        # We need to punish the member with something
+        return_payload = CorePayload(member_should_be_punished_this_message=True)
+
+        if self.options.no_punish:
+            # User will handle punishments themselves
+            return CorePayload(
+                member_should_be_punished_this_message=True,
+                member_status="Member should be punished, however, was not due to no_punish being True",
+            )
+
+        if (
+            self.options.warn_only
+            or self._get_duplicate_count(member, message.channel_id)
+            >= self.options.warn_threshold
+            and member.warn_count < self.options.kick_threshold
+            and member.kick_count < self.options.ban_threshold
+        ):
+            """
+            WARN
+            The member has yet to reach the warn threshold,
+            after the warn threshold is reached this will
+            then become a kick and so on
+            """
+            log.debug(
+                "Attempting to warn Member(id=%s) in Guild(id=%s)",
+                message.author_id,
+                message.guild_id,
+            )
+            member.warn_count += 1
+            channel = await self.handler.lib_handler.get_channel_from_message(
+                original_message
+            )
+            member_message = self.handler.lib_handler.transform_message(
+                self.options.member_warn_message,
+                original_message,
+                member.warn_count,
+                member.kick_count,
+            )
+            guild_message = self.handler.lib_handler.transform_message(
+                self.options.guild_warn_message,
+                original_message,
+                member.warn_count,
+                member.kick_count,
+            )
+            try:
+                await self.handler.lib_handler.send_message_to_(
+                    channel,
+                    member_message,
+                    original_message.author.mention,
+                    self.options.guild_warn_message_delete_after,
+                )
+            except Exception as e:
+                member.warn_count -= 1
+                raise e
+
+            # Log this within guild log channels
+            await self.handler.lib_handler.send_guild_log(
+                guild=guild,
+                message=guild_message,
+                original_channel=original_message.channel,
+                delete_after_time=self.options.guild_warn_message_delete_after,
+            )
+
+            return_payload.member_was_warned = True
+            return_payload.member_status = "Member was warned"
+
+        elif (
+            member.warn_count >= self.options.kick_threshold
+            and member.kick_count < self.options.ban_threshold
+        ):
+            # KICK
+            # Set this to False here to stop processing other messages, we can revert on failure
+            member._in_guild = False
+            member.kick_count += 1
+            log.debug(
+                "Attempting to kick Member(id=%s) from Guild(id=%s)",
+                message.author_id,
+                message.guild_id,
+            )
+
+            guild_message = self.handler.lib_handler.transform_message(
+                self.options.guild_kick_message,
+                original_message,
+                member.warn_count,
+                member.kick_count,
+            )
+            user_message = self.handler.lib_handler.transform_message(
+                self.options.member_kick_message,
+                original_message,
+                member.warn_count,
+                member.kick_count,
+            )
+            await self.handler.lib_handler.punish_member(
+                original_message,
+                member,
+                guild,
+                user_message,
+                guild_message,
+                True,
+                self.options.member_kick_message_delete_after,
+                self.options.guild_kick_message_delete_after,
+            )
+
+            return_payload.member_was_kicked = True
+            return_payload.member_status = "Member was kicked"
+
+        elif member.kick_count >= self.options.ban_threshold:
+            # BAN
+            # Set this to False here to stop processing other messages, we can revert on failure
+            member._in_guild = False
+            member.kick_count += 1
+            log.debug(
+                "Attempting to ban Member(id=%s) from Guild(id=%s)",
+                message.author_id,
+                message.guild_id,
+            )
+
+            guild_message = self.handler.lib_handler.transform_message(
+                self.options.guild_ban_message,
+                original_message,
+                member.warn_count,
+                member.kick_count,
+            )
+            user_message = self.handler.lib_handler.transform_message(
+                self.options.member_ban_message,
+                original_message,
+                member.warn_count,
+                member.kick_count,
+            )
+            await self.handler.lib_handler.punish_member(
+                original_message,
+                member,
+                guild,
+                user_message,
+                guild_message,
+                False,
+                self.options.member_ban_message_delete_after,
+                self.options.guild_ban_message_delete_after,
+            )
+
+            return_payload.member_was_banned = True
+            return_payload.member_status = "Member was banned"
+
+        else:
+            # Supports backwards compat?
+            # Not sure if this is required tbh
+            raise LogicError
+
+        # Store the updated values
+        await self.cache.set_member(member)
+
+        # Delete the message if wanted
+        if self.options.delete_spam is True and self.options.no_punish is False:
+            await self.handler.lib_handler.delete_message(original_message)
+            await self.handler.lib_handler.delete_member_messages(member)
+
+        # Finish payload and return
+        return_payload.member_warn_count = member.warn_count
+        return_payload.member_kick_count = member.kick_count
+        return_payload.member_duplicate_count = (
+            self._get_duplicate_count(
+                member=member,
+                channel_id=message.channel_id,
+            )
+            - 1
+        )
+        return return_payload
+
+    async def propagate_per_channel_per_guild(
+        self, message, core_payload: CorePayload
+    ) -> CorePayload:
+        """
+        The internal representation of core functionality.
+
+        Please see and use :meth:`discord.ext.antispam.AntiSpamHandler`
+        """
+
+        return core_payload
+
+    async def clean_up(self, member: Member, current_time, channel_id: int):
+        """
+        This logic works around checking the current
+        time vs a messages creation time. If the message
+        is older by the config amount it can be cleaned up
+
+        Parameters
+        ----------
+        member : Member
+            The member we want to clean up
+        current_time : datetime.datetime
+            A reference time used to clean up
+            past messages against
+        channel_id : int
+            The channel to clean messages in
+            if this is set to per_channel
+        """
+        log.debug(
+            "Attempting to remove outdated message's on Member(id=%s) in Guild(id=%s)",
+            member.id,
+            member.guild_id,
+        )
+
+        def _is_still_valid(message_obj):
+            """
+            Given a message, figure out if it hasn't
+            expired yet based on timestamps
+            """
+            difference = current_time - message_obj.creation_time
+            offset = datetime.timedelta(milliseconds=self.options.message_interval)
+
+            if difference >= offset:
+                return False
+            return True
+
+        current_messages = []
+        outstanding_messages = []
+
+        for message in member.messages:
+            if _is_still_valid(message):
+                current_messages.append(message)
+            else:
+                outstanding_messages.append(message)
+
+        # TODO This might need to be deepcopied
+        member.messages = current_messages
+
+        # Now if we have outstanding messages we need
+        # to process them and see if we need to decrement
+        # the duplicate counter as we are removing them from
+        # the queue otherwise everything stacks up
+        for outstanding_message in outstanding_messages:
+            if outstanding_message.is_duplicate:
+                self._remove_duplicate_count(member, channel_id)
+                log.debug(
+                    "Removing duplicate message(%s) from Member(id=%s) in Guild(id=%s)",
+                    outstanding_message.id,
+                    outstanding_message.author_id,
+                    outstanding_message.guild_id,
+                )
+            log.debug(
+                "Removing message(%s) from Member(id=%s) in Guild(id=%s)",
+                outstanding_message.id,
+                outstanding_message.author_id,
+                outstanding_message.guild_id,
+            )
+
+    def _calculate_ratios(
+        self,
+        message: Message,
+        member: Member,
+    ) -> None:
+        """
+        Calculates a messages relation to other messages
+        """
+        for message_obj in member.messages:
+            # This calculates the relation to each other
+            if message == message_obj:
+                raise DuplicateObject
+
+            elif (
+                self.options.per_channel_spam
+                and message.channel_id != message_obj.channel_id
+            ):
+                # This user's spam should only be counted per channel
+                # and these messages are in different channel
+                continue
+
+            elif (
+                fuzz.token_sort_ratio(message.content, message_obj.content)
+                >= self.options.message_duplicate_accuracy
+            ):
+                """
+                The handler works off an internal message duplicate counter
+                so just increment that and then let our logic process it later
+                """
+                self._increment_duplicate_count(member, channel_id=message.channel_id)
+                message.is_duplicate = True
+                message_obj.is_duplicate = True
+
+                if (
+                    self._get_duplicate_count(
+                        member,
+                        channel_id=message.channel_id,
+                    )
+                    >= self.options.message_duplicate_count
+                ):
+                    break
+
+    def _increment_duplicate_count(
+        self, member: Member, channel_id: int, amount: int = 1
+    ):
+        """A helper method to increment the correct duplicate counter, global or not."""
+        is_per_channel = self.options.per_channel_spam
+        if not is_per_channel:
+            # Just use the regular int, should save overhead
+            # for those who dont use per_channel_spam
+            member.duplicate_counter += amount
+
+        # We need a custom channel to save stuff in
+        elif channel_id not in member.duplicate_channel_counter_dict:
+            # since we need an extra 1 by default
+            member.duplicate_channel_counter_dict[channel_id] = amount + 1
+
+        else:
+            member.duplicate_channel_counter_dict[channel_id] += amount
+
+    def _get_duplicate_count(self, member: Member, channel_id: int = None) -> int:
+        """A helper method to get the correct duplicate counter based on settings"""
+        is_per_channel = self.options.per_channel_spam
+        if not is_per_channel:
+            return member.duplicate_counter
+
+        try:
+            channel_id = int(channel_id)
+            return member.duplicate_channel_counter_dict[channel_id]
+        except (KeyError, TypeError):
+            return 1
+
+    def _remove_duplicate_count(self, member: Member, channel_id: int, amount: int = 1):
+        """Used when cleaning the cache, to only lower the correct counter"""
+        is_per_channel = self.options.per_channel_spam
+        if not is_per_channel:
+            member.duplicate_counter -= amount
+            return
+
+        try:
+            member.duplicate_channel_counter_dict[channel_id] -= amount
+        except KeyError:
+            log.warning(
+                "Failed to de-increment duplicate count as the channel id doesnt exist. "
+                "Member(id=%s) in Guild(id=%s) with Channel(id=%s)",
+                member.id,
+                member.guild_id,
+                channel_id,
+            )
diff --git a/antispam/dataclasses/__init__.py b/antispam/dataclasses/__init__.py
new file mode 100644
index 0000000..0d70fdd
--- /dev/null
+++ b/antispam/dataclasses/__init__.py
@@ -0,0 +1,5 @@
+from .guild import Guild
+from .member import Member
+from .message import Message
+from .options import Options
+from .core import CorePayload
diff --git a/antispam/dataclasses/core.py b/antispam/dataclasses/core.py
new file mode 100644
index 0000000..fdb94b7
--- /dev/null
+++ b/antispam/dataclasses/core.py
@@ -0,0 +1,51 @@
+import attr
+
+from typing import Dict, Any
+
+
+@attr.s
+class CorePayload:
+    """
+    The CorePayload is a dataclasses which gets returned
+    within the core punishment system for this package.
+
+    This is returned from the :py:meth:`antispam.AntiSpamHandler.propagate` method.
+
+
+    Parameters
+    ----------
+    member_warn_count : int
+        How many warns this member has at this point in time
+    member_kick_count : int
+        How many kicks this member has at this point in time
+    member_duplicate_count : int
+        How many messages this member has marked as duplicates
+    member_status : str
+        The status of punishment towards the member
+    member_was_warned : bool
+        If the default punishment handler warned this member
+    member_was_kicked : bool
+        If the default punishment handler kicked this member
+    member_was_banned : bool
+        If the default punishment handler banned this member
+    member_should_be_punished_this_message : bool
+        If AntiSpamHandler thinks this member should
+        receive some form of punishment this message.
+        Useful for :py:class:`antispam.plugins.AntiSpamTracker`
+    """
+
+    # Per user things
+    member_warn_count: int = attr.ib(default=0)
+    member_kick_count: int = attr.ib(default=0)
+    member_duplicate_count: int = attr.ib(default=0)
+    member_status: str = attr.ib(default="Unknown")
+    member_was_warned: bool = attr.ib(default=False)
+    member_was_kicked: bool = attr.ib(default=False)
+    member_was_banned: bool = attr.ib(default=False)
+    member_should_be_punished_this_message: bool = attr.ib(default=False)
+
+    # Per channel things
+    # TODO Add per channel returns
+
+    pre_invoke_extensions: Dict[str, Any] = attr.ib(default=attr.Factory(dict))
+    after_invoke_extensions: Dict[str, Any] = attr.ib(default=attr.Factory(dict))
diff --git a/antispam/dataclasses/guild.py b/antispam/dataclasses/guild.py
new file mode 100644
index 0000000..bcb876f
--- /dev/null
+++ b/antispam/dataclasses/guild.py
@@ -0,0 +1,25 @@
+from typing import Dict, Any, List
+
+import attr
+
+from .message import Message
+from .member import Member
+from .options import Options
+
+
+@attr.s(slots=True)
+class Guild:
+    """A simplistic dataclass representing a Guild"""
+
+    id: int = attr.ib(eq=True)
+    options: Options = attr.ib(eq=False, default=attr.Factory(Options))
+    log_channel_id: int = attr.ib(eq=False, default=None)
+    members: Dict[int, Member] = attr.ib(default=attr.Factory(dict), eq=False)
+
+    # These messages should be the same as `member.messages`, I think
+    messages: List[Message] = attr.ib(default=attr.Factory(list), eq=False)
+
+    # So that plugins can access this data
+    # key -> Plugin.__class__.__name__
+    # Value -> Whatever they want to store
+    addons: Dict[str, Any] = attr.ib(default=attr.Factory(dict), eq=False)
diff --git a/antispam/dataclasses/member.py b/antispam/dataclasses/member.py
new file mode 100644
index 0000000..2b63243
--- /dev/null
+++ b/antispam/dataclasses/member.py
@@ -0,0 +1,26 @@
+from typing import List, Dict, Any
+
+import attr
+
+from .message import Message
+
+
+@attr.s(slots=True)
+class Member:
+    """A simplistic dataclass representing a Member"""
+
+    id: int = attr.ib(eq=True)
+    guild_id: int = attr.ib(eq=True)
+    warn_count: int = attr.ib(default=0, eq=False)
+    kick_count: int = attr.ib(default=0, eq=False)
+    duplicate_counter: int = attr.ib(default=1, eq=False)
+    duplicate_channel_counter_dict: Dict[int, int] = attr.ib(
+        default=attr.Factory(dict), eq=False
+    )
+    _in_guild: bool = attr.ib(default=True, eq=False)
+    messages: List[Message] = attr.ib(default=attr.Factory(list), eq=False)
+
+    # So that plugins can access this data
+    # key -> Plugin.__class__.__name__
+    # Value -> Whatever they want to store
+    addons: Dict[str, Any] = attr.ib(default=attr.Factory(dict), eq=False)
diff --git a/antispam/dataclasses/message.py b/antispam/dataclasses/message.py
new file mode 100644
index 0000000..df7a405
--- /dev/null
+++ b/antispam/dataclasses/message.py
@@ -0,0 +1,18 @@
+import datetime
+
+import attr
+
+from antispam.util import get_aware_time
+
+
+@attr.s(slots=True)
+class Message:
+    """A simplistic dataclass representing a Message"""
+
+    id: int = attr.ib()
+    channel_id: int = attr.ib()
+    guild_id: int = attr.ib()
+    author_id: int = attr.ib()
+    content: str = attr.ib()
+    creation_time: datetime.datetime = attr.ib(default=attr.Factory(get_aware_time))
+    is_duplicate: bool = attr.ib(default=False)
diff --git a/antispam/dataclasses/options.py b/antispam/dataclasses/options.py
new file mode 100644
index 0000000..0cfc359
--- /dev/null
+++ b/antispam/dataclasses/options.py
@@ -0,0 +1,83 @@
+from typing import Set, Dict, Any, Union
+
+import attr
+
+
+@attr.s(slots=True, eq=True, kw_only=True)
+class Options:
+    """Options for the AntiSpamHandler, see :py:class:`AntiSpamHandler` for explanations"""
+
+    # Ints
+    warn_threshold: int = attr.ib(default=3)
+    kick_threshold: int = attr.ib(default=2)
+    ban_threshold: int = attr.ib(default=2)
+    message_interval: int = attr.ib(default=30000)
+    message_duplicate_count: int = attr.ib(default=5)
+    message_duplicate_accuracy: int = attr.ib(default=90)
+
+    # delete_after
+    guild_ban_message_delete_after: int = attr.ib(default=None)
+    guild_kick_message_delete_after: int = attr.ib(default=None)
+    member_ban_message_delete_after: int = attr.ib(default=None)
+    guild_warn_message_delete_after: int = attr.ib(default=None)
+    member_kick_message_delete_after: int = attr.ib(default=None)
+
+    # Strings
+    guild_warn_message: Union[str, dict] = attr.ib(
+        default="$MEMBERNAME was warned for spamming/sending duplicate messages.",
+        kw_only=True,
+    )
+    guild_kick_message: Union[str, dict] = attr.ib(
+        default="$MEMBERNAME was kicked for spamming/sending duplicate messages.",
+        kw_only=True,
+    )
+    guild_ban_message: Union[str, dict] = attr.ib(
+        default="$MEMBERNAME was banned for spamming/sending duplicate messages.",
+        kw_only=True,
+    )
+    member_warn_message: Union[str, dict] = attr.ib(
+        default="Hey $MENTIONMEMBER, please stop spamming/sending duplicate messages.",
+        kw_only=True,
+    )
+    member_kick_message: Union[str, dict] = attr.ib(
+        default="Hey $MENTIONMEMBER, you are being kicked from $GUILDNAME for spamming/"
+        "sending duplicate messages.",
+        kw_only=True,
+    )
+    member_ban_message: Union[str, dict] = attr.ib(
+        default="Hey $MENTIONMEMBER, you are being banned from $GUILDNAME for spamming/"
+        "sending duplicate messages.",
+        kw_only=True,
+    )
+    member_failed_kick_message: Union[str, dict] = attr.ib(
+        default="I failed to punish you because I lack permissions, but still you shouldn't spam.",
+        kw_only=True,
+    )
+    member_failed_ban_message: Union[str, dict] = attr.ib(
+        default="I failed to punish you because I lack permissions, but still you shouldn't spam.",
+        kw_only=True,
+    )
+
+    # Sets
+    ignored_members: Set[int] = attr.ib(default=attr.Factory(set))
+    ignored_channels: Set[int] = attr.ib(default=attr.Factory(set))
+    ignored_roles: Set[int] = attr.ib(default=attr.Factory(set))
+    ignored_guilds: Set[int] = attr.ib(default=attr.Factory(set))
+
+    # Booleans
+    delete_spam: bool = attr.ib(default=False)
+    ignore_bots: bool = attr.ib(default=True)
+    warn_only: bool = attr.ib(default=False)
+    no_punish: bool = attr.ib(default=False)
+    mention_on_embed: bool = attr.ib(default=True)
+    delete_zero_width_chars: bool = attr.ib(default=True)
+
+    # Core punishment settings
+    per_channel_spam: bool = attr.ib(default=False)  # False implies per_user_per_guild
+
+    # TODO Implement this
+    # Catches 5 people saying the same thing
+    is_per_channel_per_guild: bool = attr.ib(default=True)
+
+    # Add on option storage for plugins
+    addons: Dict[str, Any] = attr.ib(default=attr.Factory(dict))
diff --git a/antispam/dataclasses/propagate_data.py b/antispam/dataclasses/propagate_data.py
new file mode 100644
index 0000000..8546f9e
--- /dev/null
+++ b/antispam/dataclasses/propagate_data.py
@@ -0,0 +1,12 @@
+import attr
+
+
+@attr.s(slots=True)
+class PropagateData:
+    """A simplistic dataclass representing the data propagate needs"""
+
+    guild_id: int = attr.ib()
+    member_name: str = attr.ib()
+    member_id: int = attr.ib()
+
+    has_perms_to_make_guild: bool = attr.ib()
diff --git a/antispam/enums/__init__.py b/antispam/enums/__init__.py
new file mode 100644
index 0000000..b4b88f5
--- /dev/null
+++ b/antispam/enums/__init__.py
@@ -0,0 +1,2 @@
+from .ignored_types import IgnoreType
+from .reset_type import ResetType
diff --git a/antispam/enums/ignored_types.py b/antispam/enums/ignored_types.py
new file mode 100644
index 0000000..6c234df
--- /dev/null
+++ b/antispam/enums/ignored_types.py
@@ -0,0 +1,18 @@
+from enum import Enum
+
+
+class IgnoreType(Enum):
+    """
+    This enum should be using with the following methods:
+
+    - :py:meth:`antispam.AntiSpamHandler.add_ignored_item`
+    - :py:meth:`antispam.AntiSpamHandler.remove_ignored_item`
+
+    It is used to signify the type of item you wish to
+    ignore within any following propagate calls.
+    """
+
+    MEMBER = 0
+    CHANNEL = 1
+    GUILD = 2
+    ROLE = 3
diff --git a/antispam/enums/reset_type.py b/antispam/enums/reset_type.py
new file mode 100644
index 0000000..92657cb
--- /dev/null
+++ b/antispam/enums/reset_type.py
@@ -0,0 +1,15 @@
+from enum import Enum
+
+
+class ResetType(Enum):
+    """
+    This enum should be using with the following methods:
+
+    - :py:meth:`antispam.AntiSpamHandler.reset_member_count`
+
+    It is used to signify the type of reset
+    you wish to apply to the given member.
+    """
+
+    WARN_COUNTER = 0
+    KICK_COUNTER = 1
diff --git a/antispam/exceptions.py b/antispam/exceptions.py
index b6e07b5..cb90c23 100644
--- a/antispam/exceptions.py
+++ b/antispam/exceptions.py
@@ -37,6 +37,16 @@ def __str__(self):
         return self.message
 
 
+class PropagateFailure(BaseASHException):
+    def __init__(self, *args, data: dict):
+        super().__init__(args)
+        self.data = data
+
+
+class InvocationCancelled(BaseASHException):
+    """Called when a pre-invoke plugin returned `cancel_next_invocation`"""
+
+
 class DuplicateObject(BaseASHException):
     """Raised because you attempted to create and add an object, using the exact same id's as a pre-existing one."""
 
@@ -57,13 +67,21 @@ class NotFound(BaseASHException):
     """Something could not be found."""
 
 
-class UserNotFound(NotFound):
-    """A User matching this id and guild id could not be found in the cache."""
+class MemberNotFound(NotFound):
+    """A Member matching this id and guild id could not be found in the cache."""
+
+
+class MemberAddonNotFound(NotFound):
+    """This class has not addon stored on this member."""
 
 
 class GuildNotFound(NotFound):
     """A Guild matching this guild id could not be found in the cache."""
 
 
-class ExtensionError(BaseASHException):
-    """An error occurred that was related to an extension and not AntiSpamHandler"""
+class GuildAddonNotFound(GuildNotFound):
+    """This class has not addon stored on this guild."""
+
+
+class PluginError(BaseASHException):
+    """An error occurred that was related to a plugin and not AntiSpamHandler"""
diff --git a/antispam/ext/__init__.py b/antispam/ext/__init__.py
deleted file mode 100644
index 125d175..0000000
--- a/antispam/ext/__init__.py
+++ /dev/null
@@ -1,2 +0,0 @@
-from .anti_spam_tracker import AntiSpamTracker
-from .anti_mass_mention import AntiMassMention
diff --git a/antispam/ext/anti_mass_mention.py b/antispam/ext/anti_mass_mention.py
deleted file mode 100644
index 697743b..0000000
--- a/antispam/ext/anti_mass_mention.py
+++ /dev/null
@@ -1,210 +0,0 @@
-import logging
-import datetime
-import typing
-from dataclasses import dataclass, asdict
-
-import discord
-
-from antispam.exceptions import UserNotFound
-from antispam.base_extension import BaseExtension
-from antispam.ext.user_tracking import UserTracking
-
-
-log = logging.getLogger(__name__)
-
-
-@dataclass
-class MassMentionPunishment:
-    """
-    This dataclass is what is dispatched to
-    `on_mass_mention_punishment`
-
-    Parameters
-    ----------
-    user_id : int
-        The associated users id
-    guild_id : int
-        The associated guilds id
-    is_overall_punishment : bool
-        If this is ``True``, it means the user
-        has exceeded ``total_mentions_before_punishment``.
-        Otherwise they have exceeded ``min_mentions_per_message``
-    """
-
-    user_id: int
-    guild_id: int
-    is_overall_punishment: bool
-
-
-@dataclass
-class Tracking:
-    mentions: int
-    timestamp: datetime.datetime
-
-
-class AntiMassMention(BaseExtension):
-    """A simple class used to track mentions"""
-
-    def __init__(
-        self,
-        bot,
-        *,
-        total_mentions_before_punishment: int = 10,
-        time_period: int = 15000,
-        min_mentions_per_message: int = 5,
-    ):
-        """
-
-        Parameters
-        ----------
-        bot : commands.Bot or commands.AutoShardedBot or discord.Client or discord.AutoShardedClient
-            Our bot instance
-        total_mentions_before_punishment : int
-            How many mentions within the time period
-            before we punish the user
-            *Inclusive*
-        time_period : int
-            The time period valid for mentions
-            *Is in milliseconds*
-        min_mentions_per_message : int
-            The minimum amount of mentions in a message
-            before a punishment is issued
-            *Inclusive*
-        """
-        super().__init__()
-        self.bot = bot
-        self.data = UserTracking()
-
-        if min_mentions_per_message > total_mentions_before_punishment:
-            raise ValueError(
-                "Expected `min_mentions_per_message` to be less then or equal to `total_mentions_before_punishment`"
-            )
-
-        if time_period < 1:
-            raise ValueError("Expected `time_period` to be positive")
-
-        self.min_mentions_per_message = min_mentions_per_message
-        self.total_mentions_before_punishment = total_mentions_before_punishment
-        self.time_period = time_period
-
-        log.debug("Initialized AntiMassMessage")
-
-    async def propagate(
-        self, message: discord.Message, data: typing.Optional[dict] = None
-    ) -> dict:
-        """
-        Manages and stores any mass mentions per users
-        Parameters
-        ----------
-        message : discord.Message
-            The message to interact with
-        data : None
-            Not expected/wanted
-
-        Returns
-        -------
-        dict
-            A dictionary explaining what
-            actions have been taken
-        """
-        user_id = message.author.id
-        guild_id = message.guild.id
-
-        log.debug(f"Propagating message for {user_id}, guild:{guild_id}")
-
-        try:
-            user = self.data.get_user(guild_id, user_id)
-        except UserNotFound:
-            user = {"total_mentions": []}
-            """
-            {
-                "total_mentions": [
-                    {
-                        int amount : Datetime timestamp
-                    }
-                ]
-            }
-            """
-
-        mentions = set(message.mentions)
-        user["total_mentions"].append(
-            Tracking(mentions=len(mentions), timestamp=message.created_at)
-        )
-        self.data.set_user(guild_id, user_id, user)
-        self._clean_mention_timestamps(
-            guild_id=guild_id,
-            user_id=user_id,
-            current_time=datetime.datetime.now(),
-        )
-
-        if len(mentions) >= self.min_mentions_per_message:
-            # They mention too many people in this message so punish
-            log.info("Dispatching punishment event, is_overall_punishment=False")
-            payload = MassMentionPunishment(
-                user_id=user_id,
-                guild_id=guild_id,
-                is_overall_punishment=False,
-            )
-            self.bot.dispatch(
-                "mass_mention_punishment",
-                payload,
-            )
-            return asdict(payload)
-
-        user = self.data.get_user(guild_id=guild_id, user_id=user_id)
-        if (
-            sum(item.mentions for item in user["total_mentions"])
-            >= self.total_mentions_before_punishment
-        ):
-            # They have more mentions are cleaning then allowed,
-            # So time to punish them
-            log.info("Dispatching punishment event, is_overall_punishment=True")
-            payload = MassMentionPunishment(
-                user_id=user_id,
-                guild_id=guild_id,
-                is_overall_punishment=True,
-            )
-            self.bot.dispatch(
-                "mass_mention_punishment",
-                payload,
-            )
-            return asdict(payload)
-
-        return {"action": "No action taken"}
-
-    def _clean_mention_timestamps(
-        self, guild_id: int, user_id: int, current_time: datetime.datetime
-    ):
-        """
-        Cleans the internal cache for a user to only keep current mentions
-        Parameters
-        ----------
-        guild_id : int
-            The guild the users in
-        user_id : int
-            The user to clean
-
-        Notes
-        -----
-        Expects the user to exist in ``self.data``. This
-        does no form of validation for existence
-
-        """
-        log.debug(f"Cleaning timestamps for {user_id}, guild: {guild_id}")
-
-        def _is_still_valid(timestamp):
-            difference = current_time - timestamp
-            offset = datetime.timedelta(milliseconds=self.time_period)
-
-            if difference >= offset:
-                return False
-            return True
-
-        user = self.data.get_user(guild_id=guild_id, user_id=user_id)
-        valid_items = []
-        for item in user["total_mentions"]:
-            if _is_still_valid(item.timestamp):
-                valid_items.append(item)
-
-        user["total_mentions"] = valid_items
-        self.data.set_user(guild_id=guild_id, user_id=user_id, user_data=user)
diff --git a/antispam/ext/anti_spam_tracker.py b/antispam/ext/anti_spam_tracker.py
deleted file mode 100644
index 541699f..0000000
--- a/antispam/ext/anti_spam_tracker.py
+++ /dev/null
@@ -1,564 +0,0 @@
-"""
-LICENSE
-The MIT License (MIT)
-
-Copyright (c) 2020-2021 Skelmis
-
-Permission is hereby granted, free of charge, to any person obtaining a
-copy of this software and associated documentation files (the "Software"),
-to deal in the Software without restriction, including without limitation
-the rights to use, copy, modify, merge, publish, distribute, sublicense,
-and/or sell copies of the Software, and to permit persons to whom the
-Software is furnished to do so, subject to the following conditions:
-The above copyright notice and this permission notice shall be included in
-all copies or substantial portions of the Software.
-
-THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
-OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
-FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
-DEALINGS IN THE SOFTWARE.
-LICENSE
-"""
-import collections.abc
-import datetime
-import logging
-from copy import deepcopy
-from unittest.mock import AsyncMock
-
-import discord
-import typing
-
-from antispam import AntiSpamHandler
-from antispam.base_extension import BaseExtension
-from antispam.exceptions import UserNotFound, ExtensionError
-
-log = logging.getLogger(__name__)
-
-
-class AntiSpamTracker(BaseExtension):
-    """
-    A class devoted to people who want to handle punishments themselves.
-
-    This class wraps a few things, and handles the logic of ensuring
-    everything exists (or doesnt) among other things such as
-    untracking users after the valid storage interval expires
-
-    In order to use
-    this in your code, you can either:\n
-     - Subclass this class and override the ``do_punishment`` method and then use it that way to keep it clean
-     - Initialize this class and simply use the bool ``is_spamming()`` and do punishments based off that
-     - Initialize this class and simply use ``get_user_count()`` to get the number of times the user should be punished and do your own logic
-
-    *This mainly just depends on how granular you want to be within your code base.*
-
-    The way it works, is everytime you call ``propagate`` you simply pass the returned
-    data into `update_cache` and it will update said Members cache if AntiSpamHandler
-    thinks that they should be punished. Now, you set ``spam_amount_to_punish``
-    when creating an instance of this class and that is used to check if YOU
-    think they should be punished, and what punishment to give when they hit that cap.
-
-    **Basically:**\n
-    ``propagate`` -> ``update_cache``, if the User should be punished we increment internal counter
-
-    ``is_spamming`` -> Checks if the User's internal counter meets ``spam_amount_to_punish`` and returns a bool
-
-    Notes
-    =====
-    This Class recognizes that individual guilds can have different options
-    and will attempt to work with said options to the best of its ability.
-    This is lazily conducted however, so if you wish to use any of the methods
-    listed below please call them on this class rather then on your base
-    AntiSpamHandler. (They will also update the AntiSpamHandler dont worry)
-
-    - ``add_custom_guild_options``
-    - ``remove_custom_guild_options``
-    """
-
-    __slots__ = [
-        "user_tracking",
-        "valid_global_interval",
-        "anti_spam_handler",
-        "punish_min_amount",
-    ]
-
-    def __init__(
-        self,
-        anti_spam_handler: AntiSpamHandler,
-        spam_amount_to_punish,
-        valid_timestamp_interval=None,
-    ) -> None:
-        """
-        Initialize this class and get it ready for usage.
-
-        Parameters
-        ----------
-        anti_spam_handler : AntiSpamHandler
-            Your AntiSpamHandler instance
-        spam_amount_to_punish : int
-            A number denoting the minimum value required
-            per user in order trip `is_spamming`
-
-            **NOTE this is in milliseconds**
-        valid_timestamp_interval : int
-            How long a timestamp should remain 'valid' for.
-            Defaults to ``AntiSpamHandler.options.get("message_interval")``
-
-        Raises
-        ------
-        TypeError
-            Invalid Arg Type
-        ValueError
-            Invalid Arg Type
-        """
-        super().__init__(is_pre_invoke=False)
-
-        self.punish_min_amount = int(spam_amount_to_punish)
-
-        if not isinstance(anti_spam_handler, AntiSpamHandler):
-            raise TypeError("Expected anti_spam_handler of type AntiSpamHandler")
-
-        self.anti_spam_handler = anti_spam_handler
-
-        if valid_timestamp_interval is not None:
-            if isinstance(valid_timestamp_interval, (str, float)):
-                valid_timestamp_interval = int(valid_timestamp_interval)
-            elif isinstance(valid_timestamp_interval, int):
-                pass
-            else:
-                raise TypeError("Expected valid_timestamp_interval of type int")
-
-        self.valid_global_interval = (
-            valid_timestamp_interval
-            or anti_spam_handler.options.get("message_interval")
-        )
-        self.valid_global_interval = int(self.valid_global_interval)
-
-        self.user_tracking = {}
-
-        log.info("AntiSpamTracker is initialized and ready to go")
-
-    def __str__(self):
-        guilds = len(self.user_tracking)
-        users = 0
-        for guild in self.user_tracking.values():
-            users += len(guild)
-
-        return_value = f"AntiSpamTracker(AntiSpamHandler Instance, {self.punish_min_amount}, {self.valid_global_interval})"
-        return_value += f"\nGuilds: {guilds} | Users (Non Unique): {users}"
-        return return_value
-
-    def __repr__(self):
-        return_value = f"AntiSpamTracker(AntiSpamHandler Instance, {self.punish_min_amount}, {self.valid_global_interval})\n"
-        return_value += str(self.user_tracking)
-        return return_value
-
-    async def propagate(
-        self, message: discord.Message, data: typing.Optional[dict] = None
-    ) -> dict:
-        """
-        Overwrite the base extension to call ``update_cache``
-        internally so it can be used as an extension
-        """
-        self.update_cache(message, data)
-        return {"status": "Cache updated"}
-
-    def update_cache(self, message: discord.Message, data: dict) -> None:
-        """
-        Takes the data returned from `propagate`
-        and updates this Class's internal cache
-
-        Parameters
-        ----------
-        message : discord.Message
-            The message related to `data's` propagation
-        data : dict
-            The data returned from `propagate`
-        """
-        if not isinstance(message, (discord.Message, AsyncMock)):
-            raise TypeError("Expected message of type: discord.Message")
-
-        if not isinstance(data, dict):
-            raise TypeError("Expected data of type: dict")
-
-        if not message.guild:
-            return
-
-        user_id = message.author.id
-        guild_id = message.guild.id
-        timestamp = datetime.datetime.now(datetime.timezone.utc)
-
-        if not data.get("should_be_punished_this_message"):
-            # They shouldn't be punished so don't increase cache
-            return
-
-        # We now need to increase there cache
-        if guild_id not in self.user_tracking:
-            self.user_tracking[guild_id] = {}
-
-        if user_id not in self.user_tracking[guild_id]:
-            self.user_tracking[guild_id][user_id] = []
-
-        self.user_tracking[guild_id][user_id].append(timestamp)
-        log.debug(f"Cache updated for user ({user_id}) in guild ({guild_id})")
-
-    def get_user_count(self, message: discord.Message) -> int:
-        """
-        Returns how many messages that are still 'valid'
-        (counted as spam) a certain user has
-
-        Parameters
-        ----------
-        message : discord.Message
-            The message from which to extract user
-
-        Returns
-        -------
-        int
-            How many times this user has sent a
-            message that has been marked as
-            'punishment worthy' by AntiSpamHandler
-            within the valid interval time period
-
-        Raises
-        ------
-        UserNotFound
-            The User for the ``message`` could not be found
-
-        """
-        if not isinstance(message, (discord.Message, AsyncMock)):
-            raise TypeError("Expected message of type: discord.Message")
-
-        if not message.guild:
-            raise UserNotFound("Can't find user's from dm's")
-
-        user_id = message.author.id
-        guild_id = message.guild.id
-
-        if guild_id not in self.user_tracking:
-            raise UserNotFound
-
-        if user_id not in self.user_tracking[guild_id]:
-            raise UserNotFound
-
-        self.remove_outdated_timestamps(guild_id=guild_id, user_id=user_id)
-
-        return len(self.user_tracking[guild_id][user_id])
-
-    def remove_outdated_timestamps(self, guild_id, user_id):
-        """
-        This logic works around checking the current
-        time vs a messages creation time. If the message
-        is older by the config amount it can be cleaned up
-
-        *Generally not called by the end user*
-
-        Parameters
-        ==========
-        guild_id : int
-            The guild's id to clean
-        user_id : int
-            The id of the user to clean up
-
-        """
-        log.debug("Attempting to remove outdated timestamp's")
-        current_time = datetime.datetime.now(datetime.timezone.utc)
-
-        def _is_still_valid(timestamp_obj):
-            """
-            Given a timestamp, figure out if it hasn't
-            expired yet
-            """
-            difference = current_time - timestamp_obj
-            offset = datetime.timedelta(
-                milliseconds=self._get_guild_valid_interval(guild_id=guild_id)
-            )
-
-            if difference >= offset:
-                return False
-            return True
-
-        current_timestamps = []
-
-        for timestamp in self.user_tracking[guild_id][user_id]:
-            if _is_still_valid(timestamp):
-                current_timestamps.append(timestamp)
-
-        log.debug(
-            f"Removed {len(self.user_tracking[guild_id][user_id]) - len(current_timestamps)} 'timestamps'"
-        )
-
-        self.user_tracking[guild_id][user_id] = deepcopy(current_timestamps)
-
-    def remove_punishments(self, message: discord.Message):
-        """
-        After you punish someone, call this method
-        to 'clean up' there punishments.
-
-        Parameters
-        ----------
-        message : discord.Message
-            The message to extract user from
-
-        Raises
-        ------
-        TypeError
-            Invalid arg
-
-        Notes
-        -----
-        If the user can't be found in cache,
-        it executes the same as if the user
-        could be found in cache for what should
-        be somewhat obvious reasons. (They shouldn't
-        exist after this method finishes)
-        """
-        if not isinstance(message, (discord.Message, AsyncMock)):
-            raise TypeError("Expected message of type: discord.Message")
-
-        if not message.guild:
-            return
-
-        user_id = message.author.id
-        guild_id = message.guild.id
-
-        if guild_id not in self.user_tracking:
-            return
-
-        if user_id not in self.user_tracking[guild_id]:
-            return
-
-        self.user_tracking[guild_id].pop(user_id)
-
-    def clean_cache(self) -> None:
-        """
-        Cleans the entire internal cache
-        removing any empty users, and empty
-        guilds by extension
-
-        Notes
-        -----
-        This is more part of the Optimising Package Usage section,
-        run this once a week/day or somethin depending on how big
-        your bot is. I haven't profiled things.
-        """
-        for guild_id, guild in deepcopy(self.user_tracking).items():
-            for user_id, user in deepcopy(guild).items():
-                self.remove_outdated_timestamps(guild_id=guild_id, user_id=user_id)
-
-                if len(self.user_tracking[guild_id][user_id]) == 0:
-                    self.user_tracking[guild_id].pop(user_id)
-
-            if not bool(self.user_tracking[guild_id]):
-                self.user_tracking.pop(guild_id)
-
-        log.debug("Successfully cleaned the cache")
-
-    def _get_guild_valid_interval(self, guild_id):
-        """
-        Returns the correct ``valid_global_interval``
-        except on a per guild level taking into account
-        custom guild options
-
-        Parameters
-        ----------
-        guild_id
-
-        Returns
-        -------
-        int
-            The correct interval time
-
-        Notes
-        -----
-        Silently ignores if a guild doesnt exist.
-        There is a global default for a reason.
-        """
-        if guild_id not in self.user_tracking:
-            return self.valid_global_interval
-
-        if "valid_interval" not in self.user_tracking[guild_id]:
-            return self.valid_global_interval
-
-        return self.user_tracking[guild_id]["valid_interval"]
-
-    def is_spamming(self, message: discord.Message) -> bool:
-        """
-        Given a message, deduce and return if a user
-        is classed as 'spamming' or not based on ``punish_min_amount``
-
-        Parameters
-        ----------
-        message : discord.Messsage
-            The message to extract guild and user from
-
-        Returns
-        -------
-        bool
-            True if the User is spamming else False
-
-        """
-        if not isinstance(message, (discord.Message, AsyncMock)):
-            raise TypeError("Expected message of type: discord.Message")
-
-        if not message.guild:
-            return False
-
-        try:
-            user_count = self.get_user_count(message=message)
-            if user_count >= self.punish_min_amount:
-                return True
-        except UserNotFound:
-            return False
-        else:
-            return False
-
-    async def do_punishment(self, message: discord.Message, *args, **kwargs) -> None:
-        """
-        This only exists for if the user wishes to subclass
-        this class and implement there own logic for punishments
-        here.
-
-        Parameters
-        ----------
-        message : discord.Message
-            The message to extract the guild and user from
-
-        Notes
-        -----
-        This does nothing unless you subclass
-        and implement it yourself.
-
-        """
-        pass
-
-    def save_to_dict(self) -> dict:
-        """
-        Similar to ``AntiSpamHandler.save_to_dict`` this method returns
-        a dictionary which can be used to rebuild the state of the class
-
-        Returns
-        -------
-        dict
-            The dict to rebuild state from
-
-        """
-        user_tracking = {}
-        for guild_id, guild in self.user_tracking.items():
-            user_tracking[guild_id] = {}
-
-            for user_id, timestamp_list in guild.items():
-                user_tracking[guild_id][user_id] = []
-
-                for timestamp in timestamp_list:
-                    user_tracking[guild_id][user_id].append(
-                        timestamp.strftime("%f:%S:%M:%H:%d:%Y")
-                    )
-
-        data = {
-            "min_punish_amount": self.punish_min_amount,
-            "valid_global_interval": self.valid_global_interval,
-            "user_tracking": user_tracking,
-        }
-        return data
-
-    @staticmethod
-    def load_from_dict(anti_spam_handler: AntiSpamHandler, data: dict):
-        """
-        Given a valid input *(from ``save_to_dict``)* build and
-        return a valid AntiSpamTracker state that matches the previously
-        saved state.
-
-        Parameters
-        ----------
-        anti_spam_handler : AntiSpamHandler
-            The AntiSpamHandler instance to attach this to
-        data : dict
-            The state to restore from
-
-        Returns
-        -------
-        AntiSpamTracker
-            A valid AntiSpamTracker instance restored
-            from the state provided to this method
-        """
-        if not isinstance(data, collections.abc.Mapping):
-            raise ExtensionError("Invalid datatype for load_from_dict")
-
-        tracker = AntiSpamTracker(
-            anti_spam_handler, data["min_punish_amount"], data["valid_global_interval"]
-        )
-        user_tracking = {}
-        for guild_id, guild in data["user_tracking"].items():
-            user_tracking[guild_id] = {}
-
-            for user_id, timestamp_list in guild.items():
-                user_tracking[guild_id][user_id] = []
-
-                for timestamp in timestamp_list:
-                    user_tracking[guild_id][user_id].append(
-                        datetime.datetime.strptime(timestamp, "%f:%S:%M:%H:%d:%Y")
-                    )
-
-        tracker.user_tracking = user_tracking
-
-        return tracker
-
-    # <!-- Stuff I take over from AntiSpamHandler -->
-    def add_custom_guild_options(self, guild_id: int, **kwargs):
-        """
-        To see how to use this, refer to ``antispam.AntiSpamHandler.AntiSpamHandler.add_custom_guild_options``
-
-        See Also
-        --------
-        antispam.AntiSpamHandler.AntiSpamHandler.add_custom_guild_options :
-            The method that this calls under the hood
-
-        Notes
-        -----
-        Not unit-tested, I just assume it works
-        since ``add_custom_guild_options`` is unit-tested.
-        If this doesnt work as intended, please open
-        an issue.
-
-        """
-        if kwargs.get("message_interval"):
-            if guild_id not in self.user_tracking:
-                self.user_tracking[guild_id] = {}
-
-            self.user_tracking[guild_id]["valid_interval"] = kwargs.get(
-                "message_interval"
-            )
-            log.debug(f"Set custom 'valid_interval' for guild: {guild_id}")
-
-        self.anti_spam_handler.add_custom_guild_options(guild_id=guild_id, **kwargs)
-
-        log.debug("Did method 'add_custom_guild_options'")
-
-    def remove_custom_guild_options(self, guild_id: int):
-        """
-        To see how to use this, refer to ``antispam.AntiSpamHandler.AntiSpamHandler.remove_custom_guild_options``
-
-        See Also
-        --------
-        antispam.AntiSpamHandler.AntiSpamHandler.remove_custom_guild_options :
-            The method that this calls under the hood
-
-        Notes
-        -----
-        Not unit-tested, I just assume it works
-        since ``add_custom_guild_options`` is unit-tested.
-        If this doesnt work as intended, please open
-        an issue.
-
-        """
-        if guild_id in self.user_tracking:
-            if "valid_interval" in self.user_tracking[guild_id]:
-                self.user_tracking[guild_id].pop("valid_interval")
-                log.debug(f"Removed custom 'valid_interval' for guild: {guild_id}")
-
-        self.anti_spam_handler.add_custom_guild_options(guild_id=guild_id)
-
-        log.debug("Did method 'remove_custom_guild_options'")
diff --git a/antispam/ext/user_tracking.py b/antispam/ext/user_tracking.py
deleted file mode 100644
index e244b61..0000000
--- a/antispam/ext/user_tracking.py
+++ /dev/null
@@ -1,84 +0,0 @@
-from antispam.exceptions import UserNotFound, GuildNotFound
-
-
-class UserTracking:
-    """A simple class used to track users in guilds"""
-
-    def __init__(self):
-        self.data = {}
-
-    def _get_guild(self, guild_id: int) -> dict:
-        """
-        Returns the given guild's data
-
-        Parameters
-        ----------
-        guild_id : int
-            The guild to get data for
-
-        Returns
-        -------
-        dict
-            This guilds data
-
-        Raises
-        ------
-        GuildNotFound
-            This guild could not be found
-        """
-        if guild_id not in self.data:
-            raise GuildNotFound
-
-        return self.data[guild_id]
-
-    def get_user(self, guild_id: int, user_id: int) -> dict:
-        """
-        Returns the given data for a stored user
-
-        Parameters
-        ----------
-        guild_id : int
-            The guild for the user we want
-        user_id : int
-            The user we want to get data for
-
-        Returns
-        -------
-        dict
-            A dictionary of stored data on this user
-
-        Raises
-        ------
-        UserNotFound
-            The given user/guild could not be found internally
-        """
-        try:
-            guild = self._get_guild(guild_id=guild_id)
-        except GuildNotFound:
-            raise UserNotFound
-
-        if user_id not in guild:
-            raise UserNotFound
-
-        return guild[user_id]
-
-    def set_user(self, guild_id: int, user_id: int, user_data: dict) -> None:
-        """
-        Stores a user's data within a guild
-
-        Parameters
-        ----------
-        guild_id : int
-            The guild to add this user's
-            data into
-        user_id : int
-            The user's id to store
-        user_data : dict
-            The data to store
-        """
-        try:
-            self._get_guild(guild_id=guild_id)
-        except GuildNotFound:
-            self.data[guild_id] = {}
-
-        self.data[guild_id][user_id] = user_data
diff --git a/antispam/factory.py b/antispam/factory.py
new file mode 100644
index 0000000..b10d6c8
--- /dev/null
+++ b/antispam/factory.py
@@ -0,0 +1,130 @@
+import logging
+from copy import deepcopy
+import datetime
+
+from .abc import Cache
+from .dataclasses import Guild, Member, Message, Options
+
+
+log = logging.getLogger(__name__)
+
+
+class FactoryBuilder:
+    """A factory to build dataclasses from dictionaries"""
+
+    @staticmethod
+    def create_guild_from_dict(guild_data: dict) -> Guild:
+        guild: Guild = Guild(
+            id=guild_data["id"], options=Options(**guild_data["options"])
+        )
+
+        for member in guild_data["members"]:
+            guild.members[member["id"]] = FactoryBuilder.create_member_from_dict(member)
+
+        log.info("Created Guild(id=%s) from dict", guild.id)
+
+        return guild
+
+    @staticmethod
+    def create_member_from_dict(member_data) -> Member:
+        member: Member = Member(id=member_data["id"], guild_id=member_data["guild_id"])
+        member._in_guild = member_data["is_in_guild"]
+        member.warn_count = member_data["warn_count"]
+        member.kick_count = member_data["kick_count"]
+        member.duplicate_counter = member_data["duplicate_count"]
+        member.duplicate_channel_counter_dict = deepcopy(
+            member_data["duplicate_channel_counter_dict"]
+        )
+
+        for message_data in member_data["messages"]:
+            member.messages.append(
+                FactoryBuilder.create_message_from_dict(message_data)
+            )
+
+        log.info("Created Member(id=%s) from dict", member.id)
+
+        return member
+
+    @staticmethod
+    def create_message_from_dict(message_data) -> Message:
+        message = Message(
+            id=message_data["id"],
+            content=message_data["content"],
+            guild_id=message_data["guild_id"],
+            author_id=message_data["author_id"],
+            channel_id=message_data["channel_id"],
+        )
+        message.is_duplicate = message_data["is_duplicate"]
+        message.creation_time = datetime.datetime.strptime(
+            message_data["creation_time"], "%f:%S:%M:%H:%d:%m:%Y"
+        )
+        log.info("Created Message(id=%s) from dict", message.id)
+
+        return message
+
+    @staticmethod
+    def clean_old_messages(member: Member, current_time, options):
+        def _is_still_valid(message_obj):
+            """
+            Given a message, figure out if it hasn't
+            expired yet based on timestamps
+            """
+            difference = current_time - message_obj.creation_time
+            offset = datetime.timedelta(milliseconds=options.message_interval)
+
+            if difference >= offset:
+                return False
+            return True
+
+        current_messages = []
+        outstanding_messages = []
+
+        for message in member.messages:
+            if _is_still_valid(message):
+                current_messages.append(message)
+            else:
+                outstanding_messages.append(message)
+
+        member.messages = current_messages
+
+    @staticmethod
+    async def get_all_members_as_list(cache: Cache, guild_id: int):
+        """
+        Given a cache, evaluate the async
+        generator in order to return a list
+
+        Parameters
+        ----------
+        cache : Cache
+            The cache to use to fetch stuff
+        guild_id : int
+            The guild to get members from
+
+        Returns
+        -------
+        List
+            A list of all members in the guild
+
+        Raises
+        ------
+        GuildNotFound
+        """
+        return [member async for member in cache.get_all_members(guild_id=guild_id)]
+
+    @staticmethod
+    async def get_all_guilds_as_list(cache: Cache):
+        """
+        Given a cache, evaluate the async
+        generator in order to return a list
+
+        Parameters
+        ----------
+        cache : Cache
+            The cache to use to fetch stuff
+
+        Returns
+        -------
+        List
+            A list of all cached guilds
+        """
+        return [guild async for guild in cache.get_all_guilds()]
diff --git a/antispam/guild.py b/antispam/guild.py
deleted file mode 100644
index 6299306..0000000
--- a/antispam/guild.py
+++ /dev/null
@@ -1,266 +0,0 @@
-"""
-LICENSE
-The MIT License (MIT)
-
-Copyright (c) 2020-2021 Skelmis
-
-Permission is hereby granted, free of charge, to any person obtaining a
-copy of this software and associated documentation files (the "Software"),
-to deal in the Software without restriction, including without limitation
-the rights to use, copy, modify, merge, publish, distribute, sublicense,
-and/or sell copies of the Software, and to permit persons to whom the
-Software is furnished to do so, subject to the following conditions:
-The above copyright notice and this permission notice shall be included in
-all copies or substantial portions of the Software.
-
-THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
-OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
-FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
-DEALINGS IN THE SOFTWARE.
-LICENSE
-"""
-import collections.abc
-import logging
-from copy import deepcopy
-from unittest.mock import AsyncMock
-
-import discord
-
-from antispam.user import User
-from antispam.exceptions import ObjectMismatch, DuplicateObject
-
-log = logging.getLogger(__name__)
-
-"""
-This is used to maintain a collection of User's in a relevant guild
-"""
-
-
-class Guild:
-    """Represents a guild that maintains a collection of User's"""
-
-    __slots__ = [
-        "_id",
-        "_bot",
-        "_users",
-        "_options",
-        "has_custom_options",
-    ]
-
-    def __init__(self, bot, guild_id, options, *, custom_options=False):
-        """
-
-        Parameters
-        ----------
-        bot: commands.Bot
-            The global bot instance
-        guild_id : int
-            This guilds id
-        options : dict
-            The options for this guild
-        custom_options : bool
-            Whether this guild has custom options or not
-        """
-        self.id = int(guild_id)
-        self._bot = bot
-        self._users = []
-        self._options = deepcopy(options)
-
-        self.has_custom_options = custom_options
-
-    def __repr__(self):
-        return (
-            f"'{self.__class__.__name__} object. Guild id: {self.id}, "
-            f"Len Stored Users {len(self._users)}'"
-        )
-
-    def __str__(self):
-        return f"{self.__class__.__name__} object for {self.id}."
-
-    def __eq__(self, other):
-        """
-        This is called with a 'obj1 == obj2' comparison object is made
-
-        Checks against stored id's to figure out if they are
-        representing the same User or not
-
-        Parameters
-        ----------
-        other : Guild
-            The object to compare against
-
-        Returns
-        -------
-        bool
-            `True` or `False` depending on whether they are the same or not
-
-        Raises
-        ======
-        ValueError
-            When the comparison object is not of ignore_type `Message`
-        """
-        if not isinstance(other, Guild):
-            raise ValueError("Expected two Guild objects to compare")
-
-        if self.id == other.id:
-            return True
-        return False
-
-    def __hash__(self):
-        """
-        Given we create a __eq__ dunder method, we also needed
-        to create one for __hash__ lol
-
-        Returns
-        -------
-        int
-            The hash of all id's
-        """
-        return hash(self.id)
-
-    async def propagate(self, message: discord.Message):
-        """
-        This method handles a message object and then adds it to
-        the relevant member
-
-        Parameters
-        ==========
-        message : discord.Message
-            The message that needs to be propagated out
-
-        Returns
-        =======
-        dict
-            A dictionary of useful information about the user in question
-
-        Warnings
-        ========
-        Calling this method yourself will bypass all checks
-        """
-        if not isinstance(message, discord.Message) and not isinstance(
-            message, AsyncMock
-        ):
-            raise ValueError("Expected message of ignore_type: discord.Message")
-
-        user = User(
-            self._bot,
-            message.author.id,
-            message.guild.id,
-            self.options,
-        )
-        try:
-            user = next(iter(u for u in self._users if u == user))
-        except StopIteration:
-            self.users = user
-            log.info(f"Created User: {user.id}")
-
-        return await user.propagate(message)
-
-    @staticmethod
-    def load_from_dict(bot, guild_data):
-        """
-        Loads the guild based on the passed data
-
-        Parameters
-        ----------
-        bot : commands.Bot
-            The bot
-        guild_data : dict
-            The data to load from
-
-        Returns
-        -------
-        Guild
-
-        """
-        guild = Guild(
-            bot=bot,
-            guild_id=guild_data["id"],
-            options=deepcopy(guild_data["options"]),
-            custom_options=guild_data["has_custom_options"],
-        )
-
-        for user in guild_data["users"]:
-            guild.users = User.load_from_dict(bot, user)
-
-        log.debug(f"Created Guild ({guild.id}) from saved state")
-
-        return guild
-
-    async def save_to_dict(self) -> dict:
-        """
-        Returns a dictionary that can be used
-        to reload state at a later time
-
-        Returns
-        -------
-        dict
-            The data required to reload state
-        """
-        data = {
-            "id": self.id,
-            "options": self.options,
-            "has_custom_options": self.has_custom_options,
-            "users": [],
-        }
-
-        for user in self._users:
-            data["users"].append(await user.save_to_dict())
-
-        log.debug(f"Saved state for guild: {self.id}")
-
-        return data
-
-    @property
-    def id(self):
-        return self._id
-
-    @id.setter
-    def id(self, value):
-        if not isinstance(value, int):
-            raise ValueError("Expected integer")
-        self._id = value
-
-    @property
-    def users(self):
-        return self._users
-
-    @users.setter
-    def users(self, value):
-        """
-        Raises
-        ======
-        DuplicateObject
-            It won't maintain two message objects with the same
-            id's, and it will complain about it haha
-        ObjectMismatch
-            Raised if `value` wasn't made by this person, so they
-            shouldn't be the ones maintaining the reference
-        """
-        if not isinstance(value, User):
-            raise ValueError("Expected User object")
-
-        if self.id != value.guild_id:
-            raise ObjectMismatch
-
-        if value in self._users:
-            raise DuplicateObject
-
-        self._users.append(value)
-
-    @property
-    def options(self):
-        return self._options
-
-    @options.setter
-    def options(self, value):
-        if not isinstance(value, collections.abc.Mapping):
-            raise ValueError("Expected dict")
-
-        self._options = deepcopy(value)
-
-        for user in self._users:
-            user.options = value
diff --git a/antispam/libs/__init__.py b/antispam/libs/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/antispam/libs/dpy.py b/antispam/libs/dpy.py
new file mode 100644
index 0000000..3e20e42
--- /dev/null
+++ b/antispam/libs/dpy.py
@@ -0,0 +1,562 @@
+import ast
+import datetime
+import logging
+from string import Template
+from typing import Union, Optional
+from unittest.mock import AsyncMock
+
+try:
+    import nextcord as discord
+except ModuleNotFoundError:
+    import discord
+
+
+from antispam import PropagateFailure, LogicError, MissingGuildPermissions
+from antispam.abc import Lib
+from antispam.dataclasses import Message, Member, Guild
+from antispam.dataclasses.propagate_data import PropagateData
+
+log = logging.getLogger(__name__)
+
+
+# noinspection PyProtocol
+class DPY(Lib):
+    def __init__(self, handler):
+        self.handler = handler
+
+    def get_guild_id(self, message: discord.Message) -> int:
+        return message.guild.id
+
+    def get_channel_id(self, message: discord.Message) -> int:
+        return message.channel.id
+
+    def substitute_args(
+        self,
+        message: str,
+        original_message: discord.Message,
+        warn_count: int,
+        kick_count: int,
+    ) -> str:
+        version = int(discord.__version__.split(".")[0])
+        if version >= 2:
+            member_avatar = original_message.author.avatar.url
+            guild_avatar = original_message.guild.me.avatar.url
+            guild_icon = original_message.guild.icon.url
+        else:
+            member_avatar = original_message.author.avatar_url
+            guild_avatar = original_message.guild.me.avatar_url
+            guild_icon = original_message.guild.icon_url
+
+        return Template(message).safe_substitute(
+            {
+                "MENTIONMEMBER": original_message.author.mention,
+                "MEMBERNAME": original_message.author.display_name,
+                "MEMBERID": original_message.author.id,
+                "BOTNAME": original_message.guild.me.display_name,
+                "BOTID": original_message.guild.me.id,
+                "GUILDID": original_message.guild.id,
+                "GUILDNAME": original_message.guild.name,
+                "TIMESTAMPNOW": datetime.datetime.now().strftime(
+                    "%I:%M:%S %p, %d/%m/%Y"
+                ),
+                "TIMESTAMPTODAY": datetime.datetime.now().strftime("%d/%m/%Y"),
+                "WARNCOUNT": warn_count,
+                "KICKCOUNT": kick_count,
+                "MEMBERAVATAR": member_avatar,
+                "BOTAVATAR": guild_avatar,
+                "GUILDICON": guild_icon,
+            }
+        )
+
+    def embed_to_string(self, embed: discord.Embed) -> str:
+        content = ""
+        embed = embed.to_dict()
+
+        if "title" in embed:
+            content += f"{embed['title']}\n"
+
+        if "description" in embed:
+            content += f"{embed['description']}\n"
+
+        if "footer" in embed:
+            if "text" in embed["footer"]:
+                content += f"{embed['footer']['text']}\n"
+
+        if "author" in embed:
+            content += f"{embed['author']['name']}\n"
+
+        if "fields" in embed:
+            for field in embed["fields"]:
+                content += f"{field['name']}\n{field['value']}\n"
+
+        return content
+
+    def dict_to_embed(
+        self, data: dict, message: discord.Message, warn_count: int, kick_count: int
+    ):
+        allowed_avatars = ["$MEMBERAVATAR", "$BOTAVATAR", "$GUILDICON"]
+
+        if "title" in data:
+            data["title"] = self.substitute_args(
+                data["title"], message, warn_count, kick_count
+            )
+
+        if "description" in data:
+            data["description"] = self.substitute_args(
+                data["description"], message, warn_count, kick_count
+            )
+
+        if "footer" in data:
+            if "text" in data["footer"]:
+                data["footer"]["text"] = self.substitute_args(
+                    data["footer"]["text"], message, warn_count, kick_count
+                )
+
+            if "icon_url" in data["footer"]:
+                if data["footer"]["icon_url"] in allowed_avatars:
+                    data["footer"]["icon_url"] = self.substitute_args(
+                        data["footer"]["icon_url"], message, warn_count, kick_count
+                    )
+
+        if "author" in data:
+            # name 'should' be required
+            data["author"]["name"] = self.substitute_args(
+                data["author"]["name"], message, warn_count, kick_count
+            )
+
+            if "icon_url" in data["author"]:
+                if data["author"]["icon_url"] in allowed_avatars:
+                    data["author"]["icon_url"] = self.substitute_args(
+                        data["author"]["icon_url"], message, warn_count, kick_count
+                    )
+
+        if "fields" in data:
+            for field in data["fields"]:
+                name = self.substitute_args(
+                    field["name"], message, warn_count, kick_count
+                )
+                value = self.substitute_args(
+                    field["value"], message, warn_count, kick_count
+                )
+                field["name"] = name
+                field["value"] = value
+
+                if "inline" not in field:
+                    field["inline"] = True
+
+        if "timestamp" in data:
+            data["timestamp"] = message.created_at.isoformat()
+
+        if "colour" in data:
+            data["color"] = data["colour"]
+
+        data["type"] = "rich"
+
+        return discord.Embed.from_dict(data)
+
+    def transform_message(
+        self,
+        item: Union[str, dict],
+        message: discord.Message,
+        warn_count: int,
+        kick_count: int,
+    ):
+        if isinstance(item, str):
+            return self.substitute_args(item, message, warn_count, kick_count)
+
+        return self.dict_to_embed(item, message, warn_count, kick_count)
+
+    def visualizer(
+        self,
+        content: str,
+        message: discord.Message,
+        warn_count: int = 1,
+        kick_count: int = 2,
+    ):
+        if content.startswith("{"):
+            content = ast.literal_eval(content)
+
+        return self.transform_message(content, message, warn_count, kick_count)
+
+    async def check_message_can_be_propagated(
+        self, message: discord.Message
+    ) -> PropagateData:
+        if not isinstance(message, (discord.Message, AsyncMock)):
+            raise PropagateFailure(
+                data={"status": "Expected message of type discord.Message"}
+            )
+
+        # Ensure we only moderate actual guild messages
+        if not message.guild:
+            log.debug(
+                "Message(id=%s) from Member(id=%s) was not in a guild",
+                message.id,
+                message.author.id,
+            )
+            raise PropagateFailure(data={"status": "Ignoring messages from dm's"})
+
+        # The bot is immune to spam
+        if message.author.id == self.handler.bot.user.id:
+            log.debug("Message(id=%s) was from myself", message.id)
+            raise PropagateFailure(
+                data={"status": "Ignoring messages from myself (the bot)"}
+            )
+
+        if isinstance(message.author, discord.User):  # pragma: no cover
+            log.warning(f"Given Message(id=%s) with an author of type User", message.id)
+
+        # Return if ignored bot
+        if self.handler.options.ignore_bots and message.author.bot:
+            log.debug(
+                "I ignore bots, and this is a bot message with author(id=%s)",
+                message.author.id,
+            )
+            raise PropagateFailure(data={"status": "Ignoring messages from bots"})
+
+        # Return if ignored guild
+        if message.guild.id in self.handler.options.ignored_guilds:
+            log.debug("Ignored Guild(id=%s)", message.guild.id)
+            raise PropagateFailure(
+                data={"status": f"Ignoring this guild: {message.guild.id}"}
+            )
+
+        # Return if ignored member
+        if message.author.id in self.handler.options.ignored_members:
+            log.debug(
+                "The Member(id=%s) who sent this message is ignored", message.author.id
+            )
+            raise PropagateFailure(
+                data={"status": f"Ignoring this member: {message.author.id}"}
+            )
+
+        # Return if ignored channel
+        if (
+            message.channel.id in self.handler.options.ignored_channels
+            or message.channel.name in self.handler.options.ignored_channels
+        ):
+            log.debug("channel(id=%s) is ignored", message.channel)
+            raise PropagateFailure(
+                data={"status": f"Ignoring this channel: {message.channel.id}"}
+            )
+
+        # Return if member has an ignored role
+        try:
+            user_roles = [role.id for role in message.author.roles]
+            user_roles.extend([role.name for role in message.author.roles])
+            for item in user_roles:
+                if item in self.handler.options.ignored_roles:
+                    log.debug("role(%s) is a part of ignored roles", item)
+                    raise PropagateFailure(
+                        data={"status": f"Ignoring this role: {item}"}
+                    )
+        except AttributeError:
+            log.warning(
+                "Could not compute ignored_roles for %s(%s)",
+                message.author.name,
+                message.author.id,
+            )
+
+        perms = message.guild.me.guild_permissions
+        has_perms = perms.kick_members and perms.ban_members
+
+        return PropagateData(
+            guild_id=message.guild.id,
+            member_name=message.author.name,
+            member_id=message.author.id,
+            has_perms_to_make_guild=has_perms,
+        )
+
+    def create_message(self, message: discord.Message) -> Message:
+        log.debug(
+            "Attempting to create a new message for author(id=%s) in Guild(%s)",
+            message.author.id,
+            message.guild.id,
+        )
+        if not bool(message.content and message.content.strip()):
+            if not message.embeds:
+                raise LogicError
+
+            embed = message.embeds[0]
+            if not isinstance(embed, discord.Embed):
+                raise LogicError
+
+            if embed.type.lower() != "rich":
+                raise LogicError
+
+            content = self.embed_to_string(embed)
+        else:
+            content = message.clean_content
+
+        if self.handler.options.delete_zero_width_chars:
+            content = (
+                content.replace("u200B", "")
+                .replace("u200C", "")
+                .replace("u200D", "")
+                .replace("u200E", "")
+                .replace("u200F", "")
+                .replace("uFEFF", "")
+            )
+
+        return Message(
+            id=message.id,
+            channel_id=message.channel.id,
+            guild_id=message.guild.id,
+            author_id=message.author.id,
+            content=content,
+        )
+
+    async def send_guild_log(
+        self,
+        guild,
+        message: Union[str, discord.Embed],
+        delete_after_time,
+        original_channel: Union[discord.abc.GuildChannel, discord.abc.PrivateChannel],
+        file=None,
+    ) -> None:  # pragma: no cover
+        try:
+            if not guild.log_channel_id:
+                log.debug(
+                    "Guild(id=%s) has no log channel set, defaulting to original channel",
+                    guild.id,
+                )
+                channel = original_channel
+            else:
+                channel = guild.log_channel_id
+
+                channel = self.handler.bot.get_channel(channel)
+                if not channel:
+                    channel = await self.handler.bot.fetch_channel(channel)
+
+            if isinstance(message, str):
+                await channel.send(message, delete_after=delete_after_time, file=file)
+            else:
+                await channel.send(embed=message, file=file)
+
+            log.debug("Sent message to log channel in Guild(id=%s)", guild.id)
+        except discord.HTTPException:
+            log.error(
+                "Failed to send log message in Guild(id=%s). HTTPException", guild.id
+            )
+
+    async def punish_member(
+        self,
+        original_message: discord.Message,
+        member: Member,
+        internal_guild: Guild,
+        user_message,
+        guild_message,
+        is_kick: bool,
+        user_delete_after: int = None,
+        channel_delete_after: int = None,
+    ):  # pragma: no cover
+        guild = original_message.guild
+        author = original_message.author
+
+        # Check we have perms to punish
+        perms = guild.me.guild_permissions
+        if not perms.kick_members and is_kick:
+            member._in_guild = True
+            member.kick_count -= 1
+            raise MissingGuildPermissions(
+                f"I need kick perms to punish someone in {guild.name}"
+            )
+
+        elif not perms.ban_members and not is_kick:
+            member._in_guild = True
+            member.kick_count -= 1
+            raise MissingGuildPermissions(
+                f"I need ban perms to punish someone in {guild.name}"
+            )
+
+        # We also check they don't own the guild, since ya know...
+        elif guild.owner_id == member.id:
+            member._in_guild = True
+            member.kick_count -= 1
+            raise MissingGuildPermissions(
+                f"I cannot punish {author.display_name}({author.id}) "
+                f"because they own this guild. ({guild.name})"
+            )
+
+        # Ensure we can actually punish the user, for this
+        # we just check our top role is higher then them
+        elif guild.me.top_role.position < author.top_role.position:
+            log.warning(
+                "I might not be able to punish %s(%s) in Guild: %s(%s) "
+                "because they are higher then me, which means I could lack the ability to kick/ban them.",
+                author.display_name,
+                member.id,
+                guild.name,
+                guild.id,
+            )
+
+        sent_message: Optional[discord.Message] = None
+        try:
+            if isinstance(user_message, discord.Embed):
+                sent_message = await author.send(
+                    embed=user_message, delete_after=user_delete_after
+                )
+            else:
+                sent_message = await author.send(
+                    user_message, delete_after=user_delete_after
+                )
+
+        except discord.HTTPException:
+            await self.send_guild_log(
+                guild=internal_guild,
+                message=f"Sending a message to {author.mention} about their {'kick' if is_kick else 'ban'} failed.",
+                delete_after_time=channel_delete_after,
+                original_channel=original_message.channel,
+            )
+            log.warning(
+                f"Failed to message Member(id=%s) about {'kick' if is_kick else 'ban'}",
+                author.id,
+            )
+
+        # Even if we can't tell them they are being punished
+        # We still need to punish them, so try that
+        try:
+            if is_kick:
+                await guild.kick(
+                    member, reason="Automated punishment from DPY Anti-Spam."
+                )
+                log.info("Kicked Member(id=%s)", member.id)
+            else:
+                await guild.ban(
+                    member, reason="Automated punishment from DPY Anti-Spam."
+                )
+                log.info("Banned Member(id=%s)", member.id)
+
+        except discord.Forbidden as e:
+            # In theory we send the failed punishment method
+            # here, although we check first so I think its fine
+            # to remove it from this part
+            raise e from None
+
+        except discord.HTTPException:
+            member._in_guild = True
+            member.kick_count -= 1
+            await self.send_guild_log(
+                guild=internal_guild,
+                message=f"An error occurred trying to {'kick' if is_kick else 'ban'}: <@{member.id}>",
+                delete_after_time=channel_delete_after,
+                original_channel=original_message.channel,
+            )
+            log.warning(
+                "An error occurred trying to %s: Member(id=%s)",
+                {"kick" if is_kick else "ban"},
+                member.id,
+            )
+            if sent_message is not None:
+                if is_kick:
+                    user_failed_message = self.transform_message(
+                        self.handler.options.member_failed_kick_message,
+                        original_message,
+                        member.warn_count,
+                        member.kick_count,
+                    )
+                else:
+                    user_failed_message = self.transform_message(
+                        self.handler.options.member_failed_ban_message,
+                        original_message,
+                        member.warn_count,
+                        member.kick_count,
+                    )
+
+                await self.send_guild_log(
+                    internal_guild,
+                    user_failed_message,
+                    channel_delete_after,
+                    original_message.channel,
+                )
+                await sent_message.delete()
+
+        else:
+            await self.send_guild_log(
+                guild=internal_guild,
+                message=guild_message,
+                delete_after_time=channel_delete_after,
+                original_channel=original_message.channel,
+            )
+
+        member._in_guild = True
+        await self.handler.cache.set_member(member)
+
+    async def delete_member_messages(self, member: Member) -> None:  # pragma: no cover
+        log.debug(
+            "Attempting to delete all duplicate messages for Member(id=%s) in Guild(%s)",
+            member.id,
+            member.guild_id,
+        )
+        bot = self.handler.bot
+        channels = {}
+        for message in member.messages:
+            if message.is_duplicate:
+                # cache channel for further fetches
+                if message.channel_id not in channels:
+                    channel = bot.get_channel(message.channel_id)
+                    if not channel:
+                        channel = await bot.fetch_channel(message.channel_id)
+
+                    channels[message.channel_id] = channel
+                else:
+                    channel = channels[message.channel_id]
+
+                try:
+                    actual_message = await channel.fetch_message(message.id)
+                    await self.delete_message(actual_message)
+                except discord.NotFound:
+                    continue
+
+    async def delete_message(
+        self, message: discord.Message
+    ) -> None:  # pragma: no cover
+        try:
+            await message.delete()
+            log.debug("Deleted message %s", message.id)
+        except discord.HTTPException:
+            # Failed to delete message
+            log.warning(
+                "Failed to delete message %s in Guild(id=%s). HTTPException",
+                message.id,
+                message.guild.id,
+            )
+
+    async def send_message_to_(
+        self,
+        target: discord.abc.Messageable,
+        message: Union[str, discord.Embed],
+        mention: str,
+        delete_after_time: Optional[int] = None,
+    ) -> None:  # pragma: no cover
+        if isinstance(message, discord.Embed):
+            content = None
+            if self.handler.options.mention_on_embed:
+                content = mention
+            await target.send(
+                content,
+                embed=message,
+                delete_after=delete_after_time,
+            )
+        else:
+            await target.send(
+                message,
+                delete_after=delete_after_time,
+            )
+
+    async def get_channel_from_message(
+        self, message: discord.Message
+    ):  # pragma: no cover
+        return message.channel
+
+    def get_message_mentions(self, message: discord.Message):  # pragma: no cover
+        return message.mentions
+
+    async def get_channel_by_id(self, channel_id: int):  # pragma: no cover
+        channel = self.handler.bot.get_channel(channel_id)
+        if not channel:
+            channel = await self.handler.bot.fetch_channel(channel_id)
+
+        return channel
+
+    def get_file(self, path: str):  # pragma: no cover
+        return discord.File(path)
diff --git a/antispam/libs/hikari.py b/antispam/libs/hikari.py
new file mode 100644
index 0000000..9ab5af2
--- /dev/null
+++ b/antispam/libs/hikari.py
@@ -0,0 +1,572 @@
+import ast
+import asyncio
+import datetime
+import logging
+from string import Template
+from typing import Optional, Union
+from unittest.mock import AsyncMock
+
+import hikari.errors
+from hikari import (
+    messages,
+    guilds,
+    embeds,
+    GuildTextChannel,
+    UnauthorizedError,
+    ForbiddenError,
+    NotFoundError,
+    RateLimitTooLongError,
+    InternalServerError,
+    Permissions,
+)
+
+from antispam import (
+    AntiSpamHandler,
+    PropagateFailure,
+    LogicError,
+    MissingGuildPermissions,
+)
+
+from antispam.abc import Lib
+from antispam.dataclasses import Member, Guild, Message
+from antispam.dataclasses.propagate_data import PropagateData
+
+log = logging.getLogger(__name__)
+
+
+# noinspection DuplicatedCode,PyProtocol
+class Hikari(Lib):
+    async def send_message_to_(
+        self, target, message, mention: str, delete_after_time: Optional[int] = None
+    ) -> None:  # pragma: no cover
+        if isinstance(message, hikari.Embed):
+            m = await target.send(
+                embed=message,
+            )
+        else:
+            m = await target.send(
+                message,
+            )
+
+        if delete_after_time:
+            await asyncio.sleep(delete_after_time)
+            await m.delete()
+
+    def __init__(self, handler: AntiSpamHandler):
+        self.handler = handler
+
+    def get_guild_id(self, message: messages.Message) -> int:  # pragma: no cover
+        return message.guild_id
+
+    def get_channel_id(self, message: messages.Message) -> int:  # pragma: no cover
+        return message.channel_id
+
+    async def get_channel_from_message(
+        self, message: messages.Message
+    ):  # pragma: no cover
+        return await self.handler.bot.rest.fetch_channel(message.channel_id)
+
+    async def get_channel_by_id(self, channel_id: int):  # pragma: no cover
+        return await self.handler.bot.rest.fetch_channel(channel_id)
+
+    async def check_message_can_be_propagated(
+        self, message: messages.Message
+    ) -> PropagateData:
+        if not isinstance(message, (messages.Message, AsyncMock)):
+            raise PropagateFailure(
+                data={"status": "Expected message of type messages.Message"}
+            )
+
+        # Ensure we only moderate actual guild messages
+        if not message.guild_id:
+            log.debug(
+                "Message(id=%s) from Member(id=%s) was not in a guild",
+                message.id,
+                message.author.id,
+            )
+            raise PropagateFailure(data={"status": "Ignoring messages from dm's"})
+
+        # The bot is immune to spam
+        if message.author.id == self.handler.bot.get_me().id:
+            log.debug("Message(id=%s) was from myself", message.id)
+            raise PropagateFailure(
+                data={"status": "Ignoring messages from myself (the bot)"}
+            )
+
+        guild: guilds.Guild = self.handler.bot.cache.get_guild(message.guild_id)
+        member: guilds.Member = guild.get_member(message.author.id)
+
+        if not member:
+            log.error(
+                "Idk how to fetch members, so this is returning since "
+                "cache lookup failed. Please open a github issue."
+            )
+            raise PropagateFailure(
+                data={
+                    "status": "Idk how to fetch members, so this is returning since "
+                    "cache lookup failed. Please open a github issue."
+                }
+            )
+
+        # Return if ignored bot
+        if self.handler.options.ignore_bots and message.author.is_bot:
+            log.debug(
+                "I ignore bots, and this is a bot message with author(id=%s)",
+                message.author.id,
+            )
+            raise PropagateFailure(data={"status": "Ignoring messages from bots"})
+
+        # Return if ignored guild
+        if message.guild_id in self.handler.options.ignored_guilds:
+            log.debug("Ignored Guild(id=%s)", message.guild.id)
+            raise PropagateFailure(
+                data={"status": f"Ignoring this guild: {message.guild_id}"}
+            )
+
+        # Return if ignored member
+        if message.author.id in self.handler.options.ignored_members:
+            log.debug(
+                "The Member(id=%s) who sent this message is ignored", message.author.id
+            )
+            raise PropagateFailure(
+                data={"status": f"Ignoring this member: {message.author.id}"}
+            )
+
+        # Return if ignored channel
+        channel = await message.fetch_channel()
+        if (
+            message.channel_id in self.handler.options.ignored_channels
+            or channel.name in self.handler.options.ignored_channels
+        ):
+            log.debug("channel(id=%s) is ignored", channel.channel)
+            raise PropagateFailure(
+                data={"status": f"Ignoring this channel: {message.channel_id}"}
+            )
+
+        # Return if member has an ignored role
+        try:
+            roles = await member.fetch_roles()
+            user_roles = list(member.role_ids)
+            user_roles.extend([role.name for role in roles])
+            for item in user_roles:
+                if item in self.handler.options.ignored_roles:
+                    log.debug("role(%s) is a part of ignored roles", item)
+                    raise PropagateFailure(
+                        data={"status": f"Ignoring this role: {item}"}
+                    )
+        except AttributeError:
+            log.warning(
+                "Could not compute ignored_roles for %s(%s)",
+                message.author.username,
+                message.author.id,
+            )
+
+        perms: Permissions = await self._get_perms(guild.get_my_member())
+        has_perms = bool(perms.KICK_MEMBERS and perms.BAN_MEMBERS)
+
+        return PropagateData(
+            guild_id=message.guild_id,
+            member_name=message.author.username,
+            member_id=message.author.id,
+            has_perms_to_make_guild=has_perms,
+        )
+
+    async def _get_perms(self, member: guilds.Member) -> Permissions:
+        roles = await member.fetch_roles()
+
+        perms = Permissions.NONE
+
+        for role in roles:
+            perms = perms | role.permissions
+
+        return perms
+
+    def substitute_args(
+        self,
+        message: str,
+        original_message: messages.Message,
+        warn_count: int,
+        kick_count: int,
+    ) -> str:
+        guild = self.handler.bot.cache.get_guild(original_message.guild_id)
+        me: guilds.Member = guild.get_my_member()
+        return Template(message).safe_substitute(
+            {
+                "MENTIONMEMBER": original_message.author.mention,
+                "MEMBERNAME": original_message.author.username,
+                "MEMBERID": original_message.author.id,
+                "BOTNAME": me.username,
+                "BOTID": me.id,
+                "GUILDID": original_message.guild_id,
+                "GUILDNAME": guild.name,
+                "TIMESTAMPNOW": datetime.datetime.now().strftime(
+                    "%I:%M:%S %p, %d/%m/%Y"
+                ),
+                "TIMESTAMPTODAY": datetime.datetime.now().strftime("%d/%m/%Y"),
+                "WARNCOUNT": warn_count,
+                "KICKCOUNT": kick_count,
+                "MEMBERAVATAR": original_message.author.avatar_url,
+                "BOTAVATAR": me.avatar_url,
+                "GUILDICON": guild.icon_url,
+            }
+        )
+
+    def embed_to_string(self, embed: embeds.Embed) -> str:
+        content = ""
+        embed = self.handler.bot.entity_factory.serialize_embed(embed)
+
+        if "title" in embed:
+            content += f"{embed['title']}\n"
+
+        if "description" in embed:
+            content += f"{embed['description']}\n"
+
+        if "footer" in embed:
+            if "text" in embed["footer"]:
+                content += f"{embed['footer']['text']}\n"
+
+        if "author" in embed:
+            content += f"{embed['author']['name']}\n"
+
+        if "fields" in embed:
+            for field in embed["fields"]:
+                content += f"{field['name']}\n{field['value']}\n"
+
+        return content
+
+    def dict_to_embed(
+        self, data: dict, message: messages.Message, warn_count: int, kick_count: int
+    ):
+        allowed_avatars = ["$MEMBERAVATAR", "$BOTAVATAR", "$GUILDICON"]
+
+        if "title" in data:
+            data["title"] = self.substitute_args(
+                data["title"], message, warn_count, kick_count
+            )
+
+        if "description" in data:
+            data["description"] = self.substitute_args(
+                data["description"], message, warn_count, kick_count
+            )
+
+        if "footer" in data:
+            if "text" in data["footer"]:
+                data["footer"]["text"] = self.substitute_args(
+                    data["footer"]["text"], message, warn_count, kick_count
+                )
+
+            if "icon_url" in data["footer"]:
+                if data["footer"]["icon_url"] in allowed_avatars:
+                    data["footer"]["icon_url"] = self.substitute_args(
+                        data["footer"]["icon_url"], message, warn_count, kick_count
+                    )
+
+        if "author" in data:
+            # name 'should' be required
+            data["author"]["name"] = self.substitute_args(
+                data["author"]["name"], message, warn_count, kick_count
+            )
+
+            if "icon_url" in data["author"]:
+                if data["author"]["icon_url"] in allowed_avatars:
+                    data["author"]["icon_url"] = self.substitute_args(
+                        data["author"]["icon_url"], message, warn_count, kick_count
+                    )
+
+        if "fields" in data:
+            for field in data["fields"]:
+                name = self.substitute_args(
+                    field["name"], message, warn_count, kick_count
+                )
+                value = self.substitute_args(
+                    field["value"], message, warn_count, kick_count
+                )
+                field["name"] = name
+                field["value"] = value
+
+                if "inline" not in field:
+                    field["inline"] = True
+
+        if "timestamp" in data:
+            data["timestamp"] = message.created_at.isoformat()
+
+        if "colour" in data:
+            data["color"] = data["colour"]
+
+        data["type"] = "rich"
+
+        return self.handler.bot.entity_factory.deserialize_embed(data)
+
+    def transform_message(
+        self,
+        item: Union[str, dict],
+        message: messages.Message,
+        warn_count: int,
+        kick_count: int,
+    ):
+        if isinstance(item, str):
+            return self.substitute_args(item, message, warn_count, kick_count)
+
+        return self.dict_to_embed(item, message, warn_count, kick_count)
+
+    def visualizer(
+        self,
+        content: str,
+        message: messages.Message,
+        warn_count: int = 1,
+        kick_count: int = 2,
+    ):
+        if content.startswith("{"):
+            content = ast.literal_eval(content)
+
+        return self.transform_message(content, message, warn_count, kick_count)
+
+    def create_message(self, message: messages.Message) -> Message:
+        log.debug(
+            "Attempting to create a new message for author(id=%s) in Guild(%s)",
+            message.author.id,
+            message.guild_id,
+        )
+        if not bool(message.content and message.content.strip()):
+            if not message.embeds:
+                raise LogicError
+
+            embed: embeds.Embed = message.embeds[0]
+            if not isinstance(embed, embeds.Embed):
+                raise LogicError
+
+            content = self.embed_to_string(embed)
+        else:
+            content = message.content
+
+        if self.handler.options.delete_zero_width_chars:
+            content = (
+                content.replace("u200B", "")
+                .replace("u200C", "")
+                .replace("u200D", "")
+                .replace("u200E", "")
+                .replace("u200F", "")
+                .replace("uFEFF", "")
+            )
+
+        return Message(
+            id=message.id,
+            channel_id=message.channel_id,
+            guild_id=message.guild_id,
+            author_id=message.author.id,
+            content=content,
+        )
+
+    async def send_guild_log(
+        self,
+        guild: Guild,
+        message: Union[embeds.Embed, str],
+        delete_after_time: Optional[int],
+        original_channel: GuildTextChannel,
+        file=None,
+    ) -> None:  # pragma: no cover
+        try:
+            if not guild.log_channel_id:
+                log.debug(
+                    "Guild(id=%s) has no log channel set, defaulting to original channel",
+                    guild.id,
+                )
+                channel = original_channel
+            else:
+                channel = guild.log_channel_id
+
+                channel = await self.handler.bot.rest.fetch_channel(channel)
+
+            if isinstance(message, str):
+                m = await channel.send(message, attachment=file)
+            else:
+                m = await channel.send(embed=message, attachment=file)
+
+            if delete_after_time:
+                await asyncio.sleep(delete_after_time)
+                await m.delete()
+
+            log.debug("Sent message to log channel in Guild(id=%s)", guild.id)
+        except (
+            UnauthorizedError,
+            ForbiddenError,
+            NotFoundError,
+            RateLimitTooLongError,
+            InternalServerError,
+        ):
+            log.error(
+                "Failed to send log message in Guild(id=%s). hikari.send_guild_log()",
+                guild.id,
+            )
+
+    async def punish_member(
+        self,
+        original_message: messages.Message,
+        member: Member,
+        internal_guild: Guild,
+        user_message,
+        guild_message,
+        is_kick: bool,
+        user_delete_after: int = None,
+        channel_delete_after: int = None,
+    ):  # pragma: no cover
+        guild = self.handler.bot.cache.get_guild(original_message.guild_id)
+        author = original_message.author
+        channel = await self.handler.bot.rest.fetch_channel(original_message.channel_id)
+
+        # Check we have perms to punish
+        perms = await self._get_perms(guild.get_my_member())
+        if not perms.KICK_MEMBERS and is_kick:
+            member._in_guild = True
+            member.kick_count -= 1
+            raise MissingGuildPermissions(
+                f"I need kick perms to punish someone in {guild.name}"
+            )
+
+        elif not perms.BAN_MEMBERS and not is_kick:
+            member._in_guild = True
+            member.kick_count -= 1
+            raise MissingGuildPermissions(
+                f"I need ban perms to punish someone in {guild.name}"
+            )
+
+        # We also check they don't own the guild, since ya know...
+        elif guild.owner_id == member.id:
+            member._in_guild = True
+            member.kick_count -= 1
+            raise MissingGuildPermissions(
+                f"I cannot punish {author.username}({author.id}) "
+                f"because they own this guild. ({guild.name})"
+            )
+
+        sent_message: Optional[messages.Message] = None
+        try:
+            if isinstance(user_message, embeds.Embed):
+                sent_message = await author.send(embed=user_message)
+            else:
+                sent_message = await author.send(user_message)
+
+            if user_delete_after:
+                await asyncio.sleep(user_delete_after)
+                await sent_message.delete()
+
+        except InternalServerError:
+            await self.send_guild_log(
+                guild=internal_guild,
+                message=f"Sending a message to {author.mention} about their {'kick' if is_kick else 'ban'} failed.",
+                delete_after_time=channel_delete_after,
+                original_channel=channel,
+            )
+            log.warning(
+                f"Failed to message Member(id=%s) about {'kick' if is_kick else 'ban'}",
+                author.id,
+            )
+
+        # Even if we can't tell them they are being punished
+        # We still need to punish them, so try that
+        try:
+            if is_kick:
+                await guild.kick(
+                    author, reason="Automated punishment from DPY Anti-Spam."
+                )
+                log.info("Kicked Member(id=%s)", member.id)
+            else:
+                await guild.ban(
+                    author, reason="Automated punishment from DPY Anti-Spam."
+                )
+                log.info("Banned Member(id=%s)", member.id)
+
+        except InternalServerError:
+            member._in_guild = True
+            member.kick_count -= 1
+            await self.send_guild_log(
+                guild=internal_guild,
+                message=f"An error occurred trying to {'kick' if is_kick else 'ban'}: <@{member.id}>",
+                delete_after_time=channel_delete_after,
+                original_channel=channel,
+            )
+            log.warning(
+                "An error occurred trying to %s: Member(id=%s)",
+                {"kick" if is_kick else "ban"},
+                member.id,
+            )
+            if sent_message is not None:
+                if is_kick:
+                    user_failed_message = self.transform_message(
+                        self.handler.options.member_failed_kick_message,
+                        original_message,
+                        member.warn_count,
+                        member.kick_count,
+                    )
+                else:
+                    user_failed_message = self.transform_message(
+                        self.handler.options.member_failed_ban_message,
+                        original_message,
+                        member.warn_count,
+                        member.kick_count,
+                    )
+
+                await self.send_guild_log(
+                    internal_guild, user_failed_message, channel_delete_after, channel
+                )
+                await sent_message.delete()
+
+        else:
+            await self.send_guild_log(
+                guild=internal_guild,
+                message=guild_message,
+                delete_after_time=channel_delete_after,
+                original_channel=channel,
+            )
+
+        member._in_guild = True
+        await self.handler.cache.set_member(member)
+
+    async def delete_member_messages(self, member: Member) -> None:  # pragma: no cover
+        log.debug(
+            "Attempting to delete all duplicate messages for Member(id=%s) in Guild(%s)",
+            member.id,
+            member.guild_id,
+        )
+        bot = self.handler.bot
+        channels = {}
+        for message in member.messages:
+            if message.is_duplicate:
+                # cache channel for further fetches
+                if message.channel_id not in channels:
+                    channel: GuildTextChannel = await bot.rest.fetch_channel(
+                        message.channel_id
+                    )
+
+                    channels[message.channel_id] = channel
+                else:
+                    channel = channels[message.channel_id]
+
+                try:
+                    actual_message = await channel.fetch_message(message.id)
+                    await self.delete_message(actual_message)
+                except hikari.errors.NotFoundError:
+                    continue
+
+    async def delete_message(
+        self, message: messages.Message
+    ) -> None:  # pragma: no cover
+        try:
+            await message.delete()
+            log.debug("Deleted message: %s", message.id)
+        except (NotFoundError, ForbiddenError):
+            # Failed to delete message
+            log.warning(
+                "Failed to delete message %s in Guild(id=%s). NotFoundError | ForbiddenError",
+                message.id,
+                message.guild_id,
+            )
+
+    def get_message_mentions(self, message: messages.Message):  # pragma: no cover
+        data = [message.mentions.user_ids]
+        data.extend(role for role in message.mentions.role_ids)
+        data.extend(channel for channel in message.mentions.channels_ids)
+        return data
+
+    def get_file(self, path: str):  # pragma: no cover
+        return hikari.File(path)
diff --git a/antispam/message.py b/antispam/message.py
deleted file mode 100644
index 0a16675..0000000
--- a/antispam/message.py
+++ /dev/null
@@ -1,219 +0,0 @@
-"""
-LICENSE
-The MIT License (MIT)
-
-Copyright (c) 2020-2021 Skelmis
-
-Permission is hereby granted, free of charge, to any person obtaining a
-copy of this software and associated documentation files (the "Software"),
-to deal in the Software without restriction, including without limitation
-the rights to use, copy, modify, merge, publish, distribute, sublicense,
-and/or sell copies of the Software, and to permit persons to whom the
-Software is furnished to do so, subject to the following conditions:
-The above copyright notice and this permission notice shall be included in
-all copies or substantial portions of the Software.
-
-THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
-OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
-FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
-DEALINGS IN THE SOFTWARE.
-LICENSE
-"""
-
-import datetime
-
-"""
-Used to store a message object, essentially a glorified dictionary
-"""
-
-
-class Message:
-    """Represents a lower level object needed to maintain messages"""
-
-    __slots__ = [
-        "_id",
-        "_channel_id",
-        "_guild_id",
-        "_content",
-        "_author_id",
-        "_creation_time",
-        "_is_duplicate",
-    ]
-
-    def __init__(self, message_id, content, author_id, channel_id, guild_id):
-        """
-        Set & store a smaller object footprint then a standard
-        message object for memory purposes :)
-
-        Parameters
-        ==========
-        message_id : int
-            The id of the message
-        content : String
-            The actual message content
-        author_id : int
-            The author of said message
-        channel_id : int
-            The channel this message is in
-        guild_id : int
-            The guild this message belongs to
-
-        Raises
-        ======
-        ValueError
-            When an item is not the correct ignore_type for conversion
-
-        Notes
-        =====
-        This enforces strict types by conversion and ignore_type checking
-        pass through of the correct ignore_type is required.
-        """
-        self.id = int(message_id)
-        self.content = str(content)
-        self.author_id = int(author_id)
-        self.channel_id = int(channel_id)
-        self.guild_id = int(guild_id)
-        self.is_duplicate = False
-        self._creation_time = datetime.datetime.now(datetime.timezone.utc)
-
-    def __repr__(self):
-        return (
-            f"'{self.__class__.__name__} object. Content: {self.content}, Message Id: {self.id}, "
-            f"Author Id: {self.author_id}, Channel Id: {self.channel_id}, Guild Id: {self.guild_id} "
-            f"Creation time: {self._creation_time}'"
-        )
-
-    def __str__(self):
-        return f"{self.__class__.__name__} object - '{self.content}'"
-
-    def __eq__(self, other):
-        """
-        This is called with a 'obj1 == obj2' comparison object is made
-
-        Checks everything besides message content to figure out if a message
-        is the same or not
-
-        Parameters
-        ----------
-        other : Message
-            The object to compare against
-
-        Returns
-        -------
-        bool
-            `True` or `False` depending on whether they are the same or not
-
-        Raises
-        ======
-        ValueError
-            When the comparison object is not of ignore_type `Message`
-
-        Notes
-        =====
-        Does not check creation time, because that can be different
-        and this is mainly used to ensure we don't create duplicates
-        and creation time is this class's time not the message's time
-        """
-        if not isinstance(other, Message):
-            raise ValueError
-
-        if (
-            self.id == other.id
-            and self.author_id == other.author_id
-            and self.channel_id == other.channel_id
-            and self.guild_id == other.guild_id
-        ):
-            return True
-        return False
-
-    def __hash__(self):
-        """
-        Given we create a __eq__ dunder method, we also needed
-        to create one for __hash__ lol
-
-        Returns
-        -------
-        int
-            The hash of all id's
-        """
-        return hash((self.id, self.author_id, self.guild_id, self.channel_id))
-
-    @property
-    def id(self):
-        """
-        The `getter` method
-        """
-        return self._id
-
-    @id.setter
-    def id(self, value):
-        """
-        The `setter` method
-        """
-        if not isinstance(value, int):
-            raise ValueError("Expected integer")
-        self._id = value
-
-    @property
-    def content(self):
-        return self._content
-
-    @content.setter
-    def content(self, value):
-        try:
-            self._content = str(value)
-        except ValueError:
-            raise ValueError("Expected String")
-
-    @property
-    def author_id(self):
-        return self._author_id
-
-    @author_id.setter
-    def author_id(self, value):
-        if not isinstance(value, int):
-            raise ValueError("Expected integer")
-        self._author_id = value
-
-    @property
-    def channel_id(self):
-        return self._channel_id
-
-    @channel_id.setter
-    def channel_id(self, value):
-        if not isinstance(value, int):
-            raise ValueError("Expected integer")
-        self._channel_id = value
-
-    @property
-    def guild_id(self):
-        return self._guild_id
-
-    @guild_id.setter
-    def guild_id(self, value):
-        if not isinstance(value, int):
-            raise ValueError("Expected integer")
-        self._guild_id = value
-
-    @property
-    def creation_time(self):
-        return self._creation_time
-
-    @creation_time.setter
-    def creation_time(self, value):
-        # We don't want creationTime changed
-        return
-
-    @property
-    def is_duplicate(self):
-        return self._is_duplicate
-
-    @is_duplicate.setter
-    def is_duplicate(self, value):
-        if not isinstance(value, bool):
-            raise ValueError("is_duplicate should be a bool")
-
-        self._is_duplicate = value
diff --git a/antispam/plugin_cache.py b/antispam/plugin_cache.py
new file mode 100644
index 0000000..466f224
--- /dev/null
+++ b/antispam/plugin_cache.py
@@ -0,0 +1,167 @@
+from typing import Any
+
+from antispam.exceptions import (
+    MemberNotFound,
+    GuildNotFound,
+    MemberAddonNotFound,
+    GuildAddonNotFound,
+)
+from antispam.dataclasses import Guild, Member
+from antispam import AntiSpamHandler
+
+
+class PluginCache:
+    """
+    This class handles all data storage. You should simply refer
+    to the methods in this class as your means of interacting with
+    the internal cache
+    """
+
+    def __init__(self, handler: AntiSpamHandler, caller):
+        """
+
+        Parameters
+        ----------
+        handler : AntiSpamHandler
+            Your AntiSpamHandler instance
+        caller : class
+            `self`, from the class using this class
+        """
+        self.handler = handler
+        self.cache = handler.cache
+        self.key = caller.__class__.__name__
+
+    async def get_member_data(
+        self,
+        member_id: int,
+        guild_id: int,
+    ) -> Any:
+        """
+        Returns a dictionary of data this caller is allowed to
+        access and store how they please
+
+        Parameters
+        ----------
+        member_id : int
+            The user we want to get data for
+        guild_id : int
+            The guild for the user we want
+
+        Returns
+        -------
+        Any
+            Stored data on this member
+            which has been stored by this class
+
+        Raises
+        ------
+        MemberNotFound
+            The given user/guild could not be found
+            internally or they have no stored data
+        """
+        guild = await self.cache.get_guild(guild_id=guild_id)
+
+        try:
+            member = guild.members[member_id]
+        except KeyError:
+            raise MemberNotFound
+
+        try:
+            addon_data = member.addons[self.key]
+        except KeyError:
+            # Raise here because the datatype is guaranteed
+            raise MemberAddonNotFound
+
+        return addon_data
+
+    async def set_member_data(
+        self, member_id: int, guild_id: int, addon_data: Any
+    ) -> None:
+        """
+        Stores a member's data within a guild
+
+        Parameters
+        ----------
+        guild_id : int
+            The guild to add this user's
+            data into
+        member_id : int
+            The user's id to store
+        addon_data : Any
+            The data to store
+
+        Notes
+        -----
+        Silently creates the required
+        Guild / Member objects as needed
+        """
+        try:
+            guild = await self.cache.get_guild(guild_id=guild_id)
+        except GuildNotFound:
+            # Create the guild, and store it
+            guild = Guild(id=guild_id, options=self.handler.options)
+            guild.members[member_id] = Member(id=member_id, guild_id=guild_id)
+            await self.cache.set_guild(guild)
+
+        # Get/create the member
+        try:
+            member = guild.members[member_id]
+        except KeyError:
+            member = Member(id=member_id, guild_id=guild_id)
+
+        member.addons[self.key] = addon_data
+        guild.members[member_id] = member
+        await self.cache.set_member(member)
+
+    async def get_guild_data(self, guild_id: int) -> Any:
+        """
+        Get a dictionary of all data for this guild that
+        was stored by this class
+
+        Parameters
+        ----------
+        guild_id : int
+            The guild to fetch
+
+        Returns
+        -------
+        Any
+            The data stored on this
+
+        Raises
+        ------
+        GuildNotFound
+            The given guild could not be found
+            in the cache or it has no stored data
+        """
+        guild = await self.cache.get_guild(guild_id=guild_id)
+        try:
+            addon_data = guild.addons[self.key]
+        except KeyError:
+            # Raise since datatype is `Any` and we dunno what to return
+            raise GuildAddonNotFound
+
+        return addon_data
+
+    async def set_guild_data(self, guild_id: int, addon_data: Any) -> None:
+        """
+        Stores the given addon data dictionary within the guilds cache
+
+        Parameters
+        ----------
+        guild_id : int
+            The guild to store this on
+        addon_data : Any
+            The data to store on this guild
+
+        Notes
+        -----
+        Silently creates a new Guild as required
+        """
+        try:
+            guild = await self.cache.get_guild(guild_id=guild_id)
+        except GuildNotFound:
+            guild = Guild(id=guild_id, options=self.handler.options)
+
+        guild.addons[self.key] = addon_data
+        await self.cache.set_guild(guild)
diff --git a/antispam/plugins/__init__.py b/antispam/plugins/__init__.py
new file mode 100644
index 0000000..ddecc4c
--- /dev/null
+++ b/antispam/plugins/__init__.py
@@ -0,0 +1,4 @@
+from .anti_spam_tracker import AntiSpamTracker
+from .anti_mass_mention import AntiMassMention, MassMentionPunishment
+from .stats import Stats
+from .admin_logs import AdminLogs
diff --git a/antispam/plugins/admin_logs.py b/antispam/plugins/admin_logs.py
new file mode 100644
index 0000000..b453bf7
--- /dev/null
+++ b/antispam/plugins/admin_logs.py
@@ -0,0 +1,141 @@
+import logging
+import os
+from pathlib import Path
+from typing import Any
+
+from antispam import CorePayload, AntiSpamHandler, LogicError
+from antispam.base_plugin import BasePlugin
+from antispam.dataclasses import Member, Guild
+
+log = logging.getLogger(__name__)
+
+
+class AdminLogs(BasePlugin):
+    """A plugin design to save admins hassle with
+    regard to evidence collection on automated punishments.
+    """
+
+    def __init__(self, handler: AntiSpamHandler, log_location: str):
+        """
+        Parameters
+        ----------
+        handler : AntiSpamHandler
+            Our AntiSpamHandler instance
+        log_location
+            The directory to store logs in, relative from
+            the caller location. This directory should be
+            empty or only contain previous output from this plugin.
+
+        Notes
+        -----
+        This will save transcripts for *every* punishment,
+        but it only sends ones to discord if the Guild
+        has a log_channel_id set.
+        """
+        super().__init__(is_pre_invoke=False)
+
+        if handler.options.no_punish:
+            log.warning(
+                "Using this package while in no_punish mode is likely going to cause issues"
+            )
+
+        self.handler = handler
+        self.path = log_location
+
+        log.info("Plugin ready for usage")
+
+    async def propagate(
+        self, message, data: CorePayload = None
+    ) -> Any:  # pragma: no cover
+        # This is ignored in coverage as it depends
+        # a lot on external calls I'd rather not mock
+        # and its tested manually instead
+        if not data.member_should_be_punished_this_message:
+            # Do nothing unless punished
+            return
+
+        author_id = message.author.id
+        guild_id = self.handler.lib_handler.get_guild_id(message)
+
+        log.info(
+            "Saving evidence against Member(id=%s) in Guild(id=%s)", author_id, guild_id
+        )
+
+        # Extract punishment type
+        punishment_type = None
+        if data.member_was_warned:
+            punishment_type = "warn"
+
+        elif data.member_was_kicked:
+            punishment_type = "kick"
+
+        elif data.member_was_banned:
+            punishment_type = "ban"
+
+        else:
+            raise LogicError
+
+        # Make sure a folder exists for this punishment on this Member within this Guild
+        dir_path = os.path.join(
+            self.path, str(guild_id), str(author_id), punishment_type
+        )
+        Path(dir_path).mkdir(parents=True, exist_ok=True)
+
+        # Get existing file count within dir
+        current_count = len(os.listdir(dir_path)) + 1
+
+        member: Member = await self.handler.cache.get_member(author_id, guild_id)
+        guild: Guild = await self.handler.cache.get_guild(guild_id)
+
+        # Open the punishment file
+        file_path = os.path.join(dir_path, f"{current_count}.txt")
+        with open(file_path, "w") as f:
+            # Write headers / rough details
+            f.write(f"Author id: {message.author.id}\n-----\n")
+            f.write(f"Guild id: {guild_id}\n-----\n")
+            f.write(
+                f"Current warn count: {member.warn_count}\n"
+                f"Current kick count: {member.kick_count}\n-----\n"
+            )
+            f.write(
+                f"Date & time of the message which triggered this punishment:\n"
+                f"{message.created_at.strftime('%I:%M:%S %p, %d/%m/%Y')}\n-----\n"
+            )
+            f.write(f"Punishment type: {punishment_type.title()}\n-----\n")
+
+            f.write(
+                "Each entry following this line represents a message marked as spam.\n\n"
+            )
+
+            # Write each message to the file
+            for message in member.messages:
+                if not message.is_duplicate:
+                    # Only write out duplicate messages
+                    continue
+
+                f.write(
+                    f"{message.creation_time.strftime('%I:%M:%S %p, %d/%m/%Y')} | {message.content}\n-----\n"
+                )
+
+        log.debug(
+            f"Saved evidence against Member(id=%s) in Guild(id=%s) to file at location: {file_path}",
+        )
+
+        if not guild.log_channel_id:
+            # No log channel, no problemo
+            return
+
+        channel = await self.handler.lib_handler.get_channel_by_id(guild.log_channel_id)
+
+        file = self.handler.lib_handler.get_file(file_path)
+
+        await self.handler.lib_handler.send_guild_log(
+            guild,
+            f"Punishment logs for a __{punishment_type.title()}__ on <@{author_id}>(`{author_id}`)",
+            None,
+            channel,
+            file=file,
+        )
+        log.debug(
+            "Sent evidence against Member(id=%s) in Guild(id=%s) to the Guild's log channel"
+        )
diff --git a/antispam/plugins/anti_mass_mention.py b/antispam/plugins/anti_mass_mention.py
new file mode 100644
index 0000000..ac4a18e
--- /dev/null
+++ b/antispam/plugins/anti_mass_mention.py
@@ -0,0 +1,236 @@
+import logging
+import datetime
+from typing import Union
+
+import attr
+
+from antispam.base_plugin import BasePlugin
+from antispam.exceptions import MemberNotFound
+from antispam import AntiSpamHandler, GuildNotFound, PluginCache
+from antispam.util import get_aware_time
+
+log = logging.getLogger(__name__)
+
+
+@attr.s
+class MassMentionPunishment:
+    # noinspection PyUnresolvedReferences
+    """
+    This dataclass is what is dispatched
+    when someone should be punished for mention spam.
+
+    Parameters
+    ----------
+    member_id : int
+        The associated members id
+    channel_id : int
+        The associated channels id
+    guild_id : int
+        The associated guilds id
+    is_overall_punishment : bool
+        If this is ``True``, it means the user
+        has exceeded ``total_mentions_before_punishment``.
+        Otherwise they have exceeded ``min_mentions_per_message``
+
+    Notes
+    -----
+    You shouldn't be making instances of this.
+    """
+
+    member_id: int = attr.ib()
+    guild_id: int = attr.ib()
+    channel_id: int = attr.ib()
+    is_overall_punishment: bool = attr.ib()
+
+
+@attr.s
+class Tracking:
+    mentions: int = attr.ib()
+    timestamp: datetime.datetime = attr.ib()
+
+
+class AntiMassMention(BasePlugin):
+    """
+    In order to check if you should punish someone,
+    see the below code.
+
+    .. code-block:: python
+        :linenos:
+
+        data = await AntiSpamHandler.propagate(message)
+        return_item: Union[dict, MassMentionPunishment] = data.after_invoke_extensions["AntiMassMention"]
+
+        if isinstance(return_item, MassMentionPunishment):
+            # Punish for mention spam
+    """
+
+    def __init__(
+        self,
+        bot,
+        handler: AntiSpamHandler,
+        *,
+        total_mentions_before_punishment: int = 10,
+        time_period: int = 15000,
+        min_mentions_per_message: int = 5,
+    ):
+        """
+
+        Parameters
+        ----------
+        bot
+            Our bot instance
+        handler : AntiSpamHandler
+            Our AntiSpamHandler instance
+        total_mentions_before_punishment : int
+            How many mentions within the time period
+            before we punish the user
+            *Inclusive*
+        time_period : int
+            The time period valid for mentions
+            *Is in milliseconds*
+        min_mentions_per_message : int
+            The minimum amount of mentions in a message
+            before a punishment is issued
+            *Inclusive*
+        """
+        super().__init__()
+        if min_mentions_per_message > total_mentions_before_punishment:
+            raise ValueError(
+                "Expected `min_mentions_per_message` to be less then or equal to `total_mentions_before_punishment`"
+            )
+
+        if time_period < 1:
+            raise ValueError("Expected `time_period` to be positive")
+
+        self.bot = bot
+        self.handler = handler
+        self.data = PluginCache(handler, caller=self)
+
+        self.min_mentions_per_message = min_mentions_per_message
+        self.total_mentions_before_punishment = total_mentions_before_punishment
+        self.time_period = time_period
+
+        log.info("Plugin ready for usage")
+
+    async def propagate(self, message) -> Union[dict, MassMentionPunishment]:
+        """
+        Manages and stores any mass mentions per users
+
+        Parameters
+        ----------
+        message
+            The message to interact with
+
+        Returns
+        -------
+        dict
+            A dictionary explaining what
+            actions have been taken
+        MassMentionPunishment
+            Data surrounding the punishment
+            you should be doing.
+        """
+        member_id = message.author.id
+        guild_id = self.handler.lib_handler.get_guild_id(message)
+
+        log.info(
+            "Propagating message for Member(id=%s) in Guild(id=%s)", member_id, guild_id
+        )
+
+        try:
+            member = await self.data.get_member_data(member_id, guild_id)
+        except (MemberNotFound, GuildNotFound):
+            member = {"total_mentions": []}
+            """
+            {
+                "total_mentions": [
+                    Tracking(),
+                ]
+            }
+            """
+
+        mentions = set(self.handler.lib_handler.get_message_mentions(message))
+        member["total_mentions"].append(
+            Tracking(mentions=len(mentions), timestamp=message.created_at)
+        )
+        await self.data.set_member_data(member_id, guild_id, member)
+        await self._clean_mention_timestamps(
+            guild_id=guild_id,
+            member_id=member_id,
+            current_time=get_aware_time(),
+        )
+
+        if len(mentions) >= self.min_mentions_per_message:
+            # They mention too many people in this message so punish
+            log.info("Dispatching punishment, is_overall_punishment=False")
+            payload = MassMentionPunishment(
+                member_id=member_id,
+                guild_id=guild_id,
+                channel_id=self.handler.lib_handler.get_channel_id(message),
+                is_overall_punishment=False,
+            )
+            return payload
+
+        member = await self.data.get_member_data(guild_id=guild_id, member_id=member_id)
+        if (
+            sum(item.mentions for item in member["total_mentions"])
+            >= self.total_mentions_before_punishment
+        ):
+            # They have more mentions are cleaning then allowed,
+            # So time to punish them
+            log.info("Dispatching punishment, is_overall_punishment=True")
+            payload = MassMentionPunishment(
+                member_id=member_id,
+                guild_id=guild_id,
+                channel_id=self.handler.lib_handler.get_channel_id(message),
+                is_overall_punishment=True,
+            )
+            return payload
+
+        return {"action": "No action taken"}
+
+    async def _clean_mention_timestamps(
+        self, guild_id: int, member_id: int, current_time: datetime.datetime
+    ):
+        """
+        Cleans the internal cache for a member to only keep current mentions
+        Parameters
+        ----------
+        guild_id : int
+            The guild the users in
+        member_id : int
+            The member to clean
+
+        Notes
+        -----
+        If the member does not exist in ``self.data``,
+        they are ignored and this method returns
+
+        """
+        log.debug(
+            "Cleaning timestamps for Member(id=%s) in Guild(id=%s)", member_id, guild_id
+        )
+
+        async def _is_still_valid(timestamp):
+            difference = current_time - timestamp
+            offset = datetime.timedelta(milliseconds=self.time_period)
+
+            if difference >= offset:
+                return False
+            return True
+
+        try:
+            member = await self.data.get_member_data(
+                guild_id=guild_id, member_id=member_id
+            )
+        except (GuildNotFound, MemberNotFound):
+            return
+
+        valid_items = []
+        for item in member["total_mentions"]:
+            make_aware = item.timestamp.replace(tzinfo=datetime.timezone.utc)
+            if await _is_still_valid(make_aware):
+                valid_items.append(item)
+
+        member["total_mentions"] = valid_items
+        await self.data.set_member_data(member_id, guild_id, addon_data=member)
diff --git a/antispam/plugins/anti_spam_tracker.py b/antispam/plugins/anti_spam_tracker.py
new file mode 100644
index 0000000..729a6d3
--- /dev/null
+++ b/antispam/plugins/anti_spam_tracker.py
@@ -0,0 +1,441 @@
+"""
+LICENSE
+The MIT License (MIT)
+
+Copyright (c) 2020-2021 Skelmis
+
+Permission is hereby granted, free of charge, to any person obtaining a
+copy of this software and associated documentation files (the "Software"),
+to deal in the Software without restriction, including without limitation
+the rights to use, copy, modify, merge, publish, distribute, sublicense,
+and/or sell copies of the Software, and to permit persons to whom the
+Software is furnished to do so, subject to the following conditions:
+The above copyright notice and this permission notice shall be included in
+all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
+OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+DEALINGS IN THE SOFTWARE.
+LICENSE
+"""
+import datetime
+import logging
+
+import typing
+
+from antispam import AntiSpamHandler
+from antispam.base_plugin import BasePlugin
+from antispam.dataclasses import Member, CorePayload
+from antispam.exceptions import (
+    MemberNotFound,
+    GuildNotFound,
+    MemberAddonNotFound,
+    GuildAddonNotFound,
+)
+from antispam.plugin_cache import PluginCache
+from antispam.util import get_aware_time
+
+log = logging.getLogger(__name__)
+
+
+class AntiSpamTracker(BasePlugin):
+    """
+    A class devoted to people who want to handle punishments themselves.
+
+    This class wraps a few things, and handles the logic of ensuring
+    everything exists (or doesnt) among other things such as
+    untracking users after the valid storage interval expires
+
+    In order to use
+    this in your code, you can either:\n
+     - Subclass this class and override the ``do_punishment`` method and then use it that way to keep it clean
+     - Initialize this class and simply use the bool ``is_spamming()`` and do punishments based off that
+     - Initialize this class and simply use ``get_user_count()`` to get the number of times the user should be punished and do your own logic
+
+    *This mainly just depends on how granular you want to be within your code base.*
+
+    The way it works, is everytime you call ``propagate`` you simply pass the returned
+    data into `update_cache` and it will update said Members cache if AntiSpamHandler
+    thinks that they should be punished. Now, you set ``spam_amount_to_punish``
+    when creating an instance of this class and that is used to check if YOU
+    think they should be punished, and what punishment to give when they hit that cap.
+
+    **Basically:**\n
+    ``propagate`` -> ``update_cache``, if the User should be punished we increment internal counter
+
+    ``is_spamming`` -> Checks if the User's internal counter meets ``spam_amount_to_punish`` and returns a bool
+    """
+
+    __slots__ = [
+        "member_tracking",
+        "valid_global_interval",
+        "anti_spam_handler",
+        "punish_min_amount",
+    ]
+
+    def __init__(
+        self,
+        anti_spam_handler: AntiSpamHandler,
+        spam_amount_to_punish,
+        valid_timestamp_interval=None,
+    ) -> None:
+        """
+        Initialize this class and get it ready for usage.
+
+        Parameters
+        ----------
+        anti_spam_handler : AntiSpamHandler
+            Your AntiSpamHandler instance
+        spam_amount_to_punish : int
+            A number denoting the minimum value required
+            per user in order trip `is_spamming`
+        valid_timestamp_interval : int
+            How long a timestamp should remain 'valid' for.
+            Defaults to ``AntiSpamHandler.options.get("message_interval")``
+
+            **NOTE this is in milliseconds**
+
+        Raises
+        ------
+        TypeError
+            Invalid Arg Type
+        ValueError
+            Invalid Arg Type
+        """
+        super().__init__(is_pre_invoke=False)
+
+        try:
+            self.punish_min_amount = int(spam_amount_to_punish)
+        except (TypeError, ValueError):
+            raise ValueError(
+                "Expected `spam_amount_to_punish` of type int or int convertable"
+            ) from None
+
+        if not isinstance(anti_spam_handler, AntiSpamHandler):
+            raise TypeError("Expected anti_spam_handler of type AntiSpamHandler")
+
+        if not anti_spam_handler.options.no_punish:
+            log.warning("`no_punish` is not enabled! This will likely lead to issues")
+
+        self.anti_spam_handler = anti_spam_handler
+
+        if valid_timestamp_interval is not None:
+            if isinstance(valid_timestamp_interval, (str, float)):
+                try:
+                    valid_timestamp_interval = int(valid_timestamp_interval)
+                except (TypeError, ValueError):
+                    raise ValueError(
+                        "Expected valid_timestamp_interval of type int"
+                    ) from None
+            elif isinstance(valid_timestamp_interval, int):
+                pass
+            else:
+                raise TypeError("Expected valid_timestamp_interval of type int")
+
+        self.valid_global_interval = (
+            valid_timestamp_interval or anti_spam_handler.options.message_interval
+        )
+        self.valid_global_interval = int(self.valid_global_interval)
+
+        self.member_tracking = PluginCache(handler=anti_spam_handler, caller=self)
+
+        log.info("Plugin ready for usage")
+
+    async def propagate(
+        self, message, data: typing.Optional[CorePayload] = None
+    ) -> dict:
+        """
+        Overwrite the base extension to call ``update_cache``
+        internally so it can be used as an extension
+        """
+        await self.update_cache(message, data)
+        return {"status": "Cache updated"}
+
+    async def update_cache(self, message, data: CorePayload) -> None:
+        """
+        Takes the data returned from `propagate`
+        and updates this Class's internal cache
+
+        Parameters
+        ----------
+        message
+            The message related to `data's` propagation
+        data : CorePayload
+            The data returned from `propagate`
+        """
+        # TODO Check with stubs when possible from #68
+
+        if not isinstance(data, CorePayload):
+            raise TypeError("Expected data of type: CorePayload")
+
+        if not message.guild:
+            return
+
+        member_id = message.author.id
+        guild_id = self.anti_spam_handler.lib_handler.get_guild_id(message)
+        timestamp = get_aware_time()
+
+        if not data.member_should_be_punished_this_message:
+            # They shouldn't be punished so don't increase cache
+            return
+
+        # We now need to increase their cache
+        try:
+            addon_data = await self.member_tracking.get_member_data(
+                member_id=member_id, guild_id=guild_id
+            )
+
+        except (MemberNotFound, GuildNotFound):
+            # Store the new member
+            member = Member(id=member_id, guild_id=guild_id)
+            await self.anti_spam_handler.cache.set_member(member)
+            addon_data = []
+
+        except MemberAddonNotFound:
+            # Member exists, just an addon doesnt
+            addon_data = []
+
+        addon_data.append(timestamp)
+        await self.member_tracking.set_member_data(
+            member_id, guild_id, addon_data=addon_data
+        )
+
+        log.debug("Cache updated for Member(id=%s) in Guild(id%s)", member_id, guild_id)
+
+    async def get_user_count(self, message) -> int:
+        """
+        Returns how many messages that are still 'valid'
+        (counted as spam) a certain user has
+
+        Parameters
+        ----------
+        message
+            The message from which to extract user
+
+        Returns
+        -------
+        int
+            How many times this user has sent a
+            message that has been marked as
+            'punishment worthy' by AntiSpamHandler
+            within the valid interval time period
+
+        Raises
+        ------
+        MemberNotFound
+            The User for the ``message`` could not be found
+
+        """
+        # TODO Check message within #68
+
+        if not message.guild:
+            raise MemberNotFound("Can't find user's from dm's")
+
+        member_id = message.author.id
+        guild_id = self.anti_spam_handler.lib_handler.get_guild_id(message)
+
+        addon_data = await self.member_tracking.get_member_data(
+            guild_id=guild_id, member_id=member_id
+        )
+
+        await self.remove_outdated_timestamps(
+            addon_data, member_id=member_id, guild_id=guild_id
+        )
+
+        return len(addon_data)
+
+    async def remove_outdated_timestamps(
+        self, data: typing.List, member_id: int, guild_id: int
+    ) -> None:
+        """
+        This logic works around checking the current
+        time vs a messages creation time. If the message
+        is older by the config amount it can be cleaned up
+
+        *Generally not called by the end user*
+
+        Parameters
+        ==========
+        data : List
+            The data to work with
+        member_id : int
+            The id of the member to store on
+        guild_id : int
+            The id of the guild to store on
+
+        """
+        log.debug(
+            "Attempting to remove outdated timestamp's for Member(id=%s) in Guild(id=%s)",
+            member_id,
+            guild_id,
+        )
+        current_time = get_aware_time()
+
+        async def _is_still_valid(timestamp_obj):
+            """
+            Given a timestamp, figure out if it hasn't
+            expired yet
+            """
+            difference = current_time - timestamp_obj
+            offset = datetime.timedelta(
+                milliseconds=await self._get_guild_valid_interval(guild_id=guild_id)
+            )
+
+            if difference >= offset:
+                return False
+            return True
+
+        current_timestamps = []
+
+        for timestamp in data:
+            if await _is_still_valid(timestamp):
+                current_timestamps.append(timestamp)
+
+        log.debug("Removed 'timestamps' for Member(id=%s)", member_id)
+
+        await self.member_tracking.set_member_data(
+            member_id, guild_id, addon_data=current_timestamps
+        )
+
+    async def remove_punishments(self, message):
+        """
+        After you punish someone, call this method
+        to 'clean up' there punishments.
+
+        Parameters
+        ----------
+        message
+            The message to extract user from
+
+        Raises
+        ------
+        TypeError
+            Invalid arg
+
+        Notes
+        -----
+        This will actually create a member internally
+        if one doesn't already exist for simplicities sake
+        """
+        # TODO Check message using stubs from #68
+
+        if not message.guild:
+            return
+
+        member_id = message.author.id
+        guild_id = self.anti_spam_handler.lib_handler.get_guild_id(message)
+
+        await self.member_tracking.set_member_data(member_id, guild_id, addon_data=[])
+
+    async def _set_guild_valid_interval(
+        self, guild_id: int, valid_interval: int
+    ) -> None:
+        """
+        Given an interval, set it internally for
+        usage on a guild. This is essentially like
+        using custom guild options.
+
+        Parameters
+        ----------
+        guild_id : int
+            The guild to set this on
+        valid_interval : int
+            The time interval to use
+
+        Raises
+        ------
+        ValueError
+            valid_interval must be a positive number
+
+        """
+        try:
+            guild_data = await self.member_tracking.get_guild_data(guild_id)
+            guild_data["valid_interval"] = valid_interval
+            await self.member_tracking.set_guild_data(guild_id, guild_data)
+        except (GuildNotFound, GuildAddonNotFound):
+            await self.member_tracking.set_guild_data(
+                guild_id, {"valid_interval": valid_interval}
+            )
+
+    async def _get_guild_valid_interval(self, guild_id: int) -> int:
+        """
+        Returns the correct ``valid_global_interval``
+        except on a per guild level taking into account
+        custom guild options
+
+        Parameters
+        ----------
+        guild_id
+
+        Returns
+        -------
+        int
+            The correct interval time
+
+        Notes
+        -----
+        Silently ignores if a guild doesnt exist.
+        There is a global default for a reason.
+        """
+        try:
+            guild = await self.member_tracking.get_guild_data(guild_id)
+        except GuildNotFound:
+            return self.valid_global_interval
+
+        if "valid_interval" not in guild:
+            return self.valid_global_interval
+
+        return guild["valid_interval"]
+
+    async def is_spamming(self, message) -> bool:
+        """
+        Given a message, deduce and return if a user
+        is classed as 'spamming' or not based on ``punish_min_amount``
+
+        Parameters
+        ----------
+        message
+            The message to extract guild and user from
+
+        Returns
+        -------
+        bool
+            True if the User is spamming else False
+
+        """
+        # TODO Check message with stubs from #68
+
+        if not message.guild:
+            return False
+
+        try:
+            user_count = await self.get_user_count(message=message)
+            if user_count >= self.punish_min_amount:
+                return True
+        except (MemberNotFound, MemberAddonNotFound, GuildNotFound):
+            return False
+        else:
+            return False
+
+    async def do_punishment(self, message, *args, **kwargs) -> None:
+        """
+        This only exists for if the user wishes to subclass
+        this class and implement there own logic for punishments
+        here.
+
+        Parameters
+        ----------
+        message
+            The message to extract the guild and user from
+
+        Notes
+        -----
+        This does nothing unless you subclass
+        and implement it yourself.
+
+        """
+        pass
diff --git a/antispam/ext/readme.md b/antispam/plugins/readme.md
similarity index 62%
rename from antispam/ext/readme.md
rename to antispam/plugins/readme.md
index 7c5d3f3..d5bbf01 100644
--- a/antispam/ext/readme.md
+++ b/antispam/plugins/readme.md
@@ -1,4 +1,6 @@
 Welcome!
 
 So you want to make your own extension or use some of ours?
-Please checkout the documentation [here](https://dpy-anti-spam.readthedocs.io/en/latest/modules/ext/base.html)
\ No newline at end of file
+Please checkout the documentation [here](https://dpy-anti-spam.readthedocs.io/en/latest/modules/ext/base.html)
+
+TODO fix that link on 2.0 release
\ No newline at end of file
diff --git a/antispam/plugins/stats.py b/antispam/plugins/stats.py
new file mode 100644
index 0000000..635c86d
--- /dev/null
+++ b/antispam/plugins/stats.py
@@ -0,0 +1,77 @@
+import logging
+
+from antispam import AntiSpamHandler
+from antispam.base_plugin import BasePlugin
+from antispam.dataclasses import CorePayload
+
+log = logging.getLogger(__name__)
+
+
+class Stats(BasePlugin):
+    """
+    A simplistic approach to aggregating statistics
+    across the anti spam package.
+
+    Do note however, it assumes plugins do not error out.
+    If a plugin errors out, this will be inaccurate.
+
+    This does play with internals a bit,
+    however, it is distributed within the
+    library I am okay modifying the base package
+    to make this work even better.
+    """
+
+    injectable_nonce = "Issa me, Mario!"  # For our `propagate` check
+
+    def __init__(self, anti_spam_handler: AntiSpamHandler):
+        super().__init__(is_pre_invoke=False)
+
+        self.data = {
+            "pre_invoke_calls": {},
+            "after_invoke_calls": {},
+            "propagate_calls": 0,
+            "guilds": {},
+            "members": {},
+        }
+        self.handler = anti_spam_handler
+
+        log.debug("Plugin ready for usage")
+
+    async def propagate(self, message, data: CorePayload) -> dict:
+        log.info("Updating statistics on_propagate")
+        for invoker in self.handler.pre_invoke_extensions.keys():
+            try:
+                self.data["pre_invoke_calls"][invoker]["calls"] += 1
+            except KeyError:
+                self.data["pre_invoke_calls"][invoker] = {}
+                self.data["pre_invoke_calls"][invoker]["calls"] = 1
+
+        for invoker in self.handler.after_invoke_extensions.keys():
+            try:
+                self.data["after_invoke_calls"][invoker]["calls"] += 1
+            except KeyError:
+                self.data["after_invoke_calls"][invoker] = {}
+                self.data["after_invoke_calls"][invoker]["calls"] = 1
+
+        self.data["propagate_calls"] += 1
+
+        guild_id = self.handler.lib_handler.get_guild_id(message)
+
+        if guild_id not in self.data["guilds"]:
+            self.data["guilds"][guild_id] = {
+                "calls": 0,
+                "total_messages_punished": 0,
+            }
+
+        self.data["guilds"][guild_id]["calls"] += 1
+
+        if message.author.id not in self.data["members"]:
+            self.data["members"][message.author.id] = {"calls": 0, "times_punished": 0}
+
+        self.data["members"][message.author.id]["calls"] += 1
+
+        if data.member_should_be_punished_this_message:
+            self.data["members"][message.author.id]["times_punished"] += 1
+            self.data["guilds"][guild_id]["total_messages_punished"] += 1
+
+        return {"status": "Updated stats!"}
diff --git a/antispam/static.py b/antispam/static.py
deleted file mode 100644
index 524406c..0000000
--- a/antispam/static.py
+++ /dev/null
@@ -1,71 +0,0 @@
-"""
-LICENSE
-The MIT License (MIT)
-
-Copyright (c) 2020-2021 Skelmis
-
-Permission is hereby granted, free of charge, to any person obtaining a
-copy of this software and associated documentation files (the "Software"),
-to deal in the Software without restriction, including without limitation
-the rights to use, copy, modify, merge, publish, distribute, sublicense,
-and/or sell copies of the Software, and to permit persons to whom the
-Software is furnished to do so, subject to the following conditions:
-The above copyright notice and this permission notice shall be included in
-all copies or substantial portions of the Software.
-
-THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
-OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
-FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
-DEALINGS IN THE SOFTWARE.
-LICENSE
-"""
-
-"""
-A simple, small file devoted to maintaining all the relevant static
-things associated with this project. Should help to keep everything
-findable, readable, modifiable etc
-"""
-
-
-class Static:
-    # The default options for ASH
-    DEFAULTS = {
-        "warn_threshold": 3,
-        "kick_threshold": 2,
-        "ban_threshold": 2,
-        "message_interval": 30000,  # 30 Seconds
-        "guild_warn_message": "Hey $MENTIONUSER, please stop spamming/sending duplicate messages.",
-        "guild_kick_message": "$USERNAME was kicked for spamming/sending duplicate messages.",
-        "guild_ban_message": "$USERNAME was banned for spamming/sending duplicate messages.",
-        "user_kick_message": "Hey $MENTIONUSER, you are being kicked from $GUILDNAME for spamming/"
-        "sending duplicate messages.",
-        "user_ban_message": "Hey $MENTIONUSER, you are being banned from $GUILDNAME for spamming/"
-        "sending duplicate messages.",
-        "user_failed_kick_message": "I failed to punish you because I lack permissions, but still you shouldn't spam.",
-        "user_failed_ban_message": "I failed to punish you because I lack permissions, but still you shouldn't spam.",
-        "message_duplicate_count": 5,
-        "message_duplicate_accuracy": 90,
-        "delete_spam": True,
-        "ignore_perms": [8],
-        "ignore_users": [],
-        "ignore_channels": [],
-        "ignore_roles": [],
-        "ignore_guilds": [],
-        "ignore_bots": True,
-        "warn_only": False,
-        "no_punish": False,
-        "per_channel_spam": False,
-        "guild_warn_message_delete_after": None,
-        "user_kick_message_delete_after": None,
-        "guild_kick_message_delete_after": None,
-        "user_ban_message_delete_after": None,
-        "guild_ban_message_delete_after": None,
-        "delete_zero_width_chars": True,
-    }
-    BAN = "ban"
-    KICK = "kick"
-    WARNCOUNTER = "warn_counter"
-    KICKCOUNTER = "kick_counter"
diff --git a/antispam/user.py b/antispam/user.py
deleted file mode 100644
index 923decd..0000000
--- a/antispam/user.py
+++ /dev/null
@@ -1,880 +0,0 @@
-"""
-LICENSE
-The MIT License (MIT)
-
-Copyright (c) 2020-2021 Skelmis
-
-Permission is hereby granted, free of charge, to any person obtaining a
-copy of this software and associated documentation files (the "Software"),
-to deal in the Software without restriction, including without limitation
-the rights to use, copy, modify, merge, publish, distribute, sublicense,
-and/or sell copies of the Software, and to permit persons to whom the
-Software is furnished to do so, subject to the following conditions:
-The above copyright notice and this permission notice shall be included in
-all copies or substantial portions of the Software.
-
-THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
-OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
-FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
-DEALINGS IN THE SOFTWARE.
-LICENSE
-"""
-import collections
-import logging
-import datetime
-from copy import deepcopy
-from unittest.mock import AsyncMock
-
-import discord
-
-from fuzzywuzzy import fuzz
-
-from antispam.message import Message
-from antispam.exceptions import (
-    DuplicateObject,
-    ObjectMismatch,
-    LogicError,
-    MissingGuildPermissions,
-)
-from antispam.static import Static
-from antispam.util import embed_to_string, transform_message
-
-log = logging.getLogger(__name__)
-
-
-# noinspection PyUnresolvedReferences
-class User:
-    """A class dedicated to maintaining a member, and any relevant messages in a single guild."""
-
-    __slots__ = [
-        "_id",
-        "_guild_id",
-        "_messages",
-        "_options",
-        "warn_count",
-        "kick_count",
-        "bot",
-        "in_guild",
-        "duplicate_counter",
-        "duplicate_channel_counter_dict",
-    ]
-
-    def __init__(self, bot, member_id, guild_id, options):
-        """
-        Set the relevant information in order to maintain
-        and use a per User object for a guild
-
-        Parameters
-        ==========
-        bot : commands.bot
-            Bot instance
-        member_id : int
-            The relevant member id
-        guild_id : int
-            The guild (id) this member is belonging to
-        options : Dict
-            The options we need to check against
-        """
-        self.id = int(member_id)
-        self.bot = bot
-        self.guild_id = int(guild_id)
-        self._messages = []
-        self._options = deepcopy(options)
-        self.in_guild = True  # Indicates if a user is in the guild or not
-        self.warn_count = 0
-        self.kick_count = 0
-        self.duplicate_counter = 1
-        self.duplicate_channel_counter_dict = {}
-
-    def __repr__(self):
-        return (
-            f"'{self.__class__.__name__} object. User id: {self.id}, Guild id: {self.guild_id}, "
-            f"Len Stored Messages {len(self._messages)}'"
-        )
-
-    def __str__(self):
-        return f"{self.__class__.__name__} object for {self.id}."
-
-    def __eq__(self, other):
-        """
-        This is called with a 'obj1 == obj2' comparison object is made
-
-        Checks against stored id's to figure out if they are
-        representing the same User or not
-
-        Parameters
-        ----------
-        other : User
-            The object to compare against
-
-        Returns
-        -------
-        bool
-            `True` or `False` depending on whether they are the same or not
-
-        Raises
-        ======
-        ValueError
-            When the comparison object is not of ignore_type `Message`
-        """
-        if not isinstance(other, User):
-            raise ValueError("Expected two User objects to compare")
-
-        if self.id == other.id and self.guild_id == other.guild_id:
-            return True
-        return False
-
-    def __hash__(self):
-        """
-        Given we create a __eq__ dunder method, we also needed
-        to create one for __hash__ lol
-
-        Returns
-        -------
-        int
-            The hash of all id's
-        """
-        return hash((self.id, self.guild_id))
-
-    async def propagate(self, value: discord.Message):
-        """
-        This method handles a message object and then adds it to
-        the relevant member
-
-        Parameters
-        ==========
-        value : discord.Message
-            The message that needs to be propagated out
-
-        Warnings
-        ========
-        Calling this method yourself will bypass all checks
-        """
-        if not isinstance(value, (discord.Message, AsyncMock)):
-            raise ValueError("Expected message of ignore_type: discord.Message")
-
-        # Setup our return values for the end user to use
-        return_data = {
-            "should_be_punished_this_message": False,
-            "was_warned": False,
-            "was_kicked": False,
-            "was_banned": False,
-            "status": "Unknown",
-        }
-
-        # Here we just check if the user is still in the guild by checking if the in_guild attribute is False.
-        # Because if its False then we don't need to process the message.
-        if not self.in_guild:
-            return {"status": "Bypassing message check since the user isnt in a guild"}
-
-        self.clean_up(datetime.datetime.now(datetime.timezone.utc))
-
-        # No point saving empty messages, although discord shouldn't allow them anyway
-        if not bool(value.content and value.content.strip()):
-            if not value.embeds:
-                return {"status": "No point saving empty messages"}
-
-            embed = value.embeds[0]
-            if not isinstance(embed, discord.Embed):
-                return {"status": "No point saving empty messages"}
-
-            if embed.type.lower() != "rich":
-                return {"status": "No point saving empty messages"}
-
-            content = embed_to_string(embed)
-        else:
-            content = value.clean_content
-
-        if self.options.get("delete_zero_width_chars"):
-            content = (
-                content.replace("u200B", "")
-                .replace("u200C", "")
-                .replace("u200D", "")
-                .replace("u200E", "")
-                .replace("u200F", "")
-                .replace("uFEFF", "")
-            )
-
-        message = Message(
-            value.id,
-            content,
-            value.author.id,
-            value.channel.id,
-            value.guild.id,
-        )
-
-        for message_obj in self.messages:
-            # This calculates the relation to each other
-            if message == message_obj:
-                raise DuplicateObject
-
-            elif (
-                self.options.get("per_channel_spam")
-                and message.channel_id != message_obj.channel_id
-            ):
-                # This user's spam should only be counted per channel
-                # and these messages are in different channel
-                continue
-
-            elif (
-                fuzz.token_sort_ratio(message.content, message_obj.content)
-                >= self.options["message_duplicate_accuracy"]
-            ):
-                """
-                The handler works off an internal message duplicate counter
-                so just increment that and then let our logic process it
-                """
-                self._increment_duplicate_count(message)
-                message.is_duplicate = True
-
-                if (
-                    self._get_duplicate_count(message)
-                    >= self.options["message_duplicate_count"]
-                ):
-                    break
-
-        # We check this again, because theoretically the above can take awhile to process etc
-        if not self.in_guild:
-            return {"status": "Bypassing message check since the user isnt in a guild"}
-
-        self.messages = message
-        log.info(f"Created Message: {message.id}")
-
-        if (
-            self._get_duplicate_count(message)
-            >= self.options["message_duplicate_count"]
-        ):
-            log.debug(f"Message: ({message.id}) requires some form of punishment")
-            # We need to punish the member with something
-            return_data["should_be_punished_this_message"] = True
-            only_warn = False
-
-            if (
-                self.options.get("delete_spam") is True
-                and self.options.get("no_punish") is False
-            ):
-                try:
-                    await value.delete()
-                    log.debug(f"Deleted message: {value.id}")
-                except discord.HTTPException:
-                    # Failed to delete message
-                    log.warning(
-                        f"Failed to delete message {value.id} in guild {value.guild.id}"
-                    )
-
-            if self.options["warn_only"]:
-                only_warn = True
-
-            if self.options["no_punish"]:
-                # no_punish, just return saying they should be punished
-                return_data[
-                    "status"
-                ] = "User should be punished, however, was not due to no_punish being True"
-
-            elif (
-                self._get_duplicate_count(message) >= self.options["warn_threshold"]
-                and self.warn_count < self.options["kick_threshold"]
-                and self.kick_count < self.options["ban_threshold"]
-                or only_warn
-            ):
-                log.debug(f"Attempting to warn: {message.author_id}")
-                """
-                The member has yet to reach the warn threshold,
-                after the warn threshold is reached this will
-                then become a kick and so on
-                """
-                # We are still in the warning area
-                self.warn_count += 1
-                channel = value.channel
-                guild_message = transform_message(
-                    self.options["guild_warn_message"],
-                    value,
-                    {"warn_count": self.warn_count, "kick_count": self.kick_count},
-                )
-                try:
-                    if isinstance(guild_message, discord.Embed):
-                        await channel.send(
-                            embed=guild_message,
-                            delete_after=self.options.get(
-                                "guild_warn_message_delete_after"
-                            ),
-                        )
-                    else:
-                        await channel.send(
-                            guild_message,
-                            delete_after=self.options.get(
-                                "guild_warn_message_delete_after"
-                            ),
-                        )
-                except Exception as e:
-                    self.warn_count -= 1
-                    raise e
-
-                return_data["was_warned"] = True
-                return_data["status"] = "User was warned."
-
-            elif (
-                self.warn_count >= self.options["kick_threshold"]
-                and self.kick_count < self.options["ban_threshold"]
-            ):
-                # Set this to False here to stop processing other messages, we can revert on failure
-                self.in_guild = False
-                self.kick_count += 1
-
-                log.debug(f"Attempting to kick: {message.author_id}")
-                # We should kick the member
-                guild_message = transform_message(
-                    self.options["guild_kick_message"],
-                    value,
-                    {"warn_count": self.warn_count, "kick_count": self.kick_count},
-                )
-                user_message = transform_message(
-                    self.options["user_kick_message"],
-                    value,
-                    {"warn_count": self.warn_count, "kick_count": self.kick_count},
-                )
-                await self._punish_user(
-                    value,
-                    user_message,
-                    guild_message,
-                    Static.KICK,
-                    self.options.get("user_kick_message_delete_after"),
-                    self.options.get("guild_kick_message_delete_after"),
-                )
-                return_data["was_kicked"] = True
-                return_data["status"] = "User was kicked."
-
-            elif self.kick_count >= self.options["ban_threshold"]:
-                # Set this to False here to stop processing other messages, we can revert on failure
-                self.in_guild = False
-                self.kick_count += 1
-
-                log.debug(f"Attempting to ban: {message.author_id}")
-                # We should ban the member
-                guild_message = transform_message(
-                    self.options["guild_ban_message"],
-                    value,
-                    {"warn_count": self.warn_count, "kick_count": self.kick_count},
-                )
-                user_message = transform_message(
-                    self.options["user_ban_message"],
-                    value,
-                    {"warn_count": self.warn_count, "kick_count": self.kick_count},
-                )
-                await self._punish_user(
-                    value,
-                    user_message,
-                    guild_message,
-                    Static.BAN,
-                    self.options.get("user_ban_message_delete_after"),
-                    self.options.get("guild_ban_message_delete_after"),
-                )
-
-                return_data["was_banned"] = True
-                return_data["status"] = "User was banned."
-
-            else:
-                raise LogicError
-
-        return_data["warn_count"] = self.warn_count
-        return_data["kick_count"] = self.kick_count
-        return_data["duplicate_counter"] = self.get_correct_duplicate_count(
-            message.channel_id
-        )
-        return return_data
-
-    @staticmethod
-    def load_from_dict(bot, user_data):
-        """
-        Loads a new user obj from a dict
-
-        Parameters
-        ----------
-        bot : commands.Bot
-            The bot
-        user_data : dict
-            The data to load state from
-
-        Returns
-        -------
-        User
-
-        """
-        user = User(
-            bot=bot,
-            member_id=user_data["id"],
-            guild_id=user_data["guild_id"],
-            options=deepcopy(user_data["options"]),
-        )
-        user.in_guild = user_data["is_in_guild"]
-        user.warn_count = user_data["warn_count"]
-        user.kick_count = user_data["kick_count"]
-        user.duplicate_counter = user_data["duplicate_count"]
-        user.duplicate_channel_counter_dict = deepcopy(
-            user_data["duplicate_channel_counter_dict"]
-        )
-
-        for message_data in user_data["messages"]:
-            # Do this to save overhead in the message object
-            # and keep it as small as possible
-            message = Message(
-                message_id=message_data["id"],
-                content=message_data["content"],
-                guild_id=message_data["guild_id"],
-                author_id=message_data["author_id"],
-                channel_id=message_data["channel_id"],
-            )
-            message.is_duplicate = message_data["is_duplicate"]
-            message._creation_time = datetime.datetime.strptime(
-                message_data["creation_time"], "%f:%S:%M:%H:%d:%m:%Y"
-            )
-
-            user.messages = message
-            log.debug(f"Created Message ({message.id}) from saved state")
-
-        log.debug(f"Created User ({user.id}) from saved state")
-
-        return user
-
-    async def save_to_dict(self) -> dict:
-        """
-        Returns a dictionary that can be used to
-        reload state at a later date
-
-        Returns
-        -------
-        dict
-            The state to reload from
-        """
-        data = {
-            "id": self.id,
-            "options": self.options,
-            "guild_id": self.guild_id,
-            "is_in_guild": self.in_guild,
-            "warn_count": self.warn_count,
-            "kick_count": self.kick_count,
-            "duplicate_count": self.duplicate_counter,
-            "duplicate_channel_counter_dict": self.duplicate_channel_counter_dict,
-            "messages": [],
-        }
-
-        for message in self._messages:
-            data["messages"].append(
-                {
-                    "id": message.id,
-                    "content": message.content,
-                    "guild_id": message.guild_id,
-                    "author_id": message.author_id,
-                    "channel_id": message.channel_id,
-                    "is_duplicate": message.is_duplicate,
-                    "creation_time": message.creation_time.strftime(
-                        "%f:%S:%M:%H:%d:%m:%Y"
-                    ),
-                }
-            )
-            """
-            "%f:%S:%M:%H:%d:%m:%Y"
-            microsecond:second:minute:hour:day:month:year
-            """
-
-        return data
-
-    async def _punish_user(
-        self,
-        value,
-        user_message,
-        guild_message,
-        method,
-        user_delete_after=None,
-        channel_delete_after=None,
-    ):
-        """
-        A generic method to handle multiple methods of punishment for a user.
-
-        Currently supports: kicking, banning
-
-        Parameters
-        ----------
-        value : discord.Message
-            Where we get everything from :)
-        user_message : str
-            A message to send to the user who is being punished
-        guild_message : str
-            A message to send in the guild for whoever is being punished
-        method : str
-            A string denoting the ignore_type of punishment
-        user_delete_after : int, optional
-            An int value denoting the time to
-            delete user sent messages after
-        channel_delete_after : int, optional
-            An int value denoting the time to
-            delete channel sent messages after
-
-        Raises
-        ======
-        LogicError
-            If you do not pass a support punishment method
-
-        """
-        guild = value.guild
-        member = value.author
-        dc_channel = value.channel
-        if method != Static.KICK and method != Static.BAN:
-            raise LogicError(f"{method} is not a recognized punishment method.")
-
-        # Check we have perms to punish
-        perms = guild.me.guild_permissions
-        if not perms.kick_members and method == Static.KICK:
-            self.in_guild = True
-            self.kick_count -= 1
-            raise MissingGuildPermissions(
-                f"I need kick perms to punish someone in {guild.name}"
-            )
-
-        elif not perms.ban_members and method == Static.BAN:
-            self.in_guild = True
-            self.kick_count -= 1
-            raise MissingGuildPermissions(
-                f"I need ban perms to punish someone in {guild.name}"
-            )
-
-        # We also check they don't own the guild, since ya know...
-        elif guild.owner_id == member.id:
-            self.in_guild = True
-            self.kick_count -= 1
-            raise MissingGuildPermissions(
-                f"I cannot punish {member.display_name}({member.id}) "
-                f"because they own this guild. ({guild.name})"
-            )
-
-        # Ensure we can actually punish the user, for this
-        # we just check our top role is higher then them
-        elif guild.me.top_role.position < member.top_role.position:
-            log.warning(
-                f"I might not be able to punish {member.display_name}({member.id}) in {guild.name}({guild.id}) "
-                "because they are higher then me, which means I could lack the ability to kick/ban them."
-            )
-
-        m = None
-
-        try:
-            # Attempt to message the punished member, about their punishment
-            try:
-                if isinstance(user_message, discord.Embed):
-                    m = await member.send(
-                        embed=user_message, delete_after=user_delete_after
-                    )
-                else:
-                    m = await member.send(user_message, delete_after=user_delete_after)
-            except discord.HTTPException:
-                await dc_channel.send(
-                    f"Sending a message to {member.mention} about their {method} failed.",
-                    delete_after=channel_delete_after,
-                )
-                log.warning(f"Failed to message User: ({member.id}) about {method}")
-            finally:
-
-                # Even if we can't tell them they are being punished
-                # We still need to punish them, so try that
-                try:
-                    if method == Static.KICK:
-                        await guild.kick(
-                            member, reason="Automated punishment from DPY Anti-Spam."
-                        )
-                        log.info(f"Kicked User: ({member.id})")
-                    elif method == Static.BAN:
-                        await guild.ban(
-                            member, reason="Automated punishment from DPY Anti-Spam."
-                        )
-                        log.info(f"Banned User: ({member.id})")
-                    else:
-                        raise NotImplementedError
-                except discord.Forbidden:
-                    self.in_guild = True
-                    self.kick_count -= 1
-                    await dc_channel.send(
-                        f"I do not have permission to kick: {member.mention}",
-                        delete_after=channel_delete_after,
-                    )
-                    log.warning(f"Required Permissions are missing for: {method}")
-                    if m is not None:
-                        if method == Static.KICK:
-                            user_failed_message = transform_message(
-                                self.options["user_failed_kick_message"],
-                                value,
-                                {
-                                    "warn_count": self.warn_count,
-                                    "kick_count": self.kick_count,
-                                },
-                            )
-                        else:
-                            user_failed_message = transform_message(
-                                self.options["user_failed_ban_message"],
-                                value,
-                                {
-                                    "warn_count": self.warn_count,
-                                    "kick_count": self.kick_count,
-                                },
-                            )
-                        if isinstance(user_failed_message, discord.Embed):
-                            await member.send(
-                                embed=user_failed_message,
-                                delete_after=user_delete_after,
-                            )
-                        else:
-                            await member.send(
-                                user_failed_message,
-                                delete_after=user_delete_after,
-                            )
-                        await m.delete()
-
-                except discord.HTTPException:
-                    self.in_guild = True
-                    self.kick_count -= 1
-                    await dc_channel.send(
-                        f"An error occurred trying to {method}: {member.mention}",
-                        delete_after=channel_delete_after,
-                    )
-                    log.warning(f"An error occurred trying to {method}: {member.id}")
-                    if m is not None:
-                        if method == Static.KICK:
-                            user_failed_message = transform_message(
-                                self.options["user_failed_kick_message"],
-                                value,
-                                {
-                                    "warn_count": self.warn_count,
-                                    "kick_count": self.kick_count,
-                                },
-                            )
-                        else:
-                            user_failed_message = transform_message(
-                                self.options["user_failed_ban_message"],
-                                value,
-                                {
-                                    "warn_count": self.warn_count,
-                                    "kick_count": self.kick_count,
-                                },
-                            )
-                        if isinstance(user_failed_message, discord.Embed):
-                            await member.send(
-                                embed=user_failed_message,
-                                delete_after=user_delete_after,
-                            )
-                        else:
-                            await member.send(
-                                user_failed_message,
-                                delete_after=user_delete_after,
-                            )
-                        await m.delete()
-
-                else:
-                    try:
-                        if isinstance(guild_message, discord.Embed):
-                            await dc_channel.send(
-                                embed=guild_message,
-                                delete_after=channel_delete_after,
-                            )
-                        else:
-                            await dc_channel.send(
-                                guild_message,
-                                delete_after=channel_delete_after,
-                            )
-                    except discord.HTTPException:
-                        log.error(
-                            f"Failed to send message.\n"
-                            f"Guild: {dc_channel.guild.name}({dc_channel.guild.id})\n"
-                            f"Channel: {dc_channel.name}({dc_channel.id})"
-                        )
-        except Exception as e:
-            raise e
-        finally:
-            self.in_guild = True
-
-    def get_correct_duplicate_count(self, channel_id: int = None):
-        """
-        Given the internal math has an extra number cos
-        accuracy this simply returns the correct value
-
-        Parameters
-        ----------
-        channel_id : int
-            The channel to get duplicate counters in
-
-        Returns
-        -------
-        int
-            The correct duplicate count
-        """
-        return self._get_duplicate_count(channel_id=channel_id) - 1
-
-    def clean_up(self, current_time):
-        """
-        This logic works around checking the current
-        time vs a messages creation time. If the message
-        is older by the config amount it can be cleaned up
-        """
-        log.debug("Attempting to remove outdated Message's")
-
-        def _is_still_valid(message_obj):
-            """
-            Given a message, figure out if it hasn't
-            expired yet based on timestamps
-            """
-            difference = current_time - message_obj.creation_time
-            offset = datetime.timedelta(
-                milliseconds=self.options.get("message_interval")
-            )
-
-            if difference >= offset:
-                return False
-            return True
-
-        current_messages = []
-        outstanding_messages = []
-
-        for message in self._messages:
-            if _is_still_valid(message):
-                current_messages.append(message)
-            else:
-                outstanding_messages.append(message)
-
-        self._messages = deepcopy(current_messages)
-
-        # Now if we have outstanding messages we need
-        # to process them and see if we need to decrement
-        # the duplicate counter as we are removing them from
-        # the queue otherwise everything stacks up
-        for outstanding_message in outstanding_messages:
-            if outstanding_message.is_duplicate:
-                self.duplicate_counter -= 1
-                log.debug(f"Removing duplicate Message: {outstanding_message.id}")
-            log.debug(f"Removing Message: {outstanding_message.id}")
-
-    def _increment_duplicate_count(self, message: Message, amount: int = 1):
-        """A helper method to increment the correct duplicate counter, global or not.
-
-        Warnings
-        --------
-        This is not yet implemented yet. You shouldn't
-        be touching this class yourself anyway but still.
-        """
-        is_per_channel = self.options.get("per_channel_spam")
-        if not is_per_channel:
-            # Just use the regular int, should save overhead
-            # for those who dont use per_channel_spam
-            self.duplicate_counter += amount
-
-        # We need a custom channel to save shit in
-        elif message.channel_id not in self.duplicate_channel_counter_dict:
-            self.duplicate_channel_counter_dict[message.channel_id] = (
-                amount + 1
-            )  # since we need an extra 1 by default
-
-        else:
-            self.duplicate_channel_counter_dict[message.channel_id] += amount
-
-    def _get_duplicate_count(
-        self, message: Message = None, channel_id: int = None
-    ) -> int:
-        """A helper method to get the correct duplicate counter based on settings
-
-        Warnings
-        --------
-        This is not yet implemented yet. You shouldn't
-        be touching this class yourself anyway but still.
-        """
-        is_per_channel = self.options.get("per_channel_spam")
-        if not is_per_channel:
-            return self.duplicate_counter
-
-        if message is not None and isinstance(message, Message):
-            channel_id = message.channel_id
-        elif channel_id is not None:
-            channel_id = int(channel_id)
-        else:
-            raise LogicError("Both message and channel_id are none, weird.")
-
-        if channel_id not in self.duplicate_channel_counter_dict:
-            return 1
-
-        else:
-            return self.duplicate_channel_counter_dict[channel_id]
-
-    def _remove_duplicate_count(self, message: Message, amount: int = 1):
-        """Used when cleaning the cache, to only lower the correct counter
-
-        Warnings
-        --------
-        This is not yet implemented yet. You shouldn't
-        be touching this class yourself anyway but still.
-        """
-        is_per_channel = self.options.get("per_channel_spam")
-        if not is_per_channel:
-            self.duplicate_counter -= amount
-
-        elif message.channel_id not in self.duplicate_channel_counter_dict:
-            log.warning(
-                "Failed to de-increment duplicate count as the channel id doesnt exist"
-            )
-
-        else:
-            self.duplicate_channel_counter_dict[message.channel_id] -= amount
-
-    @property
-    def id(self):
-        return self._id
-
-    @id.setter
-    def id(self, value):
-        if not isinstance(value, int):
-            raise ValueError("Expected integer")
-        self._id = value
-
-    @property
-    def guild_id(self):
-        return self._guild_id
-
-    @guild_id.setter
-    def guild_id(self, value):
-        if not isinstance(value, int):
-            raise ValueError("Expected integer")
-        self._guild_id = value
-
-    @property
-    def messages(self):
-        return self._messages
-
-    @messages.setter
-    def messages(self, value):
-        """
-        Raises
-        ======
-        DuplicateObject
-            It won't maintain two message objects with the same
-            id's, and it will complain about it haha
-        """
-        if not isinstance(value, Message):
-            raise ValueError("Expected Message object")
-
-        if value.author_id != self.id or value.guild_id != self.guild_id:
-            raise ObjectMismatch
-
-        for message in self._messages:
-            if message == value:
-                raise DuplicateObject
-
-        self._messages.append(value)
-
-    @property
-    def options(self):
-        return self._options
-
-    @options.setter
-    def options(self, value):
-        if not isinstance(value, collections.abc.Mapping):
-            raise ValueError("Expected dict")
-
-        self._options = deepcopy(value)
diff --git a/antispam/util.py b/antispam/util.py
index c98dba7..8d87063 100644
--- a/antispam/util.py
+++ b/antispam/util.py
@@ -1,215 +1,6 @@
-"""
-LICENSE
-The MIT License (MIT)
-
-Copyright (c) 2020-2021 Skelmis
-
-Permission is hereby granted, free of charge, to any person obtaining a
-copy of this software and associated documentation files (the "Software"),
-to deal in the Software without restriction, including without limitation
-the rights to use, copy, modify, merge, publish, distribute, sublicense,
-and/or sell copies of the Software, and to permit persons to whom the
-Software is furnished to do so, subject to the following conditions:
-The above copyright notice and this permission notice shall be included in
-all copies or substantial portions of the Software.
-
-THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
-OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
-FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
-DEALINGS IN THE SOFTWARE.
-LICENSE
-"""
 import datetime
-from copy import deepcopy
-from string import Template
-
-import discord
-
-"""
-A short utility for random functions which don't fit into an object
-"""
-
-
-def embed_to_string(embed) -> str:
-    """
-    Return the content of an embed,
-    built to only return fields the package
-    cares about for spam related issues
-
-    Parameters
-    ----------
-    embed : discord.Embed
-        The embed to turn into a string
-
-    Returns
-    -------
-    str
-        The content of the string
-    """
-    content = ""
-    embed = embed.to_dict()
-
-    if "title" in embed:
-        content += f"{embed['title']}\n"
-
-    if "description" in embed:
-        content += f"{embed['description']}\n"
-
-    if "footer" in embed:
-        if "text" in embed["footer"]:
-            content += f"{embed['footer']['text']}\n"
-
-    if "author" in embed:
-        if "name" in embed["author"]:
-            content += f"{embed['author']['name']}\n"
-
-    if "fields" in embed:
-        for field in embed["fields"]:
-            content += f"{field['name']}\n{field['value']}\n"
-
-    return content
-
-
-def dict_to_embed(data, message, counts) -> discord.Embed:
-    """
-    Given a dictionary, will attempt to build a
-    valid discord.Embed object to return
-
-    Parameters
-    ----------
-    data : dict
-        The given item to try build an embed from
-    message : discord.Message
-        Where we get all our info from
-    counts : dict
-        Our current warn & kick counts
-
-    Returns
-    -------
-    discord.Embed
-
-    Notes
-    -----
-    For you smart-ass's out there. This does use ``discord.Embed.from_dict()``
-    except this also lets you use templated strings within fields you
-    smart cookies
-    """
-    allowed_avatars = ["$USERAVATAR", "$BOTAVATAR", "$GUILDICON"]
-
-    if "title" in data:
-        data["title"] = substitute_args(data["title"], message, counts)
-
-    if "description" in data:
-        data["description"] = substitute_args(data["description"], message, counts)
-
-    if "footer" in data:
-        if "text" in data["footer"]:
-            data["footer"]["text"] = substitute_args(
-                data["footer"]["text"], message, counts
-            )
-
-        if "icon_url" in data["footer"]:
-            if data["footer"]["icon_url"] in allowed_avatars:
-                data["footer"]["icon_url"] = substitute_args(
-                    data["footer"]["icon_url"], message, counts
-                )
-
-    if "author" in data:
-        if "name" in data["author"]:
-            data["author"]["name"] = substitute_args(
-                data["author"]["name"], message, counts
-            )
-
-        if "icon_url" in data["author"]:
-            if data["author"]["icon_url"] in allowed_avatars:
-                data["author"]["icon_url"] = substitute_args(
-                    data["author"]["icon_url"], message, counts
-                )
-
-    if "fields" in data:
-        for field in data["fields"]:
-            name = substitute_args(field["name"], message, counts)
-            value = substitute_args(field["value"], message, counts)
-            field["name"] = name
-            field["value"] = value
-
-            if "inline" not in field:
-                field["inline"] = True
-
-    if "timestamp" in data:
-        data["timestamp"] = message.created_at.isoformat()
-
-    if "colour" in data:
-        data["color"] = data["colour"]
-
-    data["type"] = "rich"
-
-    return discord.Embed.from_dict(data)
-
-
-def substitute_args(message, value, counts) -> str:
-    """
-    Given the options string, return the string
-    with the relevant values substituted in
-
-    Parameters
-    ----------
-    message : str
-        The string to substitute with values
-    value : discord.Message
-        Where we get our values from to substitute
-    counts : dict
-        Our current warn & kick counts
-
-    Returns
-    -------
-    str
-        The correctly substituted message
-
-    """
-    return Template(message).safe_substitute(
-        {
-            "MENTIONUSER": value.author.mention,
-            "USERNAME": value.author.display_name,
-            "USERID": value.author.id,
-            "BOTNAME": value.guild.me.display_name,
-            "BOTID": value.guild.me.id,
-            "GUILDID": value.guild.id,
-            "GUILDNAME": value.guild.name,
-            "TIMESTAMPNOW": datetime.datetime.now().strftime("%I:%M:%S %p, %d/%m/%Y"),
-            "TIMESTAMPTODAY": datetime.datetime.now().strftime("%d/%m/%Y"),
-            "WARNCOUNT": counts["warn_count"],
-            "KICKCOUNT": counts["kick_count"],
-            "USERAVATAR": value.author.avatar_url,
-            "BOTAVATAR": value.guild.me.avatar_url,
-            "GUILDICON": value.guild.icon_url,
-        }
-    )
-
-
-def transform_message(item, value, counts):
-    """
-    Given an item of two possible values, create
-    and return the correct thing
-
-    Parameters
-    ----------
-    item : [str, dict]
-        Either a straight string or dict to turn in an embed
-    value : discord.Message
-        Where things come from
-    counts : dict
-        Our current warn & kick counts
-
-    Returns
-    -------
-    [str, discord.Embed]
 
-    """
-    if isinstance(item, str):
-        return substitute_args(item, value, counts)
 
-    return dict_to_embed(deepcopy(item), value, counts)
+def get_aware_time() -> datetime.datetime:
+    """Used to get an aware datetime"""
+    return datetime.datetime.now(datetime.timezone.utc)
diff --git a/count.py b/count.py
index 68012cb..ed0ce71 100644
--- a/count.py
+++ b/count.py
@@ -33,10 +33,9 @@ def countlines(start, lines=0, header=True, begin_start=None):
 
     for thing in os.listdir(start):
         try:
-            if thing in ["antispam", "testing", "examples", "mocks"]:
-                thing = os.path.join(start, thing)
-                if os.path.isdir(thing):
-                    lines = countlines(thing, lines, header=False, begin_start=start)
+            thing = os.path.join(start, thing)
+            if os.path.isdir(thing):
+                lines = countlines(thing, lines, header=False, begin_start=start)
         except:
             pass
 
@@ -44,3 +43,4 @@ def countlines(start, lines=0, header=True, begin_start=None):
 
 
 countlines("./antispam")
+countlines("./tests")
diff --git a/dev-requirements.txt b/dev-requirements.txt
new file mode 100644
index 0000000..27b5271
--- /dev/null
+++ b/dev-requirements.txt
@@ -0,0 +1,18 @@
+coveralls==3.1.0
+fuzzywuzzy==0.18.0
+nest-asyncio==1.5.1
+pip-chill==1.0.1
+python-levenshtein==0.12.2
+sphinx-rtd-theme==0.5.2
+twine==3.4.1
+typed-ast==1.4.3
+pytest
+pytest-asyncio
+hypothesis
+hikari
+sphinx
+attrs==21.2.0
+setuptools==57.0.0
+
+discord.py
+hikari
\ No newline at end of file
diff --git a/docs/conf.py b/docs/conf.py
index 8d4f316..a88f945 100644
--- a/docs/conf.py
+++ b/docs/conf.py
@@ -11,8 +11,8 @@
 # documentation root, use os.path.abspath to make it absolute, like shown here.
 #
 import os
-import re
 import sys
+
 from sphinx.ext.autodoc import between
 
 sys.path.insert(0, os.path.abspath(".."))
@@ -21,7 +21,7 @@
 # -- Project information -----------------------------------------------------
 
 project = "DPY Anti-Spam"
-copyright = "2020, Skelmis"
+copyright = "2021, Skelmis"
 author = "Skelmis"
 
 
@@ -30,7 +30,12 @@
 # Add any Sphinx extension module names here, as strings. They can be
 # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom
 # ones.
-extensions = ["sphinx.ext.autodoc", "sphinx.ext.coverage", "sphinx.ext.napoleon"]
+extensions = [
+    "sphinx.ext.autodoc",
+    "sphinx.ext.coverage",
+    "sphinx.ext.napoleon",
+    "sphinx.ext.intersphinx",
+]
 
 # Add any paths that contain templates here, relative to this directory.
 templates_path = ["_templates"]
@@ -48,16 +53,10 @@
 #
 html_theme = "sphinx_rtd_theme"
 
-# Add any paths that contain custom static files (such as style sheets) here,
-# relative to this directory. They are copied after the builtin static files,
-# so a file named "default.css" will overwrite the builtin "default.css".
-html_static_path = ["_static"]
+intersphinx_mapping = {
+    "python": ("https://docs.python.org/3", None),
+    "discord.py": ("https://discordpy.readthedocs.io/en/latest/", None),
+    "hikari": ("https://hikari-py.github.io/hikari/", None),
+}
 
 master_doc = "index"
-
-
-def setup(app):
-    # Register a sphinx.ext.autodoc.between listener to ignore everything
-    # between lines that contain the word IGNORE
-    app.connect("autodoc-process-docstring", between("^.*LICENSE.*$", exclude=True))
-    return app
diff --git a/docs/index.rst b/docs/index.rst
index 1de2c23..92b854b 100644
--- a/docs/index.rst
+++ b/docs/index.rst
@@ -1,40 +1,63 @@
-.. DPY Anti-Spam documentation master file, created by
-   sphinx-quickstart on Fri Sep 18 23:28:30 2020.
-   You can adapt this file completely to your liking, but it should at least
-   contain the root `toctree` directive.
-
 Welcome to DPY Anti-Spam's documentation!
 =========================================
 
+DPY Anti-Spam supports discord.py and all forks out of
+the box assuming they use the ``discord`` namespace.
+
+If you want to use this with hikari, please enable it
+by passing ``is_using_hikari=True`` to the
+``AntiSpamHandler`` constructor.
+
+
+The package features some built in punishments, these are:
+
+- Per member spam is treated as warns, then kicks followed by bans.
+- Per channel spam starts off as a kick straight away followed by bans
+
+.. toctree::
+   :maxdepth: 2
+   :caption: Primary Interface:
+
+   modules/main/main.rst
+   modules/main/caches.rst
+   modules/main/examples.rst
+   modules/main/logging.rst
+   modules/main/template.rst
+   modules/migrating.rst
+
 .. toctree::
    :maxdepth: 2
-   :caption: Main Interface:
+   :caption: Main Interaction Classes
 
-   modules/handler.rst
-   modules/options.rst
-   modules/examples.rst
-   modules/events.rst
-   modules/logging.rst
-   modules/optimisation.rst
+   modules/interactions/enums.rst
+   modules/interactions/options.rst
+   modules/interactions/core.rst
 
 .. toctree::
    :maxdepth: 2
-   :caption: Extension Framework
+   :caption: Plugin Framework
 
-   modules/ext/base.rst
-   modules/ext/schema.rst
-   modules/ext/custom_punishment.rst
+   modules/plugins/plugins.rst
+   modules/plugins/schema.rst
+   modules/plugins/storage.rst
+   modules/plugins/tracker.rst
+   modules/plugins/mentions.rst
+   modules/plugins/stats.rst
+   modules/plugins/logs.rst
 
 .. toctree::
    :maxdepth: 2
    :caption: Object Reference:
 
-   modules/guild.rst
-   modules/user.rst
-   modules/message.rst
-   modules/util.rst
-   modules/exceptions.rst
-   modules/ext/base_extension.rst
+   modules/objects/objects.rst
+   modules/objects/abc.rst
+   modules/objects/exceptions.rst
+   modules/objects/guild.rst
+   modules/objects/member.rst
+   modules/objects/message.rst
+   modules/objects/redis.rst
+   modules/objects/memory.rst
+   modules/objects/data.rst
 
 Install Notes
 -------------
diff --git a/docs/modules/events.rst b/docs/modules/events.rst
deleted file mode 100644
index 98614bc..0000000
--- a/docs/modules/events.rst
+++ /dev/null
@@ -1,23 +0,0 @@
-Custom Events
-=============
-
-This package utilises custom discord.py events for punishments.
-This system will be further rolled out down the line to the standard lib.
-
-
-Anti Mass Mentions
-==================
-
-.. code-block:: python
-    :linenos:
-
-    from antispam.ext import MassMentionPunishment
-
-    @bot.event
-    async def on_mass_mention_punishment(payload: MassMentionPunishment):
-        # Your code here
-
-The ``payload`` is an instance of the following:
-
-.. autoclass:: antispam.ext.anti_mass_mention.MassMentionPunishment
-    :members:
diff --git a/docs/modules/ext/base.rst b/docs/modules/ext/base.rst
deleted file mode 100644
index a749b17..0000000
--- a/docs/modules/ext/base.rst
+++ /dev/null
@@ -1,43 +0,0 @@
-Extension Framework
-===================
-
-This package features/will feature a built in extensions framework soon.
-This framework can be used to hook into the ``propagate`` method and run
-as either a **pre-invoke** or **after-invoke** (Where **invoke** is
-the built in **propagate**)
-
-All registered extensions **must** subclass ``BaseExtension``
-
-An extension can do anything, from AntiProfanity to AntiInvite.
-Assuming it is class based and follows the required schema you
-can easily develop your own extension that can be run whenever the
-end developer calls ``await AntiSpamHandler.propagate()``
-
-Some extensions don't need to be registered as an extension.
-A good example of this is the ``AntiSpamTracker`` class.
-This class does not need to be invoked with ``propagate`` as
-it can be handled by the end developer for finer control.
-However, it can also be used as an extension if users are
-happy with the default behaviour.
-
-Call Stack
-----------
-
-* Initially all checks are run, these are the checks baked into ``AntiSpamHandler``
-    * You cannot avoid these checks, if you wish to mitigate them you should
-      set them to values that will not be triggered
-    * An option to run code before checks may be added in a future version,
-      if this is something you would like, jump into discord and let me know!
-      If I know people want features, they get done quicker
-* Following that, all pre-invoke extensions will be run
-    * The ordered that these are run is loosely based on the order that
-      extensions were registered. Do not expect any form of runtime
-      ordering however. You should build them around the idea that they
-      are guaranteed to run before ``AntiSpamHandler.propagate``, not
-      other extensions
-* Run ``AntiSpamHandler.propagate``
-    * If any pre-invoke extension has returned a True value for ``cancel_next_invocation``
-      then this method, and any after-invoke extensions will not be called
-* Run all after-invoke extensions
-    * After-invoke extensions get output from both ``AntiSpamHandler``
-      and all pre-invoke extensions as a method argument
\ No newline at end of file
diff --git a/docs/modules/ext/base_extension.rst b/docs/modules/ext/base_extension.rst
deleted file mode 100644
index cf7291f..0000000
--- a/docs/modules/ext/base_extension.rst
+++ /dev/null
@@ -1,9 +0,0 @@
-Base Extension
-==============
-
-.. currentmodule:: AntiSpam
-
-.. autoclass:: BaseExtension
-    :members:
-    :undoc-members:
-    :special-members:
diff --git a/docs/modules/ext/custom_punishment.rst b/docs/modules/ext/custom_punishment.rst
deleted file mode 100644
index 5f7913a..0000000
--- a/docs/modules/ext/custom_punishment.rst
+++ /dev/null
@@ -1,12 +0,0 @@
-Custom/No Punishment Modes
-==========================
-
-This package recognizes end developers may wish
-to punish users outside of how this package does it.
-Due to this, you can use the ``AntiSpamTracker`` to
-track users yourself and work with punishments of your
-choice!
-
-.. autoclass:: antispam.ext.AntiSpamTracker
-    :members:
-    :special-members: __init__
diff --git a/docs/modules/guild.rst b/docs/modules/guild.rst
deleted file mode 100644
index f418cc6..0000000
--- a/docs/modules/guild.rst
+++ /dev/null
@@ -1,11 +0,0 @@
-Guild Object
-=============
-
-**Note, this class should not be used by you. Only use the AntiSpamHandler to work with this package.**
-
-.. currentmodule:: antispam.guild
-
-.. autoclass:: Guild
-    :members:
-    :undoc-members:
-    :special-members:
diff --git a/docs/modules/handler.rst b/docs/modules/handler.rst
deleted file mode 100644
index 8562221..0000000
--- a/docs/modules/handler.rst
+++ /dev/null
@@ -1,30 +0,0 @@
-AntiSpamHandler
-===============
-
-**Note, this is the main entrance to this entire package.
-As such this should be the only thing you interact with.**
-
-This handler propagation method also returns the following dictionary for you to use:
-
-.. currentmodule:: antispam.anti_spam_handler
-
-.. code-block:: python
-    :linenos:
-
-    {
-        "should_be_punished_this_message": boolean,
-        "was_warned": boolean,
-        "was_kicked": boolean,
-        "was_banned": boolean,
-        "status": string,
-        "warn_count": integer,
-        "kick_count": integer,
-        "duplicate_counter": integer,
-        "pre_invoke_extensions": list,
-        "after_invoke_extensions": list,
-    }
-
-
-.. autoclass:: AntiSpamHandler
-    :members:
-    :special-members: __init__
diff --git a/docs/modules/interactions/core.rst b/docs/modules/interactions/core.rst
new file mode 100644
index 0000000..65b444c
--- /dev/null
+++ b/docs/modules/interactions/core.rst
@@ -0,0 +1,11 @@
+CorePayload Reference
+---------------------
+
+*You should not be creating this object yourself.*
+
+.. currentmodule:: antispam
+
+.. autoclass:: CorePayload
+    :members:
+    :undoc-members:
+    :special-members: __init__
diff --git a/docs/modules/interactions/enums.rst b/docs/modules/interactions/enums.rst
new file mode 100644
index 0000000..4c90824
--- /dev/null
+++ b/docs/modules/interactions/enums.rst
@@ -0,0 +1,13 @@
+Enum Reference
+==============
+
+.. currentmodule:: antispam.enums
+
+.. autoclass:: IgnoreType
+    :members:
+    :undoc-members:
+
+.. autoclass:: ResetType
+    :members:
+    :undoc-members:
+
diff --git a/docs/modules/interactions/options.rst b/docs/modules/interactions/options.rst
new file mode 100644
index 0000000..b2e8ad3
--- /dev/null
+++ b/docs/modules/interactions/options.rst
@@ -0,0 +1,22 @@
+Option's Reference
+==================
+
+This class represents the Options for both
+Guilds and the AntiSpamHandler itself. It is 
+important to become familiar with this dataclass.
+
+Options can be set in two ways:
+ - Set when creating a new object ``Options(no_punish=True)``
+ - Set using an existing object ``Options.no_punish = True``
+
+For more in depth meanings and explanations, please see
+the primary docstring of :py:class:`antispam.AntiSpamHandler`
+
+
+ .. currentmodule:: antispam.dataclasses.options
+
+ .. autoclass:: Options
+     :members:
+     :undoc-members:
+     :special-members: __init__
+ 
diff --git a/docs/modules/main/caches.rst b/docs/modules/main/caches.rst
new file mode 100644
index 0000000..8de8910
--- /dev/null
+++ b/docs/modules/main/caches.rst
@@ -0,0 +1,35 @@
+Cache Choices
+=============
+
+Internally all data is 'cached' using an implementation
+which implements :py:class:`antispam.abc.Cache`
+
+In the standard package you have the following choices:
+ - :py:class:`antispam.caches.MemoryCache` (Default)
+ - :py:class:`antispam.caches.RedisCache` (Not yet implemented)
+
+In order to use a cache other then the default one, 
+simply pass in an instance of the cache you wish to
+use with the ``cache`` kwarg when initialising your
+``AntiSpamHandler``.
+
+Here is an example, note ``RedisCache`` will likely need arguments to init.
+
+.. code-block:: python
+    :linenos:
+
+    import discord
+    from discord.ext import commands
+
+    from antispam import AntiSpamHandler
+    from antispam.caches import RedisCache
+
+    bot = commands.Bot(command_prefix="!", intents=discord.Intents.all())
+    bot.handler = AntiSpamHandler(bot, cache=RedisCache())
+
+
+Once a cache is registered like so, there is nothing else you need to do. 
+The package will simply use that caching mechanism.
+
+Also note, AntiSpamHandler will call :py:meth:`antispam.abc.Cache.initialize`
+before any cache operations are undertaken.
\ No newline at end of file
diff --git a/docs/modules/examples.rst b/docs/modules/main/examples.rst
similarity index 87%
rename from docs/modules/examples.rst
rename to docs/modules/main/examples.rst
index 3b61304..d9d7e8f 100644
--- a/docs/modules/examples.rst
+++ b/docs/modules/main/examples.rst
@@ -1,6 +1,9 @@
 Example usages
 ==============
 
+Note, all of these examples are for discord.py.
+If you would like another library here, let me know.
+
 Super duper basic bot
 ---------------------
 
@@ -32,6 +35,30 @@ Super duper basic bot
         bot.run("Bot Token Here")
 
 
+Basic Hikari bot
+----------------
+
+.. code-block:: python
+    :linenos:
+
+    import hikari
+    from antispam import AntiSpamHandler
+
+    bot = hikari.GatewayBot(
+        token="..."
+    )
+    handler = AntiSpamHandler(bot, is_using_hikari=True)
+
+    @bot.listen()
+    async def ping(event: hikari.GuildMessageCreateEvent) -> None:
+        if event.is_bot or not event.content:
+            return
+
+        await handler.propagate(event.message)
+
+    bot.run()
+
+
 How to use templating in a string
 ---------------------------------
 
@@ -132,7 +159,7 @@ Custom Punishments
     from discord.ext import commands
 
     from antispam import AntiSpamHandler
-    from antispam.ext import AntiSpamTracker
+    from antispam.plugins import AntiSpamTracker
 
     bot = commands.Bot(command_prefix="!")
     bot.handler = AntiSpamHandler(bot, no_punish=True)
diff --git a/docs/modules/logging.rst b/docs/modules/main/logging.rst
similarity index 100%
rename from docs/modules/logging.rst
rename to docs/modules/main/logging.rst
diff --git a/docs/modules/main/main.rst b/docs/modules/main/main.rst
new file mode 100644
index 0000000..51da7d3
--- /dev/null
+++ b/docs/modules/main/main.rst
@@ -0,0 +1,23 @@
+Main Interface
+==============
+
+This file deals with the AntiSpamHandler
+as it is the primary Interface for you to
+interact with.
+
+**Note, this is the main entrance to this entire package.
+As such this should be the only thing you interact with.**
+
+Punishment messages won't be sent unless a guild sets a log channel.
+
+This handler propagation method also returns the following class for you to use:
+
+:py:class:`antispam.CorePayload`
+
+.. currentmodule:: antispam
+
+.. autoclass:: AntiSpamHandler
+    :members:
+    :undoc-members:
+    :special-members: __init__
+
diff --git a/docs/modules/options.rst b/docs/modules/main/template.rst
similarity index 79%
rename from docs/modules/options.rst
rename to docs/modules/main/template.rst
index cbb1a18..4b19aa3 100644
--- a/docs/modules/options.rst
+++ b/docs/modules/main/template.rst
@@ -3,14 +3,17 @@ Message Templating
 
 This package utilises safe conversions for message arguments within strings.
 
+*These use discord.py terms. But the package will work with the library
+you are using seamlessly. Don't worry about not seeing exact matches.*
+
 Templating Options
 -------------------
 
 The following are all the options you as the user have:
 
-* **$MENTIONUSER** - This will attempt to mention the user, uses ``discord.Member.mention``
-* **$USERNAME** - This will attempt to state the user's name, uses ``discord.Member.display_name``
-* **$USERID** - This will attempt to state the user's id, uses ``discord.Member.id``
+* **MENTIONMEMBER** - This will attempt to mention the user, uses ``discord.Member.mention``
+* **MEMBERNAME** - This will attempt to state the user's name, uses ``discord.Member.display_name``
+* **MEMBERID** - This will attempt to state the user's id, uses ``discord.Member.id``
 
 * **$BOTNAME** - This will attempt to state your bots name, uses ``discord.Guild.me.name``
 * **$BOTID** - This will attempt to state your bots id, uses ``discord.Guild.me.id``
@@ -27,10 +30,12 @@ The following are all the options you as the user have:
 
 The following are special case's for embeds:
 
-* **$USERAVATAR** - This will attempt to display the user's avatar, uses ``discord.Member.avatar_url``
+* **MEMBERAVATAR** - This will attempt to display the user's avatar, uses ``discord.Member.avatar_url``
 * **$BOTAVATAR** - This will attempt to display the bots avatar, uses ``discord.Guild.me.avatar_url``
 * **$GUILDICON** - This will attempt to display the guilds icon, uses ``discord.Guild.icon_url``
 
+*Note: Example usages not final. Usage works in discord.py 1.x.x and 2.x.x + hikari*
+
 The above are valid in the following uses:
 
 1. ``discord.Embed.set_author(url="")``
diff --git a/docs/modules/message.rst b/docs/modules/message.rst
deleted file mode 100644
index bb9bc2a..0000000
--- a/docs/modules/message.rst
+++ /dev/null
@@ -1,11 +0,0 @@
-Message Object
-==============
-
-**Note, this class should not be used by you. Only use the AntiSpamHandler to work with this package.**
-
-.. currentmodule:: antispam.message
-
-.. autoclass:: Message
-    :members:
-    :undoc-members:
-    :special-members:
diff --git a/docs/modules/migrating.rst b/docs/modules/migrating.rst
new file mode 100644
index 0000000..065a247
--- /dev/null
+++ b/docs/modules/migrating.rst
@@ -0,0 +1,75 @@
+Migrating to 1.0
+================
+
+The biggest change from 0.x.x to 1.x.x is
+that every is now more sanely named in regard to pep8.
+
+Likely missing things here, if you'd like support
+join our discord and we'd be happy to assist.
+
+Changes
+-------
+
+- Extensions are now called plugins
+
+- :py:class:`AntiSpamHandler` now takes an :py:class:`Options`
+  class rather then kwargs to set options.
+
+- `user_` -> `member_`
+
+- When failing to send a message, it now sends it to the guild log channels
+
+- Some type param's are now enums. See :py:class:`IgnoreType` and :py:class:`ResetType`
+
+- :py:method:`AntiSpamHandler.propagate` now returns :py:class:`CorePayload` instead of a dict
+
+- Some misc methods on the handler have signature changes
+
+- Package is typed more, however not fully. This is still a work in progress
+
+- Misc changes, no doubt I've missed heaps
+
+Features
+--------
+
+- Added support for `Hikari` and all `discord.py` forks
+
+- Added a guild log channel setting
+    - `guild_` messages will be sent here if set, otherwise same as before
+    - :py:method:`AntiSpamHandler.add_guild_log_channel`
+    - :py:method:`AntiSpamHandler.remove_guild_log_channel`
+
+- Abstracted logic and data storage to be separate. This means you
+  can setup your own cache such as redis. See :py:class:`Cache`
+
+- Now features an easy way to clean up your cache. See :py:method:`AntiSpamHandler.clean_cache`
+
+- New plugins:
+    - :py:class:`AntiMassMention` - To stop people spam pinging
+
+    - :py:class:`Stats` - For general package stats
+
+    - :py:class:`AdminLogs` - An easy way to get evidence on punishments
+
+- Plugins now have direct access to storage within the cache.
+  You should be interacting with :py:class:`PluginCache` for this.
+
+- Plugins now support blacklisting to stop runs on certain guilds.
+  See Plugin Blacklisting under ``Package Plugin System``
+
+- Roughly ``150%`` faster then 0.x.x on small test cases
+
+- Fully tested, no more pesky regression bugs
+
+- Further documented
+
+- More comprehensive logging, this is greatly improved compared to 0.x.x
+
+Fixes
+-----
+
+- When the package attempts to delete spam messages, it will
+  now actually delete *all* messages marked as spam rather then
+  just the last one.
+
+- Logging now lazily computes variables, this should be a decent speedup
\ No newline at end of file
diff --git a/docs/modules/objects/abc.rst b/docs/modules/objects/abc.rst
new file mode 100644
index 0000000..0baf163
--- /dev/null
+++ b/docs/modules/objects/abc.rst
@@ -0,0 +1,21 @@
+Abc Reference
+=============
+
+This ``Protocol`` simply defines how a ``Cache``
+should work. This is going to only be useful if 
+you either plan on working directly with an existing
+cache or wish to build your own. 
+
+Any form of internal cache is guranteed to implement 
+this so you can treat it as a source of truth for usage.
+*(Unless you bypass them)*
+
+.. currentmodule:: antispam.abc
+
+.. autoclass:: Cache
+    :members:
+    :undoc-members:
+
+.. autoclass:: Lib
+    :members:
+    :undoc-members:
diff --git a/docs/modules/objects/data.rst b/docs/modules/objects/data.rst
new file mode 100644
index 0000000..2ec7c1b
--- /dev/null
+++ b/docs/modules/objects/data.rst
@@ -0,0 +1,9 @@
+PropagateData Object Reference
+==============================
+
+.. currentmodule:: antispam.dataclasses.propagate_data
+
+.. autoclass:: PropagateData
+    :members:
+    :undoc-members:
+    :special-members: __init__
\ No newline at end of file
diff --git a/docs/modules/exceptions.rst b/docs/modules/objects/exceptions.rst
similarity index 90%
rename from docs/modules/exceptions.rst
rename to docs/modules/objects/exceptions.rst
index 348fe19..206c6d5 100644
--- a/docs/modules/exceptions.rst
+++ b/docs/modules/objects/exceptions.rst
@@ -8,4 +8,4 @@ All exceptions subclass a base exception ``BaseASHException`` which provides fun
 .. automodule:: antispam.exceptions
     :members:
     :undoc-members:
-    :special-members:
+    :special-members:  __init__
diff --git a/docs/modules/objects/guild.rst b/docs/modules/objects/guild.rst
new file mode 100644
index 0000000..3e532dc
--- /dev/null
+++ b/docs/modules/objects/guild.rst
@@ -0,0 +1,17 @@
+Guild Reference
+===============
+
+*You should not be creating this object yourself.
+It is just useful to understand how they work for say,
+plugin development.*
+
+Internally the guild object wraps a couple layers of
+data to handle custom options as well as providing an O(1)
+way of storing Members.
+
+.. currentmodule:: antispam.dataclasses.guild
+
+.. autoclass:: Guild
+    :members:
+    :undoc-members:
+    :special-members: __init__
diff --git a/docs/modules/objects/member.rst b/docs/modules/objects/member.rst
new file mode 100644
index 0000000..12f0114
--- /dev/null
+++ b/docs/modules/objects/member.rst
@@ -0,0 +1,17 @@
+Member Reference
+================
+
+*You should not be creating this object yourself.
+It is just useful to understand how they work for say,
+plugin development.*
+
+Internally this object provides an O(1) way of storing
+Messages as well as maintaining the requried data to
+track and punish spammers
+
+.. currentmodule:: antispam.dataclasses.member
+
+.. autoclass:: Member
+    :members:
+    :undoc-members:
+    :special-members: __init__
diff --git a/docs/modules/objects/memory.rst b/docs/modules/objects/memory.rst
new file mode 100644
index 0000000..f9df049
--- /dev/null
+++ b/docs/modules/objects/memory.rst
@@ -0,0 +1,14 @@
+MemoryCache Reference
+=====================
+
+This is the default cache for the package.
+You shouldn't need to implement it yourself.
+
+Furthermore, refer to :py:class:`antispam.abc.Cache` for protocol implementation.
+
+.. currentmodule:: antispam.caches
+
+.. autoclass:: MemoryCache
+    :members:
+    :undoc-members:
+    :special-members: __init__
diff --git a/docs/modules/objects/message.rst b/docs/modules/objects/message.rst
new file mode 100644
index 0000000..a0e598f
--- /dev/null
+++ b/docs/modules/objects/message.rst
@@ -0,0 +1,19 @@
+Message Reference
+=================
+
+
+*You should not be creating this object yourself.
+It is just useful to understand how they work for say,
+plugin development.*
+
+Internally the Message object just takes a few attributes
+from ``discord.Message`` and stores them in a smaller object
+to save on memory. It also maintains a ``is_duplicate`` bool
+for internal reasons.
+
+.. currentmodule:: antispam.dataclasses.message
+
+.. autoclass:: Message
+    :members:
+    :undoc-members:
+    :special-members: __init__
diff --git a/docs/modules/objects/objects.rst b/docs/modules/objects/objects.rst
new file mode 100644
index 0000000..ed2bdf5
--- /dev/null
+++ b/docs/modules/objects/objects.rst
@@ -0,0 +1,28 @@
+Object Overview
+===============
+
+The purpose of this section is to inform developers
+a bit more about how this package works Internally.
+For the everyday user, this will not be needed. It is
+aimed at plugin developers who need to interact with 
+the internals.
+
+Anyway, internally within the rewritten package all data
+is stored within a slotted ``attrs`` dataclass. Thus was 
+picked over regular class's to stop boiler plate. It is also
+better for its given use case when compared to a dictionary
+as it is a fairly set size.
+
+In the initial versions, we also included logic wrapped in
+the same class but after the move to dataclasses the logic
+was abstracted out to reduce memory overhead.
+
+
+Plugin developers
+-----------------
+
+You shouldn't in most cases be interacting directly with
+these class's as the package provides an interface for 
+getting and setting data. Your main focus is within the 
+``addons`` variable which is a dictionary which maps
+the name of your plugin class to the data you wish to store.
\ No newline at end of file
diff --git a/docs/modules/objects/redis.rst b/docs/modules/objects/redis.rst
new file mode 100644
index 0000000..e62ee46
--- /dev/null
+++ b/docs/modules/objects/redis.rst
@@ -0,0 +1,15 @@
+RedisCache Reference
+====================
+
+A caching option within the standard package.
+
+Furthermore, refer to :py:class:`antispam.abc.Cache` for protocol implementation.
+
+*Not yet made*
+
+.. currentmodule:: antispam.caches
+
+.. autoclass:: RedisCache
+    :members:
+    :undoc-members:
+    :special-members: __init__
diff --git a/docs/modules/optimisation.rst b/docs/modules/optimisation.rst
deleted file mode 100644
index 1451aaa..0000000
--- a/docs/modules/optimisation.rst
+++ /dev/null
@@ -1,27 +0,0 @@
-Optimising Package Usage
-========================
-
-I'll be the first to admit it, this package could be better
-when it comes to optimization for bigger bots or extended usage.
-
-Going forward, I plan on adding things you *should* be using on a task
-every day or so to cleanup caches. However, at the end of the day it
-will entirely come down to the end user.
-
-When using ``AntiSpamTracker`` you can periodically call ``AntiSpamTracker.clean_cache()``
-to clean up the associated internal dictionary.
-
-
-Implementation Notes
-^^^^^^^^^^^^^^^^^^^^
-
-This package heavily uses OOP to store data. This, tied in with
-the fact any classes instances outside of ``Message`` instances
-are not cleaned up (Exist forever) can lead to memory usage over time.
-This will be remedied in the future through a method you call on a dpy task.
-
-
-I do eventually plan/hope/want to allow for using redis, however, that is still
-quite awhile away and likely will require a heavy rewrite. If this is something
-you want in your bot however, please reach out to me. People wanting things is quite
-a good motivator.
\ No newline at end of file
diff --git a/docs/modules/plugins/logs.rst b/docs/modules/plugins/logs.rst
new file mode 100644
index 0000000..06d0fcb
--- /dev/null
+++ b/docs/modules/plugins/logs.rst
@@ -0,0 +1,15 @@
+AdminLogs Plugin
+----------------
+
+A plugin design to save admins hassle with
+regard to evidence collection on automated punishments.
+
+Simply register this as a plugin, and it will save the relevant
+information for all punishments to a text file.
+
+.. currentmodule:: antispam.plugins
+
+.. autoclass:: AdminLogs
+    :members:
+    :undoc-members:
+    :special-members: __init__
diff --git a/docs/modules/plugins/mentions.rst b/docs/modules/plugins/mentions.rst
new file mode 100644
index 0000000..a94279e
--- /dev/null
+++ b/docs/modules/plugins/mentions.rst
@@ -0,0 +1,16 @@
+AntiMassMention Plugin
+======================
+
+A cool plugin designed to assist you
+when dealing with mass mentions.
+
+.. currentmodule:: antispam.plugins
+
+.. autoclass:: MassMentionPunishment
+    :members:
+    :undoc-members:
+
+.. autoclass:: AntiMassMention
+    :members:
+    :undoc-members:
+    :special-members: __init__
\ No newline at end of file
diff --git a/docs/modules/plugins/plugins.rst b/docs/modules/plugins/plugins.rst
new file mode 100644
index 0000000..f17fb99
--- /dev/null
+++ b/docs/modules/plugins/plugins.rst
@@ -0,0 +1,90 @@
+Package Plugin System
+=====================
+
+This package features feature a built in plugins framework soon.
+This framework can be used to hook into the ``propagate`` method and run
+as either a **pre_invoke** or **after_invoke** (Where **invoke** is
+the built in **propagate**)
+
+All registered extensions **must** subclass ``BasePlugin``
+
+A plugin can do anything, from AntiProfanity to AntiInvite.
+Assuming it is class based and follows the required schema you
+can easily develop your own plugin that can be run whenever the
+end developer calls ``await AntiSpamHandler.propagate()``
+
+Some plugins don't need to be registered as an extension.
+A good example of this is the ``AntiSpamTracker`` class.
+This class does not need to be invoked with ``propagate`` as
+it can be handled by the end developer for finer control.
+However, it can also be used as a plugin if users are
+happy with the default behaviour.
+
+Any plugin distributed under the antispam package needs to be lib agnostic,
+so as to not a dependency of something not in use.
+
+Plugin Blacklisting
+-------------------
+
+Plugins provide a simplistic interface for skipping execution in any given guild.
+Simply add the guilds id to the set located under the `Plugin.blacklisted_guilds`
+variable and then this plugin will not be called for said guild.
+
+Custom Punishments
+------------------
+
+.. code-block:: python
+    :linenos:
+
+    from discord.ext import commands
+
+    from antispam import AntiSpamHandler
+    from antispam.ext import Stats
+
+    bot = commands.Bot(command_prefix="!")
+    bot.handler = AntiSpamHandler(bot, no_punish=True)
+    bot.stats = Stats(bot.handler)
+    bot.handler.register_extension(bot.stats)
+
+    # We don't want to collect stats on guild 12345
+    # So lets ignore it on this plugin
+    bot.stats.blacklisted_guilds.add(12345)
+
+
+    @bot.event
+    async def on_ready():
+        # On ready, print some details to standard out
+        print(f"-----\nLogged in as: {bot.user.name} : {bot.user.id}\n-----")
+
+
+    if __name__ == "__main__":
+        bot.run("Bot Token")
+
+
+Call Stack
+----------
+
+* Initially all checks are run, these are the checks baked into ``AntiSpamHandler``
+    * You cannot avoid these checks, if you wish to mitigate them you should
+      set them to values that will not be triggered
+    * An option to run code before checks may be added in a future version,
+      if this is something you would like, jump into discord and let me know!
+      If I know people want features, they get done quicker
+* Following that, all pre-invoke plugins will be run
+    * If the guild this was called on is within `Plugin.blacklisted_guilds`
+      then execution will be skipped and we move onto the next plugin.
+    * The ordered that these are run is loosely based on the order that
+      plugins were registered. Do not expect any form of runtime
+      ordering however. You should build them around the idea that they
+      are guaranteed to run before ``AntiSpamHandler.propagate``, not
+      other plugins.
+    * Returning ``cancel_next_invocation: True`` will result in ``propagate`` returning
+      straight away. It will then return the dictionary of currently processed `pre_invoke_extensions`
+* Run ``AntiSpamHandler.propagate``
+    * If any pre-invoke plugin has returned a True value for ``cancel_next_invocation``
+      then this method, and any after_invoke extensions will not be called.
+* Run all after-invoke plugins
+    * If the guild this was called on is within `Plugin.blacklisted_guilds`
+      then execution will be skipped and we move onto the next plugin.
+    * After_invoke plugins get output from both ``AntiSpamHandler``
+      and all pre-invoke plugins as a method argument
\ No newline at end of file
diff --git a/docs/modules/ext/schema.rst b/docs/modules/plugins/schema.rst
similarity index 78%
rename from docs/modules/ext/schema.rst
rename to docs/modules/plugins/schema.rst
index 202bdeb..8e4b716 100644
--- a/docs/modules/ext/schema.rst
+++ b/docs/modules/plugins/schema.rst
@@ -1,11 +1,11 @@
-Extension Class Schema
-======================
+Plugin Class Schema
+===================
 
-All extensions that aim to be used as a registered
+All plugins that aim to be used as a registered
 extension *within* ``AntiSpamHandler`` should
 have at least the following class layout.
 
-All registered extensions **must** subclass ``BaseExtension``
+All registered plugins **must** subclass ``BasePlugin``
 
 
 Pre-invoke Schema
@@ -14,9 +14,9 @@ Pre-invoke Schema
 .. code-block:: python
     :linenos:
 
-    from antispam import BaseExtension
+    from antispam import BasePlugin
 
-    class Placeholder(BaseExtension):
+    class Placeholder(BasePlugin):
         def __init__(self):
             self.is_pre_invoke = True
 
@@ -32,19 +32,19 @@ After-invoke Schema
 .. code-block:: python
     :linenos:
 
-    from antispam import BaseExtension
+    from antispam import BasePlugin
 
-    class Placeholder(BaseExtension):
+    class Placeholder(BasePlugin):
         def __init__(self):
             self.is_pre_invoke = False
 
-        async def propagate(self, message: discord.Message, propagate_data: dict) -> dict:
+        async def propagate(self, message: discord.Message, propagate_data: CorePayload) -> dict:
             # Do your code stuff here
 
 
 The only difference between these two schema's, outside of ``self.is_pre_invoke``
 being different, is that the after-invoke method will also be given an
-extra argument which is a deepcopy of the data returned by ``propagate``
+extra argument which is the data returned by ``propagate``
 
 
 Cancelling Invocation
diff --git a/docs/modules/plugins/stats.rst b/docs/modules/plugins/stats.rst
new file mode 100644
index 0000000..2a793c0
--- /dev/null
+++ b/docs/modules/plugins/stats.rst
@@ -0,0 +1,12 @@
+Statistics Plugin
+=================
+
+A simplistic approach to statistics gathering
+which works by default and requires no further setup.
+
+.. currentmodule:: antispam.plugins
+
+.. autoclass:: Stats
+    :members:
+    :undoc-members:
+    :special-members: __init__
\ No newline at end of file
diff --git a/docs/modules/plugins/storage.rst b/docs/modules/plugins/storage.rst
new file mode 100644
index 0000000..e1f0613
--- /dev/null
+++ b/docs/modules/plugins/storage.rst
@@ -0,0 +1,12 @@
+Plugin-Cache Interaction
+------------------------
+
+The interface you should use to have your plugin
+store data in the global cache.
+
+.. currentmodule:: antispam
+
+.. autoclass:: PluginCache
+    :members:
+    :undoc-members:
+    :special-members: __init__
\ No newline at end of file
diff --git a/docs/modules/plugins/tracker.rst b/docs/modules/plugins/tracker.rst
new file mode 100644
index 0000000..9f5f2f2
--- /dev/null
+++ b/docs/modules/plugins/tracker.rst
@@ -0,0 +1,11 @@
+AntiSpamTracker Plugin
+======================
+
+A cool plugin designed to assist you with custom punishments.
+
+.. currentmodule:: antispam.plugins
+
+.. autoclass:: AntiSpamTracker
+    :members:
+    :undoc-members:
+    :special-members: __init__
\ No newline at end of file
diff --git a/docs/modules/user.rst b/docs/modules/user.rst
deleted file mode 100644
index db12177..0000000
--- a/docs/modules/user.rst
+++ /dev/null
@@ -1,11 +0,0 @@
-User Object
-===========
-
-**Note, this class should not be used by you. Only use the AntiSpamHandler to work with this package.**
-
-.. currentmodule:: antispam.user
-
-.. autoclass:: User
-    :members:
-    :undoc-members:
-    :special-members:
diff --git a/docs/modules/util.rst b/docs/modules/util.rst
deleted file mode 100644
index 5934f8d..0000000
--- a/docs/modules/util.rst
+++ /dev/null
@@ -1,10 +0,0 @@
-Util Functions
-==============
-
-**Note, this file should not be used by you. Only use the AntiSpamHandler to work with this package.**
-
-
-.. automodule:: antispam.util
-    :members:
-    :undoc-members:
-    :special-members:
diff --git a/docs/requirements.txt b/docs/requirements.txt
new file mode 100644
index 0000000..4b4a96e
--- /dev/null
+++ b/docs/requirements.txt
@@ -0,0 +1,4 @@
+fuzzywuzzy>=0.18
+attrs
+discord.py
+hikari
\ No newline at end of file
diff --git a/dpy-requirements.txt b/dpy-requirements.txt
new file mode 100644
index 0000000..7750a0e
--- /dev/null
+++ b/dpy-requirements.txt
@@ -0,0 +1,1 @@
+discord.py==1.7.3
\ No newline at end of file
diff --git a/examples/basic_custom_punishment_bot.py b/examples/basic_custom_punishment_bot.py
index 67ec1f0..e4fee11 100644
--- a/examples/basic_custom_punishment_bot.py
+++ b/examples/basic_custom_punishment_bot.py
@@ -1,17 +1,17 @@
 import discord
 from discord.ext import commands
 
-from antispam import AntiSpamHandler
-from antispam.ext import AntiSpamTracker
+from antispam import AntiSpamHandler, Options
+from antispam.plugins import AntiSpamTracker
 from jsonLoader import read_json
 
 bot = commands.Bot(command_prefix="!", intents=discord.Intents.all())
 
 file = read_json("token")
 
-bot.handler = AntiSpamHandler(bot, no_punish=True)
+bot.handler = AntiSpamHandler(bot, options=Options(no_punish=True))
 bot.tracker = AntiSpamTracker(bot.handler, 3)
-bot.handler.register_extension(bot.tracker)
+bot.handler.register_plugin(bot.tracker)
 
 
 @bot.event
@@ -30,7 +30,7 @@ async def on_message(message):
         # Insert code to tell admins
 
         # ETC
-        bot.tracker.remove_punishments(message)
+        await bot.tracker.remove_punishments(message)
 
     await bot.process_commands(message)
 
diff --git a/examples/basic_hikari_example.py b/examples/basic_hikari_example.py
new file mode 100644
index 0000000..0422e8b
--- /dev/null
+++ b/examples/basic_hikari_example.py
@@ -0,0 +1,22 @@
+import hikari
+
+from antispam import AntiSpamHandler
+from examples.jsonLoader import read_json
+
+file = read_json("token")
+bot = hikari.GatewayBot(
+    token=file["token"],
+)
+
+handler = AntiSpamHandler(bot, is_using_hikari=True)
+
+
+@bot.listen()
+async def ping(event: hikari.GuildMessageCreateEvent) -> None:
+    if event.is_bot or not event.content:
+        return
+
+    await handler.propagate(event.message)
+
+
+bot.run()
diff --git a/examples/custom_multistage_punishments/AntiSpamTrackerSubclass.py b/examples/custom_multistage_punishments/AntiSpamTrackerSubclass.py
index 9a906cb..57e027b 100644
--- a/examples/custom_multistage_punishments/AntiSpamTrackerSubclass.py
+++ b/examples/custom_multistage_punishments/AntiSpamTrackerSubclass.py
@@ -1,12 +1,13 @@
 """Our subclass of AntiSpamTracker"""
 import asyncio
 import datetime
+import typing
 from copy import deepcopy
 
 import discord
 
-from antispam import UserNotFound
-from antispam.ext import AntiSpamTracker
+from antispam import MemberNotFound
+from antispam.plugins import AntiSpamTracker
 
 
 # noinspection DuplicatedCode
@@ -18,19 +19,19 @@ def update_cache(self, message: discord.Message, data: dict) -> None:
         guild_id = message.guild.id
         timestamp = datetime.datetime.now(datetime.timezone.utc)
 
-        if guild_id not in self.user_tracking:
-            self.user_tracking[guild_id] = {}
+        if guild_id not in self.member_tracking:
+            self.member_tracking[guild_id] = {}
 
-        if user_id not in self.user_tracking[guild_id]:
-            self.user_tracking[guild_id][user_id] = {}
+        if user_id not in self.member_tracking[guild_id]:
+            self.member_tracking[guild_id][user_id] = {}
 
-        if "has_been_muted" not in self.user_tracking[guild_id][user_id]:
-            self.user_tracking[guild_id][user_id]["has_been_muted"] = False
+        if "has_been_muted" not in self.member_tracking[guild_id][user_id]:
+            self.member_tracking[guild_id][user_id]["has_been_muted"] = False
 
-        if "timestamps" not in self.user_tracking[guild_id][user_id]:
-            self.user_tracking[guild_id][user_id]["timestamps"] = []
+        if "timestamps" not in self.member_tracking[guild_id][user_id]:
+            self.member_tracking[guild_id][user_id]["timestamps"] = []
 
-        self.user_tracking[guild_id][user_id]["timestamps"].append(timestamp)
+        self.member_tracking[guild_id][user_id]["timestamps"].append(timestamp)
 
     def get_user_has_been_muted(self, message: discord.Message) -> bool:
         """
@@ -58,13 +59,13 @@ def get_user_has_been_muted(self, message: discord.Message) -> bool:
         user_id = message.author.id
         guild_id = message.guild.id
 
-        if guild_id not in self.user_tracking:
-            raise UserNotFound
+        if guild_id not in self.member_tracking:
+            raise MemberNotFound
 
-        if user_id not in self.user_tracking[guild_id]:
-            raise UserNotFound
+        if user_id not in self.member_tracking[guild_id]:
+            raise MemberNotFound
 
-        return self.user_tracking[guild_id][user_id]["has_been_muted"]
+        return self.member_tracking[guild_id][user_id]["has_been_muted"]
 
     async def do_punishment(self, message: discord.Message, *args, **kwargs) -> None:
         """
@@ -94,12 +95,11 @@ async def do_punishment(self, message: discord.Message, *args, **kwargs) -> None
             await channel.send(f"Hey {user.mention}! I am kicking you for spam.")
             await asyncio.sleep(2.5)
             await guild.kick(user, reason="Kicked for spam.")
-            self.remove_punishments(
-                message
-            )  # cleanup on the assumption they won't 'just come back'
+            # cleanup on the assumption they won't 'just come back'
+            await self.remove_punishments(message)
 
         elif self.is_spamming(message=message):
-            self.user_tracking[guild_id][user_id]["has_been_muted"] = True
+            self.member_tracking[guild_id][user_id]["has_been_muted"] = True
             await channel.send(f"Hey {user.mention}! I am temp muting you for spam.")
 
             guild = self.anti_spam_handler.bot.get_guild(guild_id)
@@ -116,48 +116,50 @@ async def do_punishment(self, message: discord.Message, *args, **kwargs) -> None
 
     def clean_cache(self) -> None:
         """Override this so if the has_been_muted field exists we don't remove them"""
-        for guild_id, guild in deepcopy(self.user_tracking).items():
+        for guild_id, guild in deepcopy(self.member_tracking).items():
             for user_id, user in deepcopy(guild).items():
                 self.remove_outdated_timestamps(guild_id=guild_id, user_id=user_id)
 
-                if len(self.user_tracking[guild_id][user_id]) == 0 and not user.get(
+                if len(self.member_tracking[guild_id][user_id]) == 0 and not user.get(
                     "has_been_muted", True
                 ):
-                    self.user_tracking[guild_id].pop(user_id)
+                    self.member_tracking[guild_id].pop(user_id)
 
-            if not bool(self.user_tracking[guild_id]):
-                self.user_tracking.pop(guild_id)
+            if not bool(self.member_tracking[guild_id]):
+                self.member_tracking.pop(guild_id)
 
     def get_user_count(self, message: discord.Message) -> int:
         if not isinstance(message, discord.Message):
             raise TypeError("Expected message of type: discord.Message")
 
         if not message.guild:
-            raise UserNotFound("Can't find user's from dm's")
+            raise MemberNotFound("Can't find user's from dm's")
 
         user_id = message.author.id
         guild_id = message.guild.id
 
-        if guild_id not in self.user_tracking:
-            raise UserNotFound
+        if guild_id not in self.member_tracking:
+            raise MemberNotFound
 
-        if user_id not in self.user_tracking[guild_id]:
-            raise UserNotFound
+        if user_id not in self.member_tracking[guild_id]:
+            raise MemberNotFound
 
-        if "timestamps" not in self.user_tracking[guild_id][user_id]:
-            raise UserNotFound
+        if "timestamps" not in self.member_tracking[guild_id][user_id]:
+            raise MemberNotFound
 
         self.remove_outdated_timestamps(guild_id=guild_id, user_id=user_id)
 
-        return len(self.user_tracking[guild_id][user_id]["timestamps"])
+        return len(self.member_tracking[guild_id][user_id]["timestamps"])
 
-    def remove_outdated_timestamps(self, guild_id, user_id):
+    def remove_outdated_timestamps(
+        self, data: typing.List, member_id: int, guild_id: int
+    ):
         current_time = datetime.datetime.now(datetime.timezone.utc)
 
         def _is_still_valid(timestamp):
             difference = current_time - timestamp
             offset = datetime.timedelta(
-                milliseconds=self._get_guild_valid_interval(guild_id=guild_id)
+                milliseconds=await self._get_guild_valid_interval(guild_id=guild_id)
             )
 
             if difference >= offset:
@@ -166,10 +168,10 @@ def _is_still_valid(timestamp):
 
         current_timestamps = []
 
-        for timestamp in self.user_tracking[guild_id][user_id]["timestamps"]:
+        for timestamp in self.member_tracking[guild_id][member_id]["timestamps"]:
             if _is_still_valid(timestamp):
                 current_timestamps.append(timestamp)
 
-        self.user_tracking[guild_id][user_id]["timestamps"] = deepcopy(
+        self.member_tracking[guild_id][member_id]["timestamps"] = deepcopy(
             current_timestamps
         )
diff --git a/examples/custom_multistage_punishments/bot.py b/examples/custom_multistage_punishments/bot.py
index 7f28622..d16f1d5 100644
--- a/examples/custom_multistage_punishments/bot.py
+++ b/examples/custom_multistage_punishments/bot.py
@@ -1,7 +1,7 @@
 import discord
 from discord.ext import commands
 
-from antispam import AntiSpamHandler
+from antispam import AntiSpamHandler, Options
 from examples.jsonLoader import read_json
 
 from AntiSpamTrackerSubclass import MyCustomTracker
@@ -11,9 +11,9 @@
 file = read_json("token")
 
 # Generally you only need/want AntiSpamHandler(bot)
-bot.handler = AntiSpamHandler(bot, ignore_bots=False)
+bot.handler = AntiSpamHandler(bot, options=Options(ignore_bots=False))
 bot.tracker = MyCustomTracker(bot.handler, 3)
-bot.handler.register_extension(bot.tracker)
+bot.handler.register_plugin(bot.tracker)
 
 
 @bot.event
diff --git a/hikari-requirements.txt b/hikari-requirements.txt
new file mode 100644
index 0000000..2902223
--- /dev/null
+++ b/hikari-requirements.txt
@@ -0,0 +1,1 @@
+hikari
\ No newline at end of file
diff --git a/readme.md b/readme.md
index 424bf99..4a6db44 100644
--- a/readme.md
+++ b/readme.md
@@ -1,10 +1,9 @@
 # DPY Anti-Spam
 ---
 
-[![Unittests](https://github.com/Skelmis/DPY-Anti-Spam/actions/workflows/unittesting.yml/badge.svg?branch=master)](https://github.com/Skelmis/DPY-Anti-Spam/actions/workflows/unittesting.yml)
-[![Coverage Status](https://coveralls.io/repos/github/Skelmis/DPY-Anti-Spam/badge.svg?branch=master)](https://coveralls.io/github/Skelmis/DPY-Anti-Spam?branch=master)
+[![Pytest](https://github.com/Skelmis/DPY-Anti-Spam/actions/workflows/pytest.yml/badge.svg?branch=rewrite)](https://github.com/Skelmis/DPY-Anti-Spam/actions/workflows/pytest.yml)
+[![Coverage Status](https://coveralls.io/repos/github/Skelmis/DPY-Anti-Spam/badge.svg?branch=rewrite)](https://coveralls.io/github/Skelmis/DPY-Anti-Spam?branch=rewrite)
 [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
-[![Maintenance](https://img.shields.io/badge/Maintained%3F-yes-green.svg)](https://github.com/Skelmis/DPY-Anti-Spam/graphs/commit-activity)
 [![PyPI download month](https://img.shields.io/pypi/dm/Discord-Anti-Spam.svg)](https://pypi.python.org/pypi/Discord-Anti-Spam/)
 [![Discord](https://img.shields.io/discord/780784732484141077.svg?label=&logo=discord&logoColor=ffffff&color=7389D8&labelColor=6A7EC2)](https://discord.gg/BqPNSH2jPg)
 
@@ -30,10 +29,12 @@ intents = discord.Intents.default()
 bot = commands.Bot(command_prefix="!", intents=intents)
 bot.handler = AntiSpamHandler(bot)
 
+
 @bot.event
 async def on_ready():
     print(f"-----\nLogged in as: {bot.user.name} : {bot.user.id}\n-----")
 
+
 @bot.event
 async def on_message(message):
     await bot.handler.propagate(message)
@@ -56,9 +57,6 @@ Now, there will no doubt be bugs & changes etc. But, you can use this as is now
 - Scalable -> Multi guild support out of the box
 - Test Driven -> CI with Github Actions
 
-Testing is done at python3.8 and above, due to the usage of `unittest.IsolatedAsyncioTestCase` which
-is not available at lower python versions. The package itself however, should work with
-any version of discord.py rewrite and above.
 
 ---
 
diff --git a/requirements.txt b/requirements.txt
index 6c79400..f74d74c 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,15 +1,2 @@
-black==21.5b1
-coveralls==3.1.0
-discord==1.0.1
-fuzzywuzzy==0.18.0
-mouseinfo==0.1.3
-nest-asyncio==1.5.1
-pip-chill==1.0.1
-pre-commit==2.13.0
-pygetwindow==0.0.9
-pymsgbox==1.0.9
-pyscreeze==0.1.27
-python-levenshtein==0.12.2
-pytweening==1.0.3
-sphinx-rtd-theme==0.5.2
-twine==3.4.1
+fuzzywuzzy>=0.18
+attrs
\ No newline at end of file
diff --git a/setup.py b/setup.py
index d452b27..ac29c5d 100644
--- a/setup.py
+++ b/setup.py
@@ -1,13 +1,6 @@
 import re
 
-import setuptools
-import unittest
-
-
-def my_test_suite():
-    test_loader = unittest.TestLoader()
-    test_suite = test_loader.discover("testing", pattern="test_*.py")
-    return test_suite
+from distutils.core import setup
 
 
 with open("readme.md", "r") as fh:
@@ -17,28 +10,50 @@ def my_test_suite():
     r"^__version__ = ('|\")((?:[0-9]+\.)*[0-9]+(?:\.?([a-z]+)(?:\.?[0-9])?)?)\1$"
 )
 
-with open("antispam/__init__.py") as stream:
-    match = re.search(_version_regex, stream.read(), re.MULTILINE)
+try:
+    with open("antispam/__init__.py") as stream:
+        match = re.search(_version_regex, stream.read(), re.MULTILINE)
+        version = match.group(2)
+except FileNotFoundError:
+    version = "0.0.0"
+
 
-version = match.group(2)
+def parse_requirements_file(path):
+    with open(path) as fp:
+        dependencies = (d.strip() for d in fp.read().split("\n") if d.strip())
+        return [d for d in dependencies if not d.startswith("#")]
 
-setuptools.setup(
+
+setup(
     name="Discord Anti-Spam",
     version=version,
     author="Skelmis",
     author_email="ethan@koldfusion.xyz",
-    description="An easy to use package for anti-spam features in discord.py.",
+    description="An easy to use package for anti-spam features in python discord libraries.",
     long_description=long_description,
     long_description_content_type="text/markdown",
     url="https://github.com/Skelmis/DPY-Anti-Spam",
-    packages=setuptools.find_packages(),
-    install_requires=["fuzzywuzzy>=0.18", "discord.py>=1"],
+    packages=[
+        "antispam",
+        "antispam.caches",
+        "antispam.caches.memory",
+        "antispam.caches.redis",
+        "antispam.dataclasses",
+        "antispam.enums",
+        "antispam.plugins",
+        "antispam.libs",
+    ],
+    install_requires=parse_requirements_file("requirements.txt"),
+    extras_requires={
+        "discord.py": parse_requirements_file("dpy-requirements.txt"),
+        "hikari": parse_requirements_file("hikari-requirements.txt"),
+        "dev": parse_requirements_file("dev-requirements.txt"),
+    },
     classifiers=[
         "Programming Language :: Python :: 3",
         "License :: OSI Approved :: MIT License",
         "Operating System :: OS Independent",
         "Development Status :: 4 - Beta",
     ],
-    python_requires=">=3.6",
-    test_suite="setup.my_test_suite",
+    python_requires=">=3.8",
 )
diff --git a/tox.ini b/tox.ini
new file mode 100644
index 0000000..6fbaba7
--- /dev/null
+++ b/tox.ini
@@ -0,0 +1,6 @@
+[tox]
+envlist = py38
+
+[testenv]
+deps = -rrequirements.txt
+commands = pytest
\ No newline at end of file


Test patch:
diff --git a/.github/workflows/unittesting.yml b/.github/workflows/pytest.yml
similarity index 81%
rename from .github/workflows/unittesting.yml
rename to .github/workflows/pytest.yml
index a2610b4..948e593 100644
--- a/.github/workflows/unittesting.yml
+++ b/.github/workflows/pytest.yml
@@ -1,11 +1,11 @@
 
 # This workflow will install Python dependencies and run tests
-name: "Unittests"
+name: "Pytest"
 on:
   pull_request:
-    branches: [ master ]
+    branches: [ rewrite ]
   push:
-    branches: [ master ]
+    branches: [ rewrite ]
 
 jobs:
   build:
@@ -25,11 +25,10 @@ jobs:
       run: |
         python -m pip install --upgrade pip
         python -m pip install coverage coveralls
-        if [ -f requirements.txt ]; then pip install -r requirements.txt; fi
-# TODO add linting at some point in the future
+        if [ -f dev-requirements.txt ]; then pip install -r dev-requirements.txt; fi
     - name: Run tests with code coverage
       run: |
-        coverage run --source=antispam/ -m unittest discover testing/
+        coverage run --source=antispam/ -m pytest
     - name: Upload Coverage
       run: |
         coveralls --service=github
diff --git a/testing/json_loader.py b/testing/json_loader.py
deleted file mode 100644
index 652e229..0000000
--- a/testing/json_loader.py
+++ /dev/null
@@ -1,22 +0,0 @@
-import json
-
-from pathlib import Path
-
-
-def get_path():
-    cwd = Path(__file__).parents[0]
-    cwd = str(cwd)
-    return cwd
-
-
-def read_json(filename):
-    cwd = get_path()
-    with open(cwd + "/" + filename + ".json", "r") as file:
-        data = json.load(file)
-    return data
-
-
-def write_json(data, filename):
-    cwd = get_path()
-    with open(cwd + "/" + filename + ".json", "w") as file:
-        json.dump(data, file, indent=4)
diff --git a/testing/test_AMM.py b/testing/test_AMM.py
deleted file mode 100644
index 85477af..0000000
--- a/testing/test_AMM.py
+++ /dev/null
@@ -1,29 +0,0 @@
-import unittest
-
-from antispam.ext import AntiMassMention
-from testing.mocks.MockChannel import MockedChannel
-from testing.mocks.MockMember import MockedMember
-from testing.mocks.MockMessage import MockedMessage
-
-
-class TestAMM(unittest.IsolatedAsyncioTestCase):
-    async def test_message_mentions(self):
-        member = MockedMember(name="bot", member_id=98987, mock_type="bot").to_mock()
-        anti = AntiMassMention(member)
-
-        x = None
-        for i in range(5):
-            m = MockedMessage(
-                message_id=i,
-                author_id=0,
-                guild_id=3,
-                message_mentions=["<@218180379088125955>"],
-            ).to_mock()
-            m.channel = MockedChannel(channel_id=2).to_mock()
-            x = await anti.propagate(m)
-
-        self.assertEqual(x["action"], "No action taken")
-
-
-if __name__ == "__main__":
-    unittest.main()
diff --git a/testing/test_ASH.py b/testing/test_ASH.py
deleted file mode 100644
index 1f7a92b..0000000
--- a/testing/test_ASH.py
+++ /dev/null
@@ -1,827 +0,0 @@
-"""
-The MIT License (MIT)
-
-Copyright (c) 2020 Skelmis
-
-Permission is hereby granted, free of charge, to any person obtaining a
-copy of this software and associated documentation files (the "Software"),
-to deal in the Software without restriction, including without limitation
-the rights to use, copy, modify, merge, publish, distribute, sublicense,
-and/or sell copies of the Software, and to permit persons to whom the
-Software is furnished to do so, subject to the following conditions:
-The above copyright notice and this permission notice shall be included in
-all copies or substantial portions of the Software.
-
-THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
-OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
-FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
-DEALINGS IN THE SOFTWARE.
-"""
-import asyncio
-import unittest
-import discord
-
-from discord.ext import commands
-
-from antispam import AntiSpamHandler, BaseExtension
-from antispam.exceptions import (
-    DuplicateObject,
-    BaseASHException,
-    MissingGuildPermissions,
-    LogicError,
-    ExtensionError,
-)
-from antispam.static import Static
-from antispam.guild import Guild
-from antispam.user import User
-from testing.mocks.MockMember import MockedMember
-from testing.mocks.MockMessage import MockedMessage
-
-from json_loader import read_json, write_json
-
-
-class TestAsh(unittest.IsolatedAsyncioTestCase):
-    """
-    Used to test the ASH object (AntiSpamHandler)
-    """
-
-    async def asyncSetUp(self):
-        """
-        Simply setup our Ash obj before usage
-        """
-        self.ash = AntiSpamHandler(
-            MockedMember(name="bot", member_id=98987, mock_type="bot").to_mock()
-        )
-        self.ash.guilds = Guild(None, 12, Static.DEFAULTS)
-        self.ash.guilds = Guild(None, 15, Static.DEFAULTS)
-
-    async def test_defaults(self):
-        self.assertEqual(self.ash.options, Static.DEFAULTS)
-
-    async def test_properties(self):
-        with self.assertRaises(ValueError):
-            self.ash.guilds = "1"
-
-        with self.assertRaises(DuplicateObject):
-            self.ash.guilds = Guild(None, 15, Static.DEFAULTS)
-
-    async def test_messageAccuracyType(self):
-        with self.assertRaises(ValueError):
-            AntiSpamHandler(
-                commands.Bot(command_prefix="!"), message_duplicate_accuracy="x"
-            )
-
-    async def test_messageAccuracyRange(self):
-        with self.assertRaises(
-            ValueError, msg="Invalid message_duplicate_accuracy (To low)"
-        ):
-            AntiSpamHandler(
-                commands.Bot(command_prefix="!"), message_duplicate_accuracy=0
-            )
-
-        with self.assertRaises(
-            ValueError, msg="Invalid message_duplicate_accuracy (To high)"
-        ):
-            AntiSpamHandler(
-                commands.Bot(command_prefix="!"), message_duplicate_accuracy=101
-            )
-
-    async def test_messageAccuracyAssignment(self):
-        ash = AntiSpamHandler(
-            commands.Bot(command_prefix="!"), message_duplicate_accuracy=50
-        )
-        self.assertEqual(ash.options["message_duplicate_accuracy"], 50)
-
-    async def test_botAssignment(self):
-        with self.assertRaises(ValueError):
-            # noinspection PyTypeChecker
-            AntiSpamHandler(None)
-
-        with self.assertRaises(ValueError):
-            # noinspection PyTypeChecker
-            AntiSpamHandler(1)
-
-        with self.assertRaises(ValueError):
-            # noinspection PyTypeChecker
-            AntiSpamHandler("1")
-
-        with self.assertRaises(ValueError):
-            # noinspection PyTypeChecker
-            AntiSpamHandler({1: 2})
-
-        with self.assertRaises(ValueError):
-            # noinspection PyTypeChecker
-            AntiSpamHandler([1, None])
-
-        with self.assertRaises(ValueError):
-            # noinspection PyTypeChecker
-            AntiSpamHandler(True)
-
-        with self.assertRaises(ValueError):
-            # noinspection PyTypeChecker
-            AntiSpamHandler(False)
-
-        AntiSpamHandler(commands.Bot(command_prefix="!"))
-        AntiSpamHandler(commands.AutoShardedBot(command_prefix="!"))
-        AntiSpamHandler(discord.Client())
-        AntiSpamHandler(discord.AutoShardedClient())
-
-    async def test_warnThreshold(self):
-        with self.assertRaises(ValueError):
-            AntiSpamHandler(commands.Bot(command_prefix="!"), warn_threshold="1")
-
-        with self.assertRaises(ValueError):
-            AntiSpamHandler(commands.Bot(command_prefix="!"), warn_threshold=dict())
-
-        with self.assertRaises(ValueError):
-            AntiSpamHandler(commands.Bot(command_prefix="!"), warn_threshold=tuple())
-
-        with self.assertRaises(ValueError):
-            AntiSpamHandler(commands.Bot(command_prefix="!"), warn_threshold=[1])
-
-        AntiSpamHandler(commands.Bot(command_prefix="!"), warn_threshold=1)
-        AntiSpamHandler(commands.Bot(command_prefix="!"), warn_threshold=None)
-
-    async def test_kickThreshold(self):
-        with self.assertRaises(ValueError):
-            AntiSpamHandler(commands.Bot(command_prefix="!"), kick_threshold="1")
-
-        with self.assertRaises(ValueError):
-            AntiSpamHandler(commands.Bot(command_prefix="!"), kick_threshold=dict())
-
-        with self.assertRaises(ValueError):
-            AntiSpamHandler(commands.Bot(command_prefix="!"), kick_threshold=tuple())
-
-        with self.assertRaises(ValueError):
-            AntiSpamHandler(commands.Bot(command_prefix="!"), kick_threshold=[1])
-
-        AntiSpamHandler(commands.Bot(command_prefix="!"), kick_threshold=1)
-        AntiSpamHandler(commands.Bot(command_prefix="!"), kick_threshold=None)
-
-    async def test_banThreshold(self):
-        with self.assertRaises(ValueError):
-            AntiSpamHandler(commands.Bot(command_prefix="!"), ban_threshold="1")
-
-        with self.assertRaises(ValueError):
-            AntiSpamHandler(commands.Bot(command_prefix="!"), ban_threshold=dict())
-
-        with self.assertRaises(ValueError):
-            AntiSpamHandler(commands.Bot(command_prefix="!"), ban_threshold=tuple())
-
-        with self.assertRaises(ValueError):
-            AntiSpamHandler(commands.Bot(command_prefix="!"), ban_threshold=[1])
-
-        AntiSpamHandler(commands.Bot(command_prefix="!"), ban_threshold=1)
-        AntiSpamHandler(commands.Bot(command_prefix="!"), ban_threshold=None)
-
-    async def test_messageInterval(self):
-        with self.assertRaises(ValueError):
-            AntiSpamHandler(commands.Bot(command_prefix="!"), message_interval="1")
-
-        with self.assertRaises(ValueError):
-            AntiSpamHandler(commands.Bot(command_prefix="!"), message_interval=dict())
-
-        with self.assertRaises(ValueError):
-            AntiSpamHandler(commands.Bot(command_prefix="!"), message_interval=tuple())
-
-        with self.assertRaises(ValueError):
-            AntiSpamHandler(commands.Bot(command_prefix="!"), message_interval=[1])
-
-        AntiSpamHandler(commands.Bot(command_prefix="!"), message_interval=1000)
-        AntiSpamHandler(commands.Bot(command_prefix="!"), message_interval=None)
-
-        with self.assertRaises(BaseASHException):
-            AntiSpamHandler(commands.Bot(command_prefix="!"), message_interval=999)
-
-    async def test_guildKickMessage(self):
-        with self.assertRaises(ValueError):
-            AntiSpamHandler(commands.Bot(command_prefix="!"), guild_kick_message=1)
-
-        with self.assertRaises(ValueError):
-            AntiSpamHandler(
-                commands.Bot(command_prefix="!"), guild_kick_message=tuple()
-            )
-
-        with self.assertRaises(ValueError):
-            AntiSpamHandler(commands.Bot(command_prefix="!"), guild_kick_message=[1])
-
-        AntiSpamHandler(commands.Bot(command_prefix="!"), guild_kick_message="hi")
-        AntiSpamHandler(commands.Bot(command_prefix="!"), guild_kick_message=dict())
-
-    async def test_guildBanMessage(self):
-        with self.assertRaises(ValueError):
-            AntiSpamHandler(commands.Bot(command_prefix="!"), guild_ban_message=1)
-
-        with self.assertRaises(ValueError):
-            AntiSpamHandler(commands.Bot(command_prefix="!"), guild_ban_message=tuple())
-
-        with self.assertRaises(ValueError):
-            AntiSpamHandler(commands.Bot(command_prefix="!"), guild_ban_message=[1])
-
-        AntiSpamHandler(commands.Bot(command_prefix="!"), guild_ban_message="hi")
-        AntiSpamHandler(commands.Bot(command_prefix="!"), guild_ban_message=dict())
-
-    async def test_userKickMessage(self):
-        with self.assertRaises(ValueError):
-            AntiSpamHandler(commands.Bot(command_prefix="!"), user_kick_message=1)
-
-        with self.assertRaises(ValueError):
-            AntiSpamHandler(commands.Bot(command_prefix="!"), user_kick_message=tuple())
-
-        with self.assertRaises(ValueError):
-            AntiSpamHandler(commands.Bot(command_prefix="!"), user_kick_message=[1])
-
-        AntiSpamHandler(commands.Bot(command_prefix="!"), user_kick_message="hi")
-        AntiSpamHandler(commands.Bot(command_prefix="!"), user_kick_message=dict())
-
-    async def test_userBanMessage(self):
-        with self.assertRaises(ValueError):
-            AntiSpamHandler(commands.Bot(command_prefix="!"), user_ban_message=1)
-
-        with self.assertRaises(ValueError):
-            AntiSpamHandler(commands.Bot(command_prefix="!"), user_ban_message=tuple())
-
-        with self.assertRaises(ValueError):
-            AntiSpamHandler(commands.Bot(command_prefix="!"), user_ban_message=[1])
-
-        AntiSpamHandler(commands.Bot(command_prefix="!"), user_ban_message="hi")
-        AntiSpamHandler(commands.Bot(command_prefix="!"), user_ban_message=dict())
-
-    async def test_userFailedKickMessage(self):
-        with self.assertRaises(ValueError):
-            AntiSpamHandler(
-                commands.Bot(command_prefix="!"), user_failed_kick_message=1
-            )
-
-        with self.assertRaises(ValueError):
-            AntiSpamHandler(
-                commands.Bot(command_prefix="!"), user_failed_kick_message=tuple()
-            )
-
-        with self.assertRaises(ValueError):
-            AntiSpamHandler(
-                commands.Bot(command_prefix="!"), user_failed_kick_message=[1]
-            )
-
-        AntiSpamHandler(commands.Bot(command_prefix="!"), user_failed_kick_message="hi")
-        AntiSpamHandler(
-            commands.Bot(command_prefix="!"), user_failed_kick_message=dict()
-        )
-
-    async def test_userFailedBanMessage(self):
-        with self.assertRaises(ValueError):
-            AntiSpamHandler(commands.Bot(command_prefix="!"), user_failed_ban_message=1)
-
-        with self.assertRaises(ValueError):
-            AntiSpamHandler(
-                commands.Bot(command_prefix="!"), user_failed_ban_message=tuple()
-            )
-
-        with self.assertRaises(ValueError):
-            AntiSpamHandler(
-                commands.Bot(command_prefix="!"), user_failed_ban_message=[1]
-            )
-
-        AntiSpamHandler(commands.Bot(command_prefix="!"), user_failed_ban_message="hi")
-        AntiSpamHandler(
-            commands.Bot(command_prefix="!"), user_failed_ban_message=dict()
-        )
-
-    async def test_messageDuplicateCount(self):
-        with self.assertRaises(ValueError):
-            AntiSpamHandler(
-                commands.Bot(command_prefix="!"), message_duplicate_count="1"
-            )
-
-        with self.assertRaises(ValueError):
-            AntiSpamHandler(
-                commands.Bot(command_prefix="!"), message_duplicate_count=dict()
-            )
-
-        with self.assertRaises(ValueError):
-            AntiSpamHandler(
-                commands.Bot(command_prefix="!"), message_duplicate_count=tuple()
-            )
-
-        with self.assertRaises(ValueError):
-            AntiSpamHandler(
-                commands.Bot(command_prefix="!"), message_duplicate_count=[1]
-            )
-
-        with self.assertRaises(ValueError):
-            AntiSpamHandler(
-                commands.Bot(command_prefix="!"), message_duplicate_count=2.0
-            )
-
-        AntiSpamHandler(commands.Bot(command_prefix="!"), message_duplicate_count=2)
-        AntiSpamHandler(commands.Bot(command_prefix="!"), message_duplicate_count=None)
-
-    async def test_messageDuplicateAccuracy(self):
-        with self.assertRaises(ValueError):
-            AntiSpamHandler(
-                commands.Bot(command_prefix="!"), message_duplicate_accuracy="1"
-            )
-
-        with self.assertRaises(ValueError):
-            AntiSpamHandler(
-                commands.Bot(command_prefix="!"), message_duplicate_accuracy=dict()
-            )
-
-        with self.assertRaises(ValueError):
-            AntiSpamHandler(
-                commands.Bot(command_prefix="!"), message_duplicate_accuracy=tuple()
-            )
-
-        with self.assertRaises(ValueError):
-            AntiSpamHandler(
-                commands.Bot(command_prefix="!"), message_duplicate_accuracy=[1]
-            )
-
-        ash = AntiSpamHandler(
-            commands.Bot(command_prefix="!"), message_duplicate_accuracy=2
-        )
-        AntiSpamHandler(
-            commands.Bot(command_prefix="!"), message_duplicate_accuracy=None
-        )
-
-        self.assertIsInstance(ash.options.get("message_duplicate_accuracy"), float)
-
-        AntiSpamHandler(
-            commands.Bot(command_prefix="!"), message_duplicate_accuracy=2.0
-        )
-
-    async def test_ignorePerms(self):
-        with self.assertRaises(ValueError):
-            AntiSpamHandler(commands.Bot(command_prefix="!"), ignore_perms="1")
-
-        with self.assertRaises(ValueError):
-            AntiSpamHandler(commands.Bot(command_prefix="!"), ignore_perms=dict())
-
-        with self.assertRaises(ValueError):
-            AntiSpamHandler(commands.Bot(command_prefix="!"), ignore_perms=tuple())
-
-        with self.assertRaises(ValueError):
-            AntiSpamHandler(commands.Bot(command_prefix="!"), ignore_perms=1)
-
-        AntiSpamHandler(commands.Bot(command_prefix="!"), ignore_perms=[1])
-        AntiSpamHandler(commands.Bot(command_prefix="!"), ignore_perms=None)
-
-    async def test_ignoreUsers(self):
-        with self.assertRaises(ValueError):
-            AntiSpamHandler(commands.Bot(command_prefix="!"), ignore_users="1")
-
-        with self.assertRaises(ValueError):
-            AntiSpamHandler(commands.Bot(command_prefix="!"), ignore_users=dict())
-
-        with self.assertRaises(ValueError):
-            AntiSpamHandler(commands.Bot(command_prefix="!"), ignore_users=tuple())
-
-        with self.assertRaises(ValueError):
-            AntiSpamHandler(commands.Bot(command_prefix="!"), ignore_users=1)
-
-        AntiSpamHandler(commands.Bot(command_prefix="!"), ignore_users=[1])
-        AntiSpamHandler(commands.Bot(command_prefix="!"), ignore_users=None)
-
-    async def test_ignoreChannels(self):
-        with self.assertRaises(ValueError):
-            AntiSpamHandler(commands.Bot(command_prefix="!"), ignore_channels="1")
-
-        with self.assertRaises(ValueError):
-            AntiSpamHandler(commands.Bot(command_prefix="!"), ignore_channels=dict())
-
-        with self.assertRaises(ValueError):
-            AntiSpamHandler(commands.Bot(command_prefix="!"), ignore_channels=tuple())
-
-        with self.assertRaises(ValueError):
-            AntiSpamHandler(commands.Bot(command_prefix="!"), ignore_channels=1)
-
-        AntiSpamHandler(commands.Bot(command_prefix="!"), ignore_channels=[1])
-        AntiSpamHandler(commands.Bot(command_prefix="!"), ignore_channels=None)
-
-    async def test_ignoreRoles(self):
-        with self.assertRaises(ValueError):
-            AntiSpamHandler(commands.Bot(command_prefix="!"), ignore_roles="1")
-
-        with self.assertRaises(ValueError):
-            AntiSpamHandler(commands.Bot(command_prefix="!"), ignore_roles=dict())
-
-        with self.assertRaises(ValueError):
-            AntiSpamHandler(commands.Bot(command_prefix="!"), ignore_roles=tuple())
-
-        with self.assertRaises(ValueError):
-            AntiSpamHandler(commands.Bot(command_prefix="!"), ignore_roles=1)
-
-        AntiSpamHandler(commands.Bot(command_prefix="!"), ignore_roles=[1, "test role"])
-        AntiSpamHandler(commands.Bot(command_prefix="!"), ignore_roles=None)
-
-    async def test_ignoreGuilds(self):
-        with self.assertRaises(ValueError):
-            AntiSpamHandler(commands.Bot(command_prefix="!"), ignore_guilds="1")
-
-        with self.assertRaises(ValueError):
-            AntiSpamHandler(commands.Bot(command_prefix="!"), ignore_guilds=dict())
-
-        with self.assertRaises(ValueError):
-            AntiSpamHandler(commands.Bot(command_prefix="!"), ignore_guilds=tuple())
-
-        with self.assertRaises(ValueError):
-            AntiSpamHandler(commands.Bot(command_prefix="!"), ignore_guilds=1)
-
-        AntiSpamHandler(commands.Bot(command_prefix="!"), ignore_guilds=[1])
-        AntiSpamHandler(commands.Bot(command_prefix="!"), ignore_guilds=None)
-
-    async def test_ignoreBots(self):
-        with self.assertRaises(ValueError):
-            AntiSpamHandler(commands.Bot(command_prefix="!"), ignore_bots="1")
-
-        with self.assertRaises(ValueError):
-            AntiSpamHandler(commands.Bot(command_prefix="!"), ignore_bots=dict())
-
-        with self.assertRaises(ValueError):
-            AntiSpamHandler(commands.Bot(command_prefix="!"), ignore_bots=tuple())
-
-        with self.assertRaises(ValueError):
-            AntiSpamHandler(commands.Bot(command_prefix="!"), ignore_bots=1)
-
-        with self.assertRaises(ValueError):
-            AntiSpamHandler(commands.Bot(command_prefix="!"), ignore_bots=[1])
-
-        AntiSpamHandler(commands.Bot(command_prefix="!"), ignore_bots=True)
-        AntiSpamHandler(commands.Bot(command_prefix="!"), ignore_bots=None)
-
-    # TODO Add a ensureRaises test for each set-able option...
-    # TODO Capture standard out and ensure the logger is working,
-    #      but only at the correct levels
-
-    async def test_guildWarnMessageType(self):
-        with self.assertRaises(
-            ValueError, msg="guild_warn_message should raise, given int"
-        ):
-            AntiSpamHandler(commands.Bot(command_prefix="!"), guild_warn_message=1)
-
-        with self.assertRaises(
-            ValueError, msg="guild_warn_message should raise, given bool"
-        ):
-            AntiSpamHandler(commands.Bot(command_prefix="!"), guild_warn_message=True)
-
-        AntiSpamHandler(commands.Bot(command_prefix="!"), guild_warn_message="Test")
-        AntiSpamHandler(
-            commands.Bot(command_prefix="!"), guild_warn_message={"title": "Hello!"}
-        )
-
-    async def test_guildWarnMessageString(self):
-        ash = AntiSpamHandler(
-            commands.Bot(command_prefix="!"), guild_warn_message="This is a message"
-        )
-        self.assertEqual(ash.options["guild_warn_message"], "This is a message")
-
-    async def test_guildWarnMessageEmbed(self):
-        # Just tests this runs
-        AntiSpamHandler(commands.Bot(command_prefix="!"), guild_warn_message={})
-
-    async def test_deleteSpam(self):
-        with self.assertRaises(ValueError):
-            AntiSpamHandler(commands.Bot(command_prefix="!"), delete_spam={})
-
-        AntiSpamHandler(commands.Bot(command_prefix="!"), delete_spam=True)
-
-    async def test_propagateRoleIgnoring(self):
-        """
-        Tests if the propagate method ignores the correct roles
-        """
-        ash = AntiSpamHandler(
-            MockedMember(name="bot", member_id=87678).to_mock(), ignore_roles=[151515]
-        )
-        print(MockedMember(mock_type="bot").to_mock().user.id)
-        result = await ash.propagate(MockedMessage().to_mock())
-
-        self.assertEqual(result["status"], "Ignoring this role: 151515")
-
-    async def test_propagateTypes(self):
-        with self.assertRaises(ValueError):
-            # noinspection PyTypeChecker
-            await self.ash.propagate(1)
-
-        with self.assertRaises(ValueError):
-            # noinspection PyTypeChecker
-            await self.ash.propagate(None)
-
-        with self.assertRaises(ValueError):
-            # noinspection PyTypeChecker
-            await self.ash.propagate("Hi!")
-
-        with self.assertRaises(ValueError):
-            # noinspection PyTypeChecker
-            await self.ash.propagate(True)
-
-    async def test_propagateDmIgnore(self):
-        result = await self.ash.propagate(MockedMessage(is_in_guild=False).to_mock())
-        self.assertEqual(result["status"], "Ignoring messages from dm's")
-
-    async def test_propagateBotIgnore(self):
-        result = await self.ash.propagate(
-            MockedMessage(author_is_bot=True, author_id=98987).to_mock()
-        )
-        self.assertEqual(result["status"], "Ignoring messages from myself (the bot)")
-
-        result_two = await self.ash.propagate(
-            MockedMessage(author_is_bot=True, author_id=98798).to_mock()
-        )
-        self.assertEqual(result_two["status"], "Ignoring messages from bots")
-
-    async def test_propagateUserIgnore(self):
-        self.ash.add_ignored_item(12345, "member")
-
-        result = await self.ash.propagate(MockedMessage().to_mock())
-        self.assertEqual(result["status"], "Ignoring this user: 12345")
-
-        result_two = await self.ash.propagate(
-            MockedMessage(author_id=5433221).to_mock()
-        )
-        self.assertNotEqual(result_two["status"], "Ignoring this user: 12345")
-
-    async def test_propagateIgnoreGuild(self):
-        self.ash.add_ignored_item(123456789, "guild")
-        self.assertEqual(self.ash.options["ignore_guilds"], [123456789])
-
-        result = await self.ash.propagate(MockedMessage().to_mock())
-        self.assertEqual(result["status"], "Ignoring this guild: 123456789")
-
-        self.ash.remove_ignored_item(123456789, "guild")
-        self.assertEqual(self.ash.options["ignore_guilds"], [])
-
-    async def test_propagateIgnoredChannel(self):
-        self.ash.add_ignored_item(98987, "channel")
-        self.assertEqual(self.ash.options["ignore_channels"], [98987])
-
-        result = await self.ash.propagate(MockedMessage().to_mock())
-        self.assertEqual(result["status"], "Ignoring this channel: 98987")
-
-        self.ash.remove_ignored_item(98987, "channel")
-        self.assertEqual(self.ash.options["ignore_channels"], [])
-
-    async def test_propagateGuildCreation(self):
-        self.assertEqual(len(self.ash.guilds), 2)
-        await self.ash.propagate(MockedMessage(guild_id=15).to_mock())
-        self.assertEqual(len(self.ash.guilds), 2)
-
-        await self.ash.propagate(MockedMessage().to_mock())
-        self.assertEqual(len(self.ash.guilds), 3)
-
-    async def test_propagateGuildPerms(self):
-        ash = AntiSpamHandler(MockedMember(mock_type="bot").to_mock())
-        message = MockedMessage().to_mock()
-        message.guild.me.guild_permissions.kick_members = False
-        message.guild.me.guild_permissions.ban_members = True
-        with self.assertRaises(
-            MissingGuildPermissions, msg="Invalid kick_members perms"
-        ):
-            await ash.propagate(message)
-
-        message.guild.me.guild_permissions.kick_members = True
-        message.guild.me.guild_permissions.ban_members = False
-        with self.assertRaises(
-            MissingGuildPermissions, msg="Invalid ban_members perms"
-        ):
-            await ash.propagate(message)
-
-        message.guild.me.guild_permissions.ban_members = True
-        await ash.propagate(message)
-
-    async def test_ignoreMethods(self):
-        self.assertEqual(self.ash.options["ignore_users"], [])
-        self.assertEqual(self.ash.options["ignore_channels"], [])
-        self.assertEqual(self.ash.options["ignore_perms"], [8])
-        self.assertEqual(self.ash.options["ignore_guilds"], [])
-        self.assertEqual(self.ash.options["ignore_roles"], [])
-
-        self.ash.add_ignored_item(1, "member")
-        self.ash.add_ignored_item(2, "channel")
-        self.ash.add_ignored_item(3, "perm")
-        self.ash.add_ignored_item(4, "guild")
-        self.ash.add_ignored_item(5, "role")
-
-        self.assertEqual(self.ash.options["ignore_users"], [1])
-        self.assertEqual(self.ash.options["ignore_channels"], [2])
-        self.assertEqual(self.ash.options["ignore_perms"], [8, 3])
-        self.assertEqual(self.ash.options["ignore_guilds"], [4])
-        self.assertEqual(self.ash.options["ignore_roles"], [5])
-
-        self.ash.remove_ignored_item(1, "member")
-        self.ash.remove_ignored_item(2, "channel")
-        self.ash.remove_ignored_item(3, "perm")
-        self.ash.remove_ignored_item(4, "guild")
-        self.ash.remove_ignored_item(5, "role")
-
-        self.assertEqual(self.ash.options["ignore_users"], [])
-        self.assertEqual(self.ash.options["ignore_channels"], [])
-        self.assertEqual(self.ash.options["ignore_perms"], [8])
-        self.assertEqual(self.ash.options["ignore_guilds"], [])
-        self.assertEqual(self.ash.options["ignore_roles"], [])
-
-    async def test_ignoreMethodExceptions(self):
-        with self.assertRaises(ValueError):
-            # noinspection PyTypeChecker
-            self.ash.add_ignored_item("LOL", "test")
-
-        with self.assertRaises(ValueError):
-            # noinspection PyTypeChecker
-            self.ash.remove_ignored_item("LOL", "test")
-
-        with self.assertRaises(BaseASHException):
-            self.ash.add_ignored_item(1, "testing")
-
-        with self.assertRaises(BaseASHException):
-            self.ash.remove_ignored_item(1, "testing")
-
-        with self.assertRaises(ValueError):
-            # noinspection PyTypeChecker
-            self.ash.add_ignored_item(1, [])
-
-        with self.assertRaises(ValueError):
-            # noinspection PyTypeChecker
-            self.ash.remove_ignored_item(1, [])
-
-    async def test_customGuildOptions(self):
-        self.assertEqual(self.ash.guilds[0].options, Static.DEFAULTS)
-        self.ash.add_custom_guild_options(self.ash.guilds[0].id, warn_threshold=15)
-        self.assertNotEqual(self.ash.guilds[0].options, Static.DEFAULTS)
-        self.assertEqual(self.ash.guilds[0].has_custom_options, True)
-
-        self.ash.remove_custom_guild_options(self.ash.guilds[0].id)
-        self.assertEqual(self.ash.guilds[0].options, Static.DEFAULTS)
-
-        options = self.ash.get_guild_options(self.ash.guilds[0].id)[0]
-        self.assertEqual(options, self.ash.guilds[0].options)
-        self.assertEqual(options, self.ash.options)
-
-    async def test_ASHRandomVar(self):
-        with self.assertRaises(TypeError):
-            AntiSpamHandler(MockedMember(mock_type="bot").to_mock(), lol=1)
-
-        with self.assertRaises(TypeError):
-            self.ash.add_custom_guild_options(1234, testing=1)
-
-    async def test_warnOnly(self):
-        ash = AntiSpamHandler(MockedMember(mock_type="bot").to_mock(), warn_only=True)
-        self.assertEqual(ash.options["warn_only"], True)
-        self.assertEqual(self.ash.options["warn_only"], False)
-
-        ash = AntiSpamHandler(MockedMember(mock_type="bot").to_mock(), warn_only=None)
-        self.assertEqual(ash.options["warn_only"], False)
-
-        with self.assertRaises(ValueError):
-            AntiSpamHandler(MockedMember(mock_type="bot").to_mock(), warn_only=1)
-
-        with self.assertRaises(ValueError):
-            AntiSpamHandler(MockedMember(mock_type="bot").to_mock(), warn_only="1")
-
-        with self.assertRaises(ValueError):
-            AntiSpamHandler(MockedMember(mock_type="bot").to_mock(), warn_only=dict())
-
-        with self.assertRaises(ValueError):
-            AntiSpamHandler(MockedMember(mock_type="bot").to_mock(), warn_only=[])
-
-    async def test_resetWarnCounter(self):
-        # GIVEN / SETUP
-        user = User(
-            MockedMember(mock_type="bot").to_mock(), 123454321, 12, Static.DEFAULTS
-        )
-        self.ash.guilds[0].users = user
-        self.assertEqual(1, len(self.ash.guilds[0].users))
-        self.assertEqual(0, self.ash.guilds[0].users[0].warn_count)
-        self.ash.guilds[0].users[0].warn_count += 1
-        self.assertEqual(1, self.ash.guilds[0].users[0].warn_count)
-
-        # WHEN / TESTING
-        self.ash.reset_user_count(123454321, 12, Static.WARNCOUNTER)
-
-        # THEN / ASSERTIONS
-        self.assertEqual(0, self.ash.guilds[0].users[0].warn_count)
-
-    async def test_resetKickCounter(self):
-        # GIVEN / SETUP
-        user = User(
-            MockedMember(mock_type="bot").to_mock(), 123454321, 12, Static.DEFAULTS
-        )
-        self.ash.guilds[0].users = user
-        self.assertEqual(1, len(self.ash.guilds[0].users))
-        self.assertEqual(0, self.ash.guilds[0].users[0].kick_count)
-        self.ash.guilds[0].users[0].kick_count += 1
-        self.assertEqual(1, self.ash.guilds[0].users[0].kick_count)
-
-        # WHEN / TESTING
-        self.ash.reset_user_count(123454321, 12, Static.KICKCOUNTER)
-
-        # THEN / ASSERTIONS
-        self.assertEqual(0, self.ash.guilds[0].users[0].kick_count)
-
-    async def test_resetCountersRaises(self):
-        # SETUP
-        user = User(
-            MockedMember(mock_type="bot").to_mock(), 123454321, 12, Static.DEFAULTS
-        )
-        self.ash.guilds[0].users = user
-
-        # ASSERTIONS / TESTING
-        with self.assertRaises(LogicError):
-            self.ash.reset_user_count(123454321, 12, "Who knows")
-
-        # Invalid guild, should work silently
-        self.ash.reset_user_count(123454321, 15, "Who knows")
-
-        # Invalid user, should work silently
-        self.ash.reset_user_count(1234, 12, "Who knows")
-
-        # Invalid both, should work silently
-        self.ash.reset_user_count(1234, 15, "Who knows")
-
-    async def test_ensureOptionsRaisingModes(self):
-        with self.assertRaises(BaseASHException):
-            self.ash._ensure_options(warn_only=True, no_punish=True)
-
-        self.ash._ensure_options(warn_only=False, no_punish=True)
-        self.ash._ensure_options(warn_only=True, no_punish=False)
-
-    async def test_noPunishMode(self):
-        # SETUP
-        ash = AntiSpamHandler(MockedMember(mock_type="bot").to_mock(), no_punish=True)
-
-        # WHEN / TESTING
-        data = []
-        for num in range(6):
-            return_value = await ash.propagate(MockedMessage(message_id=num).to_mock())
-            data.append(return_value)
-
-        # THEN / ASSERTIONS
-        self.assertEqual(len(data), 6)
-
-        # TODO Fix this while fixing #39
-        self.assertEqual(data[0]["should_be_punished_this_message"], False)
-        self.assertEqual(data[2]["should_be_punished_this_message"], False)
-        self.assertEqual(data[3]["should_be_punished_this_message"], True)
-        self.assertEqual(data[5]["should_be_punished_this_message"], True)
-
-    @unittest.skip
-    async def test_statefulLoading(self):
-        data = read_json("unittests")
-        test_ash = await AntiSpamHandler.load_from_dict(
-            MockedMember(name="bot", member_id=98987, mock_type="bot").to_mock(), data
-        )
-        result = await test_ash.save_to_dict()
-        self.assertEqual(data, result)
-
-    async def test_registerPreInvoke(self):
-        class PreInvoke:
-            pass
-
-        with self.assertRaises(ExtensionError):
-            self.ash.register_extension(PreInvoke())
-
-        class PreInvoke(BaseExtension):
-            pass
-
-        self.assertEqual(len(self.ash.pre_invoke_extensions), 0)
-        self.ash.register_extension(PreInvoke())
-        self.assertEqual(len(self.ash.pre_invoke_extensions), 1)
-
-        with self.assertRaises(ExtensionError):
-            self.ash.register_extension(PreInvoke)
-
-        class AfterInvokeProp(BaseExtension):
-            def __init__(self):
-                super().__init__()
-                self.is_pre_invoke = False
-
-            async def propagate(self, message) -> dict:
-                pass
-
-        # with self.assertRaises(ExtensionError):
-        #    self.ash.register_extension(AfterInvokeProp())
-
-        # Cleanup
-        self.ash.unregister_extension("PreInvoke")
-
-        self.ash.register_extension(PreInvoke())
-        with self.assertRaises(ExtensionError):
-            self.ash.register_extension(PreInvoke())
-        self.ash.register_extension(PreInvoke(), force_overwrite=True)
-
-    async def test_unregisterExtension(self):
-        class PreInvoke(BaseExtension):
-            pass
-
-        with self.assertRaises(ExtensionError):
-            self.ash.unregister_extension("PreInvoke")
-
-        self.ash.register_extension(PreInvoke())
-
-        self.assertEqual(len(self.ash.pre_invoke_extensions), 1)
-        self.ash.unregister_extension("PreInvoke")
-        self.assertEqual(len(self.ash.pre_invoke_extensions), 0)
-
-
-# TODO test delete_after options
diff --git a/testing/test_AST.py b/testing/test_AST.py
deleted file mode 100644
index 9f3ca2e..0000000
--- a/testing/test_AST.py
+++ /dev/null
@@ -1,230 +0,0 @@
-"""
-The MIT License (MIT)
-
-Copyright (c) 2020 Skelmis
-
-Permission is hereby granted, free of charge, to any person obtaining a
-copy of this software and associated documentation files (the "Software"),
-to deal in the Software without restriction, including without limitation
-the rights to use, copy, modify, merge, publish, distribute, sublicense,
-and/or sell copies of the Software, and to permit persons to whom the
-Software is furnished to do so, subject to the following conditions:
-The above copyright notice and this permission notice shall be included in
-all copies or substantial portions of the Software.
-
-THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
-OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
-FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
-DEALINGS IN THE SOFTWARE.
-"""
-
-# TODO Make this
-import asyncio
-import unittest
-
-from antispam import AntiSpamHandler, UserNotFound
-from antispam.ext import AntiSpamTracker
-
-from testing.mocks.MockMember import MockedMember
-from testing.mocks.MockMessage import MockedMessage
-
-
-class TestAsh(unittest.IsolatedAsyncioTestCase):
-    # TODO Utilise the built_in setup func
-    async def test_initialization(self):
-        # Test AntiSpamHandler type assertion
-        with self.assertRaises(TypeError):
-            # noinspection PyTypeChecker
-            AntiSpamTracker(1, 2)
-
-        AntiSpamTracker(AntiSpamHandler(MockedMember(mock_type="bot").to_mock()), 3)
-
-        with self.assertRaises(TypeError):
-            # noinspection PyArgumentList
-            AntiSpamTracker()
-
-        ash = AntiSpamHandler(MockedMember(mock_type="bot").to_mock())
-        message_interval = ash.options.get("message_interval")
-        ast = AntiSpamTracker(ash, 3)
-        self.assertEqual(message_interval, ast.valid_global_interval)
-
-        ast = AntiSpamTracker(ash, 3, 15000)
-        self.assertNotEqual(message_interval, ast.valid_global_interval)
-        self.assertEqual(15000, ast.valid_global_interval)
-
-        self.assertEqual(False, bool(ast.user_tracking))
-
-        self.assertEqual(ast.punish_min_amount, 3)
-
-        with self.assertRaises(TypeError):
-            # noinspection PyTypeChecker
-            AntiSpamTracker(ash, 3, dict())
-
-    async def test_updateCache(self):
-        ast = AntiSpamTracker(
-            AntiSpamHandler(MockedMember(mock_type="bot").to_mock()), 3
-        )
-
-        ast.update_cache(
-            MockedMessage(is_in_guild=False).to_mock(),
-            {"should_be_punished_this_message": True},
-        )
-
-        self.assertEqual(False, bool(ast.user_tracking))
-        ast.update_cache(
-            MockedMessage().to_mock(), {"should_be_punished_this_message": True}
-        )
-        self.assertEqual(True, bool(ast.user_tracking))
-
-        ast.user_tracking = {}  # overwrite so we can test more
-
-        self.assertEqual(False, bool(ast.user_tracking))
-        ast.update_cache(
-            MockedMessage().to_mock(), {"should_be_punished_this_message": False}
-        )
-        self.assertEqual(False, bool(ast.user_tracking))
-
-        with self.assertRaises(TypeError):
-            # noinspection PyTypeChecker
-            ast.update_cache(1, dict())
-
-        with self.assertRaises(TypeError):
-            # noinspection PyTypeChecker
-            ast.update_cache(MockedMessage().to_mock(), 1)
-
-        ast.user_tracking = {}
-        self.assertEqual(0, len(ast.user_tracking))
-
-        ast.update_cache(
-            MockedMessage().to_mock(), {"should_be_punished_this_message": True}
-        )
-        ast.update_cache(
-            MockedMessage().to_mock(), {"should_be_punished_this_message": True}
-        )
-        self.assertEqual(1, len(ast.user_tracking))
-        self.assertEqual(1, len(ast.user_tracking[123456789]))
-        self.assertEqual(2, len(ast.user_tracking[123456789][12345]))
-
-    async def test_getUserCount(self):
-        ast = AntiSpamTracker(
-            AntiSpamHandler(MockedMember(mock_type="bot").to_mock()), 3
-        )
-        ast.update_cache(
-            MockedMessage().to_mock(), {"should_be_punished_this_message": True}
-        )
-
-        user_count = ast.get_user_count(MockedMessage().to_mock())
-
-        self.assertEqual(user_count, 1)
-
-        for i in range(3):
-            ast.update_cache(
-                MockedMessage().to_mock(), {"should_be_punished_this_message": True}
-            )
-        user_count = ast.get_user_count(MockedMessage().to_mock())
-        self.assertEqual(user_count, 4)
-
-        with self.assertRaises(UserNotFound):
-            message = MockedMessage(guild_id=15).to_mock()
-            ast.get_user_count(message)
-
-        with self.assertRaises(UserNotFound):
-            message = MockedMessage(author_id=25).to_mock()
-            ast.get_user_count(message)
-
-    async def test_removeOutdatedTimestamps(self):
-        ast = AntiSpamTracker(
-            AntiSpamHandler(MockedMember(mock_type="bot").to_mock()), 3, 50
-        )
-        ast.update_cache(
-            MockedMessage().to_mock(), {"should_be_punished_this_message": True}
-        )
-        self.assertEqual(ast.get_user_count(MockedMessage().to_mock()), 1)
-
-        ast.remove_outdated_timestamps(123456789, 12345)
-        self.assertEqual(ast.get_user_count(MockedMessage().to_mock()), 1)
-
-        await asyncio.sleep(0.06)
-        ast.remove_outdated_timestamps(123456789, 12345)
-        self.assertEqual(ast.get_user_count(MockedMessage().to_mock()), 0)
-
-    async def test_cleanCache(self):
-        ast = AntiSpamTracker(
-            AntiSpamHandler(MockedMember(mock_type="bot").to_mock()), 3, 50
-        )
-        ast.update_cache(
-            MockedMessage().to_mock(), {"should_be_punished_this_message": True}
-        )
-        self.assertEqual(1, len(ast.user_tracking[123456789][12345]))
-
-        ast.clean_cache()
-        self.assertEqual(len(ast.user_tracking), 1)
-        self.assertEqual(1, len(ast.user_tracking[123456789][12345]))
-
-        await asyncio.sleep(0.06)
-        # This should now fully clean the cache
-        ast.clean_cache()
-
-        self.assertEqual(ast.user_tracking, dict())
-
-    async def test_getGuildValidInterval(self):
-        ast = AntiSpamTracker(
-            AntiSpamHandler(MockedMember(mock_type="bot").to_mock()), 3
-        )
-        self.assertEqual(ast._get_guild_valid_interval(123456789), 30000)
-        ast.update_cache(
-            MockedMessage().to_mock(), {"should_be_punished_this_message": True}
-        )
-
-        self.assertEqual(ast._get_guild_valid_interval(123456789), 30000)
-
-        ast.user_tracking[123456789]["valid_interval"] = 15000
-        self.assertEqual(ast._get_guild_valid_interval(123456789), 15000)
-
-    async def test_isSpamming(self):
-        ast = AntiSpamTracker(
-            AntiSpamHandler(MockedMember(mock_type="bot").to_mock()), 5
-        )
-        self.assertEqual(ast.is_spamming(MockedMessage().to_mock()), False)
-
-        ast.update_cache(
-            MockedMessage().to_mock(), {"should_be_punished_this_message": True}
-        )
-        self.assertEqual(ast.is_spamming(MockedMessage().to_mock()), False)
-
-        for i in range(3):
-            ast.update_cache(
-                MockedMessage().to_mock(), {"should_be_punished_this_message": True}
-            )
-        # Cap is 5, should have 4 messages rn
-        self.assertEqual(ast.is_spamming(MockedMessage().to_mock()), False)
-        self.assertEqual(4, len(ast.user_tracking[123456789][12345]))
-
-        ast.update_cache(
-            MockedMessage().to_mock(), {"should_be_punished_this_message": True}
-        )
-        self.assertEqual(ast.is_spamming(MockedMessage().to_mock()), True)
-
-        ast.update_cache(
-            MockedMessage().to_mock(), {"should_be_punished_this_message": True}
-        )
-        self.assertEqual(ast.is_spamming(MockedMessage().to_mock()), True)
-
-    async def test_removePunishment(self):
-        ast = AntiSpamTracker(
-            AntiSpamHandler(MockedMember(mock_type="bot").to_mock()), 5
-        )
-
-        ast.remove_punishments(MockedMessage().to_mock())
-
-        ast.update_cache(
-            MockedMessage().to_mock(), {"should_be_punished_this_message": True}
-        )
-        self.assertEqual(1, ast.get_user_count(MockedMessage().to_mock()))
-
-        ast.remove_punishments(MockedMessage().to_mock())
-        with self.assertRaises(UserNotFound):
-            ast.get_user_count(MockedMessage().to_mock())
diff --git a/testing/test_exceptions.py b/testing/test_exceptions.py
deleted file mode 100644
index e0a5bee..0000000
--- a/testing/test_exceptions.py
+++ /dev/null
@@ -1,60 +0,0 @@
-"""
-The MIT License (MIT)
-
-Copyright (c) 2020 Skelmis
-
-Permission is hereby granted, free of charge, to any person obtaining a
-copy of this software and associated documentation files (the "Software"),
-to deal in the Software without restriction, including without limitation
-the rights to use, copy, modify, merge, publish, distribute, sublicense,
-and/or sell copies of the Software, and to permit persons to whom the
-Software is furnished to do so, subject to the following conditions:
-The above copyright notice and this permission notice shall be included in
-all copies or substantial portions of the Software.
-
-THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
-OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
-FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
-DEALINGS IN THE SOFTWARE.
-"""
-
-import unittest
-
-
-from antispam.exceptions import *
-
-
-class TestUser(unittest.TestCase):
-    """
-    Used to test the Exceptions
-    """
-
-    def test_baseException(self):
-        self.assertEqual(
-            "A base exception handler for the ASH ecosystem.", str(BaseASHException())
-        )
-
-    def test_DuplicateObject(self):
-        self.assertEqual(
-            "Raised because you attempted to create and add an object, using the exact same id's as a pre-existing one.",
-            str(DuplicateObject()),
-        )
-
-    def test_ObjectMismatch(self):
-        self.assertEqual(
-            "Raised because you attempted add a message to a member, but that member didn't create that message.",
-            str(ObjectMismatch()),
-        )
-
-    def test_LogicError(self):
-        self.assertEqual(
-            "Raised because internal logic has failed. Please create an issue in the github.",
-            str(LogicError()),
-        )
-
-    def test_MessageAssignment(self):
-        err = BaseASHException("A message")
-        self.assertEqual("A message", str(err))
diff --git a/testing/test_guild.py b/testing/test_guild.py
deleted file mode 100644
index cc7d559..0000000
--- a/testing/test_guild.py
+++ /dev/null
@@ -1,113 +0,0 @@
-"""
-The MIT License (MIT)
-
-Copyright (c) 2020 Skelmis
-
-Permission is hereby granted, free of charge, to any person obtaining a
-copy of this software and associated documentation files (the "Software"),
-to deal in the Software without restriction, including without limitation
-the rights to use, copy, modify, merge, publish, distribute, sublicense,
-and/or sell copies of the Software, and to permit persons to whom the
-Software is furnished to do so, subject to the following conditions:
-The above copyright notice and this permission notice shall be included in
-all copies or substantial portions of the Software.
-
-THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
-OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
-FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
-DEALINGS IN THE SOFTWARE.
-"""
-
-import unittest
-from copy import deepcopy
-
-from antispam.guild import Guild
-from antispam.user import User
-from antispam.static import Static
-from antispam.exceptions import DuplicateObject, ObjectMismatch
-
-
-class TestGuild(unittest.TestCase):
-    """
-    Used to test the Guild object
-    """
-
-    def setUp(self):
-        """
-        Simply setup our Guild obj before usage
-        """
-        self.guild = Guild(None, 15, Static.DEFAULTS)
-        self.guild.users = User(None, 20, 15, Static.DEFAULTS)
-        self.guild.users = User(None, 21, 15, Static.DEFAULTS)
-
-    def test_intAssignment(self):
-        self.assertEqual(self.guild.id, 15)
-
-    def test_listAssignment(self):
-        self.assertIsInstance(self.guild.users, list)
-
-    def test_valueAssignment(self):
-        self.assertEqual(self.guild.id, 15)
-
-        self.guild.id = 10
-
-        self.assertEqual(self.guild.id, 10)
-
-    def test_properties(self):
-        with self.assertRaises(ValueError):
-            self.guild.id = "1"
-
-    def test_userAssignment(self):
-        self.assertEqual(len(self.guild.users), 2)
-        self.guild.users = User(None, 22, 15, Static.DEFAULTS)
-        self.assertEqual(len(self.guild.users), 3)
-
-    def test_userRaises(self):
-        with self.assertRaises(ValueError):
-            self.guild.users = 1
-
-    def test_userRaisesDuplicate(self):
-        with self.assertRaises(DuplicateObject):
-            self.guild.users = User(None, 21, 15, Static.DEFAULTS)
-
-    def test_messageRaisesMismatch(self):
-        with self.assertRaises(ObjectMismatch):
-            self.guild.users = User(None, 22, 16, Static.DEFAULTS)
-
-    def test_str(self):
-        self.assertEqual(
-            str(self.guild),
-            f"{self.guild.__class__.__name__} object for {self.guild.id}.",
-        )
-
-    def test_repr(self):
-        self.assertEqual(
-            repr(self.guild),
-            (
-                f"'{self.guild.__class__.__name__} object. Guild id: {self.guild.id}, "
-                f"Len Stored Users {len(self.guild._users)}'"
-            ),
-        )
-
-    def test_optionsOnUsers(self):
-        # Tests the property ensuring all users within a guild
-        # also get the option changes
-        self.assertEqual(self.guild.users[0].options, self.guild.users[1].options)
-        self.assertEqual(Static.DEFAULTS, self.guild.users[0].options)
-
-        options = deepcopy(Static.DEFAULTS)
-        options["per_channel_spam"] = True
-
-        self.guild.options = options
-
-        self.assertNotEqual(Static.DEFAULTS, self.guild.users[0].options)
-
-        self.assertEqual(options, self.guild.users[0].options)
-        self.assertEqual(self.guild.users[0].options, self.guild.users[1].options)
-
-
-if __name__ == "__main__":
-    unittest.main()
diff --git a/testing/test_message.py b/testing/test_message.py
deleted file mode 100644
index 1e38cd2..0000000
--- a/testing/test_message.py
+++ /dev/null
@@ -1,128 +0,0 @@
-"""
-The MIT License (MIT)
-
-Copyright (c) 2020 Skelmis
-
-Permission is hereby granted, free of charge, to any person obtaining a
-copy of this software and associated documentation files (the "Software"),
-to deal in the Software without restriction, including without limitation
-the rights to use, copy, modify, merge, publish, distribute, sublicense,
-and/or sell copies of the Software, and to permit persons to whom the
-Software is furnished to do so, subject to the following conditions:
-The above copyright notice and this permission notice shall be included in
-all copies or substantial portions of the Software.
-
-THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
-OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
-FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
-DEALINGS IN THE SOFTWARE.
-"""
-
-import datetime
-import unittest
-
-from antispam.message import Message
-
-
-class TestMessage(unittest.TestCase):
-    """
-    Used to test the Message object
-    """
-
-    def setUp(self):
-        """
-        Simply setup our Message obj before usage
-        """
-        self.message = Message(0, "Hello world", 2, 3, 4)
-
-    def test_intAssignment(self):
-        self.assertIsInstance(self.message.id, int)
-        self.assertIsInstance(self.message.guild_id, int)
-        self.assertIsInstance(self.message.channel_id, int)
-        self.assertIsInstance(self.message.author_id, int)
-
-    def test_strAssignment(self):
-        self.assertIsInstance(self.message.content, str)
-
-    def test_datetimeAssignment(self):
-        self.assertIsInstance(self.message.creation_time, datetime.datetime)
-
-    def test_valueAssignment(self):
-        creationTime = self.message.creation_time
-
-        self.assertEqual(self.message.id, 0)
-        self.assertEqual(self.message.content, "Hello world")
-        self.assertEqual(self.message.author_id, 2)
-        self.assertEqual(self.message.channel_id, 3)
-        self.assertEqual(self.message.guild_id, 4)
-        self.assertFalse(self.message.is_duplicate)
-
-        self.message.id = 10
-        self.message.content = "Testing"
-        self.message.author_id = 10
-        self.message.channel_id = 10
-        self.message.guild_id = 10
-        self.message.creation_time = datetime.datetime.now()
-        self.message.is_duplicate = True
-
-        self.assertEqual(self.message.id, 10)
-        self.assertEqual(self.message.content, "Testing")
-        self.assertEqual(self.message.author_id, 10)
-        self.assertEqual(self.message.channel_id, 10)
-        self.assertEqual(self.message.guild_id, 10)
-        self.assertEqual(self.message.creation_time, creationTime)
-        self.assertTrue(self.message.is_duplicate)
-
-    def test_idRaises(self):
-        with self.assertRaises(ValueError):
-            self.message.id = "String"
-
-    def test_authorIdRaises(self):
-        with self.assertRaises(ValueError):
-            self.message.author_id = "String"
-
-    def test_channelIdRaises(self):
-        with self.assertRaises(ValueError):
-            self.message.channel_id = "String"
-
-    def test_guildIdRaises(self):
-        with self.assertRaises(ValueError):
-            self.message.guild_id = "String"
-
-    @unittest.skip
-    @unittest.expectedFailure
-    def test_contentRaises(self):
-        with self.assertRaises(ValueError):
-            self.message.content = {"key", "value"}
-            self.message.content = 1
-
-    def test_str(self):
-        self.assertEqual(
-            str(self.message),
-            f"{self.message.__class__.__name__} object - '{self.message.content}'",
-        )
-
-    def test_repr(self):
-        self.assertEqual(
-            repr(self.message),
-            (
-                f"'{self.message.__class__.__name__} object. Content: {self.message.content}, Message Id: {self.message.id}, "
-                f"Author Id: {self.message.author_id}, Channel Id: {self.message.channel_id}, Guild Id: {self.message.guild_id} "
-                f"Creation time: {self.message.creation_time}'"
-            ),
-        )
-
-    def test_eqEqual(self):
-        obj = Message(0, "Hello world", 2, 3, 4)
-        self.assertTrue(self.message == obj)
-
-    def test_eqNotEqual(self):
-        obj = Message(1, "Hello world", 2, 3, 4)
-        self.assertFalse(self.message == obj)
-
-
-if __name__ == "__main__":
-    unittest.main()
diff --git a/testing/test_user.py b/testing/test_user.py
deleted file mode 100644
index 72b3ad7..0000000
--- a/testing/test_user.py
+++ /dev/null
@@ -1,270 +0,0 @@
-"""
-The MIT License (MIT)
-
-Copyright (c) 2020 Skelmis
-
-Permission is hereby granted, free of charge, to any person obtaining a
-copy of this software and associated documentation files (the "Software"),
-to deal in the Software without restriction, including without limitation
-the rights to use, copy, modify, merge, publish, distribute, sublicense,
-and/or sell copies of the Software, and to permit persons to whom the
-Software is furnished to do so, subject to the following conditions:
-The above copyright notice and this permission notice shall be included in
-all copies or substantial portions of the Software.
-
-THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
-OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
-FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
-DEALINGS IN THE SOFTWARE.
-"""
-import datetime
-import unittest
-from copy import deepcopy
-
-from antispam.user import User
-from antispam.message import Message
-from antispam.static import Static
-from antispam.exceptions import DuplicateObject, ObjectMismatch, LogicError
-from testing.mocks.MockChannel import MockedChannel
-from testing.mocks.MockMessage import MockedMessage
-
-
-class TestUser(unittest.IsolatedAsyncioTestCase):
-    """
-    Used to test the user object
-    """
-
-    async def asyncSetUp(self):
-        """
-        Simply setup our User obj before usage
-        """
-        self.user = User(None, 0, 3, Static.DEFAULTS)
-        self.user.messages = Message(0, "Hello world", 0, 2, 3)
-        self.user.messages = Message(1, "Foo Bar", 0, 2, 3)
-
-    async def test_botAssignment(self):
-        # Given we don't want to populate an entire bot,
-        # lets just check its assigned correctly
-        self.assertIsNone(self.user.bot)
-
-    async def test_intAssignment(self):
-        self.assertIsInstance(self.user.id, int)
-        self.assertIsInstance(self.user.guild_id, int)
-
-    async def test_listAssignment(self):
-        self.assertIsInstance(self.user.messages, list)
-
-    async def test_dictAssignment(self):
-        self.assertIsInstance(self.user.options, dict)
-
-    async def test_valueAssignment(self):
-        self.assertEqual(self.user.id, 0)
-        self.assertEqual(self.user.guild_id, 3)
-        self.assertEqual(self.user.options, Static.DEFAULTS)
-
-        self.user.id = 10
-        self.user.guild_id = 10
-
-        self.assertEqual(self.user.id, 10)
-        self.assertEqual(self.user.guild_id, 10)
-
-    async def test_properties(self):
-        with self.assertRaises(ValueError):
-            self.user.id = "1"
-
-        with self.assertRaises(ValueError):
-            self.user.guild_id = "1"
-
-    async def test_messageAssignment(self):
-        self.assertEqual(len(self.user.messages), 2)
-        self.user.messages = Message(3, "Test", 0, 2, 3)
-        self.assertEqual(len(self.user.messages), 3)
-
-    async def test_messageRaises(self):
-        with self.assertRaises(ValueError):
-            self.user.messages = 1
-
-    async def test_messageRaisesDuplicate(self):
-        with self.assertRaises(DuplicateObject):
-            self.user.messages = Message(1, "Testing", 0, 2, 3)
-
-    async def test_messageRaisesMismatch(self):
-        with self.assertRaises(ObjectMismatch):
-            self.user.messages = Message(20, "Testing", 20, 20, 20)
-
-    async def test_repr(self):
-        self.assertEqual(
-            repr(self.user),
-            (
-                f"'{self.user.__class__.__name__} object. User id: {self.user.id}, Guild id: {self.user.guild_id}, "
-                f"Len Stored Messages {len(self.user.messages)}'"
-            ),
-        )
-
-    async def test_str(self):
-        self.assertEqual(
-            str(self.user), f"{self.user.__class__.__name__} object for {self.user.id}."
-        )
-
-    async def test_eqEqual(self):
-        obj = User(None, 0, 3, Static.DEFAULTS)
-        self.assertTrue(self.user == obj)
-
-    async def test_eqNotEqual(self):
-        obj = User(None, 2, 2, Static.DEFAULTS)
-        self.assertFalse(self.user == obj)
-
-    async def test_eqRaises(self):
-        with self.assertRaises(ValueError):
-            self.assertFalse(self.user == 1)
-
-    async def test_duplicateCounter(self):
-        self.assertNotEqual(
-            self.user.duplicate_counter, self.user.get_correct_duplicate_count()
-        )
-
-        self.assertEqual(
-            self.user.duplicate_counter - 1, self.user.get_correct_duplicate_count()
-        )
-
-    async def test_cleanUp(self):
-        x = len(self.user.messages)
-        self.user.clean_up(datetime.datetime.now(datetime.timezone.utc))
-        self.assertEqual(x, len(self.user.messages))
-
-    async def test__increment_duplicate_count(self):
-        self.assertEqual(self.user.duplicate_counter, 1)
-        self.user._increment_duplicate_count(Message(2, "A test message", 0, 2, 3))
-        self.assertEqual(self.user.duplicate_counter, 2)
-
-        self.user._increment_duplicate_count(Message(2, "A test message", 0, 2, 3), 2)
-        self.assertEqual(self.user.duplicate_counter, 4)
-
-        self.user.options["per_channel_spam"] = True
-        self.assertEqual(self.user.duplicate_channel_counter_dict, dict())
-
-        self.user._increment_duplicate_count(Message(2, "A test message", 0, 2, 3))
-
-        self.assertEqual(self.user.duplicate_channel_counter_dict, {2: 2})
-        self.user._increment_duplicate_count(Message(2, "A test message", 0, 2, 3), 2)
-        self.assertEqual(self.user.duplicate_channel_counter_dict, {2: 4})
-
-    async def test__get_duplicate_count(self):
-        result = self.user._get_duplicate_count(Message(3, "A test message", 0, 2, 3))
-        self.assertEqual(result, 1)
-
-        self.user.options["per_channel_spam"] = True
-        result = self.user._get_duplicate_count(Message(3, "A test message", 0, 2, 3))
-        self.assertEqual(result, 1)
-
-        self.user._increment_duplicate_count(Message(2, "A test message", 0, 2, 3))
-        self.user._increment_duplicate_count(Message(2, "A test message", 0, 2, 3))
-        result = self.user._get_duplicate_count(Message(3, "A test message", 0, 2, 3))
-        self.assertEqual(result, 3)
-
-        # This channel shouldn't exist / have duplicates
-        result = self.user._get_duplicate_count(Message(3, "A test message", 0, 1, 3))
-        self.assertEqual(result, 1)
-
-        with self.assertRaises(LogicError):
-            # noinspection PyTypeChecker
-            self.user._get_duplicate_count("hi")
-
-        with self.assertRaises(TypeError):
-            # noinspection PyTypeChecker
-            self.user._get_duplicate_count(channel_id=dict())
-
-        with self.assertRaises(LogicError):
-            # noinspection PyTypeChecker
-            self.user._get_duplicate_count()
-
-    async def test__remove_duplicate_count(self):
-        self.assertEqual(self.user.duplicate_counter, 1)
-        self.user._remove_duplicate_count(Message(2, "A test message", 0, 2, 3))
-        self.assertEqual(self.user.duplicate_counter, 0)
-
-        self.user.options["per_channel_spam"] = True
-        msg = Message(2, "A test message", 0, 2, 3)
-        self.user._increment_duplicate_count(msg)
-        self.user._increment_duplicate_count(msg)
-        self.assertEqual(self.user._get_duplicate_count(msg), 3)
-
-    async def test_propagatePunish(self):
-        """Checks the propagate method tries to punish at correct times"""
-        m = MockedMessage(message_id=0, author_id=0, guild_id=3).to_mock()
-        data = await self.user.propagate(m)
-        self.assertEqual(data["should_be_punished_this_message"], False)
-
-        m = MockedMessage(message_id=1, author_id=0, guild_id=3).to_mock()
-        data = await self.user.propagate(m)
-        self.assertEqual(data["should_be_punished_this_message"], False)
-
-        m = MockedMessage(message_id=2, author_id=0, guild_id=3).to_mock()
-        data = await self.user.propagate(m)
-        self.assertEqual(data["should_be_punished_this_message"], False)
-
-        m = MockedMessage(message_id=3, author_id=0, guild_id=3).to_mock()
-        data = await self.user.propagate(m)
-        self.assertEqual(data["should_be_punished_this_message"], True)
-
-        m = MockedMessage(message_id=4, author_id=0, guild_id=3).to_mock()
-        data = await self.user.propagate(m)
-        self.assertEqual(data["should_be_punished_this_message"], True)
-
-    async def test_propagateWarn(self):
-        """Tests it warns for the correct amount"""
-        for i in range(3):
-            m = MockedMessage(message_id=i, author_id=0, guild_id=3).to_mock()
-            data = await self.user.propagate(m)
-
-        # Shouldn't be punished yet. But one off
-        # noinspection PyUnboundLocalVariable
-        self.assertEqual(data["should_be_punished_this_message"], False)
-
-        m = MockedMessage(message_id=4, author_id=0, guild_id=3).to_mock()
-        data = await self.user.propagate(m)
-        self.assertEqual(data["should_be_punished_this_message"], True)
-        self.assertEqual(data["was_warned"], True)
-
-        m = MockedMessage(message_id=5, author_id=0, guild_id=3).to_mock()
-        data = await self.user.propagate(m)
-        self.assertEqual(data["should_be_punished_this_message"], True)
-        self.assertEqual(data["was_warned"], True)
-
-        m = MockedMessage(message_id=6, author_id=0, guild_id=3).to_mock()
-        data = await self.user.propagate(m)
-        self.assertEqual(data["should_be_punished_this_message"], True)
-        self.assertEqual(data["was_warned"], False)
-        self.assertEqual(data["was_kicked"], True)
-
-    async def test_multiChannelSpam(self):
-        options = deepcopy(Static.DEFAULTS)
-        options["per_channel_spam"] = True
-
-        user = User(None, 0, 3, options)
-        for i in range(3):
-            m = MockedMessage(message_id=i, author_id=0, guild_id=3).to_mock()
-            m.channel = MockedChannel(channel_id=1).to_mock()
-            await user.propagate(m)
-
-        self.assertEqual(user.get_correct_duplicate_count(1), 3)
-
-        message = MockedMessage(author_id=0, guild_id=3).to_mock()
-        message.channel = MockedChannel(channel_id=2).to_mock()
-
-        self.assertEqual(user.get_correct_duplicate_count(2), 0)
-
-        for i in range(5):
-            m = MockedMessage(message_id=i, author_id=0, guild_id=3).to_mock()
-            m.channel = MockedChannel(channel_id=2).to_mock()
-            await user.propagate(m)
-
-        self.assertEqual(user.get_correct_duplicate_count(1), 3)
-        self.assertEqual(user.get_correct_duplicate_count(2), 5)
-
-
-if __name__ == "__main__":
-    unittest.main()
diff --git a/testing/test_util.py b/testing/test_util.py
deleted file mode 100644
index 78b2fb7..0000000
--- a/testing/test_util.py
+++ /dev/null
@@ -1,110 +0,0 @@
-"""
-The MIT License (MIT)
-
-Copyright (c) 2020 Skelmis
-
-Permission is hereby granted, free of charge, to any person obtaining a
-copy of this software and associated documentation files (the "Software"),
-to deal in the Software without restriction, including without limitation
-the rights to use, copy, modify, merge, publish, distribute, sublicense,
-and/or sell copies of the Software, and to permit persons to whom the
-Software is furnished to do so, subject to the following conditions:
-The above copyright notice and this permission notice shall be included in
-all copies or substantial portions of the Software.
-
-THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
-OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
-FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
-DEALINGS IN THE SOFTWARE.
-"""
-import unittest
-
-from antispam.util import *
-from testing.mocks.MockMessage import MockedMessage
-
-
-class TestUtil(unittest.TestCase):
-    """
-    Used to test the util functions
-    """
-
-    def test_embedToString(self):
-        embed = discord.Embed()
-        self.assertEqual("", embed_to_string(embed=embed))
-
-        embed = discord.Embed(title="Hello", description="World")
-        embed.set_footer(text="This is weird")
-        embed.set_author(name="Its almost as if")
-        embed.add_field(name="I am", value="Alive")
-        embed.add_field(name="That would be weird tho", value="Right?")
-        self.assertEqual(
-            "Hello\nWorld\nThis is weird\nIts almost as if\nI am\nAlive\nThat would be weird tho\nRight?\n",
-            embed_to_string(embed),
-        )
-
-    def test_dictToEmbed(self):
-        warn_embed_dict = {
-            "title": "**Dear Dave**",
-            "description": "You are being warned for spam, please stop!",
-            "color": 0xFF0000,
-            "footer": {"text": "BOT"},
-            "author": {"name": "Guild"},
-            "fields": [
-                {"name": "Current warns:", "value": "1"},
-                {"name": "Current kicks:", "value": "2", "inline": False},
-            ],
-        }
-
-        mock_message = MockedMessage().to_mock()
-
-        test_embed = dict_to_embed(
-            warn_embed_dict, mock_message, {"warn_count": 1, "kick_count": 2}
-        )
-
-        embed = discord.Embed(
-            title="**Dear Dave**",
-            description="You are being warned for spam, please stop!",
-            color=0xFF0000,
-        )
-        embed.set_footer(text="BOT")
-        embed.set_author(name="Guild")
-        embed.add_field(name="Current warns:", value="1")
-        embed.add_field(name="Current kicks:", value="2", inline=False)
-
-        self.assertEqual(embed.to_dict(), test_embed.to_dict())
-
-    def test_transformMessage(self):
-        warn_embed_dict = {
-            "title": "**Dear $USERNAME**",
-            "description": "You are being warned for spam, please stop!",
-            "color": 0xFF0000,
-            "footer": {"text": "$BOTNAME"},
-            "author": {"name": "$GUILDNAME"},
-            "fields": [
-                {"name": "Current warns:", "value": "$WARNCOUNT"},
-                {"name": "Current kicks:", "value": "$KICKCOUNT", "inline": False},
-            ],
-        }
-
-        mock_message = MockedMessage().to_mock()
-        warn_dict = {"warn_count": 1, "kick_count": 2}
-
-        test_embed = transform_message(warn_embed_dict, mock_message, warn_dict)
-
-        embed = discord.Embed(
-            title="**Dear Skelmis**",
-            description="You are being warned for spam, please stop!",
-            color=0xFF0000,
-        )
-        embed.set_footer(text="Bot")
-        embed.set_author(name="Guild")
-        embed.add_field(name="Current warns:", value="1")
-        embed.add_field(name="Current kicks:", value="2", inline=False)
-
-        self.assertEqual(embed.to_dict(), test_embed.to_dict())
-
-        message = transform_message("Hello $MENTIONUSER", mock_message, warn_dict)
-        self.assertEqual("Hello <@12345>", message)
diff --git a/testing/tracker_tests.json b/testing/tracker_tests.json
deleted file mode 100644
index 55bf41b..0000000
--- a/testing/tracker_tests.json
+++ /dev/null
@@ -1,12 +0,0 @@
-{
-    "min_punish_amount": 3,
-    "valid_global_interval": 30000,
-    "user_tracking": {
-        "780784732484141077": {
-            "271612318947868673": [
-                "465835:38:53:12:26:2021",
-                "552492:39:53:12:26:2021"
-            ]
-        }
-    }
-}
\ No newline at end of file
diff --git a/testing/unittests.json b/testing/unittests.json
deleted file mode 100644
index 40a6a9a..0000000
--- a/testing/unittests.json
+++ /dev/null
@@ -1,350 +0,0 @@
-{
-    "options": {
-        "warn_threshold": 3,
-        "kick_threshold": 2,
-        "ban_threshold": 2,
-        "message_interval": 30000,
-        "guild_warn_message": {
-            "title": "**Dear $USERNAME**",
-            "description": "You are being warned for spam, please stop!",
-            "timestamp": true,
-            "color": 16711680,
-            "footer": {
-                "text": "$BOTNAME",
-                "icon_url": "$BOTAVATAR"
-            },
-            "author": {
-                "name": "$GUILDNAME",
-                "icon_url": "$GUILDICON"
-            },
-            "fields": [
-                {
-                    "name": "Current warns:",
-                    "value": "$WARNCOUNT",
-                    "inline": false
-                },
-                {
-                    "name": "Current kicks:",
-                    "value": "$KICKCOUNT",
-                    "inline": false
-                }
-            ]
-        },
-        "guild_kick_message": "$USERNAME was kicked for spamming/sending duplicate messages.",
-        "guild_ban_message": "$USERNAME was banned for spamming/sending duplicate messages.",
-        "user_kick_message": "Hey $MENTIONUSER, you are being kicked from $GUILDNAME for spamming/sending duplicate messages.",
-        "user_ban_message": "Hey $MENTIONUSER, you are being banned from $GUILDNAME for spamming/sending duplicate messages.",
-        "user_failed_kick_message": "I failed to punish you because I lack permissions, but still you shouldn't spam.",
-        "user_failed_ban_message": "I failed to punish you because I lack permissions, but still you shouldn't spam.",
-        "message_duplicate_count": 5,
-        "message_duplicate_accuracy": 90,
-        "delete_spam": true,
-        "ignore_perms": [
-            8
-        ],
-        "ignore_users": [],
-        "ignore_channels": [],
-        "ignore_roles": [],
-        "ignore_guilds": [],
-        "ignore_bots": true,
-        "warn_only": false,
-        "no_punish": false,
-        "per_channel_spam": false,
-        "guild_warn_message_delete_after": 10,
-        "user_kick_message_delete_after": null,
-        "guild_kick_message_delete_after": null,
-        "user_ban_message_delete_after": null,
-        "guild_ban_message_delete_after": null
-    },
-    "guilds": [
-        {
-            "id": 780784732484141077,
-            "options": {
-                "warn_threshold": 3,
-                "kick_threshold": 2,
-                "ban_threshold": 2,
-                "message_interval": 30000,
-                "guild_warn_message": {
-                    "title": "**Dear $USERNAME**",
-                    "description": "You are being warned for spam, please stop!",
-                    "timestamp": true,
-                    "color": 16711680,
-                    "footer": {
-                        "text": "$BOTNAME",
-                        "icon_url": "$BOTAVATAR"
-                    },
-                    "author": {
-                        "name": "$GUILDNAME",
-                        "icon_url": "$GUILDICON"
-                    },
-                    "fields": [
-                        {
-                            "name": "Current warns:",
-                            "value": "$WARNCOUNT",
-                            "inline": false
-                        },
-                        {
-                            "name": "Current kicks:",
-                            "value": "$KICKCOUNT",
-                            "inline": false
-                        }
-                    ]
-                },
-                "guild_kick_message": "$USERNAME was kicked for spamming/sending duplicate messages.",
-                "guild_ban_message": "$USERNAME was banned for spamming/sending duplicate messages.",
-                "user_kick_message": "Hey $MENTIONUSER, you are being kicked from $GUILDNAME for spamming/sending duplicate messages.",
-                "user_ban_message": "Hey $MENTIONUSER, you are being banned from $GUILDNAME for spamming/sending duplicate messages.",
-                "user_failed_kick_message": "I failed to punish you because I lack permissions, but still you shouldn't spam.",
-                "user_failed_ban_message": "I failed to punish you because I lack permissions, but still you shouldn't spam.",
-                "message_duplicate_count": 5,
-                "message_duplicate_accuracy": 90,
-                "delete_spam": true,
-                "ignore_perms": [
-                    8
-                ],
-                "ignore_users": [],
-                "ignore_channels": [],
-                "ignore_roles": [],
-                "ignore_guilds": [],
-                "ignore_bots": true,
-                "warn_only": false,
-                "no_punish": false,
-                "per_channel_spam": false,
-                "guild_warn_message_delete_after": 10,
-                "user_kick_message_delete_after": null,
-                "guild_kick_message_delete_after": null,
-                "user_ban_message_delete_after": null,
-                "guild_ban_message_delete_after": null
-            },
-            "has_custom_options": false,
-            "users": [
-                {
-                    "id": 271612318947868673,
-                    "options": {
-                        "warn_threshold": 3,
-                        "kick_threshold": 2,
-                        "ban_threshold": 2,
-                        "message_interval": 30000,
-                        "guild_warn_message": {
-                            "title": "**Dear $USERNAME**",
-                            "description": "You are being warned for spam, please stop!",
-                            "timestamp": true,
-                            "color": 16711680,
-                            "footer": {
-                                "text": "$BOTNAME",
-                                "icon_url": "$BOTAVATAR"
-                            },
-                            "author": {
-                                "name": "$GUILDNAME",
-                                "icon_url": "$GUILDICON"
-                            },
-                            "fields": [
-                                {
-                                    "name": "Current warns:",
-                                    "value": "$WARNCOUNT",
-                                    "inline": false
-                                },
-                                {
-                                    "name": "Current kicks:",
-                                    "value": "$KICKCOUNT",
-                                    "inline": false
-                                }
-                            ]
-                        },
-                        "guild_kick_message": "$USERNAME was kicked for spamming/sending duplicate messages.",
-                        "guild_ban_message": "$USERNAME was banned for spamming/sending duplicate messages.",
-                        "user_kick_message": "Hey $MENTIONUSER, you are being kicked from $GUILDNAME for spamming/sending duplicate messages.",
-                        "user_ban_message": "Hey $MENTIONUSER, you are being banned from $GUILDNAME for spamming/sending duplicate messages.",
-                        "user_failed_kick_message": "I failed to punish you because I lack permissions, but still you shouldn't spam.",
-                        "user_failed_ban_message": "I failed to punish you because I lack permissions, but still you shouldn't spam.",
-                        "message_duplicate_count": 5,
-                        "message_duplicate_accuracy": 90,
-                        "delete_spam": true,
-                        "ignore_perms": [
-                            8
-                        ],
-                        "ignore_users": [],
-                        "ignore_channels": [],
-                        "ignore_roles": [],
-                        "ignore_guilds": [],
-                        "ignore_bots": true,
-                        "warn_only": false,
-                        "no_punish": false,
-                        "per_channel_spam": false,
-                        "guild_warn_message_delete_after": 10,
-                        "user_kick_message_delete_after": null,
-                        "guild_kick_message_delete_after": null,
-                        "user_ban_message_delete_after": null,
-                        "guild_ban_message_delete_after": null
-                    },
-                    "guild_id": 780784732484141077,
-                    "is_in_guild": true,
-                    "warn_count": 0,
-                    "kick_count": 0,
-                    "duplicate_count": 1,
-                    "duplicate_channel_counter_dict": {},
-                    "messages": [
-                        {
-                            "id": 824259735758831647,
-                            "content": "hahah",
-                            "guild_id": 780784732484141077,
-                            "author_id": 271612318947868673,
-                            "channel_id": 780789244250816524,
-                            "is_duplicate": false,
-                            "creation_time": "071089:56:33:12:24:2021"
-                        },
-                        {
-                            "id": 824259754586931240,
-                            "content": "https://tenor.com/view/petty-bratz-fab-gif-13092559",
-                            "guild_id": 780784732484141077,
-                            "author_id": 271612318947868673,
-                            "channel_id": 780789244250816524,
-                            "is_duplicate": false,
-                            "creation_time": "557088:00:34:12:24:2021"
-                        },
-                        {
-                            "id": 824259811167436822,
-                            "content": "I need more then 1 data point lol",
-                            "guild_id": 780784732484141077,
-                            "author_id": 271612318947868673,
-                            "channel_id": 780789244250816524,
-                            "is_duplicate": false,
-                            "creation_time": "125092:14:34:12:24:2021"
-                        },
-                        {
-                            "id": 824259831988748330,
-                            "content": "spooood",
-                            "guild_id": 780784732484141077,
-                            "author_id": 271612318947868673,
-                            "channel_id": 780789244250816524,
-                            "is_duplicate": false,
-                            "creation_time": "128089:19:34:12:24:2021"
-                        },
-                        {
-                            "id": 824259857199661067,
-                            "content": "?save unittests",
-                            "guild_id": 780784732484141077,
-                            "author_id": 271612318947868673,
-                            "channel_id": 780789244250816524,
-                            "is_duplicate": false,
-                            "creation_time": "062087:25:34:12:24:2021"
-                        }
-                    ]
-                },
-                {
-                    "id": 668423998777982997,
-                    "options": {
-                        "warn_threshold": 3,
-                        "kick_threshold": 2,
-                        "ban_threshold": 2,
-                        "message_interval": 30000,
-                        "guild_warn_message": {
-                            "title": "**Dear $USERNAME**",
-                            "description": "You are being warned for spam, please stop!",
-                            "timestamp": true,
-                            "color": 16711680,
-                            "footer": {
-                                "text": "$BOTNAME",
-                                "icon_url": "$BOTAVATAR"
-                            },
-                            "author": {
-                                "name": "$GUILDNAME",
-                                "icon_url": "$GUILDICON"
-                            },
-                            "fields": [
-                                {
-                                    "name": "Current warns:",
-                                    "value": "$WARNCOUNT",
-                                    "inline": false
-                                },
-                                {
-                                    "name": "Current kicks:",
-                                    "value": "$KICKCOUNT",
-                                    "inline": false
-                                }
-                            ]
-                        },
-                        "guild_kick_message": "$USERNAME was kicked for spamming/sending duplicate messages.",
-                        "guild_ban_message": "$USERNAME was banned for spamming/sending duplicate messages.",
-                        "user_kick_message": "Hey $MENTIONUSER, you are being kicked from $GUILDNAME for spamming/sending duplicate messages.",
-                        "user_ban_message": "Hey $MENTIONUSER, you are being banned from $GUILDNAME for spamming/sending duplicate messages.",
-                        "user_failed_kick_message": "I failed to punish you because I lack permissions, but still you shouldn't spam.",
-                        "user_failed_ban_message": "I failed to punish you because I lack permissions, but still you shouldn't spam.",
-                        "message_duplicate_count": 5,
-                        "message_duplicate_accuracy": 90,
-                        "delete_spam": true,
-                        "ignore_perms": [
-                            8
-                        ],
-                        "ignore_users": [],
-                        "ignore_channels": [],
-                        "ignore_roles": [],
-                        "ignore_guilds": [],
-                        "ignore_bots": true,
-                        "warn_only": false,
-                        "no_punish": false,
-                        "per_channel_spam": false,
-                        "guild_warn_message_delete_after": 10,
-                        "user_kick_message_delete_after": null,
-                        "guild_kick_message_delete_after": null,
-                        "user_ban_message_delete_after": null,
-                        "guild_ban_message_delete_after": null
-                    },
-                    "guild_id": 780784732484141077,
-                    "is_in_guild": true,
-                    "warn_count": 0,
-                    "kick_count": 0,
-                    "duplicate_count": 1,
-                    "duplicate_channel_counter_dict": {},
-                    "messages": [
-                        {
-                            "id": 824259787151769600,
-                            "content": "ah",
-                            "guild_id": 780784732484141077,
-                            "author_id": 668423998777982997,
-                            "channel_id": 780789244250816524,
-                            "is_duplicate": false,
-                            "creation_time": "322089:08:34:12:24:2021"
-                        },
-                        {
-                            "id": 824259817660088330,
-                            "content": "I am speeeeed",
-                            "guild_id": 780784732484141077,
-                            "author_id": 668423998777982997,
-                            "channel_id": 780789244250816524,
-                            "is_duplicate": false,
-                            "creation_time": "600569:15:34:12:24:2021"
-                        },
-                        {
-                            "id": 824259832009457674,
-                            "content": "Neowing",
-                            "guild_id": 780784732484141077,
-                            "author_id": 668423998777982997,
-                            "channel_id": 780789244250816524,
-                            "is_duplicate": false,
-                            "creation_time": "011097:19:34:12:24:2021"
-                        },
-                        {
-                            "id": 824259841086193687,
-                            "content": "Brrr",
-                            "guild_id": 780784732484141077,
-                            "author_id": 668423998777982997,
-                            "channel_id": 780789244250816524,
-                            "is_duplicate": false,
-                            "creation_time": "225596:21:34:12:24:2021"
-                        },
-                        {
-                            "id": 824259852418678804,
-                            "content": "Bwoahhhh",
-                            "guild_id": 780784732484141077,
-                            "author_id": 668423998777982997,
-                            "channel_id": 780789244250816524,
-                            "is_duplicate": false,
-                            "creation_time": "883070:23:34:12:24:2021"
-                        }
-                    ]
-                }
-            ]
-        }
-    ]
-}
\ No newline at end of file
diff --git a/tests/__init__.py b/tests/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/tests/_test_hypothesis.py b/tests/_test_hypothesis.py
new file mode 100644
index 0000000..709d3e6
--- /dev/null
+++ b/tests/_test_hypothesis.py
@@ -0,0 +1,107 @@
+import pytest
+
+from discord.ext import commands
+
+# noinspection PyUnresolvedReferences
+from antispam import (
+    PluginCache,
+    GuildNotFound,
+    MemberNotFound,
+    MemberAddonNotFound,
+    GuildAddonNotFound,
+    Options,
+    AntiSpamHandler,
+)
+
+from antispam.dataclasses import Member, Guild  # noqa
+
+from hypothesis import given
+from hypothesis.strategies import text, dictionaries, floats, lists, datetimes
+
+from .fixtures import create_bot, create_handler, create_plugin_cache, MockClass
+
+"""A test file devoted to hypothesis.
+These tests do not run on ci due to time
+constraints however they are used for
+better test argument coverage
+"""
+
+
+class TestHypoth:
+    @pytest.mark.asyncio
+    @given(arg=text())
+    async def test_set_member_data_text(self, arg):
+        """Test the cache sets member addon's correct using text"""
+        plugin_cache = PluginCache(AntiSpamHandler(commands.Bot("!")), MockClass())
+
+        with pytest.raises(GuildNotFound):
+            await plugin_cache.get_member_data(1, 1)
+
+        await plugin_cache.set_member_data(1, 1, arg)
+
+        assert await plugin_cache.get_member_data(1, 1) == arg
+
+    @pytest.mark.asyncio
+    @given(arg=dictionaries(text(), floats()))
+    async def test_set_member_data_dictionaries(self, arg):
+        """Test the cache sets member addon's correct using dictionaries"""
+        plugin_cache = PluginCache(AntiSpamHandler(commands.Bot("!")), MockClass())
+
+        with pytest.raises(GuildNotFound):
+            await plugin_cache.get_member_data(1, 1)
+
+        await plugin_cache.set_member_data(1, 1, arg)
+
+        assert await plugin_cache.get_member_data(1, 1) == arg
+
+    @pytest.mark.asyncio
+    @given(arg=lists(datetimes()))
+    async def test_set_member_data_dictionaries(self, arg):
+        """Test the cache sets member addon's correct using lists of datetimes"""
+        plugin_cache = PluginCache(AntiSpamHandler(commands.Bot("!")), MockClass())
+
+        with pytest.raises(GuildNotFound):
+            await plugin_cache.get_member_data(1, 1)
+
+        await plugin_cache.set_member_data(1, 1, arg)
+
+        assert await plugin_cache.get_member_data(1, 1) == arg
+
+    @pytest.mark.asyncio
+    @given(arg=text())
+    async def test_set_guild_data_text(self, arg):
+        """Test the cache sets guild addon's correct using text"""
+        plugin_cache = PluginCache(AntiSpamHandler(commands.Bot("!")), MockClass())
+
+        with pytest.raises(GuildNotFound):
+            await plugin_cache.get_guild_data(1)
+
+        await plugin_cache.set_guild_data(1, arg)
+
+        assert await plugin_cache.get_guild_data(1) == arg
+
+    @pytest.mark.asyncio
+    @given(arg=dictionaries(text(), floats()))
+    async def test_set_guild_data_dictionaries(self, arg):
+        """Test the cache sets guild addon's correct using dictionaries"""
+        plugin_cache = PluginCache(AntiSpamHandler(commands.Bot("!")), MockClass())
+
+        with pytest.raises(GuildNotFound):
+            await plugin_cache.get_guild_data(1)
+
+        await plugin_cache.set_guild_data(1, arg)
+
+        assert await plugin_cache.get_guild_data(1) == arg
+
+    @pytest.mark.asyncio
+    @given(arg=lists(datetimes()))
+    async def test_set_guild_data_dictionaries(self, arg):
+        """Test the cache sets guild addon's correct using lists of datetimes"""
+        plugin_cache = PluginCache(AntiSpamHandler(commands.Bot("!")), MockClass())
+
+        with pytest.raises(GuildNotFound):
+            await plugin_cache.get_guild_data(1)
+
+        await plugin_cache.set_guild_data(1, arg)
+
+        assert await plugin_cache.get_guild_data(1) == arg
diff --git a/tests/_test_util.py b/tests/_test_util.py
new file mode 100644
index 0000000..7e8b866
--- /dev/null
+++ b/tests/_test_util.py
@@ -0,0 +1,188 @@
+import datetime
+
+import discord
+
+
+from antispam.util import (
+    embed_to_string,
+    dict_to_embed,
+    transform_message,
+    get_aware_time,
+    substitute_args,
+)
+
+from antispam import visualizer  # noqa
+from tests.mocks import MockedMessage
+
+
+class TestUtil:
+    def test_embed_to_string(self):
+        embed = discord.Embed()
+        assert "" == embed_to_string(embed=embed)
+
+        embed = discord.Embed(title="Hello", description="World")
+        embed.set_footer(text="This is weird")
+        embed.set_author(name="Its almost as if")
+        embed.add_field(name="I am", value="Alive")
+        embed.add_field(name="That would be weird tho", value="Right?")
+        assert (
+            "Hello\nWorld\nThis is weird\nIts almost as if\nI am\nAlive\nThat would be weird tho\nRight?\n"
+            == embed_to_string(embed)
+        )
+
+    def test_embed_skips_values(self):
+        """Tests we skip the footer and author checks on some cases"""
+        embed = discord.Embed()
+        assert "" == embed_to_string(embed=embed)
+
+        embed = discord.Embed(title="Hello", description="World")
+        embed.set_footer(icon_url="This is weird")
+        embed.set_author(name="test", url="Its almost as if")
+        assert "Hello\nWorld\ntest\n" == embed_to_string(embed)
+
+    def test_dict_to_embed(self):
+        warn_embed_dict = {
+            "title": "**Dear Dave**",
+            "description": "You are being warned for spam, please stop!",
+            "color": 0xFF0000,
+            "footer": {"text": "BOT"},
+            "author": {"name": "Guild"},
+            "fields": [
+                {"name": "Current warns:", "value": "1"},
+                {"name": "Current kicks:", "value": "2", "inline": False},
+            ],
+        }
+
+        mock_message = MockedMessage().to_mock()
+
+        test_embed = dict_to_embed(
+            warn_embed_dict, mock_message, {"warn_count": 1, "kick_count": 2}
+        )
+
+        embed = discord.Embed(
+            title="**Dear Dave**",
+            description="You are being warned for spam, please stop!",
+            color=0xFF0000,
+        )
+        embed.set_footer(text="BOT")
+        embed.set_author(name="Guild")
+        embed.add_field(name="Current warns:", value="1")
+        embed.add_field(name="Current kicks:", value="2", inline=False)
+
+        assert embed.to_dict() == test_embed.to_dict()
+
+    def test_edge_dict_to_embed(self):
+        """Tests edge cases for dict_to_embed"""
+        data = {
+            "author": {"icon_url": "$USERAVATAR", "name": "test"},
+            "footer": {"icon_url": "$USERAVATAR"},
+            "timestamp": True,
+        }
+
+        mock_message = MockedMessage().to_mock()
+
+        test_embed = dict_to_embed(
+            data, mock_message, {"warn_count": 1, "kick_count": 2}
+        )
+
+        user_footer = substitute_args(
+            "$USERAVATAR", mock_message, {"warn_count": 1, "kick_count": 2}
+        )
+
+        embed = discord.Embed(timestamp=mock_message.created_at)
+        embed.set_footer(icon_url=user_footer)
+        embed.set_author(
+            name="test",
+            icon_url=user_footer,
+        )
+
+        assert embed.to_dict() == test_embed.to_dict()
+
+    def test_further_edge_cases_for_to_dict(self):
+        data = {
+            "author": {"icon_url": "y", "name": "test"},
+            "footer": {"icon_url": "x"},
+            "timestamp": True,
+        }
+
+        mock_message = MockedMessage().to_mock()
+
+        test_embed = dict_to_embed(
+            data, mock_message, {"warn_count": 1, "kick_count": 2}
+        )
+
+        embed = discord.Embed(timestamp=mock_message.created_at)
+        embed.set_footer(icon_url="x")
+        embed.set_author(
+            name="test",
+            icon_url="y",
+        )
+
+        assert embed.to_dict() == test_embed.to_dict()
+
+    def test_to_dict_colors(self):
+        data = {"colour": 0xFFFFFF, "color": 0x000000}
+        mock_message = MockedMessage().to_mock()
+        data_embed = dict_to_embed(
+            data, mock_message, {"warn_count": 1, "kick_count": 2}
+        )
+        embed = discord.Embed(color=0xFFFFFF)
+
+        assert embed.to_dict() == data_embed.to_dict()
+
+    def test_transform_message(self):
+        warn_embed_dict = {
+            "title": "**Dear $USERNAME**",
+            "description": "You are being warned for spam, please stop!",
+            "color": 0xFF0000,
+            "footer": {"text": "$BOTNAME"},
+            "author": {"name": "$GUILDNAME"},
+            "fields": [
+                {"name": "Current warns:", "value": "$WARNCOUNT"},
+                {"name": "Current kicks:", "value": "$KICKCOUNT", "inline": False},
+            ],
+        }
+
+        mock_message = MockedMessage().to_mock()
+        warn_dict = {"warn_count": 1, "kick_count": 2}
+
+        test_embed = transform_message(warn_embed_dict, mock_message, warn_dict)
+
+        embed = discord.Embed(
+            title="**Dear Skelmis**",
+            description="You are being warned for spam, please stop!",
+            color=0xFF0000,
+        )
+        embed.set_footer(text="Bot")
+        embed.set_author(name="Guild")
+        embed.add_field(name="Current warns:", value="1")
+        embed.add_field(name="Current kicks:", value="2", inline=False)
+
+        assert embed.to_dict() == test_embed.to_dict()
+
+        message = transform_message("Hello $MENTIONUSER", mock_message, warn_dict)
+        assert "Hello <@12345>" == message
+
+    def test_visualizer(self):
+        message = visualizer(
+            "Hello $MENTIONUSER",
+            MockedMessage().to_mock(),
+            {"warn_count": 1, "kick_count": 2},
+        )
+        assert "Hello <@12345>" == message
+
+        warn_embed_dict = '{"title": "test"}'
+        test_embed = visualizer(
+            warn_embed_dict,
+            MockedMessage().to_mock(),
+            {"warn_count": 1, "kick_count": 2},
+        )
+        embed = discord.Embed(title="test")
+
+        assert isinstance(test_embed, discord.Embed)
+        assert embed.to_dict() == test_embed.to_dict()
+
+    def test_get_aware_time(self):
+        assert datetime.datetime.now(datetime.timezone.utc).replace(
+            microsecond=0
+        ) == get_aware_time().replace(microsecond=0)
diff --git a/tests/fixtures.py b/tests/fixtures.py
new file mode 100644
index 0000000..3b77dfd
--- /dev/null
+++ b/tests/fixtures.py
@@ -0,0 +1,66 @@
+import pytest
+from discord.ext import commands  # noqa
+
+from antispam import AntiSpamHandler, PluginCache
+from antispam.core import Core
+from antispam.caches import MemoryCache
+from antispam.libs.dpy import DPY
+from antispam.plugins import AntiMassMention, AntiSpamTracker, Stats, AdminLogs
+from tests.mocks import MockedMember
+
+
+class MockClass:
+    pass
+
+
+@pytest.fixture
+def create_bot():
+    """Creates a commands.Bot instance"""
+    return commands.Bot(command_prefix="!")
+
+
+@pytest.fixture
+def create_handler():
+    """Create a simple handler for usage"""
+    return AntiSpamHandler(MockedMember(mock_type="bot").to_mock())
+
+
+@pytest.fixture
+def create_plugin_cache(create_handler):
+    """Creates a PluginCache instance"""
+    return PluginCache(create_handler, MockClass())
+
+
+@pytest.fixture
+def create_memory_cache(create_handler):
+    return MemoryCache(create_handler)
+
+
+@pytest.fixture
+def create_core(create_handler):
+    return Core(create_handler)
+
+
+@pytest.fixture
+def create_anti_mass_mention(create_bot, create_handler):
+    return AntiMassMention(create_bot, create_handler)
+
+
+@pytest.fixture
+def create_anti_spam_tracker(create_handler):
+    return AntiSpamTracker(create_handler, 3)
+
+
+@pytest.fixture
+def create_stats(create_handler):
+    return Stats(create_handler)
+
+
+@pytest.fixture
+def create_dpy_lib_handler(create_handler):
+    return DPY(create_handler)
+
+
+@pytest.fixture
+def create_admin_logs(create_handler):
+    return AdminLogs(create_handler, "test")
diff --git a/tests/mocks/__init__.py b/tests/mocks/__init__.py
new file mode 100644
index 0000000..e887610
--- /dev/null
+++ b/tests/mocks/__init__.py
@@ -0,0 +1,5 @@
+from .mocked_member import MockedMember
+from .mock_channel import MockedChannel
+from .mocked_guild import MockedGuild
+from .mocked_message import MockedMessage
+from .mock_role import MockedRole
diff --git a/testing/mocks/MockChannel.py b/tests/mocks/mock_channel.py
similarity index 76%
rename from testing/mocks/MockChannel.py
rename to tests/mocks/mock_channel.py
index d05d290..0b66040 100644
--- a/testing/mocks/MockChannel.py
+++ b/tests/mocks/mock_channel.py
@@ -21,4 +21,12 @@ def to_mock(self):
         mock.id = self.id
         mock.mention = f"<@&{self.id}>"
 
+        mock.fetch_message = self.fetch_message
+
         return mock
+
+    @staticmethod
+    async def fetch_message(message_id):
+        from tests.mocks import MockedMessage
+
+        return MockedMessage(message_id=message_id).to_mock()
diff --git a/testing/mocks/MockRole.py b/tests/mocks/mock_role.py
similarity index 100%
rename from testing/mocks/MockRole.py
rename to tests/mocks/mock_role.py
diff --git a/testing/mocks/MockGuild.py b/tests/mocks/mocked_guild.py
similarity index 94%
rename from testing/mocks/MockGuild.py
rename to tests/mocks/mocked_guild.py
index 6cfe665..8d8f455 100644
--- a/testing/mocks/MockGuild.py
+++ b/tests/mocks/mocked_guild.py
@@ -5,7 +5,7 @@
 
 import discord
 
-from testing.mocks.MockMember import MockedMember
+from .mocked_member import MockedMember
 
 
 class MockedGuild:
diff --git a/testing/mocks/MockMember.py b/tests/mocks/mocked_member.py
similarity index 73%
rename from testing/mocks/MockMember.py
rename to tests/mocks/mocked_member.py
index 48942fe..be2759a 100644
--- a/testing/mocks/MockMember.py
+++ b/tests/mocks/mocked_member.py
@@ -1,12 +1,9 @@
 """
 This 'mocks' a discord.Member so we can use it for testing
 """
-import discord
-from unittest.mock import AsyncMock
+from unittest.mock import AsyncMock, Mock
 
-from discord.ext import commands
-
-from testing.mocks.MockRole import MockedRole
+from tests.mocks.mock_role import MockedRole
 
 
 class MockedMember:
@@ -33,9 +30,13 @@ def to_mock(self):
             mock.user.bot = True
 
             mock.user.roles = [
-                MockedRole(),
+                MockedRole().to_mock(),
                 MockedRole(name="test role 2", role_id=252525).to_mock(),
             ]
+
+            mock.get_channel = self.get_channel
+            mock.fetch_channel = self.fetch_channel
+
             return mock
         elif self.type == "member":
             mock.roles = [
@@ -51,3 +52,15 @@ def to_mock(self):
         mock.mention = f"<@{self.id}>"
 
         return mock
+
+    @staticmethod
+    def get_channel(channel_id):
+        from tests.mocks import MockedChannel
+
+        return MockedChannel(channel_id=channel_id).to_mock()
+
+    @staticmethod
+    async def fetch_channel(channel_id):
+        from tests.mocks import MockedChannel
+
+        return MockedChannel(channel_id=channel_id).to_mock()
diff --git a/testing/mocks/MockMessage.py b/tests/mocks/mocked_message.py
similarity index 93%
rename from testing/mocks/MockMessage.py
rename to tests/mocks/mocked_message.py
index 5e9e271..26b5e1b 100644
--- a/testing/mocks/MockMessage.py
+++ b/tests/mocks/mocked_message.py
@@ -6,9 +6,9 @@
 
 import discord
 
-from testing.mocks.MockGuild import MockedGuild
-from testing.mocks.MockMember import MockedMember
-from testing.mocks.MockChannel import MockedChannel
+from .mocked_guild import MockedGuild
+from .mocked_member import MockedMember
+from .mock_channel import MockedChannel
 
 
 class MockedMessage:
diff --git a/tests/raw.json b/tests/raw.json
new file mode 100644
index 0000000..d419dfc
--- /dev/null
+++ b/tests/raw.json
@@ -0,0 +1,80 @@
+{
+    "cache": "MemoryCache",
+    "options": {
+        "warn_threshold": 3,
+        "kick_threshold": 2,
+        "ban_threshold": 2,
+        "message_interval": 30000,
+        "message_duplicate_count": 5,
+        "message_duplicate_accuracy": 90,
+        "guild_ban_message_delete_after": null,
+        "guild_kick_message_delete_after": null,
+        "member_ban_message_delete_after": null,
+        "guild_warn_message_delete_after": null,
+        "member_kick_message_delete_after": null,
+        "guild_warn_message": "$MEMBERNAME was warned for spamming/sending duplicate messages.",
+        "member_warn_message": "Hey $MENTIONMEMBER, please stop spamming/sending duplicate messages.",
+        "guild_kick_message": "$MEMBERNAME was kicked for spamming/sending duplicate messages.",
+        "guild_ban_message": "$MEMBERNAME was banned for spamming/sending duplicate messages.",
+        "member_kick_message": "Hey $MENTIONMEMBER, you are being kicked from $GUILDNAME for spamming/sending duplicate messages.",
+        "member_ban_message": "Hey $MENTIONMEMBER, you are being banned from $GUILDNAME for spamming/sending duplicate messages.",
+        "member_failed_kick_message": "I failed to punish you because I lack permissions, but still you shouldn't spam.",
+        "member_failed_ban_message": "I failed to punish you because I lack permissions, but still you shouldn't spam.",
+        "ignored_members": [],
+        "ignored_channels": [],
+        "ignored_roles": [],
+        "ignored_guilds": [],
+        "delete_spam": false,
+        "ignore_bots": true,
+        "warn_only": false,
+        "no_punish": false,
+        "mention_on_embed": true,
+        "delete_zero_width_chars": true,
+        "per_channel_spam": false,
+        "is_per_channel_per_guild": true,
+        "addons": {}
+    },
+    "guilds": [
+        {
+            "id": 1,
+            "options": {
+                "warn_threshold": 3,
+                "kick_threshold": 2,
+                "ban_threshold": 2,
+                "message_interval": 30000,
+                "message_duplicate_count": 5,
+                "message_duplicate_accuracy": 90,
+                "guild_ban_message_delete_after": null,
+                "guild_kick_message_delete_after": null,
+                "member_ban_message_delete_after": null,
+                "guild_warn_message_delete_after": null,
+                "member_kick_message_delete_after": null,
+                "guild_warn_message": "$MEMBERNAME was warned for spamming/sending duplicate messages.",
+                "member_warn_message": "Hey $MENTIONMEMBER, please stop spamming/sending duplicate messages.",
+                "guild_kick_message": "$MEMBERNAME was kicked for spamming/sending duplicate messages.",
+                "guild_ban_message": "$MEMBERNAME was banned for spamming/sending duplicate messages.",
+                "member_kick_message": "Hey $MENTIONMEMBER, you are being kicked from $GUILDNAME for spamming/sending duplicate messages.",
+                "member_ban_message": "Hey $MENTIONMEMBER, you are being banned from $GUILDNAME for spamming/sending duplicate messages.",
+                "member_failed_kick_message": "I failed to punish you because I lack permissions, but still you shouldn't spam.",
+                "member_failed_ban_message": "I failed to punish you because I lack permissions, but still you shouldn't spam.",
+                "ignored_members": [],
+                "ignored_channels": [],
+                "ignored_roles": [],
+                "ignored_guilds": [],
+                "delete_spam": false,
+                "ignore_bots": true,
+                "warn_only": false,
+                "no_punish": false,
+                "mention_on_embed": true,
+                "delete_zero_width_chars": true,
+                "per_channel_spam": false,
+                "is_per_channel_per_guild": true,
+                "addons": {}
+            },
+            "messages": [],
+            "log_channel_id": null,
+            "members": {},
+            "addons": {}
+        }
+    ]
+}
\ No newline at end of file
diff --git a/tests/test_admin_logs.py b/tests/test_admin_logs.py
new file mode 100644
index 0000000..ec563fe
--- /dev/null
+++ b/tests/test_admin_logs.py
@@ -0,0 +1,52 @@
+import pytest
+
+from antispam import CorePayload, GuildNotFound, LogicError
+from antispam.plugins import AdminLogs
+
+from .fixtures import create_handler, create_admin_logs  # noqa
+from .mocks import MockedMessage
+
+
+class TestAdminLogs:
+    def test_init(self, create_handler):
+        cls = AdminLogs(create_handler, "test")
+        assert cls.path == "test"
+
+        create_handler.options.no_punish = True
+        AdminLogs(create_handler, "x")
+
+    @pytest.mark.asyncio
+    async def test_no_punish(self, create_admin_logs):
+        await create_admin_logs.propagate(MockedMessage().to_mock(), CorePayload())
+
+    @pytest.mark.asyncio
+    async def test_set_punish_types(self, create_admin_logs):
+        with pytest.raises(LogicError):
+            await create_admin_logs.propagate(
+                MockedMessage().to_mock(),
+                CorePayload(member_should_be_punished_this_message=True),
+            )
+
+        with pytest.raises(GuildNotFound):
+            await create_admin_logs.propagate(
+                MockedMessage().to_mock(),
+                CorePayload(
+                    member_should_be_punished_this_message=True, member_was_banned=True
+                ),
+            )
+
+        with pytest.raises(GuildNotFound):
+            await create_admin_logs.propagate(
+                MockedMessage().to_mock(),
+                CorePayload(
+                    member_should_be_punished_this_message=True, member_was_kicked=True
+                ),
+            )
+
+        with pytest.raises(GuildNotFound):
+            await create_admin_logs.propagate(
+                MockedMessage().to_mock(),
+                CorePayload(
+                    member_should_be_punished_this_message=True, member_was_warned=True
+                ),
+            )
diff --git a/tests/test_anti_mass_mention.py b/tests/test_anti_mass_mention.py
new file mode 100644
index 0000000..375acb1
--- /dev/null
+++ b/tests/test_anti_mass_mention.py
@@ -0,0 +1,129 @@
+import datetime
+
+import pytest
+
+from antispam.plugins.anti_mass_mention import (
+    MassMentionPunishment,
+    Tracking,
+)
+
+from antispam.plugins import AntiMassMention
+
+from antispam.util import get_aware_time
+from .fixtures import create_bot, create_handler, create_anti_mass_mention
+from .mocks import MockedMessage
+
+
+class TestMassMention:
+    def test_mass_mention_payload(self):
+        test = MassMentionPunishment(1, 2, 3, True)
+        assert test == MassMentionPunishment(1, 2, 3, True)
+
+    def test_tracking(self):
+        date = datetime.datetime.now()
+        test = Tracking(1, date)
+        assert test == Tracking(1, date)
+
+    def test_anti_mass_mention_init_raises(self):
+        with pytest.raises(ValueError):
+            AntiMassMention(
+                None,
+                None,
+                min_mentions_per_message=5,
+                total_mentions_before_punishment=1,
+            )
+
+        with pytest.raises(ValueError):
+            AntiMassMention(None, None, time_period=0)
+
+        with pytest.raises(ValueError):
+            AntiMassMention(None, None, time_period=-1)
+
+    def test_anti_mass_mention_init(self, create_bot, create_handler):
+        cls = AntiMassMention(
+            create_bot,
+            create_handler,
+            total_mentions_before_punishment=15,
+            time_period=25000,
+            min_mentions_per_message=10,
+        )
+        assert cls.bot == create_bot
+        assert cls.min_mentions_per_message == 10
+        assert cls.time_period == 25000
+        assert cls.total_mentions_before_punishment == 15
+
+    @pytest.mark.asyncio
+    async def test_clean_mention_timestamps(self, create_anti_mass_mention):
+        await create_anti_mass_mention._clean_mention_timestamps(
+            1, 1, datetime.datetime.now()
+        )
+
+        await create_anti_mass_mention.data.set_member_data(
+            1,
+            1,
+            {
+                "total_mentions": [
+                    Tracking(
+                        mentions=1,
+                        timestamp=get_aware_time() - datetime.timedelta(seconds=45),
+                    ),
+                    Tracking(mentions=5, timestamp=get_aware_time()),
+                ]
+            },
+        )
+
+        result = await create_anti_mass_mention.data.get_member_data(1, 1)
+        assert len(result["total_mentions"]) == 2
+
+        await create_anti_mass_mention._clean_mention_timestamps(1, 1, get_aware_time())
+
+        result = await create_anti_mass_mention.data.get_member_data(1, 1)
+        assert len(result["total_mentions"]) == 1
+
+    @pytest.mark.asyncio
+    async def test_propagate(self, create_anti_mass_mention):
+        message = MockedMessage(message_id=1).to_mock()
+        return_value = await create_anti_mass_mention.propagate(message)
+
+        assert return_value == {"action": "No action taken"}
+
+    @pytest.mark.asyncio
+    async def test_propagate_set(self, create_anti_mass_mention):
+        """Tests the set ignores duplicate mentions"""
+        message = MockedMessage(message_mentions=[1, 1, 1, 1, 1, 1]).to_mock()
+        return_value = await create_anti_mass_mention.propagate(message)
+
+        assert return_value == {"action": "No action taken"}
+
+    @pytest.mark.asyncio
+    async def test_propagate_non_overall(self, create_anti_mass_mention):
+        message = MockedMessage(
+            author_id=1, guild_id=1, message_mentions=[1, 2, 3, 4, 5, 6, 7, 8, 9]
+        ).to_mock()
+        return_value = await create_anti_mass_mention.propagate(message)
+
+        assert return_value == MassMentionPunishment(
+            member_id=1, guild_id=1, channel_id=98987, is_overall_punishment=False
+        )
+
+    @pytest.mark.asyncio
+    async def test_propagate_is_overall(self, create_anti_mass_mention):
+        message = MockedMessage(message_id=1, message_mentions=[1, 2, 3, 4]).to_mock()
+
+        return_value = await create_anti_mass_mention.propagate(message)
+        assert return_value == {"action": "No action taken"}
+
+        # Again
+        message = MockedMessage(message_id=2, message_mentions=[1, 2, 3, 4]).to_mock()
+        return_value = await create_anti_mass_mention.propagate(message)
+        assert return_value == {"action": "No action taken"}
+
+        # We should now trip it
+        message = MockedMessage(message_id=3, message_mentions=[1, 2, 3, 4]).to_mock()
+        return_value = await create_anti_mass_mention.propagate(message)
+        assert return_value == MassMentionPunishment(
+            member_id=12345,
+            guild_id=123456789,
+            channel_id=98987,
+            is_overall_punishment=True,
+        )
diff --git a/tests/test_anti_spam_handler.py b/tests/test_anti_spam_handler.py
new file mode 100644
index 0000000..655ca88
--- /dev/null
+++ b/tests/test_anti_spam_handler.py
@@ -0,0 +1,755 @@
+import json
+from typing import Optional
+from unittest.mock import MagicMock, AsyncMock
+
+import discord
+import pytest
+from attr import asdict
+from hypothesis import given, strategies as st
+from hypothesis.strategies import text
+from discord.ext import commands  # noqa
+
+from antispam import (
+    AntiSpamHandler,
+    Options,
+    GuildNotFound,
+    PluginError,
+    MissingGuildPermissions,
+    PropagateFailure,
+    InvocationCancelled,
+)  # noqa
+
+from antispam.enums import IgnoreType, ResetType
+
+from antispam.dataclasses import Guild, Member, CorePayload, Message
+
+from antispam.base_plugin import BasePlugin
+from antispam.plugins import Stats as StatsPlugin
+from .fixtures import create_bot, create_handler, MockClass
+from .mocks import MockedMessage
+
+"""
+    How to use hypothesis
+    @given(st.none(), text())
+    def test_options_typing(self, create_bot, option):
+        with pytest.raises(ValueError):
+            AntiSpamHandler(create_bot, options=option)
+    """
+
+
+class TestAntiSpamHandler:
+    @pytest.mark.asyncio
+    async def test_base(self):
+        pass
+
+    def test_initial_options(self, create_handler: AntiSpamHandler):
+        """Tests the initial options are equal to the dataclass"""
+        assert create_handler.options == Options()
+
+    def test_custom_options(self, create_bot):
+        """Tests custom options get set correct"""
+        handler = AntiSpamHandler(create_bot, options=Options(no_punish=True))
+
+        assert handler.options != Options()
+        assert handler.options == Options(no_punish=True)
+
+    def test_options_typing(self, create_bot):
+        """Tests the handler raises on incorrect option types"""
+        with pytest.raises(ValueError):
+            AntiSpamHandler(create_bot, options=1)
+
+    def test_cache_typing(self, create_bot):
+        """Tests the cache typing raises on incorrect instances"""
+        with pytest.raises(ValueError):
+            AntiSpamHandler(create_bot, cache=MockClass())
+
+        with pytest.raises(ValueError):
+            AntiSpamHandler(create_bot, cache=MockClass)
+
+        with pytest.raises(ValueError):
+            AntiSpamHandler(create_bot, cache=1)
+
+    def test_add_ignored_item(self, create_handler):
+        """Tests the handler adds ignored items correctly"""
+        # ensure they start empty
+        assert create_handler.options == Options()
+
+    # TODO Cannot test this until files get stubbed
+    """
+    def test_init_bot_raises(self, create_bot):
+        with pytest.raises(ValueError):
+            AntiSpamHandler(1)
+
+        with pytest.raises(ValueError):
+            AntiSpamHandler("1")
+
+        with pytest.raises(ValueError):
+            AntiSpamHandler(AntiSpamHandler(create_bot))
+
+        with pytest.raises(ValueError):
+            AntiSpamHandler(MockClass)
+    """
+
+    def test_add_ignored_item_raises(self, create_handler):
+        # Test IGNORE_TYPE
+        with pytest.raises(ValueError):
+            create_handler.add_ignored_item(1, 1)
+
+        with pytest.raises(ValueError):
+            create_handler.add_ignored_item(1, "1")
+
+        with pytest.raises(ValueError):
+            create_handler.add_ignored_item(1, MockClass())
+
+        # Test ITEM
+        with pytest.raises(ValueError):
+            create_handler.add_ignored_item("one", IgnoreType.ROLE)
+
+        with pytest.raises(ValueError):
+            create_handler.add_ignored_item(MockClass(), IgnoreType.ROLE)
+
+    def test_add_ignored_member(self, create_handler: AntiSpamHandler):
+        assert len(create_handler.options.ignored_members) == 0
+        create_handler.add_ignored_item(1, IgnoreType.MEMBER)
+        assert create_handler.options.ignored_members == {1}
+        assert len(create_handler.options.ignored_members) == 1
+
+    def test_add_ignored_channel(self, create_handler: AntiSpamHandler):
+        assert len(create_handler.options.ignored_channels) == 0
+        create_handler.add_ignored_item(1, IgnoreType.CHANNEL)
+        assert create_handler.options.ignored_channels == {1}
+        assert len(create_handler.options.ignored_channels) == 1
+
+    def test_add_ignored_guild(self, create_handler: AntiSpamHandler):
+        assert len(create_handler.options.ignored_guilds) == 0
+        create_handler.add_ignored_item(1, IgnoreType.GUILD)
+        assert create_handler.options.ignored_guilds == {1}
+        assert len(create_handler.options.ignored_guilds) == 1
+
+    def test_add_ignored_role(self, create_handler: AntiSpamHandler):
+        assert len(create_handler.options.ignored_roles) == 0
+        create_handler.add_ignored_item(1, IgnoreType.ROLE)
+        assert create_handler.options.ignored_roles == {1}
+        assert len(create_handler.options.ignored_roles) == 1
+
+    def test_remove_ignored_raises(self, create_handler):
+        # Test IGNORE_TYPE
+        with pytest.raises(ValueError):
+            create_handler.remove_ignored_item(1, 1)
+
+        with pytest.raises(ValueError):
+            create_handler.remove_ignored_item(1, "1")
+
+        with pytest.raises(ValueError):
+            create_handler.remove_ignored_item(1, MockClass())
+
+        # Test ITEM
+        with pytest.raises(ValueError):
+            create_handler.remove_ignored_item("one", IgnoreType.ROLE)
+
+        with pytest.raises(ValueError):
+            create_handler.remove_ignored_item(MockClass(), IgnoreType.ROLE)
+
+    def test_remove_ignored_member(self, create_handler: AntiSpamHandler):
+        assert len(create_handler.options.ignored_members) == 0
+        create_handler.remove_ignored_item(1, IgnoreType.MEMBER)
+        assert len(create_handler.options.ignored_members) == 0
+
+        create_handler.add_ignored_item(1, IgnoreType.MEMBER)
+        assert len(create_handler.options.ignored_members) == 1
+        create_handler.remove_ignored_item(1, IgnoreType.MEMBER)
+        assert len(create_handler.options.ignored_members) == 0
+
+    def test_remove_ignored_channel(self, create_handler: AntiSpamHandler):
+        assert len(create_handler.options.ignored_channels) == 0
+        create_handler.remove_ignored_item(1, IgnoreType.CHANNEL)
+        assert len(create_handler.options.ignored_channels) == 0
+
+        create_handler.add_ignored_item(1, IgnoreType.CHANNEL)
+        assert len(create_handler.options.ignored_channels) == 1
+        create_handler.remove_ignored_item(1, IgnoreType.CHANNEL)
+        assert len(create_handler.options.ignored_channels) == 0
+
+    def test_remove_ignored_guild(self, create_handler: AntiSpamHandler):
+        assert len(create_handler.options.ignored_guilds) == 0
+        create_handler.remove_ignored_item(1, IgnoreType.GUILD)
+        assert len(create_handler.options.ignored_guilds) == 0
+
+        create_handler.add_ignored_item(1, IgnoreType.GUILD)
+        assert len(create_handler.options.ignored_guilds) == 1
+        create_handler.remove_ignored_item(1, IgnoreType.GUILD)
+        assert len(create_handler.options.ignored_guilds) == 0
+
+    def test_remove_ignored_role(self, create_handler: AntiSpamHandler):
+        assert len(create_handler.options.ignored_roles) == 0
+        create_handler.remove_ignored_item(1, IgnoreType.ROLE)
+        assert len(create_handler.options.ignored_roles) == 0
+
+        create_handler.add_ignored_item(1, IgnoreType.ROLE)
+        assert len(create_handler.options.ignored_roles) == 1
+        create_handler.remove_ignored_item(1, IgnoreType.ROLE)
+        assert len(create_handler.options.ignored_roles) == 0
+
+    @pytest.mark.asyncio
+    async def test_add_guild_options_raises(self, create_handler: AntiSpamHandler):
+        with pytest.raises(ValueError):
+            await create_handler.add_guild_options(1, 2)
+
+    @pytest.mark.asyncio
+    async def test_add_guild_options(self, create_handler: AntiSpamHandler):
+        assert len(create_handler.cache.cache) == 0
+
+        await create_handler.add_guild_options(1, Options(no_punish=True))
+        assert len(create_handler.cache.cache) == 1
+        assert create_handler.cache.cache.get(1) is not None
+
+        await create_handler.add_guild_options(1, Options(no_punish=False))
+
+        assert create_handler.cache.cache.get(1).options == Options(no_punish=False)
+
+    @pytest.mark.asyncio
+    async def test_get_guild_options(self, create_handler: AntiSpamHandler):
+        with pytest.raises(GuildNotFound):
+            await create_handler.get_guild_options(1)
+
+        await create_handler.add_guild_options(1, Options(no_punish=False))
+
+        await create_handler.get_guild_options(1)
+        assert create_handler.cache.cache.get(1).options == Options(no_punish=False)
+
+    @pytest.mark.asyncio
+    async def test_remove_custom_options(self, create_handler: AntiSpamHandler):
+        await create_handler.remove_guild_options(1)  # Shouldn't raise or anything
+
+        await create_handler.add_guild_options(1, Options(no_punish=False))
+        assert create_handler.cache.cache.get(1).options == Options(no_punish=False)
+
+        await create_handler.remove_guild_options(1)
+        assert create_handler.cache.cache.get(1).options == Options()
+
+    @pytest.mark.asyncio
+    async def test_reset_member_count(self, create_handler):
+        with pytest.raises(ValueError):
+            await create_handler.reset_member_count(1, 1, 1)
+
+        await create_handler.reset_member_count(1, 1, ResetType.WARN_COUNTER)
+
+        await create_handler.cache.set_guild(Guild(id=1, options=Options()))
+        await create_handler.reset_member_count(1, 1, ResetType.WARN_COUNTER)
+
+        await create_handler.cache.set_member(Member(1, 1, warn_count=5, kick_count=6))
+        await create_handler.reset_member_count(1, 1, ResetType.WARN_COUNTER)
+        assert await create_handler.cache.get_member(1, 1) == Member(
+            1, 1, warn_count=0, kick_count=6
+        )
+
+        await create_handler.cache.set_member(Member(1, 1, warn_count=5, kick_count=6))
+        await create_handler.reset_member_count(1, 1, ResetType.KICK_COUNTER)
+        assert await create_handler.cache.get_member(1, 1) == Member(
+            1, 1, warn_count=5, kick_count=0
+        )
+
+    @pytest.mark.asyncio
+    async def test_add_guild_log_channel_raises(self, create_handler: AntiSpamHandler):
+        with pytest.raises(ValueError):
+            await create_handler.add_guild_log_channel("one", 1)
+
+        with pytest.raises(ValueError):
+            await create_handler.add_guild_log_channel({"one": 1}, 1)
+
+    @pytest.mark.asyncio
+    async def test_add_guild_log_channel(self):
+        # Mock it up
+        mock_channel = MagicMock()
+        mock_channel.id = 2
+        mock_channel.guild.id = 1
+
+        mock_bot = MagicMock()
+        mock_bot.fetch_channel = AsyncMock(return_value=mock_channel)
+
+        assert await mock_bot.fetch_channel.called_with(1)
+
+        handler = AntiSpamHandler(mock_bot)
+
+        await handler.add_guild_log_channel(2, 1)
+        g = await handler.cache.get_guild(1)
+        assert g.log_channel_id == 2
+
+    @pytest.mark.asyncio
+    async def test_add_guild_log_channel_exists(self):
+        mock_channel = MagicMock()
+        mock_channel.id = 2
+        mock_channel.guild.id = 1
+
+        mock_bot = MagicMock()
+        mock_bot.fetch_channel = AsyncMock(return_value=mock_channel)
+
+        assert await mock_bot.fetch_channel.called_with(1)
+
+        handler = AntiSpamHandler(mock_bot)
+        await handler.cache.set_guild(Guild(1, Options()))
+
+        await handler.add_guild_log_channel(2, 1)
+        g = await handler.cache.get_guild(1)
+        assert g.log_channel_id == 2
+
+    @pytest.mark.asyncio
+    async def test_remove_guild_log_channel(self, create_handler: AntiSpamHandler):
+        await create_handler.remove_guild_log_channel(1)  # shouldnt raise
+
+        await create_handler.cache.set_guild(Guild(1, Options(), log_channel_id=1))
+        result = await create_handler.cache.get_guild(1)
+        assert result.log_channel_id == 1
+
+        await create_handler.remove_guild_log_channel(1)
+        result = await create_handler.cache.get_guild(1)
+        assert result.log_channel_id is None
+
+    @pytest.mark.asyncio
+    async def test_load_from_dict(self, create_bot):
+        test_data = {
+            "cache": "MemoryCache",
+            "options": asdict(Options()),
+            "guilds": [
+                {
+                    "id": 1,
+                    "options": asdict(Options()),
+                    "members": [
+                        {
+                            "id": 1,
+                            "guild_id": 2,
+                            "is_in_guild": True,
+                            "warn_count": 5,
+                            "kick_count": 6,
+                            "duplicate_count": 7,
+                            "duplicate_channel_counter_dict": {},
+                            "messages": [
+                                {
+                                    "id": 1,
+                                    "content": "Hello World!",
+                                    "guild_id": 2,
+                                    "author_id": 3,
+                                    "channel_id": 4,
+                                    "is_duplicate": True,
+                                    "creation_time": "225596:21:8:3:12:5:2021",
+                                }
+                            ],
+                        }
+                    ],
+                }
+            ],
+        }
+
+        await AntiSpamHandler.load_from_dict(create_bot, test_data)
+
+    @pytest.mark.asyncio
+    async def test_load_from_dict_fails(self, create_bot):
+        test_data = {
+            "cache": "MemoryCache",
+            "options": asdict(Options()),
+            "XD": 1,
+            "guilds": [
+                {
+                    "id": 1,
+                    "options": asdict(Options()),
+                    "log_channel": MockClass,
+                    "members": [
+                        {
+                            "id": 1,
+                            "guild_id": 2,
+                            "is_in_guild": True,
+                            "warn_count": 5,
+                            "kick_count": 6,
+                            "duplicate_count": 7,
+                            "duplicate_channel_counter_dict": {},
+                            "messages": [
+                                {
+                                    "id": 1,
+                                    "content": "Hello World!",
+                                    "guild_id": 2,
+                                    "author_id": 3,
+                                    "channel_id": 4,
+                                    "is_duplicate": True,
+                                    "creation_time": "225596:21:8:3:12:5:2021",
+                                }
+                            ],
+                        },
+                    ],
+                    "Random_data": "LOL",
+                }
+            ],
+        }
+
+        await AntiSpamHandler.load_from_dict(
+            MagicMock(side_effect=discord.HTTPException), test_data
+        )
+
+        await AntiSpamHandler.load_from_dict(create_bot, 1, raise_on_exception=False)
+
+        with pytest.raises(TypeError):
+            await AntiSpamHandler.load_from_dict(create_bot, 1)
+
+    @pytest.mark.asyncio
+    async def test_save_to_dict(self, create_handler: AntiSpamHandler):
+        await create_handler.save_to_dict()
+
+        await create_handler.cache.set_guild(Guild(1, Options()))
+        data = await create_handler.save_to_dict()
+        with open("tests/raw.json", "r") as file:
+            stored_data = json.load(file)
+
+        assert data == stored_data
+
+    def test_register_extension(self, create_handler: AntiSpamHandler):
+        with pytest.raises(PluginError):
+            create_handler.register_plugin(MockClass())
+
+        class Test(BasePlugin):
+            def __init__(self, invoke=True):
+                self.is_pre_invoke = invoke
+
+        assert len(create_handler.pre_invoke_extensions.values()) == 0
+        create_handler.register_plugin(Test())
+        assert len(create_handler.pre_invoke_extensions.values()) == 1
+
+        # Test overwrite
+        with pytest.raises(PluginError):
+            create_handler.register_plugin(Test())
+
+        create_handler.register_plugin(Test(), force_overwrite=True)
+
+        assert len(create_handler.after_invoke_extensions.values()) == 0
+        create_handler.register_plugin(Test(invoke=False), force_overwrite=True)
+        assert len(create_handler.after_invoke_extensions.values()) == 1
+
+    def test_unregister_extension(self, create_handler: AntiSpamHandler):
+        class Test(BasePlugin):
+            def __init__(self, invoke=True):
+                self.is_pre_invoke = invoke
+
+        create_handler.register_plugin(Test(invoke=False))
+        assert len(create_handler.after_invoke_extensions.values()) == 1
+        create_handler.unregister_plugin("Test")
+        assert len(create_handler.after_invoke_extensions.values()) == 0
+
+        with pytest.raises(PluginError):
+            create_handler.unregister_plugin("Invalid Extension")
+
+        create_handler.register_plugin(Test())
+        assert len(create_handler.pre_invoke_extensions.values()) == 1
+        create_handler.unregister_plugin("Test")
+        assert len(create_handler.pre_invoke_extensions.values()) == 0
+
+    @pytest.mark.asyncio()
+    async def test_propagate_exits(self):
+        bot = AsyncMock()
+        bot.user.id = 9
+        create_handler = AntiSpamHandler(bot)
+
+        # TODO Until files get stubbed, we cant check this.
+        """
+        with pytest.raises(ValueError):
+            await create_handler.propagate(1)
+
+        with pytest.raises(ValueError):
+            await create_handler.propagate("2")
+
+        with pytest.raises(ValueError):
+            await create_handler.propagate(MockClass)
+        """
+
+        return_data = await create_handler.propagate(
+            MockedMessage(is_in_guild=False).to_mock()
+        )
+        assert return_data["status"] == "Ignoring messages from dm's"
+
+        return_data = await create_handler.propagate(
+            MockedMessage(author_id=9).to_mock()
+        )
+        assert return_data["status"] == "Ignoring messages from myself (the bot)"
+
+        create_handler.options = Options(ignore_bots=True)
+        return_data = await create_handler.propagate(
+            MockedMessage(author_is_bot=True).to_mock()
+        )
+        assert return_data["status"] == "Ignoring messages from bots"
+        create_handler.options = Options()
+
+        create_handler.options.ignored_members.add(12345)
+        return_data = await create_handler.propagate(MockedMessage().to_mock())
+        assert return_data["status"] == "Ignoring this member: 12345"
+        create_handler.options.ignored_members.discard(12345)
+
+        create_handler.options.ignored_channels.add(98987)
+        return_data = await create_handler.propagate(MockedMessage().to_mock())
+        assert return_data["status"] == "Ignoring this channel: 98987"
+        create_handler.options.ignored_channels.discard(98987)
+
+    @pytest.mark.asyncio
+    async def test_propagate_guild_ignore(self):
+        bot = AsyncMock()
+        bot.user.id = 919191
+        create_handler = AntiSpamHandler(bot)
+
+        create_handler.options.ignored_guilds.add(1)
+
+        message = MockedMessage(guild_id=1).to_mock()
+        return_data = await create_handler.propagate(message)
+
+        assert return_data["status"] == "Ignoring this guild: 1"
+
+    @pytest.mark.asyncio
+    async def test_propagate_role_ignores(self):
+        bot = AsyncMock()
+        bot.user.id = 919191
+        create_handler = AntiSpamHandler(bot)
+
+        create_handler.options.ignored_roles.add(252525)
+
+        message = MockedMessage().to_mock()
+        return_data = await create_handler.propagate(message)
+        assert return_data["status"] == "Ignoring this role: 252525"
+
+    @pytest.mark.asyncio
+    async def test_propagate_missing_perms(self):
+        bot = AsyncMock()
+        bot.user.id = 919191
+        create_handler = AntiSpamHandler(bot)
+
+        message = MockedMessage().to_mock()
+        message.guild.me.guild_permissions.kick_members = False
+        message.guild.me.guild_permissions.ban_members = False
+
+        with pytest.raises(MissingGuildPermissions):
+            await create_handler.propagate(message)
+
+        # Reset cache rather then make a new test
+        create_handler.cache.cache = {}
+
+        message.guild.me.guild_permissions.kick_members = True
+        message.guild.me.guild_permissions.ban_members = False
+
+        with pytest.raises(MissingGuildPermissions):
+            await create_handler.propagate(message)
+
+    @pytest.mark.asyncio
+    async def test_propagate_pre_invoke(self):
+        bot = AsyncMock()
+        bot.user.id = 919191
+        create_handler = AntiSpamHandler(bot)
+
+        class PreInvoke(BasePlugin):
+            async def propagate(self, msg):
+                return 1
+
+        create_handler.register_plugin(PreInvoke())
+
+        message = MockedMessage().to_mock()
+        return_data = await create_handler.propagate(message)
+
+        assert len(return_data.pre_invoke_extensions) == 1
+        assert return_data.pre_invoke_extensions["PreInvoke"] == 1
+
+    @pytest.mark.asyncio
+    async def test_propagate_after_invoke(self):
+        bot = AsyncMock()
+        bot.user.id = 919191
+        create_handler = AntiSpamHandler(bot)
+
+        class AfterInvoke(BasePlugin):
+            def __init__(self):
+                super().__init__(False)
+
+            async def propagate(self, msg, data):
+                return 2
+
+        create_handler.register_plugin(AfterInvoke())
+
+        message = MockedMessage().to_mock()
+        return_data = await create_handler.propagate(message)
+
+        assert len(return_data.after_invoke_extensions) == 1
+        assert return_data.after_invoke_extensions["AfterInvoke"] == 2
+
+    @pytest.mark.asyncio
+    async def test_propagate_role_raises(self):
+        bot = AsyncMock()
+        bot.user.id = 919191
+        create_handler = AntiSpamHandler(bot)
+
+        create_handler.options.ignored_roles.add(252525)
+
+        message = MockedMessage().to_mock()
+        return_data = await create_handler.propagate(message)
+        assert return_data["status"] == "Ignoring this role: 252525"
+
+    @pytest.mark.asyncio
+    async def test_propagate_stats_hook(self, create_handler):
+        """Tests the stats cancel_next_invocation hook works"""
+
+        class MockPlugin(BasePlugin):
+            async def propagate(
+                self, message, data: Optional[CorePayload] = None
+            ) -> dict:
+                return {"cancel_next_invocation": True}
+
+        bot_mock = AsyncMock()
+        bot_mock.bot.user.id = 1500
+        create_handler.bot = bot_mock
+
+        stats_plugin = StatsPlugin(create_handler)
+
+        create_handler.register_plugin(MockPlugin())
+        create_handler.register_plugin(stats_plugin)
+
+        with pytest.raises(InvocationCancelled):
+            await create_handler.propagate(MockedMessage().to_mock())
+
+        stats_plugin.data = {"pre_invoke_calls": {"MockPlugin": {}}}
+
+        with pytest.raises(InvocationCancelled):
+            await create_handler.propagate(MockedMessage().to_mock())
+
+        create_handler.unregister_plugin("Stats")
+
+        class Stats(BasePlugin):
+            def __init__(self):
+                super().__init__(False)
+
+            async def propagate(
+                self, message, data: Optional[CorePayload] = None
+            ) -> dict:
+                return {"cancel_next_invocation": True}
+
+        create_handler.register_plugin(Stats())
+
+        with pytest.raises(InvocationCancelled):
+            # Should skip tryna set stats stuff
+            await create_handler.propagate(MockedMessage().to_mock())
+
+    @pytest.mark.asyncio
+    async def test_propagate_cancel_next_invoke(self, create_handler):
+        class MockPlugin(BasePlugin):
+            async def propagate(
+                self, message, data: Optional[CorePayload] = None
+            ) -> dict:
+                return {"cancel_next_invocation": True}
+
+        bot_mock = AsyncMock()
+        bot_mock.bot.user.id = 1500
+        create_handler.bot = bot_mock
+
+        create_handler.register_plugin(MockPlugin())
+
+        with pytest.raises(InvocationCancelled):
+            await create_handler.propagate(MockedMessage().to_mock())
+
+        class MockPluginTwo(BasePlugin):
+            async def propagate(
+                self, message, data: Optional[CorePayload] = None
+            ) -> dict:
+                return {}
+
+        create_handler.unregister_plugin("MockPlugin")
+        create_handler.register_plugin(MockPluginTwo())
+        await create_handler.propagate(MockedMessage().to_mock())
+
+    def test_hikari_setup(self):
+        handler = AntiSpamHandler(
+            commands.Bot(command_prefix="!"), is_using_hikari=True
+        )
+        assert handler.lib_handler.__class__.__name__ == "Hikari"
+
+    def test_dpy_setup(self):
+        handler = AntiSpamHandler(commands.Bot(command_prefix="!"))
+        assert handler.lib_handler.__class__.__name__ == "DPY"
+
+    @pytest.mark.asyncio
+    async def test_clean_cache_basic(self, create_handler):
+        """Tests the basics of clean cache without strict mode or messages"""
+        assert not bool(create_handler.cache.cache)
+
+        await create_handler.cache.set_member(Member(1, 2))
+        assert bool(create_handler.cache.cache)
+
+        await create_handler.clean_cache()
+        assert not bool(create_handler.cache.cache)
+
+        await create_handler.cache.set_guild(Guild(1))
+        assert bool(create_handler.cache.cache)
+
+        await create_handler.clean_cache()
+        assert not bool(create_handler.cache.cache)
+
+    @pytest.mark.asyncio
+    async def test_clean_cache_member_warn_count(self, create_handler):
+        await create_handler.cache.set_member(Member(1, 2, warn_count=2))
+        assert bool(create_handler.cache.cache)
+
+        await create_handler.clean_cache()
+        assert bool(create_handler.cache.cache)
+
+    @pytest.mark.asyncio
+    async def test_clean_cache_strict_member(self, create_handler):
+        """Tests clean_cache on members with strict mode"""
+        await create_handler.cache.set_member(Member(1, 1))
+        assert bool(create_handler.cache.cache)
+
+        await create_handler.clean_cache(strict=True)
+        assert not bool(create_handler.cache.cache)
+
+        await create_handler.cache.set_member(
+            Member(1, 1, messages=[Message(1, 2, 3, 4, "Hello")])
+        )
+        assert bool(create_handler.cache.cache)
+
+        await create_handler.clean_cache(strict=True)
+        assert bool(create_handler.cache.cache)
+
+    @pytest.mark.asyncio
+    async def test_clean_cache_strict_guild(self, create_handler):
+        """Tests clean_cache on guilds with strict mode"""
+        await create_handler.cache.set_guild(Guild(1))
+        assert bool(create_handler.cache.cache)
+
+        await create_handler.clean_cache(strict=True)
+        assert not bool(create_handler.cache.cache)
+
+        await create_handler.cache.set_guild(Guild(1, Options(no_punish=True)))
+        assert bool(create_handler.cache.cache)
+
+        await create_handler.clean_cache(strict=True)
+        assert bool(create_handler.cache.cache)
+
+    @pytest.mark.asyncio
+    async def test_plugin_blacklists(self, create_handler):
+        """Tests blacklisted guilds get skipped within plugin calls"""
+
+        class Test(BasePlugin):
+            def __init__(self, invoke=True):
+                super().__init__(invoke)
+
+        test = Test()
+        test.blacklisted_guilds.add(13123)
+        test.propagate = AsyncMock(return_value={})
+        create_handler.register_plugin(test)
+
+        assert test.propagate.call_count == 0
+
+        await create_handler.propagate(MockedMessage().to_mock())
+        await create_handler.propagate(MockedMessage(guild_id=13123).to_mock())
+
+        assert test.propagate.call_count == 1
+        create_handler.unregister_plugin("test")
+
+        test = Test(False)
+        test.blacklisted_guilds.add(13123)
+        test.propagate = AsyncMock(return_value={})
+        create_handler.register_plugin(test)
+
+        assert test.propagate.call_count == 0
+
+        await create_handler.propagate(MockedMessage().to_mock())
+        await create_handler.propagate(MockedMessage(guild_id=13123).to_mock())
+
+        assert test.propagate.call_count == 1
diff --git a/tests/test_anti_spam_tracker.py b/tests/test_anti_spam_tracker.py
new file mode 100644
index 0000000..0dbe35d
--- /dev/null
+++ b/tests/test_anti_spam_tracker.py
@@ -0,0 +1,272 @@
+import datetime
+
+import pytest
+
+from antispam.plugins import AntiSpamTracker
+
+from antispam import PluginCache, MemberNotFound, Options
+
+from antispam.dataclasses import Member, Guild, CorePayload
+from .fixtures import create_anti_spam_tracker, create_handler, MockClass, create_bot
+from .mocks import MockedMessage
+
+
+class TestAntiSpamTracker:
+    @pytest.mark.asyncio
+    async def test_do_punishment(self, create_anti_spam_tracker):
+        """Just for coverage"""
+        await create_anti_spam_tracker.do_punishment(None)
+
+    def test_init_raises(self, create_handler):
+        with pytest.raises(ValueError):
+            AntiSpamTracker(create_handler, None)
+
+        with pytest.raises(TypeError):
+            AntiSpamTracker(None, 1)
+
+        with pytest.raises(TypeError):
+            AntiSpamTracker(MockClass, 1)
+
+        with pytest.raises(ValueError):
+            AntiSpamTracker(create_handler, 1, valid_timestamp_interval="lol")
+
+        with pytest.raises(TypeError):
+            AntiSpamTracker(create_handler, 1, valid_timestamp_interval=MockClass)
+
+    def test_init(self, create_handler):
+        create_handler.options.no_punish = True  # Code coverage
+        AntiSpamTracker(create_handler, 1)
+        create_handler.options.no_punish = False
+        AntiSpamTracker(create_handler, 1, valid_timestamp_interval=1)
+        AntiSpamTracker(create_handler, 1, valid_timestamp_interval=1.0)
+
+        tracker = AntiSpamTracker(create_handler, 5, 15)
+        assert tracker.valid_global_interval == 15
+        assert tracker.punish_min_amount == 5
+        assert isinstance(tracker.member_tracking, PluginCache)
+
+    @pytest.mark.asyncio
+    async def test_propagate(self, create_anti_spam_tracker):
+        """Tests this propagates the call to update_cache"""
+        return_value = await create_anti_spam_tracker.propagate(
+            MockedMessage().to_mock(), CorePayload()
+        )
+        assert return_value == {"status": "Cache updated"}
+
+    @pytest.mark.asyncio
+    async def test_update_cache_raises(self, create_anti_spam_tracker):
+        with pytest.raises(TypeError):
+            await create_anti_spam_tracker.update_cache(None, None)
+
+        with pytest.raises(TypeError):
+            await create_anti_spam_tracker.update_cache(
+                MockedMessage().to_mock(), list()
+            )
+
+        with pytest.raises(TypeError):
+            await create_anti_spam_tracker.update_cache(
+                MockedMessage().to_mock(), set()
+            )
+
+        await create_anti_spam_tracker.update_cache(
+            MockedMessage(is_in_guild=False).to_mock(), CorePayload()
+        )
+        await create_anti_spam_tracker.update_cache(
+            MockedMessage().to_mock(), CorePayload()
+        )
+
+    @pytest.mark.asyncio
+    async def test_update_cache(self, create_anti_spam_tracker):
+        message = MockedMessage(guild_id=1, author_id=1).to_mock()
+
+        # All we need to mock for this
+        data = CorePayload(member_should_be_punished_this_message=True)
+
+        await create_anti_spam_tracker.update_cache(message, data)
+
+        values = await create_anti_spam_tracker.member_tracking.get_member_data(1, 1)
+        assert len(values) == 1
+
+        message = MockedMessage(message_id=2, author_id=1, guild_id=1).to_mock()
+        await create_anti_spam_tracker.update_cache(message, data)
+
+        values = await create_anti_spam_tracker.member_tracking.get_member_data(1, 1)
+        assert len(values) == 2
+
+    @pytest.mark.asyncio
+    async def test_update_cache_addon_not_found(self, create_anti_spam_tracker):
+        """For coverage"""
+        await create_anti_spam_tracker.anti_spam_handler.cache.set_member(Member(1, 1))
+
+        data = CorePayload(member_should_be_punished_this_message=True)
+
+        await create_anti_spam_tracker.update_cache(
+            MockedMessage(author_id=1, guild_id=1).to_mock(), data
+        )
+
+        values = await create_anti_spam_tracker.member_tracking.get_member_data(1, 1)
+        assert len(values) == 1
+
+    @pytest.mark.asyncio
+    async def test_get_user_count(self, create_anti_spam_tracker):
+        data = CorePayload(member_should_be_punished_this_message=True)
+        await create_anti_spam_tracker.update_cache(
+            MockedMessage(author_id=1, guild_id=1).to_mock(), data
+        )
+        values = await create_anti_spam_tracker.member_tracking.get_member_data(1, 1)
+        assert len(values) == 1
+
+        result = await create_anti_spam_tracker.get_user_count(
+            MockedMessage(author_id=1, guild_id=1).to_mock()
+        )
+        assert result == 1
+
+    @pytest.mark.asyncio
+    async def test_get_user_count_raises(self, create_anti_spam_tracker):
+        # TODO Uncomment #68
+        # with pytest.raises(TypeError):
+        #   await create_anti_spam_tracker.get_user_count(None)
+
+        with pytest.raises(MemberNotFound):
+            await create_anti_spam_tracker.get_user_count(
+                MockedMessage(is_in_guild=False).to_mock()
+            )
+
+    @pytest.mark.asyncio
+    async def test_remove_outdated_timestamps(self, create_anti_spam_tracker):
+        timestamps = [
+            datetime.datetime.now(tz=datetime.timezone.utc)
+            - datetime.timedelta(seconds=45),
+            datetime.datetime.now(tz=datetime.timezone.utc),
+        ]
+        assert len(timestamps) == 2
+
+        await create_anti_spam_tracker.remove_outdated_timestamps(timestamps, 1, 1)
+
+        result = await create_anti_spam_tracker.member_tracking.get_member_data(1, 1)
+        assert len(result) == 1
+
+    @pytest.mark.asyncio
+    async def test_remove_punishment(self, create_anti_spam_tracker):
+        # TODO Uncomment #68
+        # with pytest.raises(TypeError):
+        #    await create_anti_spam_tracker.remove_punishments(MockClass)
+
+        # Skip non guild messages
+        await create_anti_spam_tracker.remove_punishments(
+            MockedMessage(is_in_guild=False).to_mock()
+        )
+
+        # Return since guild wasnt found
+        await create_anti_spam_tracker.remove_punishments(
+            MockedMessage(author_id=1, guild_id=1).to_mock()
+        )
+
+        await create_anti_spam_tracker.anti_spam_handler.cache.set_member(Member(1, 1))
+        await create_anti_spam_tracker.update_cache(
+            MockedMessage(author_id=1, guild_id=1).to_mock(),
+            CorePayload(member_should_be_punished_this_message=True),
+        )
+
+        result = await create_anti_spam_tracker.member_tracking.get_member_data(1, 1)
+        assert len(result) == 1
+
+        await create_anti_spam_tracker.remove_punishments(
+            MockedMessage(author_id=1, guild_id=1).to_mock()
+        )
+
+        result = await create_anti_spam_tracker.member_tracking.get_member_data(1, 1)
+        assert len(result) == 0
+
+    @pytest.mark.asyncio
+    async def test_set_get_guild_valid_interval(self, create_anti_spam_tracker):
+        await create_anti_spam_tracker.anti_spam_handler.cache.set_guild(
+            Guild(1, Options())
+        )
+
+        result = await create_anti_spam_tracker.anti_spam_handler.cache.get_guild(1)
+        assert len(result.addons) == 0
+
+        await create_anti_spam_tracker._set_guild_valid_interval(1, 15)
+        result = await create_anti_spam_tracker.anti_spam_handler.cache.get_guild(1)
+        assert len(result.addons) == 1
+
+        get_result = await create_anti_spam_tracker._get_guild_valid_interval(1)
+        assert get_result == 15
+
+        # Code coverage to ensure it works within try
+        await create_anti_spam_tracker._set_guild_valid_interval(1, 25)
+        result = await create_anti_spam_tracker.anti_spam_handler.cache.get_guild(1)
+        assert len(result.addons) == 1
+
+        get_result = await create_anti_spam_tracker._get_guild_valid_interval(1)
+        assert get_result == 25
+
+    @pytest.mark.asyncio
+    async def test_set_guild_interval_without_guild(self, create_anti_spam_tracker):
+        await create_anti_spam_tracker._set_guild_valid_interval(1, 15)
+        result = await create_anti_spam_tracker.anti_spam_handler.cache.get_guild(1)
+        assert len(result.addons) == 1
+
+    @pytest.mark.asyncio
+    async def test_get_guild_interval(self, create_anti_spam_tracker):
+        result_one = await create_anti_spam_tracker._get_guild_valid_interval(1)
+        assert result_one == 30000
+
+        await create_anti_spam_tracker.anti_spam_handler.cache.set_guild(
+            Guild(1, Options())
+        )
+
+        # Test where guild exists, just not within plugin
+        result_two = await create_anti_spam_tracker._get_guild_valid_interval(1)
+        assert result_two == 30000
+
+        await create_anti_spam_tracker._set_guild_valid_interval(1, 15)
+        result_three = await create_anti_spam_tracker._get_guild_valid_interval(1)
+        assert result_three == 15
+
+    @pytest.mark.asyncio
+    async def test_get_guild_interval_check(self, create_anti_spam_tracker):
+        await create_anti_spam_tracker.member_tracking.set_guild_data(
+            1, addon_data={"test": []}
+        )
+
+        result_one = await create_anti_spam_tracker._get_guild_valid_interval(1)
+        assert result_one == 30000
+
+    @pytest.mark.asyncio
+    async def test_is_spamming(self, create_anti_spam_tracker):
+        # TODO Fix on #68
+        # with pytest.raises(TypeError):
+        #    await create_anti_spam_tracker.is_spamming(MockClass)
+
+        # Test not in guild returns
+        await create_anti_spam_tracker.is_spamming(
+            MockedMessage(is_in_guild=False).to_mock()
+        )
+
+        result_one = await create_anti_spam_tracker.is_spamming(
+            MockedMessage().to_mock()
+        )
+        assert result_one is False
+
+        data = CorePayload(member_should_be_punished_this_message=True)
+        await create_anti_spam_tracker.update_cache(
+            MockedMessage(message_id=1).to_mock(), data
+        )
+        await create_anti_spam_tracker.update_cache(
+            MockedMessage(message_id=2).to_mock(), data
+        )
+
+        result_two = await create_anti_spam_tracker.is_spamming(
+            MockedMessage().to_mock()
+        )
+        assert result_two is False
+
+        await create_anti_spam_tracker.update_cache(
+            MockedMessage(message_id=3).to_mock(), data
+        )
+        result_three = await create_anti_spam_tracker.is_spamming(
+            MockedMessage().to_mock()
+        )
+        assert result_three is True
diff --git a/tests/test_core.py b/tests/test_core.py
new file mode 100644
index 0000000..584b42b
--- /dev/null
+++ b/tests/test_core.py
@@ -0,0 +1,338 @@
+import datetime
+from unittest.mock import AsyncMock
+
+import discord
+import pytest
+
+from antispam.dataclasses import Guild, Member, CorePayload, Message
+
+from antispam import Options, LogicError, MissingGuildPermissions, DuplicateObject
+from .fixtures import (
+    create_bot,
+    create_handler,
+    create_memory_cache,
+    create_core,
+    MockClass,
+)
+from .mocks import MockedMessage
+
+
+# noinspection DuplicatedCode
+class TestCore:
+    """A class devoted to testing core.py"""
+
+    @pytest.mark.asyncio
+    async def test_user_propagate_skip_guild(self, create_core):
+        """Tests if the member gets skipped if not 'in guild'"""
+        msg = MockedMessage(author_id=1, guild_id=1).to_mock()
+        guild = Guild(1, Options)
+        guild.members[1] = Member(1, 1, in_guild=False)
+
+        payload = await create_core.propagate(msg, guild)
+
+        assert payload == CorePayload(
+            member_status="Bypassing message check since the member isn't seen to be in a guild"
+        )
+
+    @pytest.mark.asyncio
+    async def test_skip_per_channel_per_guild(self, create_core):
+        create_core.options.is_per_channel_per_guild = False
+        await create_core.cache.set_guild(Guild(1, Options()))
+        await create_core.propagate(
+            MockedMessage(guild_id=1).to_mock(), Guild(1, Options())
+        )
+
+    @pytest.mark.asyncio
+    async def test_clean_up(self, create_core):
+        member = Member(1, 1)
+        messages = [
+            Message(
+                1,
+                1,
+                1,
+                1,
+                "First message",
+                datetime.datetime.now() - datetime.timedelta(minutes=1),
+            ),
+            Message(
+                2,
+                1,
+                1,
+                1,
+                "Second message",
+                datetime.datetime.now() - datetime.timedelta(minutes=1),
+            ),
+            Message(
+                3,
+                1,
+                1,
+                1,
+                "Third message",
+                datetime.datetime.now() - datetime.timedelta(minutes=1),
+            ),
+            Message(4, 1, 1, 1, "Fourth message", datetime.datetime.now()),
+        ]
+        member.messages = messages
+
+        assert len(member.messages) == 4
+
+        await create_core.clean_up(member, datetime.datetime.now(), 1)
+
+        assert len(member.messages) == 1
+
+    @pytest.mark.asyncio
+    async def test_clean_up_duplicate_count(self, create_core):
+        member = Member(1, 1)
+        messages = [
+            Message(
+                1,
+                1,
+                1,
+                1,
+                "First message",
+                datetime.datetime.now() - datetime.timedelta(minutes=1),
+            ),
+            Message(
+                2,
+                1,
+                1,
+                1,
+                "Second message",
+                datetime.datetime.now() - datetime.timedelta(minutes=1),
+            ),
+            Message(
+                3,
+                1,
+                1,
+                1,
+                "Third message",
+                datetime.datetime.now() - datetime.timedelta(minutes=1),
+                is_duplicate=True,
+            ),
+            Message(4, 1, 1, 1, "Fourth message", datetime.datetime.now()),
+        ]
+        member.messages = messages
+        member.duplicate_counter = 1
+
+        await create_core.clean_up(member, datetime.datetime.now(), 1)
+
+        assert member.duplicate_counter == 0
+
+    def test_remove_duplicate_count(self, create_core):
+        member = Member(1, 1)
+        member.duplicate_counter = 5
+        member.duplicate_channel_counter_dict = {15: 2}
+
+        assert member.duplicate_counter == 5
+        assert member.duplicate_channel_counter_dict == {15: 2}
+
+        create_core._remove_duplicate_count(member, 1)
+        assert member.duplicate_counter == 4
+        assert member.duplicate_channel_counter_dict == {15: 2}
+
+        create_core._remove_duplicate_count(member, 1, 2)
+        assert member.duplicate_counter == 2
+        assert member.duplicate_channel_counter_dict == {15: 2}
+
+        create_core.options = Options(per_channel_spam=True)
+
+        create_core._remove_duplicate_count(member, 15)
+        assert member.duplicate_counter == 2
+        assert member.duplicate_channel_counter_dict == {15: 1}
+
+        create_core._remove_duplicate_count(member, 1)
+        assert member.duplicate_counter == 2
+        assert member.duplicate_channel_counter_dict == {15: 1}
+
+    def test_get_duplicate_count(self, create_core):
+        member = Member(1, 1)
+        member.duplicate_counter = 5
+        member.duplicate_channel_counter_dict = {15: 2}
+
+        assert create_core._get_duplicate_count(member, 1) == 5
+
+        create_core.options = Options(per_channel_spam=True)
+
+        assert create_core._get_duplicate_count(member) == 1
+        assert create_core._get_duplicate_count(member, 5) == 1
+        assert create_core._get_duplicate_count(member, 15) == 2
+
+    def test_increment_duplicate_count(self, create_core):
+        member = Member(1, 1)
+        assert member.duplicate_counter == 1
+
+        create_core._increment_duplicate_count(member, 1)
+        assert member.duplicate_counter == 2
+
+        create_core._increment_duplicate_count(member, 1, 3)
+        assert member.duplicate_counter == 5
+
+        create_core.options = Options(per_channel_spam=True)
+
+        create_core._increment_duplicate_count(member, 1, 1)
+        assert member.duplicate_channel_counter_dict == {1: 2}
+
+    def test_calculate_ratios(self, create_core):
+        member = Member(1, 1)
+        member.messages = [Message(1, 1, 1, 1, "Hello world", datetime.datetime.now())]
+        message = Message(2, 1, 1, 1, "Hello world", datetime.datetime.now())
+
+        assert member.duplicate_counter == 1
+
+        create_core._calculate_ratios(message, member)
+
+        assert member.duplicate_counter == 2
+        assert message.is_duplicate is True
+
+    def test_calculate_ratio_edge_case(self, create_core):
+        """Tests calculate ratios correctly marks an edge case as spam
+
+        See:
+        https://mystb.in/HarderTournamentsPrimary.properties
+
+        Previously it would not mark all "Spam tho" as spam when it should have
+        """
+        member = Member(1, 1)
+        member.messages = [
+            Message(1, 1, 1, 1, "This is a test", datetime.datetime.now()),
+            Message(2, 1, 1, 1, "Heres another message", datetime.datetime.now()),
+            Message(3, 1, 1, 1, "Spam tho", datetime.datetime.now()),
+            Message(4, 1, 1, 1, "Spam tho", datetime.datetime.now()),
+            Message(5, 1, 1, 1, "Spam tho", datetime.datetime.now()),
+        ]
+        message = Message(6, 1, 1, 1, "Spam tho", datetime.datetime.now())
+
+        create_core._calculate_ratios(message, member)
+
+        assert message.is_duplicate is True
+        assert member.messages[0].is_duplicate is False
+        assert member.messages[1].is_duplicate is False
+        assert member.messages[2].is_duplicate is True
+        assert member.messages[3].is_duplicate is True
+        assert member.messages[4].is_duplicate is True
+
+    def test_calculate_ratios_per_channel(self, create_core):
+        member = Member(1, 1)
+        member.messages = [Message(1, 1, 1, 1, "Hello world", datetime.datetime.now())]
+        message = Message(2, 2, 1, 1, "Hello world", datetime.datetime.now())
+        create_core.options = Options(per_channel_spam=True)
+
+        assert member.duplicate_counter == 1
+
+        create_core._calculate_ratios(message, member)
+
+        assert member.duplicate_counter == 1
+        assert message.is_duplicate is False
+
+    @pytest.mark.asyncio
+    async def test_propagate_no_punish(self, create_core):
+        await create_core.cache.set_guild(Guild(1, Options()))
+        member = Member(1, 1)
+        await create_core.cache.set_member(member)
+        create_core._increment_duplicate_count(member, 1, 15)
+
+        create_core.options.no_punish = True
+        guild = await create_core.cache.get_guild(1)
+        return_data = await create_core.propagate_user(
+            MockedMessage(guild_id=1, author_id=1).to_mock(), guild
+        )
+        assert return_data == CorePayload(
+            member_should_be_punished_this_message=True,
+            member_status="Member should be punished, however, was not due to no_punish being True",
+        )
+
+    @pytest.mark.asyncio
+    async def test_propagate_warn_only(self, create_core):
+        member = Member(1, 1)
+        await create_core.cache.set_member(member)
+        create_core._increment_duplicate_count(member, 1, 15)
+        guild = await create_core.cache.get_guild(1)
+
+        create_core.options.warn_only = True
+        return_data = await create_core.propagate_user(
+            MockedMessage(guild_id=1, author_id=1).to_mock(), guild
+        )
+        assert return_data == CorePayload(
+            member_should_be_punished_this_message=True,
+            member_status="Member was warned",
+            member_was_warned=True,
+            member_warn_count=1,
+            member_duplicate_count=15,
+        )
+
+        # Test embed coverage
+        create_core.options.guild_warn_message = {"title": "test"}
+        return_data = await create_core.propagate_user(
+            MockedMessage(guild_id=1, author_id=1, message_id=2).to_mock(), guild
+        )
+        assert return_data == CorePayload(
+            member_should_be_punished_this_message=True,
+            member_status="Member was warned",
+            member_was_warned=True,
+            member_warn_count=2,
+            member_duplicate_count=16,
+        )
+
+    @pytest.mark.asyncio
+    async def test_propagate_kicks(self, create_core):
+        member = Member(1, 1)
+        member.warn_count = 3
+        create_core._increment_duplicate_count(member, 1, 7)
+        await create_core.cache.set_member(member)
+        guild = await create_core.cache.get_guild(1)
+
+        return_data = await create_core.propagate_user(
+            MockedMessage(guild_id=1, author_id=1).to_mock(), guild
+        )
+        assert return_data == CorePayload(
+            member_should_be_punished_this_message=True,
+            member_status="Member was kicked",
+            member_was_kicked=True,
+            member_warn_count=3,
+            member_kick_count=1,
+            member_duplicate_count=7,
+        )
+
+    @pytest.mark.asyncio
+    async def test_propagate_bans(self, create_core):
+        member = Member(1, 1)
+        member.warn_count = 3
+        member.kick_count = 3
+        create_core._increment_duplicate_count(member, 1, 7)
+        await create_core.cache.set_member(member)
+        guild = await create_core.cache.get_guild(1)
+
+        return_data = await create_core.propagate_user(
+            MockedMessage(guild_id=1, author_id=1).to_mock(), guild
+        )
+        assert return_data == CorePayload(
+            member_should_be_punished_this_message=True,
+            member_status="Member was banned",
+            member_was_banned=True,
+            member_warn_count=3,
+            member_kick_count=4,
+            member_duplicate_count=7,
+        )
+
+    def test_calculate_ratios_raises(self, create_core):
+        member = Member(
+            1, 1, messages=[Message(1, 2, 3, 4, "One"), Message(2, 2, 3, 4, "Two")]
+        )
+
+        m = Message(1, 2, 3, 4, "One")
+        m.creation_time = member.messages[0].creation_time
+        with pytest.raises(DuplicateObject):
+            create_core._calculate_ratios(m, member)
+
+    def test_calculate_ratios_does_nothing(self, create_core):
+        """Tests the loop does nothing on different messages"""
+        member = Member(1, 1, messages=[Message(1, 2, 3, 4, "Hello I am the world")])
+
+        create_core._calculate_ratios(Message(2, 2, 3, 4, "My name is Ethan!"), member)
+
+    def test_increment_duplicate_existing(self, create_core):
+        """Tests the count increments even when already existing"""
+        member = Member(1, 1, duplicate_channel_counter_dict={5: 1})
+        create_core.options.per_channel_spam = True
+        create_core._increment_duplicate_count(member, 5)
+        assert member.duplicate_channel_counter_dict[5] == 2
diff --git a/tests/test_exceptions.py b/tests/test_exceptions.py
new file mode 100644
index 0000000..1579428
--- /dev/null
+++ b/tests/test_exceptions.py
@@ -0,0 +1,82 @@
+# noinspection PyUnresolvedReferences,PyPackageRequirements
+from antispam import (
+    BaseASHException,
+    PluginError,
+    GuildAddonNotFound,
+    GuildNotFound,
+    MemberAddonNotFound,
+    MemberNotFound,
+    NotFound,
+    MissingGuildPermissions,
+    LogicError,
+    ObjectMismatch,
+    DuplicateObject,
+)
+
+
+class TestExceptions:
+    def test_base(self):
+        assert (
+            str(BaseASHException()) == "A base exception handler for the ASH ecosystem."
+        )
+
+    def test_base_init(self):
+        assert str(BaseASHException("test")) == "test"
+
+    def test_duplicate_object(self):
+        assert (
+            str(DuplicateObject())
+            == "Raised because you attempted to create and add an object, using the exact "
+            "same id's as a pre-existing one."
+        )
+
+    def test_object_mismatch(self):
+        assert (
+            str(ObjectMismatch())
+            == "Raised because you attempted add a message to a member, but that member didn't create that message."
+        )
+
+    def test_logic_error(self):
+        assert (
+            str(LogicError())
+            == "Raised because internal logic has failed. Please create an issue in the github."
+        )
+
+    def test_missing_guild_perms(self):
+        assert (
+            str(MissingGuildPermissions())
+            == "I need both permissions to kick & ban people from this guild in order to work!"
+        )
+
+    def test_not_found(self):
+        assert str(NotFound()) == "Something could not be found."
+
+    def test_member_not_found(self):
+        assert (
+            str(MemberNotFound())
+            == "A Member matching this id and guild id could not be found in the cache."
+        )
+
+    def test_member_addon_not_found(self):
+        assert (
+            str(MemberAddonNotFound())
+            == "This class has not addon stored on this member."
+        )
+
+    def test_guild_not_found(self):
+        assert (
+            str(GuildNotFound())
+            == "A Guild matching this guild id could not be found in the cache."
+        )
+
+    def test_guild_addon_not_found(self):
+        assert (
+            str(GuildAddonNotFound())
+            == "This class has not addon stored on this guild."
+        )
+
+    def test_extension_error(self):
+        assert (
+            str(PluginError())
+            == "An error occurred that was related to a plugin and not AntiSpamHandler"
+        )
diff --git a/tests/test_extras.py b/tests/test_extras.py
new file mode 100644
index 0000000..fd63b9d
--- /dev/null
+++ b/tests/test_extras.py
@@ -0,0 +1,18 @@
+import pytest
+
+from antispam.base_plugin import BasePlugin
+from tests.mocks import MockedMessage
+
+"""Some simple tests which don't need a whole file"""
+
+
+def test_base_init():
+    b = BasePlugin()
+    assert type(b) == BasePlugin
+
+
+@pytest.mark.asyncio
+async def test_base_prop():
+    b = BasePlugin()
+    with pytest.raises(NotImplementedError):
+        await b.propagate(MockedMessage().to_mock())
diff --git a/tests/test_factory.py b/tests/test_factory.py
new file mode 100644
index 0000000..d969fb6
--- /dev/null
+++ b/tests/test_factory.py
@@ -0,0 +1,191 @@
+import datetime
+
+import pytest
+from attr import asdict
+
+from antispam.dataclasses import Message, Member, Guild
+from antispam.factory import FactoryBuilder
+from antispam import Options
+from antispam.util import get_aware_time
+
+
+class TestFactory:
+    def test_create_message(self):
+        test_dict = {
+            "id": 1,
+            "content": "Hello World!",
+            "guild_id": 2,
+            "author_id": 3,
+            "channel_id": 4,
+            "is_duplicate": True,
+            "creation_time": "225596:21:8:3:12:5:2021",
+        }
+        time = datetime.datetime.strptime(
+            "225596:21:8:3:12:5:2021", "%f:%S:%M:%H:%d:%m:%Y"
+        )
+        message = Message(
+            id=1,
+            content="Hello World!",
+            guild_id=2,
+            author_id=3,
+            channel_id=4,
+            is_duplicate=True,
+            creation_time=time,
+        )
+
+        test_message = FactoryBuilder.create_message_from_dict(test_dict)
+
+        assert test_message == message
+
+    def test_create_member(self):
+        test_data = {
+            "id": 1,
+            "guild_id": 2,
+            "is_in_guild": True,
+            "warn_count": 5,
+            "kick_count": 6,
+            "duplicate_count": 7,
+            "duplicate_channel_counter_dict": {},
+            "messages": [],
+        }
+        test_member = FactoryBuilder.create_member_from_dict(test_data)
+        member = Member(
+            id=1,
+            guild_id=2,
+            in_guild=True,
+            warn_count=5,
+            kick_count=6,
+            duplicate_counter=7,
+            duplicate_channel_counter_dict={},
+            messages=[],
+        )
+        assert test_member == member
+
+        test_data_two = {
+            "id": 1,
+            "guild_id": 2,
+            "is_in_guild": True,
+            "warn_count": 5,
+            "kick_count": 6,
+            "duplicate_count": 7,
+            "duplicate_channel_counter_dict": {},
+            "messages": [
+                {
+                    "id": 1,
+                    "content": "Hello World!",
+                    "guild_id": 2,
+                    "author_id": 3,
+                    "channel_id": 4,
+                    "is_duplicate": True,
+                    "creation_time": "225596:21:8:3:12:5:2021",
+                }
+            ],
+        }
+        test_member_two = FactoryBuilder.create_member_from_dict(test_data_two)
+
+        time = datetime.datetime.strptime(
+            "225596:21:8:3:12:5:2021", "%f:%S:%M:%H:%d:%m:%Y"
+        )
+        message = Message(
+            id=1,
+            content="Hello World!",
+            guild_id=2,
+            author_id=3,
+            channel_id=4,
+            is_duplicate=True,
+            creation_time=time,
+        )
+        member_two = Member(
+            id=1,
+            guild_id=2,
+            in_guild=True,
+            warn_count=5,
+            kick_count=6,
+            duplicate_counter=7,
+            duplicate_channel_counter_dict={},
+            messages=[message],
+        )
+
+        assert member_two == test_member_two
+
+    def test_create_guild(self):
+        test_data = {"id": 1, "options": asdict(Options()), "members": []}
+        test_guild = FactoryBuilder.create_guild_from_dict(test_data)
+        guild = Guild(id=1, options=Options())
+        assert test_guild == guild
+
+        test_data_two = {
+            "id": 1,
+            "options": asdict(Options()),
+            "members": [
+                {
+                    "id": 1,
+                    "guild_id": 2,
+                    "is_in_guild": True,
+                    "warn_count": 5,
+                    "kick_count": 6,
+                    "duplicate_count": 7,
+                    "duplicate_channel_counter_dict": {},
+                    "messages": [
+                        {
+                            "id": 1,
+                            "content": "Hello World!",
+                            "guild_id": 2,
+                            "author_id": 3,
+                            "channel_id": 4,
+                            "is_duplicate": True,
+                            "creation_time": "225596:21:8:3:12:5:2021",
+                        }
+                    ],
+                }
+            ],
+        }
+        test_guild_two = FactoryBuilder.create_guild_from_dict(test_data_two)
+
+        time = datetime.datetime.strptime(
+            "225596:21:8:3:12:5:2021", "%f:%S:%M:%H:%d:%m:%Y"
+        )
+        message = Message(
+            id=1,
+            content="Hello World!",
+            guild_id=2,
+            author_id=3,
+            channel_id=4,
+            is_duplicate=True,
+            creation_time=time,
+        )
+        member_two = Member(
+            id=1,
+            guild_id=2,
+            in_guild=True,
+            warn_count=5,
+            kick_count=6,
+            duplicate_counter=7,
+            duplicate_channel_counter_dict={},
+            messages=[message],
+        )
+        guild_two = Guild(id=1, options=Options())
+        guild_two.members[1] = member_two
+        assert test_guild_two == guild_two
+
+    def test_clean_old_messages(self):
+        member = Member(
+            1,
+            2,
+            messages=[
+                Message(1, 2, 3, 4, "Hello"),
+                Message(
+                    2,
+                    2,
+                    3,
+                    4,
+                    "World",
+                    creation_time=get_aware_time() - datetime.timedelta(seconds=45),
+                ),
+            ],
+        )
+        assert len(member.messages) == 2
+
+        FactoryBuilder.clean_old_messages(member, get_aware_time(), Options())
+
+        assert len(member.messages) == 1
diff --git a/tests/test_lib_dpy.py b/tests/test_lib_dpy.py
new file mode 100644
index 0000000..9e04f9c
--- /dev/null
+++ b/tests/test_lib_dpy.py
@@ -0,0 +1,241 @@
+import datetime
+from unittest.mock import AsyncMock, Mock, patch
+
+import discord
+import pytest
+
+from antispam.dataclasses import Guild, Member, CorePayload, Message
+
+from antispam import Options, LogicError, MissingGuildPermissions, PropagateFailure
+from .fixtures import (
+    create_bot,
+    create_handler,
+    create_memory_cache,
+    create_dpy_lib_handler,
+    MockClass,
+    create_dpy_lib_handler,
+)
+from .mocks import MockedMessage
+
+
+# noinspection DuplicatedCode
+class TestLibDPY:
+    """A class devoted to testing dpy.py"""
+
+    def test_create_message_raises(self, create_dpy_lib_handler):
+        message = MockedMessage().to_mock()
+        message.content = None
+        message.embeds = None
+
+        with pytest.raises(LogicError):
+            create_dpy_lib_handler.create_message(message)
+
+        with pytest.raises(LogicError):
+            # Test embeds that ain't embeds raise
+            message.embeds = [MockClass()]
+            create_dpy_lib_handler.create_message(message)
+
+        with pytest.raises(LogicError):
+            # test embed type raises
+            embed = discord.Embed()
+            embed.type = "Not rich"
+            message.embeds = [embed]
+            create_dpy_lib_handler.create_message(message)
+
+    def test_create_message_blank_space(self, create_dpy_lib_handler):
+        message = MockedMessage(
+            message_clean_content=u"u200Bu200Cu200Du200Eu200FuFEFF Hi"
+        ).to_mock()
+
+        returned_message = create_dpy_lib_handler.create_message(message)
+        assert returned_message.content == " Hi"
+
+        create_dpy_lib_handler.handler.options.delete_zero_width_chars = False
+        returned_message = create_dpy_lib_handler.create_message(message)
+        assert returned_message.content == u"u200Bu200Cu200Du200Eu200FuFEFF Hi"
+
+    def test_create_message_embed(self, create_dpy_lib_handler):
+        embed = discord.Embed(title="Hello", description="world")
+        mock = MockedMessage(message_clean_content=None, message_content=None).to_mock()
+        mock.embeds = [embed]
+        message = create_dpy_lib_handler.create_message(mock)
+        assert message.content == "Hello\nworld\n"
+
+    @pytest.mark.asyncio
+    async def test_punish_member_raises(self, create_dpy_lib_handler):
+        with pytest.raises(MissingGuildPermissions):
+            # Test kick has kick perms
+            message = MockedMessage(author_id=1, guild_id=1).to_mock()
+            message.guild.me.guild_permissions.kick_members = False
+            await create_dpy_lib_handler.punish_member(
+                message,
+                Member(1, 1),
+                Guild(1, Options()),
+                "test user",
+                "test guild",
+                True,
+            )
+
+        with pytest.raises(MissingGuildPermissions):
+            # Test ban has ban perms
+            message = MockedMessage(author_id=1, guild_id=1).to_mock()
+            message.guild.me.guild_permissions.ban_members = False
+            await create_dpy_lib_handler.punish_member(
+                message,
+                Member(1, 1),
+                Guild(1, Options()),
+                "test user",
+                "test guild",
+                False,
+            )
+
+        with pytest.raises(MissingGuildPermissions):
+            # Check errors on guild owner
+            message = MockedMessage(author_id=1, guild_id=1).to_mock()
+            message.guild.owner_id = 1
+
+            await create_dpy_lib_handler.punish_member(
+                message,
+                Member(1, 1),
+                Guild(1, Options()),
+                "test user",
+                "test guild",
+                True,
+            )
+
+    @pytest.mark.asyncio
+    async def test_punish_member(self, create_dpy_lib_handler):
+        """Adds test coverage"""
+        await create_dpy_lib_handler.punish_member(
+            MockedMessage(author_id=1, guild_id=1).to_mock(),
+            Member(1, 1),
+            Guild(1, Options()),
+            "test user",
+            "test guild",
+            True,
+        )
+
+    def test_embed_to_string(self, create_dpy_lib_handler):
+        embed = discord.Embed()
+        assert "" == create_dpy_lib_handler.embed_to_string(embed=embed)
+
+        embed = discord.Embed(title="Hello", description="World")
+        embed.set_footer(text="This is weird")
+        embed.set_author(name="Its almost as if")
+        embed.add_field(name="I am", value="Alive")
+        embed.add_field(name="That would be weird tho", value="Right?")
+        assert (
+            "Hello\nWorld\nThis is weird\nIts almost as if\nI am\nAlive\nThat would be weird tho\nRight?\n"
+            == create_dpy_lib_handler.embed_to_string(embed)
+        )
+
+        embed = discord.Embed(title="Hello", description="World")
+        embed.set_footer(icon_url="This is weird")
+        embed.set_author(name="Its almost as if")
+        embed.add_field(name="I am", value="Alive")
+        embed.add_field(name="That would be weird tho", value="Right?")
+        assert (
+            "Hello\nWorld\nIts almost as if\nI am\nAlive\nThat would be weird tho\nRight?\n"
+            == create_dpy_lib_handler.embed_to_string(embed)
+        )
+
+    def test_dict_to_embed(self, create_dpy_lib_handler):
+        warn_embed_dict = {
+            "title": "**Dear Dave**",
+            "description": "You are being warned for spam, please stop!",
+            "color": 0xFF0000,
+            "footer": {"text": "BOT"},
+            "author": {"name": "Guild"},
+            "fields": [
+                {"name": "Current warns:", "value": "1"},
+                {"name": "Current kicks:", "value": "2", "inline": False},
+            ],
+        }
+
+        mock_message = MockedMessage().to_mock()
+
+        test_embed = create_dpy_lib_handler.dict_to_embed(
+            warn_embed_dict, mock_message, 1, 2
+        )
+
+        embed = discord.Embed(
+            title="**Dear Dave**",
+            description="You are being warned for spam, please stop!",
+            color=0xFF0000,
+        )
+        embed.set_footer(text="BOT")
+        embed.set_author(name="Guild")
+        embed.add_field(name="Current warns:", value="1")
+        embed.add_field(name="Current kicks:", value="2", inline=False)
+
+        assert embed.to_dict() == test_embed.to_dict()
+
+        bounds_dict = {
+            "footer": {"icon_url": "$MEMBERAVATAR"},
+            "author": {"icon_url": "$BOTAVATAR", "name": "d"},
+            "timestamp": True,
+            "colour": 0xFFFFFF,
+        }
+        mock_message.created_at = datetime.datetime.now()
+        mock_message.guild.me.avatar_url = "test"
+        mock_message.author.avatar_url = "author"
+        test_embed = create_dpy_lib_handler.dict_to_embed(
+            bounds_dict, mock_message, 1, 2
+        )
+
+        embed_two = discord.Embed(color=0xFFFFFF, timestamp=mock_message.created_at)
+        embed_two.set_footer(icon_url="author")
+        embed_two.set_author(icon_url="test", name="d")
+
+        assert embed_two.to_dict() == test_embed.to_dict()
+
+        bounds_dict = {
+            "footer": {"icon_url": "x"},
+            "author": {"icon_url": "y", "name": "d"},
+            "timestamp": True,
+            "colour": 0xFFFFFF,
+        }
+        test_embed = create_dpy_lib_handler.dict_to_embed(
+            bounds_dict, mock_message, 1, 2
+        )
+
+        embed_two = discord.Embed(color=0xFFFFFF, timestamp=mock_message.created_at)
+        embed_two.set_footer(icon_url="x")
+        embed_two.set_author(icon_url="y", name="d")
+
+        assert embed_two.to_dict() == test_embed.to_dict()
+
+    def test_visualizer(self, create_dpy_lib_handler):
+        create_dpy_lib_handler.visualizer("Lol", MockedMessage().to_mock())
+        create_dpy_lib_handler.visualizer("{'data': 1}", MockedMessage().to_mock())
+
+    @pytest.mark.asyncio()
+    async def test_propagate_type_fails(self, create_dpy_lib_handler):
+        with pytest.raises(PropagateFailure):
+            await create_dpy_lib_handler.check_message_can_be_propagated("lol")
+
+    @pytest.mark.asyncio()
+    async def test_delete_message_called(self, create_dpy_lib_handler):
+        msg = MockedMessage().to_mock()
+        msg.delete = AsyncMock(return_value=True)
+
+        assert msg.delete.call_count == 0
+
+        await create_dpy_lib_handler.delete_message(msg)
+
+        assert msg.delete.call_count == 1
+
+    @pytest.mark.asyncio
+    async def test_delete_member_messages(self, create_dpy_lib_handler):
+        member = Member(1, 2)
+        member.messages = [
+            Message(1, 2, 3, 4, "First"),
+            Message(2, 2, 3, 4, "second", is_duplicate=True),
+            Message(3, 2, 3, 4, "third", is_duplicate=True),
+        ]
+
+        with patch(
+            "antispam.libs.dpy.DPY.delete_message", new_callable=AsyncMock
+        ) as delete_call:
+            await create_dpy_lib_handler.delete_member_messages(member)
+            assert delete_call.call_count == 2
diff --git a/tests/test_lib_hikari.py b/tests/test_lib_hikari.py
new file mode 100644
index 0000000..c6a1288
--- /dev/null
+++ b/tests/test_lib_hikari.py
@@ -0,0 +1,27 @@
+import datetime
+from unittest.mock import AsyncMock
+
+import hikari
+import pytest
+
+from antispam.dataclasses import Guild, Member, CorePayload, Message
+
+from antispam import Options, LogicError, MissingGuildPermissions
+from antispam.libs.hikari import Hikari
+from .fixtures import (
+    create_bot,
+    create_handler,
+    create_memory_cache,
+    create_dpy_lib_handler,
+    MockClass,
+    create_dpy_lib_handler,
+)
+from .mocks import MockedMessage
+
+
+# noinspection DuplicatedCode
+class TestLibHikari:
+    """A class devoted to testing hikari.py"""
+
+    def test_init(self, create_handler):
+        Hikari(create_handler)
diff --git a/tests/test_memory_cache.py b/tests/test_memory_cache.py
new file mode 100644
index 0000000..e99d8e4
--- /dev/null
+++ b/tests/test_memory_cache.py
@@ -0,0 +1,158 @@
+import pytest
+
+from antispam import GuildNotFound, Options, MemberNotFound  # noqa
+from antispam.dataclasses import Guild, Member, Message  # noqa
+from antispam.enums import ResetType
+from antispam.factory import FactoryBuilder
+
+from .fixtures import create_handler, create_memory_cache, create_bot  # noqa
+
+
+class TestMemoryCache:
+    @pytest.mark.asyncio
+    async def test_memory_init(self, create_memory_cache):
+        # Coverage
+        await create_memory_cache.initialize()
+
+    @pytest.mark.asyncio
+    async def test_get_guild(self, create_memory_cache):
+        with pytest.raises(GuildNotFound):
+            await create_memory_cache.get_guild(1)
+
+        create_memory_cache.cache[1] = 2
+
+        val = await create_memory_cache.get_guild(1)
+        assert val == 2
+
+    @pytest.mark.asyncio
+    async def test_set_guild(self, create_memory_cache):
+        assert create_memory_cache.cache == {}
+        await create_memory_cache.set_guild(Guild(1, Options()))
+        assert create_memory_cache.cache == {1: Guild(1, Options())}
+
+    @pytest.mark.asyncio
+    async def test_get_member(self, create_memory_cache):
+        await create_memory_cache.set_guild(Guild(1, Options()))
+        with pytest.raises(MemberNotFound):
+            await create_memory_cache.get_member(1, 1)
+
+        create_memory_cache.cache[1].members[1] = 2
+
+        val = await create_memory_cache.get_member(1, 1)
+        assert val == 2
+
+    @pytest.mark.asyncio
+    async def test_set_member(self, create_memory_cache):
+        await create_memory_cache.set_member(Member(1, 1))
+        await create_memory_cache.set_member(Member(2, 1))
+
+        assert len(create_memory_cache.cache[1].members) == 2
+
+        await create_memory_cache.set_guild(Guild(1, Options()))
+        assert len(create_memory_cache.cache[1].members) == 0
+
+        await create_memory_cache.set_member(Member(1, 1))
+        assert len(create_memory_cache.cache[1].members) == 1
+
+    @pytest.mark.asyncio
+    async def test_add_message_raw(self, create_memory_cache):
+        """Test without a pre-filled cache"""
+        await create_memory_cache.add_message(Message(1, 2, 3, 4, "Content"))
+        assert len(create_memory_cache.cache[3].members) == 1
+        assert len(create_memory_cache.cache[3].members[4].messages) == 1
+
+    @pytest.mark.asyncio
+    async def test_add_message_filled_guild(self, create_memory_cache):
+        """Test with an existing guild"""
+        await create_memory_cache.set_guild(Guild(3, Options()))
+        await create_memory_cache.add_message(Message(1, 2, 3, 4, "Content"))
+        assert len(create_memory_cache.cache[3].members) == 1
+        assert len(create_memory_cache.cache[3].members[4].messages) == 1
+
+    @pytest.mark.asyncio
+    async def test_add_message_filled(self, create_memory_cache):
+        """Test with a pre-filled member"""
+        await create_memory_cache.set_member(Member(4, 3))
+        await create_memory_cache.add_message(Message(1, 2, 3, 4, "Content"))
+
+        assert len(create_memory_cache.cache[3].members) == 1
+        assert len(create_memory_cache.cache[3].members[4].messages) == 1
+
+    @pytest.mark.asyncio
+    async def test_reset_member_count(self, create_memory_cache):
+        await create_memory_cache.set_member(Member(1, 1, kick_count=1, warn_count=2))
+
+        member = await create_memory_cache.get_member(1, 1)
+        assert (member.kick_count, member.warn_count) == (1, 2)
+
+        await create_memory_cache.reset_member_count(1, 1, ResetType.KICK_COUNTER)
+
+        member = await create_memory_cache.get_member(1, 1)
+        assert (member.kick_count, member.warn_count) == (0, 2)
+
+        await create_memory_cache.reset_member_count(1, 1, ResetType.WARN_COUNTER)
+
+        member = await create_memory_cache.get_member(1, 1)
+        assert (member.kick_count, member.warn_count) == (0, 0)
+
+    @pytest.mark.asyncio
+    async def test_get_all_members(self, create_memory_cache):
+        with pytest.raises(GuildNotFound):
+            await FactoryBuilder.get_all_members_as_list(create_memory_cache, 1)
+
+        await create_memory_cache.set_member(Member(1, 1))
+        await create_memory_cache.set_member(Member(2, 1))
+        await create_memory_cache.set_member(Member(3, 1))
+
+        members = await FactoryBuilder.get_all_members_as_list(create_memory_cache, 1)
+        assert len(members) == 3
+        assert members == [Member(1, 1), Member(2, 1), Member(3, 1)]
+
+    @pytest.mark.asyncio
+    async def test_get_all_guilds(self, create_memory_cache):
+        guilds = await FactoryBuilder.get_all_guilds_as_list(create_memory_cache)
+        assert len(guilds) == 0
+
+        await create_memory_cache.set_guild(Guild(1, Options()))
+        guilds = await FactoryBuilder.get_all_guilds_as_list(create_memory_cache)
+        assert len(guilds) == 1
+
+        await create_memory_cache.set_guild(Guild(2, Options()))
+        guilds = await FactoryBuilder.get_all_guilds_as_list(create_memory_cache)
+        assert len(guilds) == 2
+        assert guilds == [Guild(1, Options()), Guild(2, Options())]
+
+    @pytest.mark.asyncio
+    async def test_delete_guild(self, create_memory_cache):
+        guilds = await FactoryBuilder.get_all_guilds_as_list(create_memory_cache)
+        assert len(guilds) == 0
+
+        await create_memory_cache.delete_guild(1)
+
+        guilds = await FactoryBuilder.get_all_guilds_as_list(create_memory_cache)
+        assert len(guilds) == 0
+
+        await create_memory_cache.set_guild(Guild(1))
+
+        guilds = await FactoryBuilder.get_all_guilds_as_list(create_memory_cache)
+        assert len(guilds) == 1
+
+        await create_memory_cache.delete_guild(1)
+
+        guilds = await FactoryBuilder.get_all_guilds_as_list(create_memory_cache)
+        assert len(guilds) == 0
+
+    @pytest.mark.asyncio
+    async def test_delete_member(self, create_memory_cache):
+        await create_memory_cache.delete_member(1, 2)
+        await create_memory_cache.set_guild(Guild(2))
+        await create_memory_cache.delete_member(1, 2)
+
+        guild = await create_memory_cache.get_guild(2)
+        guild.members[1] = Member(1, 2)
+        await create_memory_cache.set_guild(guild)
+        assert len(guild.members) == 1
+
+        await create_memory_cache.delete_member(1, 2)
+        g = await create_memory_cache.get_guild(2)
+        assert len(g.members) == 0
diff --git a/tests/test_plugin_cache.py b/tests/test_plugin_cache.py
new file mode 100644
index 0000000..7a9ecb1
--- /dev/null
+++ b/tests/test_plugin_cache.py
@@ -0,0 +1,110 @@
+import pytest
+
+from discord.ext import commands
+
+# noinspection PyUnresolvedReferences
+from antispam import (
+    PluginCache,
+    GuildNotFound,
+    MemberNotFound,
+    MemberAddonNotFound,
+    GuildAddonNotFound,
+    Options,
+    AntiSpamHandler,
+)
+
+from antispam.dataclasses import Member, Guild  # noqa
+
+from .fixtures import create_bot, create_handler, create_plugin_cache, MockClass
+
+
+# noinspection DuplicatedCode
+class TestPluginCache:
+    def test_init_key(self, create_plugin_cache):
+        """Test the cache extracts the caller's name for a key"""
+        assert create_plugin_cache.key == "MockClass"
+
+    # MEMBER GET
+    @pytest.mark.asyncio
+    async def test_get_member_data_fails_on_guild(
+        self, create_plugin_cache: PluginCache
+    ):
+        """Test the cache raises GuildNotFound"""
+        with pytest.raises(GuildNotFound):
+            await create_plugin_cache.get_member_data(1, 1)
+
+    @pytest.mark.asyncio
+    async def test_get_member_data_fails_on_member(
+        self, create_plugin_cache: PluginCache
+    ):
+        """Test the cache raises MemberNotFound"""
+        await create_plugin_cache.set_guild_data(1, [])
+
+        with pytest.raises(MemberNotFound):
+            await create_plugin_cache.get_member_data(1, 1)
+
+    @pytest.mark.asyncio
+    async def test_get_member_data_fails_on_member_addon(
+        self, create_plugin_cache: PluginCache, create_handler
+    ):
+        """Test the cache raises MemberNotFound"""
+        await create_handler.cache.set_member(Member(1, 1))
+
+        with pytest.raises(MemberAddonNotFound):
+            await create_plugin_cache.get_member_data(1, 1)
+
+    # MEMBER SET
+    @pytest.mark.asyncio
+    async def test_set_member_data_dictionaries(self):
+        """Test the cache sets member addon's correct"""
+        plugin_cache = PluginCache(AntiSpamHandler(commands.Bot("!")), MockClass())
+        arg = {"test": "tester"}
+
+        with pytest.raises(GuildNotFound):
+            await plugin_cache.get_member_data(1, 1)
+
+        await plugin_cache.set_member_data(1, 1, arg)
+
+        assert await plugin_cache.get_member_data(1, 1) == arg
+
+    # GUILD GET
+    @pytest.mark.asyncio
+    async def test_get_guild_data_fails(self, create_plugin_cache: PluginCache):
+        """Test the cache raises GuildNotFound"""
+        with pytest.raises(GuildNotFound):
+            await create_plugin_cache.get_guild_data(1)
+
+    @pytest.mark.asyncio
+    async def test_get_guild_data_fails_on_guild_addon(
+        self, create_plugin_cache: PluginCache, create_handler
+    ):
+        """Test the cache raises GuildAddonNotFound"""
+        await create_handler.cache.set_guild(Guild(1, Options()))
+
+        with pytest.raises(GuildAddonNotFound):
+            await create_plugin_cache.get_guild_data(1)
+
+    # GUILD SET
+    @pytest.mark.asyncio
+    async def test_set_guild_data_text(self):
+        """Test the cache sets guild addon's correct using text"""
+        plugin_cache = PluginCache(AntiSpamHandler(commands.Bot("!")), MockClass())
+
+        arg = "Hello world"
+
+        with pytest.raises(GuildNotFound):
+            await plugin_cache.get_guild_data(1)
+
+        await plugin_cache.set_guild_data(1, arg)
+
+        assert await plugin_cache.get_guild_data(1) == arg
+
+    @pytest.mark.asyncio
+    async def test_set_member_keyerror(self):
+        """A test to test set_member_data throws a keyerror"""
+        plugin_cache = PluginCache(AntiSpamHandler(commands.Bot("!")), MockClass())
+        await plugin_cache.set_guild_data(1, "A test")
+        with pytest.raises(MemberNotFound):
+            await plugin_cache.get_member_data(1, 1)
+
+        await plugin_cache.set_member_data(1, 1, "A member test")
diff --git a/tests/test_stats.py b/tests/test_stats.py
new file mode 100644
index 0000000..7aedc75
--- /dev/null
+++ b/tests/test_stats.py
@@ -0,0 +1,75 @@
+import pytest
+
+from antispam.dataclasses import CorePayload
+from antispam.plugins import Stats  # noqa
+
+from .fixtures import create_bot, create_handler, create_stats
+from .mocks import MockedMessage
+
+
+class TestStats:
+    def test_init(self, create_handler):
+        s = Stats(create_handler)
+        assert s.data == {
+            "pre_invoke_calls": {},
+            "after_invoke_calls": {},
+            "propagate_calls": 0,
+            "guilds": {},
+            "members": {},
+        }
+        assert s.is_pre_invoke is False
+
+    @pytest.mark.asyncio()
+    async def test_stats_creates(self, create_stats):
+        """Tests the plugin creates stats entries where required"""
+        create_stats.handler.pre_invoke_extensions["before"] = True
+        create_stats.handler.after_invoke_extensions["after"] = True
+
+        assert create_stats.data == {
+            "pre_invoke_calls": {},
+            "after_invoke_calls": {},
+            "propagate_calls": 0,
+            "guilds": {},
+            "members": {},
+        }
+
+        await create_stats.propagate(
+            MockedMessage().to_mock(),
+            CorePayload(member_should_be_punished_this_message=True),
+        )
+
+        assert len(create_stats.data["pre_invoke_calls"]) == 1
+        assert len(create_stats.data["after_invoke_calls"]) == 1
+        assert len(create_stats.data["members"]) == 1
+        assert len(create_stats.data["guilds"]) == 1
+        assert create_stats.data["propagate_calls"] == 1
+
+    @pytest.mark.asyncio()
+    async def test_stats_adds(self, create_stats):
+        """Tests the plugin adds to existing data"""
+        create_stats.handler.pre_invoke_extensions["before"] = True
+        create_stats.handler.after_invoke_extensions["after"] = True
+        create_stats.data = {
+            "pre_invoke_calls": {"before": {"calls": 1}},
+            "after_invoke_calls": {"after": {"calls": 1}},
+            "propagate_calls": 1,
+            "guilds": {123456789: {"calls": 1, "total_messages_punished": 1}},
+            "members": {12345: {"calls": 1, "times_punished": 1}},
+        }
+
+        await create_stats.propagate(
+            MockedMessage().to_mock(),
+            CorePayload(),
+        )
+        await create_stats.propagate(
+            MockedMessage().to_mock(),
+            CorePayload(member_should_be_punished_this_message=True),
+        )
+
+        assert create_stats.data == {
+            "pre_invoke_calls": {"before": {"calls": 3}},
+            "after_invoke_calls": {"after": {"calls": 3}},
+            "propagate_calls": 3,
+            "guilds": {123456789: {"calls": 3, "total_messages_punished": 2}},
+            "members": {12345: {"calls": 3, "times_punished": 2}},
+        }