Repository: opsdroid/opsdroid

Issue:
Implement disconnect method to connectors
<!-- Before you post an issue or if you are unsure about something join our gitter channel https://gitter.im/opsdroid/ and ask away! We are more than happy to help you. -->
# Description
Opsdroid can now exit in a clean way after #711 was merged. But most of the connectors (with the exception of websockets) don't implement the `disconnect` method and when opsdroid tries to exit, an exception occurs, because the connector task was destroyed while pending.

Implementing the disconnect method on every connector will avoid this exception to occur.

---
_As a work around until every connector is addressed should we just get all tasks and cancel every single one within the `core.unload` method?_

## Steps to Reproduce
Start opsdroid, wait for load and then kill it with `ctrl+c` the shell connector will throw the exception.


## Expected Functionality
Opsdroid should be able to exit without any exception being thrown.


## Experienced Functionality
Every single thing is cancelled but the connectors that don't implement the disconnect method.

```python
Caught exception

ERROR opsdroid.core: {'message': 'Task was destroyed but it is pending!', 'task': <Task pending coro=<ConnectorShell.listen() done, defined at /Users/fabiorosado/Library/Application Support/opsdroid/opsdroid-modules/connector/shell/__init__.py:64> wait_for=<Future pending cb=[<TaskWakeupMethWrapper object at 0x10dbfd9a8>()]> cb=[gather.<locals>._done_callback(0)() at /usr/local/Cellar/python3/3.6.1/Frameworks/Python.framework/Versions/3.6/lib/python3.6/asyncio/tasks.py:609]>}
```



<!-- Love opsdroid? Please consider supporting our collective:
 +👉  https://opencollective.com/opsdroid/donate -->


Discussion:
Related to #723 
This issue has been automatically marked as stale because it has not had recent activity. It will be closed if no further activity occurs. Thank you for your contributions.

This still needs fixing.
This issue has been automatically marked as stale because it has not had recent activity. It will be closed if no further activity occurs. Thank you for your contributions.

This issue has been automatically marked as stale because it has not had recent activity. It will be closed if no further activity occurs. Thank you for your contributions.

This issue has been automatically marked as stale because it has not had recent activity. It will be closed if no further activity occurs. Thank you for your contributions.

Only the Rocket Chat connector needs this implementing now. @FabioRosado is this something you could pick up?

Resolving patch:
diff --git a/opsdroid/connector/rocketchat/__init__.py b/opsdroid/connector/rocketchat/__init__.py
--- a/opsdroid/connector/rocketchat/__init__.py
+++ b/opsdroid/connector/rocketchat/__init__.py
@@ -35,6 +35,9 @@ def __init__(self, config, opsdroid=None):
         self.bot_name = config.get("bot-name", "opsdroid")
         self.listening = True
         self.latest_update = datetime.datetime.utcnow().isoformat()
+        self._closing = asyncio.Event()
+        self.loop = asyncio.get_event_loop()
+        self.session = None
 
         try:
             self.user_id = config["user-id"]
@@ -73,15 +76,14 @@ async def connect(self):
 
         """
         _LOGGER.info("Connecting to Rocket.Chat")
-
-        async with aiohttp.ClientSession() as session:
-            resp = await session.get(self.build_url("me"), headers=self.headers)
-            if resp.status != 200:
-                _LOGGER.error("Unable to connect.")
-                _LOGGER.error("Rocket.Chat error %s, %s", resp.status, resp.text)
-            else:
-                json = await resp.json()
-                _LOGGER.debug("Connected to Rocket.Chat as %s", json["username"])
+        self.session = aiohttp.ClientSession()
+        resp = await self.session.get(self.build_url("me"), headers=self.headers)
+        if resp.status != 200:
+            _LOGGER.error("Unable to connect.")
+            _LOGGER.error("Rocket.Chat error %s, %s", resp.status, resp.text)
+        else:
+            json = await resp.json()
+            _LOGGER.debug("Connected to Rocket.Chat as %s", json["username"])
 
     async def _parse_message(self, response):
         """Parse the message received.
@@ -124,8 +126,8 @@ async def _get_message(self):
         if self.latest_update:
             url += "&oldest={}".format(self.latest_update)
 
