Repository: opsdroid/opsdroid

Issue:
Telegram connector crashes when receiving a message from a channel
Hi,

I have found an issue that make telegram connector craches when receiving a message from a channel. the problem is that the json does not contain a **message**  entry

```
DEBUG opsdroid.core: Parsing input: hi
DEBUG opsdroid.core: Processing parsers...
DEBUG opsdroid.connector.telegram: {'update_id': 539026669, 'channel_post': {'message_id': 10, 'chat': {'id': -1001448219498, 'title': 'test-channel', 'type': 'channel'}, 'date': 1550093042, 'text': 'hi'}}
DEBUG opsdroid.connector.telegram: Responding with: Hi IObreaker

DEBUG asyncio: Using selector: EpollSelector
Traceback (most recent call last):
  File "/usr/bin/opsdroid", line 11, in <module>
    load_entry_point('opsdroid==0.14.1', 'console_scripts', 'opsdroid')()
  File "/usr/lib64/python3.6/site-packages/click/core.py", line 764, in __call__
    return self.main(*args, **kwargs)
  File "/usr/lib64/python3.6/site-packages/click/core.py", line 717, in main
    rv = self.invoke(ctx)
  File "/usr/lib64/python3.6/site-packages/click/core.py", line 956, in invoke
    return ctx.invoke(self.callback, **ctx.params)
  File "/usr/lib64/python3.6/site-packages/click/core.py", line 555, in invoke
    return callback(*args, **kwargs)
  File "/usr/lib/python3.6/site-packages/opsdroid/__main__.py", line 197, in main
    opsdroid.run()
  File "/usr/lib/python3.6/site-packages/opsdroid/core.py", line 135, in run
    self.eventloop.run_until_complete(asyncio.gather(*pending))
  File "/usr/lib64/python3.6/asyncio/base_events.py", line 473, in run_until_complete
    return future.result()
  File "/usr/lib/python3.6/site-packages/opsdroid/connector/telegram/__init__.py", line 164, in listen
    await self._get_messages()
  File "/usr/lib/python3.6/site-packages/opsdroid/connector/telegram/__init__.py", line 144, in _get_messages
    await self._parse_message(json)
  File "/usr/lib/python3.6/site-packages/opsdroid/connector/telegram/__init__.py", line 96, in _parse_message
    if result["message"]["text"]:
KeyError: 'message'
```

Testing if "message" in result before parsing should correct this issue in function in function **async def _parse_message(self, response):**

"Telegram changes many entries name, testing if they exists is mandatory before processing to avoid exception"

opsdroid v0.14.1
Python 3.6.7


Opsdroid rocks :-)
Regards



Discussion:

Resolving patch:
diff --git a/opsdroid/connector/telegram/__init__.py b/opsdroid/connector/telegram/__init__.py
--- a/opsdroid/connector/telegram/__init__.py
+++ b/opsdroid/connector/telegram/__init__.py
@@ -32,6 +32,9 @@ def __init__(self, config, opsdroid=None):
         self.default_user = config.get("default-user", None)
         self.whitelisted_users = config.get("whitelisted-users", None)
         self.update_interval = config.get("update_interval", 1)
+        self.session = None
+        self._closing = asyncio.Event()
+        self.loop = asyncio.get_event_loop()
 
         try:
             self.token = config["token"]
@@ -39,6 +42,45 @@ def __init__(self, config, opsdroid=None):
             _LOGGER.error("Unable to login: Access token is missing. "
                           "Telegram connector will be unavailable.")
 
