Repository: opsdroid/opsdroid

Issue:
Move Telegram connector into core
This issue covers adding the [Telegram connector](https://github.com/opsdroid/connector-telegram) to core.

## Background
A while ago we began moving connectors from external plugins into the core of the project (see #185 for more context). We started with [slack](https://github.com/opsdroid/opsdroid/blob/master/opsdroid/connector/slack/__init__.py) and [websockets](https://github.com/opsdroid/opsdroid/blob/master/opsdroid/connector/websocket/__init__.py) but need to go through all the other existing plugins and move them into the core.

## Steps

 - Make a new submodule directory in [`opsdroid.connector`](https://github.com/opsdroid/opsdroid/tree/master/opsdroid/connector) and copy the connector code over.
 - Update the [`requirements.txt`](https://github.com/opsdroid/opsdroid/blob/master/requirements.txt) with any 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).
 - Copy the relevant information from the connector `README.md` into 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).
 - Add a deprecation notice to the old connector. (See [the slack connector](https://github.com/opsdroid/connector-slack))


Discussion:
I will be working on this issue 👍 

Resolving patch:
diff --git a/opsdroid/connector/telegram/__init__.py b/opsdroid/connector/telegram/__init__.py
new file mode 100644
--- /dev/null
+++ b/opsdroid/connector/telegram/__init__.py
@@ -0,0 +1,190 @@
+"""A connector for Telegram."""
+import asyncio
+import logging
+import aiohttp
+
+
+from opsdroid.connector import Connector
+from opsdroid.message import Message
+
+
+_LOGGER = logging.getLogger(__name__)
+
+
+class ConnectorTelegram(Connector):
+    """A connector the the char service Telegram."""
+
+    def __init__(self, config):
+        """Create the connector.
+
+        Args:
+            config (dict): configuration settings from the
+                file config.yaml.
+
+        """
+        _LOGGER.debug("Loaded telegram connector")
+        super().__init__(config)
+        self.name = "telegram"
+        self.latest_update = None
+        self.default_room = None
+        self.listening = True
+        self.default_user = config.get("default-user", None)
+        self.whitelisted_users = config.get("whitelisted-users", None)
+        self.update_interval = config.get("update_interval", 1)
+
+        try:
+            self.token = config["token"]
+        except (KeyError, AttributeError):
+            _LOGGER.error("Unable to login: Access token is missing. "
+                          "Telegram connector will be unavailable.")
+
+    def build_url(self, method):
+        """Build the url to connect to the API.
+
+        Args:
+            methods (string): API call end point.
+
+        Return:
+            String that represents the full API url.
+
+        """
+        return "https://api.telegram.org/bot{}/{}".format(self.token, method)
+
+    async def connect(self, opsdroid):
+        """Connect to Telegram.
+
+        This method is not an authorization call. It basically
+        checks if the API token was provided and makes an API
+        call to Telegram and evaluates the status of the call.
+
+        Args:
+            opsdroid (OpsDroid): An instance of opsdroid core.
+
+        """
+        _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"])
+
+    async def _parse_message(self, opsdroid, response):
+        """Handle logic to parse a received message.
+
+        Since everyone can send a private message to any user/bot
+        in Telegram, this method allows to set a list of whitelisted
+        users that can interact with the bot. If any other user tries
+        to interact with the bot the command is not parsed and instead
+        the bot will inform that user that he is not allowed to talk
+        with the bot.
+
+        We also set self.latest_update to +1 in order to get the next
+        available message (or an empty {} if no message has been received
+        yet) with the method self._get_messages().
+
+        Args:
+            opsdroid (OpsDroid): An instance of opsdroid core.
+            response (dict): Response returned by aiohttp.ClientSession.
+        """
+        for result in response["result"]:
+            _LOGGER.debug(result)
+            if result["message"]["text"]:
+                user = result["message"]["from"]["username"]
+
+                message = Message(
+                    result["message"]["text"],
+                    user,
+                    result["message"]["chat"],
+                    self)
+
+                if not self.whitelisted_users or \
+                        user in self.whitelisted_users:
+                    await 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
+
+    async def _get_messages(self, opsdroid):
+        """Connect to the Telegram API.
+
+        Uses an aiohttp ClientSession to connect to Telegram API
+        and get the latest messages from the chat service.
+
+        The data["offset"] is used to consume every new message, the API
+        returns an  int - "update_id" value. In order to get the next
+        message this value needs to be increased by 1 the next time
+        the API is called. If no new messages exists the API will just
+        return an empty {}.
+
+        Args:
+            opsdroid (OpsDroid): An instance of opsdroid core.
+
+        """
+        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()
+                # _LOGGER.debug(json)
+
+                await self._parse_message(opsdroid, json)
+
+    async def listen(self, opsdroid):
+        """Listen for and parse new messages.
+
+        The bot will always listen to all opened chat windows,
+        as long as opsdroid is running. Since anyone can start
+        a new chat with the bot is recommended that a list of
+        users to be whitelisted be provided in config.yaml.
+
+        The method will sleep asynchronously at the end of
+        every loop. The time can either be specified in the
+        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(opsdroid)
+
+            await asyncio.sleep(self.update_interval)
+
+    async def respond(self, message, room=None):
+        """Respond with a message.
+
+        Args:
+            message (object): An instance of Message.
+            room (string, optional): Name of the room to respond to.
+
+        """
+        _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.")


Test patch:
diff --git a/tests/test_connector_telegram.py b/tests/test_connector_telegram.py
new file mode 100644
--- /dev/null
+++ b/tests/test_connector_telegram.py
@@ -0,0 +1,245 @@
+"""Tests for the ConnectorTelegram class."""
+import asyncio
+import unittest
+import unittest.mock as mock
+import asynctest
+import asynctest.mock as amock
+
+from opsdroid.core import OpsDroid
+from opsdroid.connector.telegram import ConnectorTelegram
+from opsdroid.message import Message
+
+
+class TestConnectorTelegram(unittest.TestCase):
+    """Test the opsdroid Telegram connector class."""
+
+    def setUp(self):
+        self.loop = asyncio.new_event_loop()
+
+    def test_init(self):
+        """Test that the connector is initialised properly."""
+        connector = ConnectorTelegram({
+            'name': 'telegram',
+            'token': 'test',
+        })
+        self.assertEqual(None, connector.default_room)
+        self.assertEqual("telegram", connector.name)
+
+    def test_missing_token(self):
+        """Test that attempt to connect without info raises an error."""
+        ConnectorTelegram({})
+        self.assertLogs('_LOGGER', 'error')
+
+
+class TestConnectorTelegramAsync(asynctest.TestCase):
+    """Test the async methods of the opsdroid Telegram connector class."""
+
+    def setUp(self):
+        self.connector = ConnectorTelegram({
+                'name': 'telegram',
+                'token': 'bot:765test',
+                'whitelisted-users': ['user', 'test']
+            })
+
+    async def test_connect(self):
+        connect_response = amock.Mock()
+        connect_response.status = 200
+        connect_response.json = amock.CoroutineMock()
+        connect_response.return_value = {
+            "ok": True,
+            "result": {
+                "id": 635392558,
+                "is_bot": True,
+                "first_name": "opsdroid",
+                "username": "opsdroid_bot"
+            }
+        }
+
+        with OpsDroid() as opsdroid, \
+            amock.patch('aiohttp.ClientSession.get') as patched_request:
+
+            patched_request.return_value = asyncio.Future()
+            patched_request.return_value.set_result(connect_response)
+            await self.connector.connect(opsdroid)
+            self.assertLogs('_LOGGER', 'debug')
+            self.assertNotEqual(200, patched_request.status)
+            self.assertTrue(patched_request.called)
+
+    async def test_connect_failure(self):
+        result = amock.MagicMock()
+        result.status = 401
+
+        with OpsDroid() as opsdroid, \
+            amock.patch('aiohttp.ClientSession.get') as patched_request:
+
+            patched_request.return_value = asyncio.Future()
+            patched_request.return_value.set_result(result)
+
+            await self.connector.connect(opsdroid)
+            self.assertLogs('_LOGGER', 'error')
+
+    async def test_parse_message(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": "a_user",
+                    "type": "private"
+                },
+                "date": 1538756863,
+                "text": "Hello"
+            }
+        }]}
+
+        with OpsDroid() as opsdroid, \
+                amock.patch('opsdroid.core.OpsDroid.parse') as mocked_parse:
+            await self.connector._parse_message(opsdroid, response)
+            self.assertTrue(mocked_parse.called)
+
+    async def test_parse_message_unauthorized(self):
+        self.connector.config['whitelisted-users'] = ['user', 'test']
+        response = { 'result': [{
+            "update_id": 427647860,
+            "message": {
+                "message_id": 12,
+                "from": {
+                    "id": 649671308,
+                    "is_bot": False,
+                    "first_name": "A",
+                    "last_name": "User",
+                    "username": "a_user",
+                    "language_code": "en-GB"
+                },
+                "chat": {
+                    "id": 649671308,
+                    "first_name": "A",
+                    "last_name": "User",
+                    "username": "a_user",
+                    "type": "private"
+                },
+                "date": 1538756863,
+                "text": "Hello"
+            }
+        }]}
+
+        self.assertEqual(
+            self.connector.config['whitelisted-users'], ['user', 'test'])
+
+        message_text = "Sorry, you're not allowed to speak with this bot."
+
+        with OpsDroid() as opsdroid, \
+                amock.patch.object(self.connector, 'respond') \
+                as mocked_respond:
+            await self.connector._parse_message(opsdroid, response)
+            self.assertTrue(mocked_respond.called)
+            self.assertTrue(mocked_respond.called_with(message_text))
+
+    async def test_get_messages(self):
+        listen_response = amock.Mock()
+        listen_response.status = 200
+        listen_response.json = amock.CoroutineMock()
+        listen_response.return_value = {"result": [
+            {
+                "update_id": 427647860,
+                "message": {
+                    "message_id": 54,
+                    "from": {
+                        "id": 639889348,
+                        "is_bot": False,
+                        "first_name": "Fabio",
+                        "last_name": "Rosado",
+                        "username": "FabioRosado",
+                        "language_code": "en-GB"
+                    },
+                    "chat": {
+                        "id": 639889348,
+                        "first_name": "Fabio",
+                        "last_name": "Rosado",
+                        "username": "FabioRosado",
+                        "type": "private"
+                    },
+                    "date": 1538756863,
+                    "text": "Hello"
+                }
+            }
+        ]}
+
+        with OpsDroid() as opsdroid, \
+            amock.patch('aiohttp.ClientSession.get') as patched_request,\
+            amock.patch.object(self.connector, '_parse_message') \
+                as mocked_parse_message:
+
+            self.connector.latest_update = 54
+
+            patched_request.return_value = asyncio.Future()
+            patched_request.return_value.set_result(listen_response)
+            await self.connector._get_messages(opsdroid)
+            self.assertTrue(patched_request.called)
+            self.assertLogs('_LOGGER', 'debug')
+            # self.assertTrue(mocked_parse_message.called)
+
+    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:
+
+            patched_request.return_value = asyncio.Future()
+            patched_request.return_value.set_result(listen_response)
+            await self.connector._get_messages(opsdroid)
+            self.assertLogs('_LOGGER', 'error')
+
+    async def test_listen(self):
+        self.connector.listening = amock.CoroutineMock()
+        self.connector.listening.side_effect = Exception()
+        await self.connector.listen(amock.CoroutineMock())
+
+    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:
+
+            self.assertTrue(opsdroid.__class__.instances)
+            test_message = Message(text="This is a test",
+                                   user="opsdroid",
+                                   room={"id": 12404},
+                                   connector=self.connector)
+
+            patched_request.return_value = asyncio.Future()
+            patched_request.return_value.set_result(post_response)
+            await test_message.respond("Response")
+            self.assertTrue(patched_request.called)
+            self.assertLogs("_LOGGER", "debug")
+
+    async def test_respond_failure(self):
+        post_response = amock.Mock()
+        post_response.status = 401
+
+        with OpsDroid() as opsdroid, \
+            amock.patch('aiohttp.ClientSession.post') as patched_request:
+
+            self.assertTrue(opsdroid.__class__.instances)
+            test_message = Message(text="This is a test",
+                                   user="opsdroid",
+                                   room={"id": 12404},
+                                   connector=self.connector)
+
+            patched_request.return_value = asyncio.Future()
+            patched_request.return_value.set_result(post_response)
+            await test_message.respond("Response")
+            self.assertLogs('_LOGGER', 'debug')