-        async with aiohttp.ClientSession() as session:
-            resp = await session.get(url, headers=self.headers)
+            await asyncio.sleep(self.update_interval)
+            resp = await self.session.get(url, headers=self.headers)
 
             if resp.status != 200:
                 _LOGGER.error("Rocket.Chat error %s, %s", resp.status, resp.text)
@@ -134,7 +136,7 @@ async def _get_message(self):
                 json = await resp.json()
                 await self._parse_message(json)
 
-    async def listen(self):
+    async def get_messages_loop(self):
         """Listen for and parse new messages.
 
         The method will sleep asynchronously at the end of
@@ -148,7 +150,19 @@ async def listen(self):
         """
         while self.listening:
             await self._get_message()
-            await asyncio.sleep(self.update_interval)
+
+    async def listen(self):
+        """Listen for and parse new messages.
+
+        Every connector has to implement the listen method. When an
+        infinite loop is running, it becomes hard to cancel this task.
+        So we are creating a task and set it on a variable so we can
+        cancel the task.
+
+        """
+        message_getter = self.loop.create_task(self.get_messages_loop())
+        await self._closing.wait()
+        message_getter.cancel()
 
     @register_event(Message)
     async def send_message(self, message):
@@ -163,17 +177,28 @@ async def send_message(self, message):
 
         """
         _LOGGER.debug("Responding with: %s", message.text)
-        async with aiohttp.ClientSession() as session:
-            data = {}
-            data["channel"] = message.target
-            data["alias"] = self.bot_name
-            data["text"] = message.text
-            data["avatar"] = ""
-            resp = await session.post(
-                self.build_url("chat.postMessage"), headers=self.headers, data=data
-            )
 
-            if resp.status == 200:
-                _LOGGER.debug("Successfully responded")
-            else:
-                _LOGGER.debug("Error - %s: Unable to respond", resp.status)
+        data = {}
+        data["channel"] = message.target
+        data["alias"] = self.bot_name
+        data["text"] = message.text
+        data["avatar"] = ""
+        resp = await self.session.post(
+            self.build_url("chat.postMessage"), headers=self.headers, data=data
+        )
+
+        if resp.status == 200:
+            _LOGGER.debug("Successfully responded")
+        else:
+            _LOGGER.debug("Error - %s: Unable to respond", resp.status)
+
+    async def disconnect(self):
+        """Disconnect from Rocket.Chat.
+
+        Stops the infinite loop found in self._listen() and closes
+        aiohttp session.
+
+        """
+        self.listening = False
+        self._closing.set()
+        await self.session.close()


Test patch:
diff --git a/tests/test_connector_rocketchat.py b/tests/test_connector_rocketchat.py
--- a/tests/test_connector_rocketchat.py
+++ b/tests/test_connector_rocketchat.py
@@ -1,11 +1,10 @@
 """Tests for the RocketChat class."""
 import asyncio
 import unittest
-import unittest.mock as mock
+import contextlib
 import asynctest
 import asynctest.mock as amock
 
-from opsdroid.__main__ import configure_lang
 from opsdroid.core import OpsDroid
 from opsdroid.connector.rocketchat import RocketChat
 from opsdroid.events import Message
@@ -22,7 +21,12 @@ def setUp(self):
     def test_init(self):
         """Test that the connector is initialised properly."""
         connector = RocketChat(
-            {"name": "rocket.chat", "access-token": "test", "user-id": "userID"},
+            {
+                "name": "rocket.chat",
+                "access-token": "test",
+                "user-id": "userID",
+                "update-interval": 0.1,
+            },
             opsdroid=OpsDroid(),
         )
         self.assertEqual("general", connector.default_target)
@@ -52,6 +56,9 @@ def setUp(self):
 
         self.connector.latest_update = "2018-10-08T12:57:37.126Z"
 
+        with amock.patch("aiohttp.ClientSession") as mocked_session:
+            self.connector.session = mocked_session
+
     async def test_connect(self):
         connect_response = amock.Mock()
         connect_response.status = 200
@@ -99,15 +106,7 @@ async def test_connect_failure(self):
             self.assertLogs("_LOGGER", "error")
 
     async def test_get_message(self):
-        connector_group = RocketChat(
-            {
-                "name": "rocket.chat",
-                "token": "test",
-                "user-id": "userID",
-                "group": "test",
-            },
-            opsdroid=OpsDroid(),
-        )
+        self.connector.group = "test"
         response = amock.Mock()
         response.status = 200
         response.json = amock.CoroutineMock()
@@ -137,19 +136,23 @@ async def test_get_message(self):
             ]
         }
 
-        with OpsDroid() as opsdroid, amock.patch(
-            "aiohttp.ClientSession.get"
+        with OpsDroid() as opsdroid, amock.patch.object(
+            self.connector.session, "get"
         ) as patched_request, amock.patch.object(
-            connector_group, "_parse_message"
-        ) as mocked_parse_message:
+            self.connector, "_parse_message"
+        ) as mocked_parse_message, amock.patch(
+            "asyncio.sleep"
+        ) as mocked_sleep:
 
             patched_request.return_value = asyncio.Future()
             patched_request.return_value.set_result(response)
 
-            await connector_group._get_message()
+            await self.connector._get_message()
 
             self.assertTrue(patched_request.called)
             self.assertTrue(mocked_parse_message.called)
+            self.assertTrue(mocked_sleep.called)
+            self.assertLogs("_LOGGER", "debug")
 
     async def test_parse_message(self):
         response = {
@@ -187,15 +190,25 @@ async def test_parse_message(self):
             self.assertEqual("2018-05-11T16:05:41.047Z", self.connector.latest_update)
 
     async def test_listen(self):
-        self.connector.side_effect = Exception()
-        await self.connector.listen()
+        with amock.patch.object(
+            self.connector.loop, "create_task"
+        ) as mocked_task, amock.patch.object(
+            self.connector._closing, "wait"
+        ) as mocked_event:
+            mocked_event.return_value = asyncio.Future()
+            mocked_event.return_value.set_result(True)
+            mocked_task.return_value = asyncio.Future()
+            await self.connector.listen()
+
+            self.assertTrue(mocked_event.called)
+            self.assertTrue(mocked_task.called)
 
     async def test_get_message_failure(self):
         listen_response = amock.Mock()
         listen_response.status = 401
 
-        with OpsDroid() as opsdroid, amock.patch(
-            "aiohttp.ClientSession.get"
+        with OpsDroid() as opsdroid, amock.patch.object(
+            self.connector.session, "get"
         ) as patched_request:
 
             patched_request.return_value = asyncio.Future()
@@ -204,12 +217,18 @@ async def test_get_message_failure(self):
             self.assertLogs("_LOGGER", "error")
             self.assertEqual(False, self.connector.listening)
 
+    async def test_get_messages_loop(self):
+        self.connector._get_messages = amock.CoroutineMock()
+        self.connector._get_messages.side_effect = Exception()
+        with contextlib.suppress(Exception):
+            await self.connector.get_messages_loop()
+
     async def test_respond(self):
         post_response = amock.Mock()
         post_response.status = 200
 
-        with OpsDroid() as opsdroid, amock.patch(
-            "aiohttp.ClientSession.post"
+        with OpsDroid() as opsdroid, amock.patch.object(
+            self.connector.session, "post"
         ) as patched_request:
 
             self.assertTrue(opsdroid.__class__.instances)
@@ -230,8 +249,8 @@ async def test_respond_failure(self):
         post_response = amock.Mock()
         post_response.status = 401
 
-        with OpsDroid() as opsdroid, amock.patch(
-            "aiohttp.ClientSession.post"
+        with OpsDroid() as opsdroid, amock.patch.object(
+            self.connector.session, "post"
         ) as patched_request:
 
             self.assertTrue(opsdroid.__class__.instances)
@@ -246,3 +265,13 @@ async def test_respond_failure(self):
             patched_request.return_value.set_result(post_response)
             await test_message.respond("Response")
             self.assertLogs("_LOGGER", "debug")
+
+    async def test_disconnect(self):
+        with amock.patch.object(self.connector.session, "close") as mocked_close:
+            mocked_close.return_value = asyncio.Future()
+            mocked_close.return_value.set_result(True)
+
+            await self.connector.disconnect()
+            self.assertFalse(self.connector.listening)
+            self.assertTrue(self.connector.session.closed())
+            self.assertEqual(self.connector._closing.set(), None)