Repository: opsdroid/opsdroid

Issue:
Move shell connector into core
This issue covers adding the [Shell connector](https://github.com/opsdroid/connector-shell) 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:
@jacobtomlinson  I'd like to take this issue.
Hello @Replayattack please go ahead and work on this issue, if you need any help let us know 👍 
Hey @Replayattack I'm guessing you've not had time to work on this. If anyone else wants to take this up then please go ahead.

Resolving patch:
diff --git a/opsdroid/connector/shell/__init__.py b/opsdroid/connector/shell/__init__.py
new file mode 100644
--- /dev/null
+++ b/opsdroid/connector/shell/__init__.py
@@ -0,0 +1,132 @@
+"""A connector to send messages using the command line."""
+import logging
+import os
+import sys
+import platform
+import asyncio
+
+from opsdroid.connector import Connector, register_event
+from opsdroid.events import Message
+
+_LOGGER = logging.getLogger(__name__)
+
+
+class ConnectorShell(Connector):
+    """A connector to send messages using the command line."""
+
+    def __init__(self, config, opsdroid=None):
+        """Create the connector."""
+        _LOGGER.debug(_("Loaded shell connector"))
+        super().__init__(config, opsdroid=opsdroid)
+        self.name = "shell"
+        self.config = config
+        self.bot_name = config.get("bot-name", "opsdroid")
+        self.prompt_length = None
+        self.listening = True
+        self.reader = None
+        self._closing = asyncio.Event()
+        self.loop = asyncio.get_event_loop()
+
+        for name in ("LOGNAME", "USER", "LNAME", "USERNAME"):
+            user = os.environ.get(name)
+            if user:
+                self.user = user
+
+    @property
+    def is_listening(self):
+        """Helper, gets listening."""
+        return self.listening
+
+    @is_listening.setter
+    def is_listening(self, val):
+        """Helper, sets listening."""
+        self.listening = val
+
+    async def read_stdin(self):
+        """Create a stream reader to read stdin asynchronously.
+
+        Returns:
+            class: asyncio.streams.StreamReader
+
+        """
+        self.reader = asyncio.StreamReader(loop=self.loop)
+        reader_protocol = asyncio.StreamReaderProtocol(self.reader)
+
+        await self.loop.connect_read_pipe(lambda: reader_protocol, sys.stdin)
+
+        return self.reader
+
+    async def async_input(self):
+        """Read user input asynchronously from stdin.
+
+        Returns:
+            string: A decoded string from user input.
+
+        """
+        if not self.reader:
+            self.reader = await self.read_stdin()
+        line = await self.reader.readline()
+
+        return line.decode("utf8").replace("\r", "").replace("\n", "")
+
+    def draw_prompt(self):
+        """Draw the user input prompt."""
+        prompt = self.bot_name + "> "
+        self.prompt_length = len(prompt)
+        print(prompt, end="", flush=True)
+
+    def clear_prompt(self):
+        """Clear the prompt."""
+        print("\r" + (" " * self.prompt_length) + "\r", end="", flush=True)
+
+    async def parseloop(self):
+        """Parseloop moved out for testing."""
+        self.draw_prompt()
+        user_input = await self.async_input()
+        message = Message(user_input, self.user, None, self)
+        await self.opsdroid.parse(message)
+
+    async def _parse_message(self):
+        """Parse user input."""
+        while self.is_listening:
+            await self.parseloop()
+
+    async def connect(self):
+        """Connect to the shell.
+
+        There is nothing to do here since stdin is already available.
+
+        Since this is the first method called when opsdroid starts, a logging
+        message is shown if the user is using windows.
+
+        """
+        if platform.system() == "Windows":
+            _LOGGER.warning(
+                "The shell connector does not work on windows."
+                " Please install the Opsdroid Desktop App."
+            )
+        pass
+
+    async def listen(self):
+        """Listen for and parse new user input."""
+        _LOGGER.debug(_("Connecting to shell"))
+        message_processor = self.loop.create_task(self._parse_message())
+        await self._closing.wait()
+        message_processor.cancel()
+
+    @register_event(Message)
+    async def respond(self, message):
+        """Respond with a message.
+
+        Args:
+            message (object): An instance of Message
+
+        """
+        _LOGGER.debug(_("Responding with: %s"), message.text)
+        self.clear_prompt()
+        print(message.text)
+        self.draw_prompt()
+
+    async def disconnect(self):
+        """Disconnects the connector."""
+        self._closing.set()


Test patch:
diff --git a/tests/test_connector_shell.py b/tests/test_connector_shell.py
new file mode 100644
--- /dev/null
+++ b/tests/test_connector_shell.py
@@ -0,0 +1,169 @@
+"""Tests for the shell connector class."""
+import os
+import io
+import contextlib
+import asyncio
+import unittest
+import unittest.mock as mock
+import asynctest
+import asynctest.mock as amock
+
+from opsdroid.core import OpsDroid
+from opsdroid.connector.shell import ConnectorShell
+from opsdroid.message import Message
+from opsdroid.cli.start import configure_lang
+
+
+class TestConnectorShell(unittest.TestCase):
+    """Test the opsdroid shell connector class."""
+
+    def setUp(self):
+        self.connector = ConnectorShell({"name": "shell", "bot-name": "opsdroid-test"})
+        self.loop = asyncio.new_event_loop()
+        configure_lang({})
+        os.environ["USERNAME"] = "opsdroid"
+
+    def test_init(self):
+        """Test that the connector is initialised properly."""
+        self.assertEqual(self.connector.user, "opsdroid")
+        self.assertEqual(len(self.connector.config), 2)
+        self.assertEqual("shell", self.connector.name)
+        self.assertEqual("opsdroid-test", self.connector.bot_name)
+
+    def test_is_listening(self):
+        self.assertEqual(self.connector.listening, self.connector.is_listening)
+
+    def test_is_listening_setter(self):
+        self.assertEqual(self.connector.listening, self.connector.is_listening)
+        self.connector.is_listening = False
+        self.assertFalse(self.connector.listening)
+
+    def test_draw_prompt(self):
+        self.assertEqual(self.connector.prompt_length, None)
+
+        f = io.StringIO()
+        with contextlib.redirect_stdout(f):
+            self.connector.prompt_length = 1
+            self.connector.draw_prompt()
+            prompt = f.getvalue()
+            self.assertEqual(prompt, "opsdroid-test> ")
+        self.connector.draw_prompt()
+        self.assertEqual(self.connector.prompt_length, 15)
+
+    def test_clear_prompt(self):
+        self.connector.prompt_length = 1
+
+        f = io.StringIO()
+        with contextlib.redirect_stdout(f):
+            self.connector.clear_prompt()
+            prompt = f.getvalue()
+            self.assertEqual(prompt, "\r \r")
+
+
+class TestConnectorShellAsync(asynctest.TestCase):
+    """Test the async methods of the opsdroid shell connector class."""
+
+    def setUp(self):
+        os.environ["LOGNAME"] = "opsdroid"
+        self.connector = ConnectorShell({"name": "shell", "bot-name": "opsdroid"})
+
+    async def test_read_stdin(self):
+        with amock.patch(
+            "opsdroid.connector.shell.ConnectorShell.read_stdin"
+        ) as mocked_read_stdin:
+            await self.connector.read_stdin()
+            self.assertTrue(mocked_read_stdin.called)
+
+        if os.name == "nt":
+            with amock.patch(
+                "asyncio.events.AbstractEventLoop.connect_read_pipe"
+            ) as mock:
+                with contextlib.suppress(NotImplementedError):
+                    await self.connector.read_stdin()
+        else:
+            self.connector.loop.connect_read_pipe = amock.CoroutineMock()
+            self.connector.reader = None
+            self.assertIsNone(self.connector.reader)
+            result = await self.connector.read_stdin()
+            self.assertEqual(result, self.connector.reader)
+
+    async def test_connect(self):
+        connector = ConnectorShell({}, opsdroid=OpsDroid())
+        await connector.connect()
+        self.assertTrue(connector.connect)
+
+        with amock.patch("platform.system", amock.MagicMock(return_value="Windows")):
+            await connector.connect()
+            self.assertTrue(connector.connect)
+
+    @amock.patch("opsdroid.connector.shell.ConnectorShell.read_stdin")
+    async def test_async_input(self, mocked_read):
+        mocked_read.readline.return_value.side_effect = "hi"
+        with contextlib.suppress(AttributeError, TypeError):
+            await self.connector.async_input()
+            self.assertEqual(mocked_read, "hi")
+
+        connector = ConnectorShell({}, opsdroid=OpsDroid())
+        f = asyncio.Future()
+        f.set_result(b"hi\n")
+
+        with asynctest.patch("asyncio.streams.StreamReader.readline") as mocked_line:
+            mocked_line.return_value = f
+            connector.reader = asyncio.streams.StreamReader
+            returned = await connector.async_input()
+            self.assertEqual(returned, "hi")
+
+    async def test_parse_message(self):
+        connector = ConnectorShell({}, opsdroid=OpsDroid())
+
+        with amock.patch(
+            "opsdroid.connector.shell.ConnectorShell.parseloop"
+        ) as mockedloop:
+            self.assertTrue(connector.listening)
+            connector.listening = True
+            with amock.patch(
+                "opsdroid.connector.shell.ConnectorShell.is_listening",
+                new_callable=amock.PropertyMock,
+                side_effect=[True, False],
+            ) as mocklistening:
+                await connector._parse_message()
+                mockedloop.assert_called()
+
+    async def test_parseloop(self):
+        connector = ConnectorShell({}, opsdroid=OpsDroid())
+
+        connector.draw_prompt = amock.CoroutineMock()
+        connector.draw_prompt.return_value = "opsdroid> "
+        connector.async_input = amock.CoroutineMock()
+        connector.async_input.return_value = "hello"
+
+        connector.opsdroid = amock.CoroutineMock()
+        connector.opsdroid.parse = amock.CoroutineMock()
+
+        await connector.parseloop()
+        self.assertTrue(connector.opsdroid.parse.called)
+
+    async def test_listen(self):
+        connector = ConnectorShell({}, opsdroid=OpsDroid())
+        connector.listening = False
+        with amock.patch(
+            "asyncio.events.AbstractEventLoop.create_task"
+        ) as mock, amock.patch("asyncio.locks.Event.wait") as mockwait:
+            await connector.listen()
+
+    async def test_respond(self):
+        message = Message(
+            text="Hi", user="opsdroid", room="test", connector=self.connector
+        )
+        self.connector.prompt_length = 1
+
+        f = io.StringIO()
+        with contextlib.redirect_stdout(f):
+            await self.connector.respond(message)
+            prompt = f.getvalue()
+            self.assertEqual(prompt.strip(), "Hi\nopsdroid>")
+
+    async def test_disconnect(self):
+        connector = ConnectorShell({}, opsdroid=OpsDroid())
+        await self.connector.disconnect()
+        self.assertEqual(self.connector._closing.set(), None)