Repository: attzonko/mmpy_bot (language: python)

Issue:
Implement PluginManager (3 of 3)
Reopened #212 targeting `main` instead of `master`.
Implement FunctionInfo (1 of 3)
This is an attempt to split #226 into smaller PRs, easier to review.
This PR should be merged before the others.

However due to how dependent these are from each other, it's not easy to ensure each PR is self consistent.
The glue code will be part of the original #226 PR.
PluginManager interface (2 of 3)
This is an attempt to split #226 into smaller PRs, easier to review.
This PR should be merged after #323 and before #226.

Most glue code is present in this PR.


Discussion / hints:
Some significant changes from #212 - Tests are not yet in sync. More on that in the next push:

* `Plugin.help` and `Plugin.get_help_string` is now `HelpPlugin.help` and `HelpPlugin.get_help_string`.
* The logic to collect help information about plugins is now `PluginManager.get_help`.
* `HelpPlugin` is enabled by default but needs to be explicitly enabled if you specify your own plugins.
* `PluginManager` is now available to `Plugin` instances as `self.manager` initialized via `Plugin.initialize`
* `help_trigger` and `help_trigger_bang` are no more. There's only `settings.RESPOND_CHANNEL_HELP` that defaults to `False` and defines if the bot should respond to `!help` in any channel.
* Arbitrary information can be passed via `listen_to` and `listen_webhook` as `kwargs` and made available via `PluginHelpInfo.annotations`. (e.g. `@listen_to(..., category="webhook", syntax="Command Arg1 Arg2"`)
  * Two attributes are currently recognized by `HelpPlugin.get_help_string`:
    * `category` - grouping functions with the same category label (see screenshot below)
    * `syntax` - providing an alternative to displaying the regex pattern. Instead of `^reply at (.*)$` you can get `reply at TIMESTAMP` or any arbitrary text.
* All help information is now set via function/class docstrings instead of `get_help_string`. The first line of the docstring will be displayed in the help output. The entire docstring is available as `PluginHelpInfo.function_docfull`. Plugin level docstrings are also available, see `plugin_docfull` and `plugin_docheader` 
* `get_help_string` is no longer part of the base ABC `Plugin` class nor `Function` subclasses.
* Docstrings were added to the `ExamplePlugin` where missing.