+    @staticmethod
+    def get_user(response):
+        """Get user from response.
+
+        The API response is different depending on how
+        the bot is set up and where the message is coming
+        from. This method was created to keep if/else
+        statements to a minium on  _parse_message.
+
+        Args:
+            response (dict): Response returned by aiohttp.ClientSession.
+
+        """
+        user = None
+        if "username" in response["message"]["from"]:
+            user = response["message"]["from"]["username"]
+
+        elif "first_name" in response["message"]["from"]:
+            user = response["message"]["from"]["first_name"]
+
+        return user
+
+    def handle_user_permission(self, response, user):
+        """Handle user permissions.
+
+        This will check if the user that tried to talk with
+        the bot is allowed to do so. It will also work with
+        userid to improve security.
+
+        """
+        user_id = response["message"]["from"]["id"]
+
+        if not self.whitelisted_users or \
+            user in self.whitelisted_users or \
+                user_id in self.whitelisted_users:
+            return True
+
+        return False
+
     def build_url(self, method):
         """Build the url to connect to the API.
 
@@ -51,6 +93,23 @@ def build_url(self, method):
         """
         return "https://api.telegram.org/bot{}/{}".format(self.token, method)
 
+    async def delete_webhook(self):
+        """Delete Telegram webhook.
+
+        The Telegram api will thrown an 409 error when an webhook is
+        active and a call to getUpdates is made. This method will
+        try to request the deletion of the webhook to make the getUpdate
+        request possible.
+
+        """
+        _LOGGER.debug("Sending deleteWebhook request to Telegram...")
+        resp = await self.session.get(self.build_url("deleteWebhook"))
+
+        if resp.status == 200:
+            _LOGGER.debug("Telegram webhook deleted successfully.")
+        else:
+            _LOGGER.debug("Unable to delete webhook.")
+
     async def connect(self):
         """Connect to Telegram.
 
@@ -60,18 +119,18 @@ async def connect(self):
 
         """
         _LOGGER.debug("Connecting to telegram")
-        async with aiohttp.ClientSession() as session:
-            resp = await session.get(self.build_url("getMe"))
-
-            if resp.status != 200:
-                _LOGGER.error("Unable to connect")
-                _LOGGER.error("Telegram error %s, %s",
-                              resp.status, resp.text)
-            else:
-                json = await resp.json()
-                _LOGGER.debug(json)
-                _LOGGER.debug("Connected to telegram as %s",
-                              json["result"]["username"])
+        self.session = aiohttp.ClientSession()
+        resp = await self.session.get(self.build_url("getMe"))
+
+        if resp.status != 200:
+            _LOGGER.error("Unable to connect")
+            _LOGGER.error("Telegram error %s, %s",
+                          resp.status, resp.text)
+        else:
+            json = await resp.json()
+            _LOGGER.debug(json)
+            _LOGGER.debug("Connected to telegram as %s",
+                          json["result"]["username"])
 
     async def _parse_message(self, response):
         """Handle logic to parse a received message.
@@ -93,23 +152,26 @@ async def _parse_message(self, response):
         """
         for result in response["result"]:
             _LOGGER.debug(result)
-            if result["message"]["text"]:
-                user = result["message"]["from"]["username"]
-
+            if "channel" in result["message"]["chat"]["type"]:
+                _LOGGER.debug("Channel message parsing not supported "
+                              "- Ignoring message")
+            elif "message" in result and "text" in result["message"]:
+                user = self.get_user(result)
                 message = Message(
                     user,
                     result["message"]["chat"],
                     self,
                     result["message"]["text"])
 
-                if not self.whitelisted_users or \
-                        user in self.whitelisted_users:
+                if self.handle_user_permission(result, user):
                     await self.opsdroid.parse(message)
                 else:
                     message.text = "Sorry, you're not allowed " \
                                    "to speak with this bot."
                     await self.respond(message)
                 self.latest_update = result["update_id"] + 1
+            else:
+                _LOGGER.error("Unable to parse the message.")
 
     async def _get_messages(self):
         """Connect to the Telegram API.
@@ -124,23 +186,30 @@ async def _get_messages(self):
         return an empty {}.
 
         """
