Repository: opsdroid/opsdroid

Issue:
Create a Mattermost connector module
Let's add a [Mattermost connector](https://mattermost.com/).

## Steps

 - Make a new submodule directory in [`opsdroid.connector`](https://github.com/opsdroid/opsdroid/tree/master/opsdroid/connector) called `mattermost` and create an `__init__.py` file inside.
 - Implement the connector according to the [documentation on writing connectors](https://opsdroid.readthedocs.io/en/stable/extending/connectors/).
 - Update the [`requirements.txt`](https://github.com/opsdroid/opsdroid/blob/master/requirements.txt) with any new dependencies from the connector if necessary.
 - Write tests for the connector. (See the [Slack connector tests](https://github.com/jacobtomlinson/opsdroid/blob/master/tests/test_connector_slack.py) for inspiration).
 - Create a [new documentation page](https://github.com/opsdroid/opsdroid/tree/master/docs/connectors).
 - Add the new page to the [mkdocs.yml](https://github.com/opsdroid/opsdroid/blob/master/mkdocs.yml).
 - Add to the [list of connectors](https://github.com/opsdroid/opsdroid/blob/master/docs/configuration-reference.md#connector-modules).

### Resources to get you started
   - [Mattermost API](https://api.mattermost.com/)
   - [Asyncio Mattermost Python module](https://pypi.org/project/mattermostdriver-asyncio/)

### Asyncio
 Opsdroid uses [asyncio and an event loop](https://docs.python.org/3/library/asyncio.html), so here are some things to bear in mind:
   - Functions should be coroutines which are defined with `async def myfunc(): ...`
   - Coroutines are called with `await`. For example `await myfunc()`. This is how asyncio allows many functions to take it in turns as they swap over when the current thread calls `await`.
   - **Don't block the thread!** If you need to make a call to a blocking function such as `time.sleep()` or any kind of IO request such as `requests.get()` then you should `await` an async variant. For example  you can call `await asyncio.sleep(10)` to sleep without blocking the whole application and you can use `aiohttp` as an async url library.


Discussion:
Hi, @jacobtomlinson can I take this issue?
Hello @leeyjjoel please go ahead and work on the issue and let us know if you need any help while working on it 👍

Resolving patch:
diff --git a/opsdroid/connector/mattermost/__init__.py b/opsdroid/connector/mattermost/__init__.py
new file mode 100644
--- /dev/null
+++ b/opsdroid/connector/mattermost/__init__.py
@@ -0,0 +1,107 @@
+"""A connector for Mattermost."""
+import logging
+import json
+
+from mattermostdriver import Driver, Websocket
+
+from opsdroid.connector import Connector, register_event
+from opsdroid.events import Message
+
+_LOGGER = logging.getLogger(__name__)
+
+
+class ConnectorMattermost(Connector):
+    """A connector for Mattermost."""
+
+    def __init__(self, config, opsdroid=None):
+        """Create the connector."""
+        super().__init__(config, opsdroid=opsdroid)
+        _LOGGER.debug(_("Starting Mattermost connector"))
+        self.name = "mattermost"
+        self.token = config["api-token"]
+        self.url = config["url"]
+        self.team_name = config["team-name"]
+        self.scheme = config.get("scheme", "https")
+        self.port = config.get("port", 8065)
+        self.verify = config.get("ssl-verify", True)
+        self.timeout = config.get("connect-timeout", 30)
+        self.request_timeout = None
+        self.mfa_token = None
+        self.debug = False
+        self.listening = True
+
+        self.mm_driver = Driver(
+            {
+                "url": self.url,
+                "token": self.token,
+                "scheme": self.scheme,
+                "port": self.port,
+                "verify": self.verify,
+                "timeout": self.timeout,
+                "request_timeout": self.request_timeout,
+                "mfa_token": self.mfa_token,
+                "debug": self.debug,
+            }
+        )
+
+    async def connect(self):
+        """Connect to the chat service."""
+        _LOGGER.info(_("Connecting to Mattermost"))
+
+        login_response = self.mm_driver.login()
+
+        _LOGGER.info(login_response)
+
+        if "id" in login_response:
+            self.bot_id = login_response["id"]
+        if "username" in login_response:
+            self.bot_name = login_response["username"]
+
+        _LOGGER.info(_("Connected as %s"), self.bot_name)
+
+        self.mm_driver.websocket = Websocket(
+            self.mm_driver.options, self.mm_driver.client.token
+        )
+
+        _LOGGER.info(_("Connected successfully"))
+
+    async def disconnect(self):
+        """Disconnect from Mattermost."""
+        self.listening = False
+        self.mm_driver.logout()
+
+    async def listen(self):
+        """Listen for and parse new messages."""
+        await self.mm_driver.websocket.connect(self.process_message)
+
+    async def process_message(self, raw_message):
+        """Process a raw message and pass it to the parser."""
+        _LOGGER.info(raw_message)
+
+        message = json.loads(raw_message)
+
+        if "event" in message and message["event"] == "posted":
+            data = message["data"]
+            post = json.loads(data["post"])
+            await self.opsdroid.parse(
+                Message(
+                    post["message"],
+                    data["sender_name"],
+                    data["channel_name"],
+                    self,
+                    raw_event=message,
+                )
+            )
+
+    @register_event(Message)
+    async def send_message(self, message):
+        """Respond with a message."""
+        _LOGGER.debug(
+            _("Responding with: '%s' in room  %s"), message.text, message.target
+        )
+        channel_id = self.mm_driver.channels.get_channel_by_name_and_team_name(
+            self.team_name, message.target
+        )["id"]
+        self.mm_driver.posts.create_post(
+            options={"channel_id": channel_id, "message": message.text}
+        )


Test patch:
diff --git a/tests/test_connector_mattermost.py b/tests/test_connector_mattermost.py
new file mode 100644
--- /dev/null
+++ b/tests/test_connector_mattermost.py
@@ -0,0 +1,175 @@
+"""Tests for the ConnectorMattermost class."""
+import asyncio
+import json
+
+import unittest
+import unittest.mock as mock
+import asynctest
+import asynctest.mock as amock
+
+from opsdroid.core import OpsDroid
+from opsdroid.connector.mattermost import ConnectorMattermost
+from opsdroid.cli.start import configure_lang
+from opsdroid.events import Message
+
+
+class TestConnectorMattermost(unittest.TestCase):
+    """Test the opsdroid Mattermost connector class."""
+
+    def setUp(self):
+        self.loop = asyncio.new_event_loop()
+        configure_lang({})
+
+    def test_init(self):
+        """Test that the connector is initialised properly."""
+        connector = ConnectorMattermost(
+            {"api-token": "abc123", "url": "localhost", "team-name": "opsdroid"},
+            opsdroid=OpsDroid(),
+        )
+        self.assertEqual("mattermost", connector.name)
+        self.assertEqual(30, connector.timeout)
+
+    def test_missing_api_key(self):
+        """Test that creating without an API key raises an error."""
+        with self.assertRaises(KeyError):
+            ConnectorMattermost({}, opsdroid=OpsDroid())
+
+
+class TestConnectorMattermostAsync(asynctest.TestCase):
+    """Test the async methods of the opsdroid Mattermost connector class."""
+
+    async def setUp(self):
+        configure_lang({})
+
+    async def test_connect(self):
+        connector = ConnectorMattermost(
+            {
+                "api-token": "abc123",
+                "url": "localhost",
+                "team-name": "opsdroid",
+                "scheme": "http",
+            },
+            opsdroid=OpsDroid(),
+        )
+        opsdroid = amock.CoroutineMock()
+        opsdroid.eventloop = self.loop
+        connector.mm_driver.login = mock.MagicMock()
+        connector.mm_driver.login.return_value = {"id": "1", "username": "opsdroid_bot"}
+        await connector.connect()
+        self.assertEqual("1", connector.bot_id)
+        self.assertEqual("opsdroid_bot", connector.bot_name)
+
+    async def test_disconnect(self):
+        connector = ConnectorMattermost(
+            {
+                "api-token": "abc123",
+                "url": "localhost",
+                "team-name": "opsdroid",
+                "scheme": "http",
+            },
+            opsdroid=OpsDroid(),
+        )
+        opsdroid = amock.CoroutineMock()
+        opsdroid.eventloop = self.loop
+        connector.mm_driver.login = mock.MagicMock()
+        connector.mm_driver.login.return_value = {"id": "1", "username": "opsdroid_bot"}
+        connector.mm_driver.logout = mock.MagicMock()
+        await connector.connect()
+        await connector.disconnect()
+        self.assertTrue(connector.mm_driver.logout.called)
+
+    async def test_listen(self):
+        """Test that listening consumes from the socket."""
+        connector = ConnectorMattermost(
+            {
+                "api-token": "abc123",
+                "url": "localhost",
+                "team-name": "opsdroid",
+                "scheme": "http",
+            },
+            opsdroid=OpsDroid(),
+        )
+        connector.mm_driver.websocket = mock.Mock()
+        connector.mm_driver.websocket.connect = amock.CoroutineMock()
+        await connector.listen()
+
+    async def test_process_message(self):
+        """Test processing a mattermost message."""
+        connector = ConnectorMattermost(
+            {
+                "api-token": "abc123",
+                "url": "localhost",
+                "team-name": "opsdroid",
+                "scheme": "http",
+            },
+            opsdroid=OpsDroid(),
+        )
+        connector.opsdroid = amock.CoroutineMock()
+        connector.opsdroid.parse = amock.CoroutineMock()
+
+        post = json.dumps(
+            {
+                "id": "wr9wetwc87bgdcx6opkjaxwb6a",
+                "create_at": 1574001673419,
+                "update_at": 1574001673419,
+                "edit_at": 0,
+                "delete_at": 0,
+                "is_pinned": False,
+                "user_id": "mrtopue9oigr8poa3bgfq4if4a",
+                "channel_id": "hdnm8gbxfp8bmcns7oswmwur4r",
+                "root_id": "",
+                "parent_id": "",
+                "original_id": "",
+                "message": "hello",
+                "type": "",
+                "props": {},
+                "hashtags": "",
+                "pending_post_id": "mrtopue9oigr8poa3bgfq4if4a:1574001673362",
+                "metadata": {},
+            }
+        )
+
+        message = json.dumps(
+            {
+                "event": "posted",
+                "data": {
+                    "channel_display_name": "@daniccan",
+                    "channel_name": "546qd6zsyffcfcafd77a3kdadr__mrtopue9oigr8poa3bgfq4if4a",
+                    "channel_type": "D",
+                    "mentions": '["546qd6zsyffcfcafd77a3kdadr"]',
+                    "post": post,
+                    "sender_name": "@daniccan",
+                    "team_id": "",
+                },
+                "broadcast": {
+                    "omit_users": "",
+                    "user_id": "",
+                    "channel_id": "hdnm8gbxfp8bmcns7oswmwur4r",
+                    "team_id": "",
+                },
+                "seq": 3,
+            }
+        )
+        await connector.process_message(message)
+        self.assertTrue(connector.opsdroid.parse.called)
+
+    async def test_send_message(self):
+        connector = ConnectorMattermost(
+            {
+                "api-token": "abc123",
+                "url": "localhost",
+                "team-name": "opsdroid",
+                "scheme": "http",
+            },
+            opsdroid=OpsDroid(),
+        )
+        connector.mm_driver = mock.Mock()
+        connector.mm_driver.channels = mock.Mock()
+        connector.mm_driver.channels.get_channel_by_name_and_team_name = (
+            mock.MagicMock()
+        )
+        connector.mm_driver.posts = mock.Mock()
+        connector.mm_driver.posts.create_post = mock.MagicMock()
+        await connector.send(Message("test", "user", "room", connector))
+        self.assertTrue(connector.mm_driver.channels.get_channel_by_name_and_team_name)
+        self.assertTrue(connector.mm_driver.posts.create_post.called)