The current help output with plugins `HelpPlugin`, `ExamplePlugin` and `WebHookExample` looks like:
![screenshot_2021-05-31_03-27-06_647421429](https://user-images.githubusercontent.com/122319/120127610-2004d000-c1c0-11eb-8acb-c20d844d6e45.png)


I think this is ready for a final of review.
This PR will also close #165 
Again @unode thanks for putting in all this work. I have a quick request, can you elaborate on what you mean by the bullet below and what exactly does it mean for users who use their own plugins: 
>  `HelpPlugin` is enabled by default but needs to be explicitly enabled if you specify your own plugins.
> Again @unode thanks for putting in all this work. I have a quick request, can you elaborate on what you mean by the bullet below and what exactly does it mean for users who use their own plugins:
> 
> > `HelpPlugin` is enabled by default but needs to be explicitly enabled if you specify your own plugins.

I mean this https://github.com/attzonko/mmpy_bot/blob/a584bf242673f7355b25b32f6d0afe0d33b76fb7/mmpy_bot/bot.py#L37

If you run the bot without adding any custom plugins, it will be included among the default plugins.
If add custom plugins (the most likely case) you should include it, or a subclass of it, in the list of plugins to enable.

Initially I thought of having it always enabled (i.e. `plugins + [HelpPlugin()]`) but that would remove the ability to define or customize your own help plugin.
Pulled #233 out of this one. That's a pretty self contained feature.
After trying to customize a little the help info I noticed the current approach actually forces you to modify `PluginManager.get_help` if you want to filter out some functions and `HelpPlugin.get_help_string` if you want to customize the format of the help.

To simplify the use-case of filtering listeners, I now introduced a `HelpPlugin.get_help` method. It currently only calls to `PluginManager.get_help` but provides a location for users to override what is included without having to redefine `HelpPlugin.get_help_string` entirely.
See also https://github.com/attzonko/mmpy_bot/pull/226/files#diff-6cc66ec79b3b320572cf875b2e985afab611933a46d625218fc030c3a000131dR188 as an example of such use-case.
> Round two sweat_smile These reviews are taking me a long time, so I would be grateful if you could either make PRs into this branch and/or not force-push to it, so I can look only at the latest changes

Sorry, I will stop pulling things in/out until the review is complete.
Thanks @jneeven!

I'll give it another try at pulling out different PRs. Will likely go with your suggestion of making the PRs against the previous.
Im eagerly awaiting this PR! Currently have a .help command to show what commands are available and what they do



Fix patch:
diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml
index 841659f5..3352e702 100644
--- a/.github/workflows/lint.yml
+++ b/.github/workflows/lint.yml
@@ -35,7 +35,7 @@ jobs:
       - name: Run Flake8
         run: flake8
       - name: Black code style
-        run: black . --check --target-version py38 --exclude '\.mypy_cache/|\.venv/|env/|(.*/)*snapshots/|.pytype/'
+        run: black . --check --target-version py310 --exclude '\.mypy_cache/|\.venv/|env/|(.*/)*snapshots/|.pytype/'
       - name: Docstring formatting
         run: docformatter -c -r . --wrap-summaries 88 --wrap-descriptions 88
       - name: Check import order with isort
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 13cb0f43..2edb7a14 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -22,7 +22,7 @@ repos:
           - "--filter-files"
   - repo: https://github.com/psf/black
     # Code style formatting
-    rev: 21.5b1
+    rev: 22.3.0
     hooks:
       - id: black
         exclude: (.*/)*snapshots/
@@ -31,6 +31,7 @@ repos:
     rev: 3.9.0
     hooks:
       - id: flake8
+        exclude: (.*/)*snapshots/
   - repo: https://github.com/mattseymour/pre-commit-pytype
     rev: '2020.10.8'
     hooks:
diff --git a/mmpy_bot/bot.py b/mmpy_bot/bot.py
index 7559c2db..6a5b5f69 100644
--- a/mmpy_bot/bot.py
+++ b/mmpy_bot/bot.py
@@ -1,11 +1,17 @@
 import asyncio
 import logging
 import sys
-from typing import Optional, Sequence
+from typing import List, Optional, Union
 
 from mmpy_bot.driver import Driver
 from mmpy_bot.event_handler import EventHandler
-from mmpy_bot.plugins import ExamplePlugin, Plugin, WebHookExample
+from mmpy_bot.plugins import (
+    ExamplePlugin,
+    HelpPlugin,
+    Plugin,
+    PluginManager,
+    WebHookExample,
+)
 from mmpy_bot.settings import Settings
 from mmpy_bot.webhook_server import WebHookServer
 
@@ -22,18 +28,18 @@ class Bot:
     def __init__(
         self,
         settings: Optional[Settings] = None,
-        plugins: Optional[Sequence[Plugin]] = None,
+        plugins: Optional[Union[List[Plugin], PluginManager]] = None,
         enable_logging: bool = True,
     ):
-        if plugins is None:
-            plugins = [ExamplePlugin(), WebHookExample()]
+        self._setup_plugin_manager(plugins)
+
         # Use default settings if none were specified.
         self.settings = settings or Settings()
 
+        self.console = None
+
         if enable_logging:
             self._register_logger()
-        else:
-            self.console = None
 
         self.driver = Driver(
             {
@@ -48,9 +54,9 @@ def __init__(
             }
         )
         self.driver.login()
-        self.plugins = self._initialize_plugins(plugins)
+        self.plugin_manager.initialize(self.driver, self.settings)
         self.event_handler = EventHandler(
-            self.driver, settings=self.settings, plugins=self.plugins
+            self.driver, settings=self.settings, plugin_manager=self.plugin_manager
         )
         self.webhook_server = None
 
@@ -59,6 +65,16 @@ def __init__(
 
         self.running = False
 
+    def _setup_plugin_manager(self, plugins):
+        if plugins is None:
+            self.plugin_manager = PluginManager(
+                [HelpPlugin(), ExamplePlugin(), WebHookExample()]
+            )
+        elif isinstance(plugins, PluginManager):
+            self.plugin_manager = plugins
+        else:
+            self.plugin_manager = PluginManager(plugins)
+
     def _register_logger(self):
         logging.basicConfig(
             **{
@@ -80,11 +96,6 @@ def _register_logger(self):
             )
             logging.getLogger("").addHandler(self.console)
 
-    def _initialize_plugins(self, plugins: Sequence[Plugin]):
-        for plugin in plugins:
-            plugin.initialize(self.driver, self.settings)
-        return plugins
-
     def _initialize_webhook_server(self):
         self.webhook_server = WebHookServer(
             url=self.settings.WEBHOOK_HOST_URL, port=self.settings.WEBHOOK_HOST_PORT
@@ -110,8 +121,8 @@ def run(self):
             if self.settings.WEBHOOK_HOST_ENABLED:
                 self.driver.threadpool.start_webhook_server_thread(self.webhook_server)
 
-            for plugin in self.plugins:
-                plugin.on_start()
+            # Trigger "start" methods on every plugin
+            self.plugin_manager.start()
 
             # Start listening for events
             self.event_handler.start()
@@ -129,9 +140,10 @@ def stop(self):
             return
 
         log.info("Stopping bot.")
+
         # Shutdown the running plugins
-        for plugin in self.plugins:
-            plugin.on_stop()
+        self.plugin_manager.stop()
+
         # Stop the threadpool
         self.driver.threadpool.stop()
         self.running = False
diff --git a/mmpy_bot/event_handler.py b/mmpy_bot/event_handler.py
index e0318d15..ae873cb3 100644
--- a/mmpy_bot/event_handler.py
+++ b/mmpy_bot/event_handler.py
@@ -3,11 +3,9 @@
 import logging
 import queue
 import re
-from collections import defaultdict
-from typing import Sequence
 
 from mmpy_bot.driver import Driver
-from mmpy_bot.plugins import Plugin
+from mmpy_bot.plugins import PluginManager
 from mmpy_bot.settings import Settings
 from mmpy_bot.webhook_server import NoResponse
 from mmpy_bot.wrappers import Message, WebHookEvent
@@ -20,7 +18,7 @@ def __init__(
         self,
         driver: Driver,
         settings: Settings,
-        plugins: Sequence[Plugin],
+        plugin_manager: PluginManager,
         ignore_own_messages=True,
     ):
         """The EventHandler class takes care of the connection to mattermost and calling
@@ -28,19 +26,10 @@ def __init__(
         self.driver = driver
         self.settings = settings
         self.ignore_own_messages = ignore_own_messages
-        self.plugins = plugins
+        self.plugin_manager = plugin_manager
 
         self._name_matcher = re.compile(rf"^@?{self.driver.username}\:?\s?")
 
-        # Collect the listeners from all plugins
-        self.message_listeners = defaultdict(list)
-        self.webhook_listeners = defaultdict(list)
-        for plugin in self.plugins:
-            for matcher, functions in plugin.message_listeners.items():
-                self.message_listeners[matcher].extend(functions)
-            for matcher, functions in plugin.webhook_listeners.items():
-                self.webhook_listeners[matcher].extend(functions)
-
     def start(self):
         # This is blocking, will loop forever
         self.driver.init_websocket(self._handle_event)
@@ -87,7 +76,7 @@ async def _handle_post(self, post):
         # Find all the listeners that match this message, and have their plugins handle
         # the rest.
         tasks = []
-        for matcher, functions in self.message_listeners.items():
+        for matcher, functions in self.plugin_manager.message_listeners.items():
             match = matcher.search(message.text)
             if match:
                 groups = list([group for group in match.groups() if group != ""])
@@ -107,7 +96,7 @@ async def _handle_webhook(self, event: WebHookEvent):
         # Find all the listeners that match this webhook id, and have their plugins
         # handle the rest.
         tasks = []
-        for matcher, functions in self.webhook_listeners.items():
+        for matcher, functions in self.plugin_manager.webhook_listeners.items():
             match = matcher.search(event.webhook_id)
             if match:
                 for function in functions:
diff --git a/mmpy_bot/function.py b/mmpy_bot/function.py
index 42971009..52bbc1b7 100644
--- a/mmpy_bot/function.py
+++ b/mmpy_bot/function.py
@@ -5,21 +5,25 @@
 import logging
 import re
 from abc import ABC, abstractmethod
-from typing import Callable, Optional, Sequence, Union
+from typing import TYPE_CHECKING, Callable, Optional, Sequence, Union
 
 import click
 
-from mmpy_bot.utils import completed_future, spaces
+from mmpy_bot.utils import completed_future
 from mmpy_bot.webhook_server import NoResponse
 from mmpy_bot.wrappers import Message, WebHookEvent
 
+if TYPE_CHECKING:
+    from mmpy_bot.plugins import Plugin
+
+
 log = logging.getLogger("mmpy.function")
 
 
 class Function(ABC):
     def __init__(
         self,
-        function: Union[Function, click.command],
+        function: Union[Function, click.Command],
         matcher: re.Pattern,
         **metadata,
     ):
@@ -32,7 +36,11 @@ def __init__(
             self.siblings.append(function)
             function = function.function
 
-        # FIXME: After this while loop it is possible that function is not a Function, do we really want to assign self.function to something which is not a Function? Check if this is needed for the click.Command case
+        if function is None:
+            raise ValueError(
+                "ERROR: Possible bug, inside the Function class function should not end up being None"
+            )
+
         self.function = function
         self.is_coroutine = asyncio.iscoroutinefunction(function)
         self.is_click_function: bool = False
@@ -46,21 +54,14 @@ def __init__(
             self.function.invoke = None
 
         # To be set in the child class or from the parent plugin
-        self.plugin = None
+        self.plugin: Optional[Plugin] = None
         self.name: Optional[str] = None
-        self.docstring: Optional[str] = None
+        self.docstring = self.function.__doc__ or ""
 
         @abstractmethod
         def __call__(self, *args):
             pass
 
-    def get_help_string(self):
-        string = f"`{self.matcher.pattern}`:\n"
-        # Add a docstring
-        doc = self.docstring or "No description provided."
-        string += f"{spaces(8)}{doc}\n"
-        return string
-
 
 class MessageFunction(Function):
     """Wrapper around a Plugin class method that should respond to certain Mattermost
@@ -95,7 +96,6 @@ def __init__(
 
         # Default for non-click functions
         _function: Union[Callable, click.Command] = self.function
-        self.docstring = self.function.__doc__
 
         if self.is_click_function:
             _function = self.function.callback
@@ -109,9 +109,8 @@ def __init__(
                 info_name=self.matcher.pattern.strip("^").split(" (.*)?")[0],
             ) as ctx:
                 # Get click help string and do some extra formatting
-                self.docstring = self.function.get_help(ctx).replace(
-                    "\n", f"\n{spaces(8)}"
-                )
+                self.docstring += f"\n\n{self.function.get_help(ctx)}"
+
         if _function is not None:
             self.name = _function.__qualname__
 
@@ -169,38 +168,6 @@ def __call__(self, message: Message, *args):
 
         return self.function(self.plugin, message, *args)
 
-    def get_help_string(self):
-        string = super().get_help_string()
-        if any(
-            [
-                self.needs_mention,
-                self.direct_only,
-                self.allowed_users,
-                self.allowed_channels,
-                self.silence_fail_msg,
-            ]
-        ):
-            # Print some information describing the usage settings.
-            string += f"{spaces(4)}Additional information:\n"
-            if self.needs_mention:
-                string += (
-                    f"{spaces(4)}- Needs to either mention @{self.plugin.driver.username}"
-                    " or be a direct message.\n"
-                )
-            if self.direct_only:
-                string += f"{spaces(4)}- Needs to be a direct message.\n"
-
-            if self.allowed_users:
-                string += f"{spaces(4)}- Restricted to certain users.\n"
-
-            if self.allowed_channels:
-                string += f"{spaces(4)}- Restricted to certain channels.\n"
-
-            if self.silence_fail_msg:
-                string += f"{spaces(4)}- If it should reply to a non privileged user / in a non privileged channel.\n"
-
-        return string
-
 
 def listen_to(
     regexp: str,
@@ -238,7 +205,7 @@ def wrapped_func(func):
             reg = f"^{reg.strip('^')} (.*)?"  # noqa
 
         pattern = re.compile(reg, regexp_flag)
-        return MessageFunction(
+        new_func = MessageFunction(
             func,
             matcher=pattern,
             direct_only=direct_only,
@@ -249,6 +216,10 @@ def wrapped_func(func):
             **metadata,
         )
 
+        # Preserve docstring
+        new_func.__doc__ = func.__doc__
+        return new_func
+
     return wrapped_func
 
 
@@ -267,7 +238,6 @@ def __init__(
             )
 
         self.name = self.function.__qualname__
-        self.docstring = self.function.__doc__
 
         argspec = list(inspect.signature(self.function).parameters.keys())
         if not argspec == ["self", "event"]:
@@ -305,10 +275,14 @@ def listen_webhook(
 
     def wrapped_func(func):
         pattern = re.compile(regexp)
-        return WebHookFunction(
+        new_func = WebHookFunction(
             func,
             matcher=pattern,
             **metadata,
         )
 
+        # Preserve docstring
+        new_func.__doc__ = func.__doc__
+        return new_func
+
     return wrapped_func
diff --git a/mmpy_bot/plugins/__init__.py b/mmpy_bot/plugins/__init__.py
index 52712e70..59970445 100644
--- a/mmpy_bot/plugins/__init__.py
+++ b/mmpy_bot/plugins/__init__.py
@@ -1,5 +1,6 @@
-from mmpy_bot.plugins.base import Plugin
+from mmpy_bot.plugins.base import Plugin, PluginManager
 from mmpy_bot.plugins.example import ExamplePlugin
+from mmpy_bot.plugins.help_example import HelpPlugin
 from mmpy_bot.plugins.webhook_example import WebHookExample
 
-__all__ = ["Plugin", "ExamplePlugin", "WebHookExample"]
+__all__ = ["Plugin", "PluginManager", "HelpPlugin", "ExamplePlugin", "WebHookExample"]
diff --git a/mmpy_bot/plugins/base.py b/mmpy_bot/plugins/base.py
index 6a437206..37f24bf6 100644
--- a/mmpy_bot/plugins/base.py
+++ b/mmpy_bot/plugins/base.py
@@ -5,13 +5,13 @@
 from abc import ABC
 from collections import defaultdict
 from dataclasses import dataclass
-from typing import Any, Dict, List, MutableSequence, Optional, Sequence, Union
+from typing import Any, Dict, List, Optional, Sequence, Union
 
 from mmpy_bot.driver import Driver
-from mmpy_bot.function import Function, MessageFunction, WebHookFunction, listen_to
+from mmpy_bot.function import Function, MessageFunction, WebHookFunction
 from mmpy_bot.settings import Settings
 from mmpy_bot.utils import split_docstring
-from mmpy_bot.wrappers import EventWrapper, Message
+from mmpy_bot.wrappers import EventWrapper
 
 log = logging.getLogger("mmpy.plugin_base")
 
@@ -27,39 +27,19 @@ class Plugin(ABC):
 
     def __init__(self):
         self.driver: Optional[Driver] = None
-        self.message_listeners: Dict[
-            re.Pattern, MutableSequence[MessageFunction]
-        ] = defaultdict(list)
-        self.webhook_listeners: Dict[
-            re.Pattern, MutableSequence[WebHookFunction]
-        ] = defaultdict(list)
-
-        # We have to register the help function listeners at runtime to prevent the
-        # Function object from being shared across different Plugins.
-        self.help = listen_to("^help$", needs_mention=True)(Plugin.help)
-        self.help = listen_to("^!help$")(self.help)
-
-    def initialize(self, driver: Driver, settings: Optional[Settings] = None):
-        self.driver = driver
-
-        # Register listeners for any listener functions we might have
-        for attribute in dir(self):
-            attribute = getattr(self, attribute)
-            if isinstance(attribute, Function):
-                # Register this function and any potential siblings
-                for function in [attribute] + attribute.siblings:
-                    function.plugin = self
-                    if isinstance(function, MessageFunction):
-                        self.message_listeners[function.matcher].append(function)
-                    elif isinstance(function, WebHookFunction):
-                        self.webhook_listeners[function.matcher].append(function)
-                    else:
-                        raise TypeError(
-                            f"{self.__class__.__name__} has a function of unsupported"
-                            f" type {type(function)}."
-                        )
+        self.plugin_manager: Optional[PluginManager] = None
+        self.settings: Optional[Settings] = None
+        self.docstring = self.__doc__ if self.__doc__ != Plugin.__doc__ else None
 
-        return self
+    def initialize(
+        self,
+        driver: Driver,
+        plugin_manager: PluginManager,
+        settings: Settings,
+    ):
+        self.driver = driver
+        self.plugin_manager = plugin_manager
+        self.settings = settings
 
     def on_start(self):
         """Will be called after initialization.
@@ -90,25 +70,6 @@ async def call_function(
             # a plugin-specific thread or process pool if we wanted.
             self.driver.threadpool.add_task(function, event, *groups)
 
-    def get_help_string(self):
-        string = f"Plugin {self.__class__.__name__} has the following functions:\n"
-        string += "----\n"
-        for functions in self.message_listeners.values():
-            for function in functions:
-                string += f"- {function.get_help_string()}"
-            string += "----\n"
-        if len(self.webhook_listeners) > 0:
-            string += "### Registered webhooks:\n"
-            for functions in self.webhook_listeners.values():
-                for function in functions:
-                    string += f"- {function.get_help_string()}"
-
-        return string
-
-    async def help(self, message: Message):
-        """Prints the list of functions registered on every active plugin."""
-        self.driver.reply_to(message, self.get_help_string())
-
 
 @dataclass
 class FunctionInfo:
@@ -124,6 +85,21 @@ class FunctionInfo:
     metadata: Dict
 
 
+def get_function_characteristics(function):
+    """Returns a tuple describing the function user interface.
+
+    Returns (direct_only, needs_mention, help_type)
+    """
+    if isinstance(function, MessageFunction):
+        return (function.direct_only, function.needs_mention, "message")
+    elif isinstance(function, WebHookFunction):
+        return (False, False, "webhook")
+    else:
+        raise NotImplementedError(
+            f"Unknown/Unsupported listener type: '{type(function)}'"
+        )
+
+
 def generate_plugin_help(
     listeners: Dict[re.Pattern[Any], List[Union[MessageFunction, WebHookFunction]]],
 ):
@@ -138,18 +114,7 @@ def generate_plugin_help(
         for function in functions:
             plug_head, plug_full = split_docstring(function.plugin.__doc__)
             func_head, func_full = split_docstring(function.docstring)
-
-            if isinstance(function, MessageFunction):
-                direct = function.direct_only
-                mention = function.needs_mention
-                help_type = "message"
-            elif isinstance(function, WebHookFunction):
-                direct = mention = False
-                help_type = "webhook"
-            else:
-                raise NotImplementedError(
-                    f"Unknown/Unsupported listener type: '{type(function)}'"
-                )
+            direct, mention, help_type = get_function_characteristics(function)
 
             plug_help.append(
                 FunctionInfo(
@@ -167,3 +132,70 @@ def generate_plugin_help(
             )
 
     return plug_help
+
+
+class PluginManager:
+    """PluginManager is responsible for initializing all plugins and display aggregated
+    help from each of them.
+
+    It is supposed to be transparent to EventHandler that interacts directly with each
+    individual Plugin.
+    """
+
+    def __init__(
+        self,
+        plugins: Sequence[Plugin],
+    ):
+        self.settings: Optional[Settings] = None
+        self.plugins = plugins
+
+        self.message_listeners: Dict[re.Pattern, List[MessageFunction]] = defaultdict(
+            list
+        )
+        self.webhook_listeners: Dict[re.Pattern, List[WebHookFunction]] = defaultdict(
+            list
+        )
+
+    def initialize(self, driver: Driver, settings: Settings):
+        for plugin in self.plugins:
+            plugin.initialize(driver, self, settings)
+
+            # Register listeners for any listener functions in the plugin
+            for attribute in dir(plugin):
+                attribute = getattr(plugin, attribute)
+                if not isinstance(attribute, Function):
+                    continue
+
+                # Register this function and any potential siblings
+                for function in [attribute] + attribute.siblings:
+                    # Plugin message/webhook handlers can be decorated multiple times
+                    # resulting in multiple siblings that do not have .plugin defined
+                    # or where the relationship with the parent plugin is incorrect
+                    function.plugin = plugin
+                    if isinstance(function, MessageFunction):
+                        self.message_listeners[function.matcher].append(function)
+                    elif isinstance(function, WebHookFunction):
+                        self.webhook_listeners[function.matcher].append(function)
+                    else:
+                        raise TypeError(
+                            f"{plugin.__class__.__name__} has a function of unsupported"
+                            f" type {type(function)}."
+                        )
+
+    def start(self):
+        """Trigger on_start() on every registered plugin."""
+        for plugin in self.plugins:
+            plugin.on_start()
+
+    def stop(self):
+        """Trigger on_stop() on every registered plugin."""
+        for plugin in self.plugins:
+            plugin.on_stop()
+
+    def get_help(self) -> List[FunctionInfo]:
+        """Returns a list of FunctionInfo items for every registered message and webhook
+        listener."""
+        plug_help = generate_plugin_help(self.message_listeners)
+        plug_help += generate_plugin_help(self.webhook_listeners)
+
+        return plug_help
diff --git a/mmpy_bot/plugins/example.py b/mmpy_bot/plugins/example.py
index ee1f10a4..1fce9962 100644
--- a/mmpy_bot/plugins/example.py
+++ b/mmpy_bot/plugins/example.py
@@ -6,7 +6,8 @@
 import click
 import mattermostdriver
 
-from mmpy_bot.plugins.base import Plugin, listen_to
+from mmpy_bot.function import listen_to
+from mmpy_bot.plugins.base import Plugin
 from mmpy_bot.scheduler import schedule
 from mmpy_bot.wrappers import Message
 
@@ -14,17 +15,19 @@
 class ExamplePlugin(Plugin):
     """Default plugin with examples of bot functionality and usage."""
 
-    @listen_to("^admin$", direct_only=True, allowed_users=["admin", "root"])
+    @listen_to(
+        "^admin$", direct_only=True, allowed_users=["admin", "root"], category="admin"
+    )
     async def users_access(self, message: Message):
         """Showcases a function with restricted access."""
         self.driver.reply_to(message, "Access allowed!")
 
-    @listen_to("^offtopic_channel$", allowed_channels=["off-topic"])
+    @listen_to("^offtopic_channel$", allowed_channels=["off-topic"], category="admin")
     async def channels_access(self, message: Message):
         """Showcases a function which can only be used in specific channels."""
         self.driver.reply_to(message, "Access allowed!")
 
-    @listen_to("^busy|jobs$", re.IGNORECASE, needs_mention=True)
+    @listen_to("^busy|jobs$", re.IGNORECASE, needs_mention=True, category="admin")
     async def busy_reply(self, message: Message):
         """Show the number of busy worker threads."""
         busy = self.driver.threadpool.get_busy_workers()
@@ -33,7 +36,7 @@ async def busy_reply(self, message: Message):
             f"Number of busy worker threads: {busy}",
         )
 
-    @listen_to("hello_click", needs_mention=True)
+    @listen_to("hello_click", needs_mention=True, category="click")
     @click.command(help="An example click command with various arguments.")
     @click.argument("POSITIONAL_ARG", type=str)
     @click.option("--keyword-arg", type=float, default=5.0, help="A keyword arg.")
@@ -41,6 +44,7 @@ async def busy_reply(self, message: Message):
     def hello_click(
         self, message: Message, positional_arg: str, keyword_arg: float, flag: bool
     ):
+        """A click function documented via docstring."""
         response = (
             "Received the following arguments:\n"
             f"- positional_arg: {positional_arg}\n"
@@ -78,8 +82,9 @@ async def hello_file(self, message: Message):
         file.write_text("Hello from this file!")
         self.driver.reply_to(message, "Here you go", file_paths=[file])
 
-    @listen_to("^!hello_webhook$", re.IGNORECASE)
+    @listen_to("^!hello_webhook$", re.IGNORECASE, category="webhook")
     async def hello_webhook(self, message: Message):
+        """A webhook that says hello."""
         self.driver.webhooks.call_webhook(
             "eauegoqk4ibxigfybqrsfmt48r",
             options={
@@ -115,7 +120,9 @@ async def ping_reply(self, message: Message):
         """Pong."""
         self.driver.reply_to(message, "pong")
 
-    @listen_to("^reply at (.*)$", re.IGNORECASE, needs_mention=True)
+    @listen_to(
+        "^reply at (.*)$", re.IGNORECASE, needs_mention=True, category="schedule"
+    )
     def schedule_once(self, message: Message, trigger_time: str):
         """Schedules a reply to be sent at the given time.
 
@@ -132,7 +139,12 @@ def schedule_once(self, message: Message, trigger_time: str):
         except ValueError as e:
             self.driver.reply_to(message, str(e))
 
-    @listen_to("^schedule every ([0-9]+)$", re.IGNORECASE, needs_mention=True)
+    @listen_to(
+        "^schedule every ([0-9]+)$",
+        re.IGNORECASE,
+        needs_mention=True,
+        category="schedule",
+    )
     def schedule_every(self, message: Message, seconds: int):
         """Schedules a reply every x seconds. Use the `cancel jobs` command to stop.
 
@@ -143,7 +155,7 @@ def schedule_every(self, message: Message, seconds: int):
             self.driver.reply_to, message, f"Scheduled message every {seconds} seconds!"
         )
 
-    @listen_to("^cancel jobs$", re.IGNORECASE, needs_mention=True)
+    @listen_to("^cancel jobs$", re.IGNORECASE, needs_mention=True, category="schedule")
     def cancel_jobs(self, message: Message):
         """Cancels all scheduled jobs, including recurring and one-time events."""
         schedule.clear()
diff --git a/mmpy_bot/plugins/help_example.py b/mmpy_bot/plugins/help_example.py
new file mode 100644
index 00000000..e9108970
--- /dev/null
+++ b/mmpy_bot/plugins/help_example.py
@@ -0,0 +1,100 @@
+from __future__ import annotations
+
+from mmpy_bot.driver import Driver
+from mmpy_bot.function import listen_to
+from mmpy_bot.plugins.base import Plugin, PluginManager
+from mmpy_bot.settings import Settings
+from mmpy_bot.wrappers import Message
+
+
+def _custom_help_sort(rec):
+    return (
+        rec.metadata.get("category", ""),  # No categories first
+        rec.help_type,
+        rec.pattern.lstrip("^[(-"),
+    )
+
+
+def _prepare_function_help_message(h, string):
+    cmd = h.metadata.get("human_description", h.pattern)
+    direct = "`(*)`" if h.direct else ""
+    mention = "`(+)`" if h.mention else ""
+
+    if h.help_type == "webhook":
+        string += f"- `{cmd}` {direct} {mention} - (webhook) {h.docheader}\n"
+    else:
+        if not h.docheader:
+            string += f"- `{cmd}` {direct} {mention}\n"
+        else:
+            string += f"- `{cmd}` {direct} {mention} - {h.docheader}\n"
+
+    return string
+
+
+class HelpPlugin(Plugin):
+    """Provide a `help` command that lists functions provided by all plugins.
+
+    With a few plugins enabled the help text can become quite verbose. For this reason,
+    this plugin defaults to sending a private/direct message with the information.
+
+    If you wish to disable this behavior pass `direct_help=False` and the help text will
+    be displayed in the channel where it is requested.
+    """
+
+    def __init__(
+        self,
+        direct_help: bool = True,
+    ):
+        super().__init__()
+        self.direct_help: bool = direct_help
+
+    def initialize(
+        self,
+        driver: Driver,
+        plugin_manager: PluginManager,
+        settings: Settings,
+    ):
+        super().initialize(driver, plugin_manager, settings)
+
+        if self.settings.RESPOND_CHANNEL_HELP:
+            self.help = listen_to("^!help$")(self.help)
+
+    def get_help_string(self, message: Message) -> str:
+        """Renders help information (FunctionInfo objects) into a markdown string.
+
+        Help information is presented in a condensed format, grouped into categories
+        """
+
+        help_function_info = sorted(self.get_help(message), key=_custom_help_sort)
+
+        string = "### The following functions have been registered:\n\n"
+        string += "###### `(*)` require the use of `@botname`, "
+        string += "`(+)` can only be used in direct message\n"
+        old_category = None
+
+        for h in help_function_info:
+            # If categories are defined, group functions accordingly
+            category = h.metadata.get("category")
+            if category != old_category:
+                old_category = category
+                category = "uncategorized" if category is None else category
+                string += f"Category `{category}`:\n"
+
+            string = _prepare_function_help_message(h, string)
+
+        return string
+
+    def get_help(self, message: Message):
+        """Obtain Help info from PluginManager.
+
+        Override this method if you need to customize which listeners will be included
+        in the help.
+        """
+        return self.plugin_manager.get_help()
+
+    @listen_to("^help$", needs_mention=True)
+    async def help(self, message: Message):
+        """Shows this help information."""
+        self.driver.reply_to(
+            message, self.get_help_string(message), direct=self.direct_help
+        )
diff --git a/mmpy_bot/plugins/webhook_example.py b/mmpy_bot/plugins/webhook_example.py
index f577a4ee..8a152d25 100644
--- a/mmpy_bot/plugins/webhook_example.py
+++ b/mmpy_bot/plugins/webhook_example.py
@@ -1,6 +1,6 @@
 from mmpy_bot.driver import Driver
 from mmpy_bot.function import listen_to, listen_webhook
-from mmpy_bot.plugins.base import Plugin
+from mmpy_bot.plugins.base import Plugin, PluginManager
 from mmpy_bot.settings import Settings
 from mmpy_bot.wrappers import ActionEvent, Message, WebHookEvent
 
@@ -8,8 +8,10 @@
 class WebHookExample(Plugin):
     """Webhook plugin with examples of webhook server functionality."""
 
-    def initialize(self, driver: Driver, settings: Settings):
-        super().initialize(driver, settings)
+    def initialize(
+        self, driver: Driver, plugin_manager: PluginManager, settings: Settings
+    ):
+        super().initialize(driver, plugin_manager, settings)
         self.webhook_host_url = settings.WEBHOOK_HOST_URL
         self.webhook_host_port = settings.WEBHOOK_HOST_PORT
         return self
diff --git a/mmpy_bot/settings.py b/mmpy_bot/settings.py
index 0025c522..0021c88f 100644
--- a/mmpy_bot/settings.py
+++ b/mmpy_bot/settings.py
@@ -56,6 +56,8 @@ class Settings:
     WEBHOOK_HOST_URL: str = "http://127.0.0.1"
     WEBHOOK_HOST_PORT: int = 8579
     DEBUG: bool = False
+    # Respond to channel message "!help" (without @bot)
+    RESPOND_CHANNEL_HELP: bool = False
     LOG_FILE: Optional[str] = None
     LOG_FORMAT: str = "[%(asctime)s][%(name)s][%(levelname)s] %(message)s"
     LOG_DATE_FORMAT: str = "%m/%d/%Y %H:%M:%S"


Test patch:
diff --git a/tests/unit_tests/bot_test.py b/tests/unit_tests/bot_test.py
index 7d6ecb71..60032ce2 100644
--- a/tests/unit_tests/bot_test.py
+++ b/tests/unit_tests/bot_test.py
@@ -3,6 +3,7 @@
 import pytest
 
 from mmpy_bot import Bot, ExamplePlugin, Settings
+from mmpy_bot.plugins import PluginManager
 
 from ..integration_tests.utils import TestPlugin
 
@@ -29,7 +30,8 @@ def test_init(self, login):
         )
         assert bot.driver.options["url"] == "test_url.org"
         assert bot.driver.options["token"] == "random_token"
-        assert bot.plugins == plugins
+        assert isinstance(bot.plugin_manager, PluginManager)
+        assert bot.plugin_manager.plugins == plugins
         login.assert_called_once()
 
         # Verify that all of the passed plugins were initialized
@@ -42,10 +44,10 @@ def test_run(self, bot, **mocks):
             bot.run()
             init_websocket.assert_called_once()
 
-            for plugin in bot.plugins:
+            for plugin in bot.plugin_manager.plugins:
                 plugin.on_start.assert_called_once()
 
             bot.stop()
 
-            for plugin in bot.plugins:
+            for plugin in bot.plugin_manager.plugins:
                 plugin.on_stop.assert_called_once()
diff --git a/tests/unit_tests/event_handler_test.py b/tests/unit_tests/event_handler_test.py
index 7959eedb..832f7f9d 100644
--- a/tests/unit_tests/event_handler_test.py
+++ b/tests/unit_tests/event_handler_test.py
@@ -5,6 +5,7 @@
 from mmpy_bot import ExamplePlugin, Message, Settings, WebHookExample
 from mmpy_bot.driver import Driver
 from mmpy_bot.event_handler import EventHandler
+from mmpy_bot.plugins import PluginManager
 from mmpy_bot.wrappers import WebHookEvent
 
 
@@ -59,45 +60,20 @@ class TestEventHandler:
     @mock.patch("mmpy_bot.driver.Driver.username", new="my_username")
     def test_init(self):
         handler = EventHandler(
-            Driver(), Settings(), plugins=[ExamplePlugin(), WebHookExample()]
+            Driver(),
+            Settings(),
+            plugin_manager=PluginManager([ExamplePlugin(), WebHookExample()]),
         )
         # Test the name matcher regexp
         assert handler._name_matcher.match("@my_username are you there?")
         assert not handler._name_matcher.match("@other_username are you there?")
 
-        # Test that all listeners from the individual plugins are now registered on
-        # the handler
-        for plugin in handler.plugins:
-            for pattern, listener in plugin.message_listeners.items():
-                assert listener in handler.message_listeners[pattern]
-            for pattern, listener in plugin.webhook_listeners.items():
-                assert listener in handler.webhook_listeners[pattern]
-
-        # And vice versa, check that any listeners on the handler come from the
-        # registered plugins
-        for pattern, listeners in handler.message_listeners.items():
-            for listener in listeners:
-                assert any(
-                    [
-                        pattern in plugin.message_listeners
-                        and listener in plugin.message_listeners[pattern]
-                        for plugin in handler.plugins
-                    ]
-                )
-        for pattern, listeners in handler.webhook_listeners.items():
-            for listener in listeners:
-                assert any(
-                    [
-                        pattern in plugin.webhook_listeners
-                        and listener in plugin.webhook_listeners[pattern]
-                        for plugin in handler.plugins
-                    ]
-                )
-
     @mock.patch("mmpy_bot.driver.Driver.username", new="my_username")
     def test_should_ignore(self):
         handler = EventHandler(
-            Driver(), Settings(IGNORE_USERS=["ignore_me"]), plugins=[]
+            Driver(),
+            Settings(IGNORE_USERS=["ignore_me"]),
+            plugin_manager=PluginManager([]),
         )
         # We shouldn't ignore a message from betty, since she is not listed
         assert not handler._should_ignore(create_message(sender_name="betty"))
@@ -110,14 +86,14 @@ def test_should_ignore(self):
         handler = EventHandler(
             Driver(),
             Settings(IGNORE_USERS=["ignore_me"]),
-            plugins=[],
+            plugin_manager=PluginManager([]),
             ignore_own_messages=False,
         )
         assert not handler._should_ignore(create_message(sender_name="my_username"))
 
     @mock.patch("mmpy_bot.event_handler.EventHandler._handle_post")
     def test_handle_event(self, handle_post):
-        handler = EventHandler(Driver(), Settings(), plugins=[])
+        handler = EventHandler(Driver(), Settings(), plugin_manager=PluginManager([]))
         # This event should trigger _handle_post
         asyncio.run(handler._handle_event(json.dumps(create_message().body)))
         # This event should not
@@ -128,10 +104,14 @@ def test_handle_event(self, handle_post):
     @mock.patch("mmpy_bot.driver.Driver.username", new="my_username")
     def test_handle_post(self):
         # Create an initialized plugin so its listeners are registered
+        plugin = ExamplePlugin()
         driver = Driver()
-        plugin = ExamplePlugin().initialize(driver)
+        plugin_manager = PluginManager([plugin])
+        settings = Settings()
+        # Initialization should happen via PluginManager
+        plugin_manager.initialize(driver, settings)
         # Construct a handler with it
-        handler = EventHandler(driver, Settings(), plugins=[plugin])
+        handler = EventHandler(driver, settings, plugin_manager)
 
         # Mock the call_function of the plugin so we can make some asserts
         async def mock_call_function(function, message, groups):
@@ -154,10 +134,14 @@ async def mock_call_function(function, message, groups):
 
     def test_handle_webhook(self):
         # Create an initialized plugin so its listeners are registered
+        plugin = WebHookExample()
         driver = Driver()
-        plugin = WebHookExample().initialize(driver, Settings())
+        plugin_manager = PluginManager([plugin])
+        settings = Settings()
+        # Initialization should happen via PluginManager
+        plugin_manager.initialize(driver, settings)
         # Construct a handler with it
-        handler = EventHandler(driver, Settings(), plugins=[plugin])
+        handler = EventHandler(driver, settings, plugin_manager)
 
         # Mock the call_function of the plugin so we can make some asserts
         async def mock_call_function(function, event, groups):
diff --git a/tests/unit_tests/function_test.py b/tests/unit_tests/function_test.py
index 3097251d..4f621b20 100644
--- a/tests/unit_tests/function_test.py
+++ b/tests/unit_tests/function_test.py
@@ -8,6 +8,7 @@
 from mmpy_bot import ExamplePlugin, Settings, listen_to
 from mmpy_bot.driver import Driver
 from mmpy_bot.function import Function, MessageFunction, WebHookFunction, listen_webhook
+from mmpy_bot.plugins import PluginManager
 from mmpy_bot.webhook_server import NoResponse
 from mmpy_bot.wrappers import WebHookEvent
 
@@ -124,7 +125,8 @@ def mocked_reply(message, response):
             assert "no such option: --nonexistent-arg" in response.lower()
             assert f.docstring in response
 
-        f.plugin = ExamplePlugin().initialize(Driver(), Settings())
+        f.plugin = ExamplePlugin()
+        f.plugin.initialize(Driver(), PluginManager([f.plugin]), Settings())
         with mock.patch.object(
             f.plugin.driver, "reply_to", wraps=mocked_reply
         ) as mock_function:
@@ -136,7 +138,8 @@ def test_needs_mention(self):  # noqa
         wrapped = mock.create_autospec(example_listener)
         wrapped.__qualname__ = "wrapped"
         f = listen_to("", needs_mention=True)(wrapped)
-        f.plugin = ExamplePlugin().initialize(Driver())
+        f.plugin = ExamplePlugin()
+        f.plugin.initialize(Driver(), PluginManager([f.plugin]), Settings())
 
         # The default message mentions the specified user ID, so should be called
         f(create_message(mentions=["qmw86q7qsjriura9jos75i4why"]))
@@ -176,7 +179,8 @@ def fake_reply(message, text):
         driver.reply_to = mock.Mock(wraps=fake_reply)
 
         f = listen_to("", allowed_users=["Betty"])(wrapped)
-        f.plugin = ExamplePlugin().initialize(driver)
+        f.plugin = ExamplePlugin()
+        f.plugin.initialize(driver, PluginManager([f.plugin]), Settings())
 
         # This is fine, the names are not caps sensitive
         f(create_message(sender_name="betty"))
@@ -200,7 +204,8 @@ def fake_reply(message, text):
         driver.reply_to = mock.Mock(wraps=fake_reply)
 
         f = listen_to("", allowed_channels=["off-topic"])(wrapped)
-        f.plugin = ExamplePlugin().initialize(driver)
+        f.plugin = ExamplePlugin()
+        f.plugin.initialize(driver, PluginManager([f.plugin]), Settings())
 
         # This is fine, the names are not caps sensitive
         f(create_message(channel_name="off-topic"))
@@ -226,7 +231,8 @@ def fake_reply(message, text):
         f = listen_to("", allowed_channels=["off-topic"], silence_fail_msg=True)(
             wrapped
         )
-        f.plugin = ExamplePlugin().initialize(driver)
+        f.plugin = ExamplePlugin()
+        f.plugin.initialize(driver, PluginManager([f.plugin]), Settings())
 
         # This is fine, the names are not caps sensitive
         f(create_message(channel_name="off-topic"))
@@ -286,7 +292,9 @@ def test_click_exception(self):
             listen_webhook("")(click.command()(example_webhook_listener))
 
     def test_ensure_response(self):
-        p = ExamplePlugin().initialize(Driver())
+        p = ExamplePlugin()
+        plugin_manager = PluginManager([p])
+        p.initialize(Driver(), plugin_manager, Settings())
 
         def mock_respond(event, response):
             event.responded = True
diff --git a/tests/unit_tests/plugin_manager_test.py b/tests/unit_tests/plugin_manager_test.py
new file mode 100644
index 00000000..f08617df
--- /dev/null
+++ b/tests/unit_tests/plugin_manager_test.py
@@ -0,0 +1,169 @@
+import re
+from unittest import mock
+
+import click
+
+from mmpy_bot import Plugin, Settings, listen_to, listen_webhook
+from mmpy_bot.driver import Driver
+from mmpy_bot.plugins import PluginManager
+
+
+# Used in the plugin tests below
+class FakePlugin(Plugin):
+    """Hello FakePlugin.
+
+    This is a plugin level docstring
+    """
+
+    @listen_to("pattern", needs_mention=True)
+    def my_function(self, message, another_arg=True):
+        """This is the docstring of my_function."""
+        pass
+
+    @listen_to("direct_pattern", direct_only=True, allowed_users=["admin"])
+    def direct_function(self, message):
+        """Help direct function."""
+        pass
+
+    @listen_to("async_pattern")
+    @listen_to("another_async_pattern", direct_only=True)
+    async def my_async_function(self, message):
+        """Async function docstring."""
+        pass
+
+    @listen_to("click_command")
+    @click.command(help="Help string for the entire function.")
+    @click.option(
+        "--option", type=int, default=0, help="Help string for the optional argument."
+    )
+    def click_commmand(self, message, option):
+        """Extended docstring.
+
+        This docstring will have 'click --help' appended
+        """
+        pass
+
+    @listen_to("hi_custom", custom="Custom attribute")
+    def hi_custom(self, message):
+        """A custom function."""
+        pass
+
+    @listen_webhook("webhook_id")
+    def webhook_listener(self, event):
+        """A webhook function."""
+        pass
+
+
+def expand_func_names(f):
+    return [f] + f.siblings
+
+
+msg_listeners = {
+    re.compile("pattern"): expand_func_names(FakePlugin.my_function),
+    re.compile("direct_pattern"): expand_func_names(FakePlugin.direct_function),
+    re.compile("another_async_pattern"): expand_func_names(
+        FakePlugin.my_async_function
+    ),
+    re.compile("async_pattern"): expand_func_names(FakePlugin.my_async_function),
+    re.compile("hi_custom"): expand_func_names(FakePlugin.hi_custom),
+    # Click commands construct a regex pattern from the listen_to pattern
+    re.compile("^click_command (.*)?"): expand_func_names(FakePlugin.click_commmand),
+}
+
+hook_listeners = {
+    re.compile("webhook_id"): expand_func_names(FakePlugin.webhook_listener)
+}
+
+
+class TestPlugin:
+    def test_init_plugin(self):
+        p = FakePlugin()
+        m = PluginManager([p])
+        with mock.patch.object(p, "initialize") as mocked:
+            m.initialize(Driver(), Settings())
+
+            mocked.assert_called_once()
+
+    def test_initialize(self):
+        m = PluginManager([FakePlugin()])
+        m.initialize(Driver(), Settings())
+
+        # Test whether the function was registered properly
+        assert m.message_listeners[re.compile("pattern")] == [
+            FakePlugin.my_function,
+        ]
+
+        # This function should be registered twice, once for each listener
+        assert len(m.message_listeners[re.compile("async_pattern")]) == 1
+        assert (
+            m.message_listeners[re.compile("async_pattern")][0].function
+            == FakePlugin.my_async_function.function
+        )
+
+        assert len(m.message_listeners[re.compile("another_async_pattern")]) == 1
+        assert (
+            m.message_listeners[re.compile("another_async_pattern")][0].function
+            == FakePlugin.my_async_function.function
+        )
+
+        assert len(m.webhook_listeners) == 1
+        assert m.webhook_listeners[re.compile("webhook_id")] == [
+            FakePlugin.webhook_listener
+        ]
+
+
+class TestPluginManager:
+    def setup_method(self):
+        self.p = FakePlugin()
+        self.plugin_manager = PluginManager([self.p])
+
+    def test_init(self):
+        self.plugin_manager.initialize(Driver(), Settings())
+
+        # Test that listeners of individual plugins are now registered on the plugin_manager
+        assert len(msg_listeners) == len(self.plugin_manager.message_listeners)
+        for pattern, listeners in self.plugin_manager.message_listeners.items():
+            for listener in listeners:
+                assert pattern in msg_listeners
+                assert listener in msg_listeners[pattern]
+
+        assert len(hook_listeners) == len(self.plugin_manager.webhook_listeners)
+        for pattern, listeners in self.plugin_manager.webhook_listeners.items():
+            for listener in listeners:
+                assert pattern in hook_listeners
+                assert listener in hook_listeners[pattern]
+
+    def test_get_help(self):
+        # Prior to initialization there is no help
+        assert self.plugin_manager.get_help() == []
+
+        self.plugin_manager.initialize(Driver(), Settings())
+
+        assert len(self.plugin_manager.get_help()) != 0
+
+        for hlp in self.plugin_manager.get_help():
+            assert hlp.location == "FakePlugin"
+            assert (
+                hlp.function.plugin.__doc__
+                == """Hello FakePlugin.
+
+    This is a plugin level docstring
+    """
+            )
+            assert hlp.is_click == hlp.function.is_click_function
+            assert hlp.docfull.startswith(hlp.function.__doc__)
+
+            if hlp.pattern in ["direct_pattern", "another_async_pattern"]:
+                assert hlp.direct
+            else:
+                assert not hlp.direct
+
+            if hlp.pattern in ["pattern"]:
+                assert hlp.mention
+            else:
+                assert not hlp.mention
+
+            if hlp.help_type == "message":
+                assert hlp.pattern in map(lambda x: x.pattern, msg_listeners)
+            elif hlp.help_type == "webhook":
+                assert hlp.pattern in map(lambda x: x.pattern, hook_listeners)
diff --git a/tests/unit_tests/plugins_test.py b/tests/unit_tests/plugins_test.py
index 3df4b501..fdce1692 100644
--- a/tests/unit_tests/plugins_test.py
+++ b/tests/unit_tests/plugins_test.py
@@ -1,79 +1,33 @@
 import asyncio
-import re
 from unittest import mock
 
-import click
-
-from mmpy_bot import Plugin, listen_to, listen_webhook
+from mmpy_bot import Plugin, Settings, listen_to
 from mmpy_bot.driver import Driver
+from mmpy_bot.plugins import PluginManager
 
 from .event_handler_test import create_message
 
 
 # Used in the plugin tests below
 class FakePlugin(Plugin):
-    @listen_to("pattern")
-    def my_function(self, message, needs_mention=True):
+    @listen_to("pattern", needs_mention=True)
+    def my_function(self, message, another_arg=True):
         """This is the docstring of my_function."""
         pass
 
-    @listen_to("direct_pattern", direct_only=True, allowed_users=["admin"])
-    def direct_function(self, message):
-        pass
-
     @listen_to("async_pattern")
     @listen_to("another_async_pattern", direct_only=True)
     async def my_async_function(self, message):
         """Async function docstring."""
         pass
 
-    @listen_to("click_command")
-    @click.command(help="Help string for the entire function.")
-    @click.option(
-        "--option", type=int, default=0, help="Help string for the optional argument."
-    )
-    def click_commmand(self, message, option):
-        """Ignored docstring.
-
-        Just for code readability.
-        """
-        pass
-
-    @listen_webhook("webhook_id")
-    def webhook_listener(self, event):
-        """A webhook function."""
-        pass
-
 
 class TestPlugin:
-    def test_initialize(self):
-        p = FakePlugin().initialize(Driver())
-        # Test whether the function was registered properly
-        assert p.message_listeners[re.compile("pattern")] == [
-            FakePlugin.my_function,
-        ]
-
-        # This function should be registered twice, once for each listener
-        assert len(p.message_listeners[re.compile("async_pattern")]) == 1
-        assert (
-            p.message_listeners[re.compile("async_pattern")][0].function
-            == FakePlugin.my_async_function.function
-        )
-
-        assert len(p.message_listeners[re.compile("another_async_pattern")]) == 1
-        assert (
-            p.message_listeners[re.compile("another_async_pattern")][0].function
-            == FakePlugin.my_async_function.function
-        )
-
-        assert len(p.webhook_listeners) == 1
-        assert p.webhook_listeners[re.compile("webhook_id")] == [
-            FakePlugin.webhook_listener
-        ]
-
     @mock.patch("mmpy_bot.driver.ThreadPool.add_task")
     def test_call_function(self, add_task):
-        p = FakePlugin().initialize(Driver())
+        p = FakePlugin()
+        # Functions only listen for events when initialized via PluginManager
+        PluginManager([p]).initialize(Driver(), Settings())
 
         # Since this is not an async function, a task should be added to the threadpool
         message = create_message(text="pattern")
@@ -91,8 +45,3 @@ def test_call_function(self, add_task):
                 p.call_function(FakePlugin.my_async_function, message, groups=[])
             )
             mock_function.assert_called_once_with(p, message)
-
-    def test_help_string(self, snapshot):
-        p = FakePlugin().initialize(Driver())
-        # Compare the help string with the snapshotted version.
-        snapshot.assert_match(p.get_help_string())
diff --git a/tests/unit_tests/snapshots/snap_plugins_test.py b/tests/unit_tests/snapshots/snap_plugins_test.py
deleted file mode 100644
index faa0502a..00000000
--- a/tests/unit_tests/snapshots/snap_plugins_test.py
+++ /dev/null
@@ -1,48 +0,0 @@
-# -*- coding: utf-8 -*-
-# snapshottest: v1 - https://goo.gl/zC4yUc
-from __future__ import unicode_literals
-
-from snapshottest import Snapshot
-
-snapshots = Snapshot()
-
-snapshots['TestPlugin.test_help_string 1'] = '''Plugin FakePlugin has the following functions:
-----
-- `^click_command (.*)?`:
-        Usage: click_command [OPTIONS]
-        
-          Help string for the entire function.
-        
-        Options:
-          --option INTEGER  Help string for the optional argument.
-          --help            Show this message and exit.
-----
-- `direct_pattern`:
-        No description provided.
-    Additional information:
-    - Needs to be a direct message.
-    - Restricted to certain users.
-----
-- `^!help$`:
-        Prints the list of functions registered on every active plugin.
-----
-- `^help$`:
-        Prints the list of functions registered on every active plugin.
-    Additional information:
-    - Needs to either mention @ or be a direct message.
-----
-- `async_pattern`:
-        Async function docstring.
-----
-- `another_async_pattern`:
-        Async function docstring.
-    Additional information:
-    - Needs to be a direct message.
-----
-- `pattern`:
-        This is the docstring of my_function.
-----
-### Registered webhooks:
-- `webhook_id`:
-        A webhook function.
-'''