-        async with aiohttp.ClientSession() as session:
-            data = {}
-            if self.latest_update is not None:
-                data["offset"] = self.latest_update
-            resp = await session.get(self.build_url("getUpdates"),
-                                     params=data)
-            if resp.status != 200:
-                _LOGGER.error("Telegram error %s, %s",
-                              resp.status, resp.text)
-                self.listening = False
-
-            else:
-                json = await resp.json()
-
-                await self._parse_message(json)
-
-    async def listen(self):
+        data = {}
+        if self.latest_update is not None:
+            data["offset"] = self.latest_update
+
+        await asyncio.sleep(self.update_interval)
+        resp = await self.session.get(self.build_url("getUpdates"),
+                                      params=data)
+
+        if resp.status == 409:
+            _LOGGER.info("Can't get updates because previous "
+                         "webhook is still active. Will try to "
+                         "delete webhook.")
+            await self.delete_webhook()
+
+        if resp.status != 200:
+            _LOGGER.error("Telegram error %s, %s",
+                          resp.status, resp.text)
+            self.listening = False
+        else:
+            json = await resp.json()
+
+            await self._parse_message(json)
+
+    async def get_messages_loop(self):
         """Listen for and parse new messages.
 
         The bot will always listen to all opened chat windows,
@@ -153,14 +222,22 @@ async def listen(self):
         config.yaml with the param update-interval - this
         defaults to 1 second.
 
-        Args:
-            opsdroid (OpsDroid): An instance of opsdroid core.
-
         """
         while self.listening:
             await self._get_messages()
 
-            await asyncio.sleep(self.update_interval)
+    async def listen(self):
+        """Listen method of the connector.
+
+        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()
 
     async def respond(self, message, room=None):
         """Respond with a message.
@@ -172,13 +249,23 @@ async def respond(self, message, room=None):
         """
         _LOGGER.debug("Responding with: %s", message.text)
 
-        async with aiohttp.ClientSession() as session:
-            data = {}
-            data["chat_id"] = message.room["id"]
-            data["text"] = message.text
-            resp = await session.post(self.build_url("sendMessage"),
-                                      data=data)
-            if resp.status == 200:
-                _LOGGER.debug("Successfully responded")
-            else:
-                _LOGGER.error("Unable to respond.")
+        data = dict()
+        data["chat_id"] = message.room["id"]
+        data["text"] = message.text
+        resp = await self.session.post(self.build_url("sendMessage"),
+                                       data=data)
+        if resp.status == 200:
+            _LOGGER.debug("Successfully responded")
+        else:
+            _LOGGER.error("Unable to respond.")
+
+    async def disconnect(self):
+        """Disconnect from Telegram.
+
+        Stops the infinite loop found in self._listen(), closes
+        aiohttp session.
+
+        """
+        self.listening = False
+        self._closing.set()
+        await self.session.close()


Test patch:
diff --git a/tests/test_connector_telegram.py b/tests/test_connector_telegram.py
--- a/tests/test_connector_telegram.py
+++ b/tests/test_connector_telegram.py
@@ -1,11 +1,10 @@
 """Tests for the ConnectorTelegram class."""
 import asyncio
+import contextlib
 import unittest
-import unittest.mock as mock
 import asynctest
 import asynctest.mock as amock
 
-from opsdroid.__main__ import configure_lang
 from opsdroid.core import OpsDroid
 from opsdroid.connector.telegram import ConnectorTelegram
 from opsdroid.events import Message
@@ -42,8 +41,10 @@ def setUp(self):
         self.connector = ConnectorTelegram({
                 'name': 'telegram',
                 'token': 'bot:765test',
-                'whitelisted-users': ['user', 'test']
+                'whitelisted-users': ['user', 'test', 'AnUser']
             }, opsdroid=OpsDroid())
+        with amock.patch('aiohttp.ClientSession') as mocked_session:
+            self.connector.session = mocked_session
 
     async def test_connect(self):
         connect_response = amock.Mock()
@@ -59,8 +60,8 @@ async def test_connect(self):
             }
         }
 
-        with OpsDroid() as opsdroid, \
-            amock.patch('aiohttp.ClientSession.get') as patched_request:
+        with amock.patch('aiohttp.ClientSession.get')\
+                as patched_request:
 
             patched_request.return_value = asyncio.Future()
             patched_request.return_value.set_result(connect_response)
@@ -73,8 +74,8 @@ async def test_connect_failure(self):
         result = amock.MagicMock()
         result.status = 401
 
-        with OpsDroid() as opsdroid, \
-            amock.patch('aiohttp.ClientSession.get') as patched_request:
+        with amock.patch('aiohttp.ClientSession.get')\
+                as patched_request:
 
             patched_request.return_value = asyncio.Future()
             patched_request.return_value.set_result(result)
@@ -82,8 +83,8 @@ async def test_connect_failure(self):
             await self.connector.connect()
             self.assertLogs('_LOGGER', 'error')
 
-    async def test_parse_message(self):
-        response = { 'result': [{
+    async def test_parse_message_username(self):
+        response = {'result': [{
             "update_id": 427647860,
             "message": {
                 "message_id": 12,
@@ -99,7 +100,7 @@ async def test_parse_message(self):
                     "id": 649671308,
                     "first_name": "A",
                     "last_name": "User",
-                    "username": "a_user",
+                    "username": "user",
                     "type": "private"
                 },
                 "date": 1538756863,
@@ -107,14 +108,91 @@ async def test_parse_message(self):
             }
         }]}
 
-        with OpsDroid() as opsdroid, \
-                amock.patch('opsdroid.core.OpsDroid.parse') as mocked_parse:
+        with amock.patch('opsdroid.core.OpsDroid.parse') as mocked_parse:
+            await self.connector._parse_message(response)
+            self.assertTrue(mocked_parse.called)
+
+    async def test_parse_message_channel(self):
+        response = {'result': [{
+            "update_id": 427647860,
+            "message": {
+                "message_id": 12,
+                "from": {
+                    "id": 649671308,
+                    "is_bot": False,
+                    "first_name": "A",
+                    "last_name": "User",
+                    "username": "user",
+                    "language_code": "en-GB"
+                },
+                "chat": {
+                    "id": 649671308,
+                    "first_name": "A",
+                    "last_name": "User",
+                    "username": "user",
+                    "type": "channel"
+                },
+                "date": 1538756863,
+                "text": "Hello"
+            }
+        }]}
+
+        with amock.patch('opsdroid.core.OpsDroid.parse') as mocked_parse:
+            await self.connector._parse_message(response)
+            self.assertLogs('_LOGGER', 'debug')
+
+    async def test_parse_message_first_name(self):
+        response = { 'result': [{
+            "update_id": 427647860,
+            "message": {
+                "message_id": 12,
+                "from": {
+                    "id": 649671308,
+                    "is_bot": False,
+                    "first_name": "AnUser",
+                    "type": "private",
+                    "language_code": "en-GB"
+                },
+                "chat": {
+                    "id": 649671308,
+                    "first_name": "AnUser",
+                    "type": "private"
+                },
+                "date": 1538756863,
+                "text": "Hello"
+            }
+        }]}
+
+        with amock.patch('opsdroid.core.OpsDroid.parse') as mocked_parse:
             await self.connector._parse_message(response)
             self.assertTrue(mocked_parse.called)
 
+    async def test_parse_message_bad_result(self):
+        response = {'result': [{
+            "update_id": 427647860,
+            "message": {
+                "message_id": 12,
+                "from": {
+                    "id": 649671308,
+                    "is_bot": False,
+                    "first_name": "test",
+                    "language_code": "en-GB"
+                },
+                "chat": {
+                    "id": 649671308,
+                    "first_name": "test",
+                    "type": "private"
+                },
+                "date": 1538756863,
+            }
+        }]}
+
+        await self.connector._parse_message(response)
+        self.assertLogs('error', '_LOGGER')
+
     async def test_parse_message_unauthorized(self):
         self.connector.config['whitelisted-users'] = ['user', 'test']
-        response = { 'result': [{
+        response = {'result': [{
             "update_id": 427647860,
             "message": {
                 "message_id": 12,
@@ -143,8 +221,7 @@ async def test_parse_message_unauthorized(self):
 
         message_text = "Sorry, you're not allowed to speak with this bot."
 
-        with OpsDroid() as opsdroid, \
-                amock.patch.object(self.connector, 'respond') \
+        with amock.patch.object(self.connector, 'respond') \
                 as mocked_respond:
             await self.connector._parse_message(response)
             self.assertTrue(mocked_respond.called)
@@ -180,9 +257,9 @@ async def test_get_messages(self):
             }
         ]}
 
-        with OpsDroid() as opsdroid, \
-            amock.patch('aiohttp.ClientSession.get') as patched_request,\
-            amock.patch.object(self.connector, '_parse_message') \
+        with amock.patch.object(self.connector.session, 'get') \
+                as patched_request,\
+                amock.patch.object(self.connector, '_parse_message') \
                 as mocked_parse_message:
 
             self.connector.latest_update = 54
@@ -194,29 +271,71 @@ async def test_get_messages(self):
             self.assertLogs('_LOGGER', 'debug')
             self.assertTrue(mocked_parse_message.called)
 
+    async def test_delete_webhook(self):
+        response = amock.Mock()
+        response.status = 200
+
+        with amock.patch.object(self.connector.session, 'get') \
+                as mock_request:
+            mock_request.return_value = asyncio.Future()
+            mock_request.return_value.set_result(response)
+
+            await self.connector.delete_webhook()
+            self.assertLogs('_LOGGER', 'debug')
+
+    async def test_get_message_webhook(self):
+        response = amock.Mock()
+        response.status = 409
+
+        with amock.patch.object(self.connector.session, 'get') \
+                as mock_request, \
+                amock.patch.object(self.connector, 'delete_webhook') \
+                as mock_method:
+            mock_request.return_value = asyncio.Future()
+            mock_request.return_value.set_result(response)
+
+            await self.connector._get_messages()
+            self.assertLogs('_LOGGER', 'info')
+            self.assertTrue(mock_method.called)
+
+
+    async def test_delete_webhook_failure(self):
+        response = amock.Mock()
+        response.status = 401
+
+        with amock.patch.object(self.connector.session, 'get') \
+                as mock_request:
+            mock_request.return_value = asyncio.Future()
+            mock_request.return_value.set_result(response)
+
+            await self.connector.delete_webhook()
+            self.assertLogs('_LOGGER', 'debug')
+
     async def test_get_messages_failure(self):
         listen_response = amock.Mock()
         listen_response.status = 401
 
-        with OpsDroid() as opsdroid, \
-            amock.patch('aiohttp.ClientSession.get') as patched_request:
+        with amock.patch.object(self.connector.session, 'get') \
+                as patched_request:
 
             patched_request.return_value = asyncio.Future()
             patched_request.return_value.set_result(listen_response)
             await self.connector._get_messages()
             self.assertLogs('_LOGGER', 'error')
 
-    async def test_listen(self):
-        self.connector.listening = amock.CoroutineMock()
-        self.connector.listening.side_effect = Exception()
-        await self.connector.listen()
+    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') as patched_request:
+                amock.patch.object(self.connector.session, 'post')\
+                as patched_request:
 
             self.assertTrue(opsdroid.__class__.instances)
             test_message = Message(text="This is a test",
@@ -235,7 +354,8 @@ async def test_respond_failure(self):
         post_response.status = 401
 
         with OpsDroid() as opsdroid, \
-            amock.patch('aiohttp.ClientSession.post') as patched_request:
+                amock.patch.object(self.connector.session, 'post')\
+                as patched_request:
 
             self.assertTrue(opsdroid.__class__.instances)
             test_message = Message(text="This is a test",
@@ -247,3 +367,26 @@ 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_listen(self):
+        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_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)
\ No newline at end of file