Repository: opsdroid/opsdroid

Issue:
Feature request: Please consider implementing a 'catch-all' responder
<!-- Before you post an issue or if you are unsure about something join our matrix channel https://riot.im/app/#/room/#opsdroid-general:matrix.org and ask away! We are more than happy to help you. -->
# Description
I have a skill with many regex matchers, all starting with some flavor of "botname".  ex: 
```
botname get environment
botname get hostinfo
botname get verbose hostinfo
botname get hosthealth
botname get verbose hosthealth
```

etc, with various complexities of regex applied to capture hostnames, etc.  It would be nice to be able to define a 'catchall' matcher that would fire only if it was the only string matched.  For example, if a user typed the invalid string: 
```
botname gte infohost
``` 
that would match nothing in the above list.  If there was a 'catchall' that was set to match strings for "^botname", it could fire a "print help text" message so long as the string didn't match any of the other responders.  

## Versions
- **Opsdroid version:** 0.19.0
- **Python version:** 3.8.4
- **OS/Docker version:**

## Configuration File
n/a

## Additional Details
Any other details you wish to include such as screenshots, console messages, etc.


<!-- Love opsdroid? Please consider supporting our collective:
 +👉  https://opencollective.com/opsdroid/donate -->



Discussion:
This is an example of opsdroid being treated like a command line in your chat client. 

Many design decisions in opsdroid were based on moving away from this use case to something more natural where the bot would be in a room with many other people and it would robustly respond to queries like "what is the environment?", "can you tell me the environment?", "please get environment", etc. These kind of requests would be mixed with other chat between colleagues which is not aimed at the bot and the bot would just stay quiet.

One thing that use case didn't consider is someone directly addressing the bot but asking something the bot doesn't understand. For example "botname please get the flargle bargle". In that case the bot is being addressed directly and so it should respond, but the question doesn't match anything.

In #273 we discuss the bot being able to know when it is being spoken to. This would open us up to being able to catch that situation.

Given that many folks seem to use opsdroid as a CLI in chat, rather than a natural language bot, I think it would be reasonable to add a catchall matcher. But we should be thoughtful about users who do have bots in rooms with other conversations going on.

My initial design would be something like this.

```python
class MySkill(Skill):

    @match_catchall
    async def catchall(self, event):
        await event.respond("Sorry I have no idea what you are talking about")
```

The catchall logic would probably go somewhere like `get_ranked_skills`. If the `ranked_skills` list was still empty at the end of the function we would check for the catchall matcher and return that. This does assume only one use of `match_catchall`.

https://github.com/opsdroid/opsdroid/blob/c2792eab5196e2cbbd4f62d1c8e9e70db36d3e36/opsdroid/core.py#L436

This would then support the design from #273 (which would probably be implemented as a constraint in modern opsdroid) where we could do something like this.

```python
class MySkill(Skill):

    @match_catchall
    @constrain_spoken_to
    async def catchall(self, event):
        await event.respond("I think you were talking to me, but I have no idea what you're asking")
```

That way the catchall matcher would only trigger if the bot is addressed directly.

Resolving patch:
diff --git a/opsdroid/core.py b/opsdroid/core.py
--- a/opsdroid/core.py
+++ b/opsdroid/core.py
@@ -21,6 +21,7 @@
 from opsdroid.loader import Loader
 from opsdroid.web import Web
 from opsdroid.parsers.always import parse_always
+from opsdroid.parsers.catchall import parse_catchall
 from opsdroid.parsers.event_type import parse_event_type
 from opsdroid.parsers.regex import parse_regex
 from opsdroid.parsers.parseformat import parse_format
@@ -577,7 +578,8 @@ async def parse(self, event):
                         )
                     )
                 )
-
+        if len(tasks) == 2:  # no other skills ran other than 2 default ones
+            tasks.append(self.eventloop.create_task(parse_catchall(self, event)))
         await asyncio.gather(*tasks)
 
         return tasks
diff --git a/opsdroid/matchers.py b/opsdroid/matchers.py
--- a/opsdroid/matchers.py
+++ b/opsdroid/matchers.py
@@ -358,3 +358,28 @@ def matcher(func):
     if callable(func):
         return matcher(func)
     return matcher
+
+
+def match_catchall(func=None, messages_only=False):
+    """Return catch-all match decorator.
+
+    Decorator that runs the function only if no other skills were matched for a message
+
+    Args:
+        messages_only(bool): Whether to match only on messages, or on all events. Defaults to False.
+
+    Returns:
+        Decorated Function
+
+    """
+
+    def matcher(func):
+        """Add decorated function to skills list for catch-all matching."""
+        func = add_skill_attributes(func)
+        func.matchers.append({"catchall": True, "messages_only": messages_only})
+        return func
+
+    # Allow for decorator with or without parenthesis as there are no args.
+    if callable(func):
+        return matcher(func)
+    return matcher
diff --git a/opsdroid/parsers/catchall.py b/opsdroid/parsers/catchall.py
new file mode 100644
--- /dev/null
+++ b/opsdroid/parsers/catchall.py
@@ -0,0 +1,20 @@
+"""A helper function for parsing and executing catch-all skills."""
+
+import logging
+
+from opsdroid import events
+
+_LOGGER = logging.getLogger(__name__)
+
+
+async def parse_catchall(opsdroid, event):
+    """Parse an event against catch-all skills, if found."""
+    for skill in opsdroid.skills:
+        for matcher in skill.matchers:
+            if "catchall" in matcher:
+                if (
+                    matcher["messages_only"]
+                    and isinstance(event, events.Message)
+                    or not matcher["messages_only"]
+                ):
+                    await opsdroid.run_skill(skill, skill.config, event)


Test patch:
diff --git a/opsdroid/parsers/tests/test_parser_catchall.py b/opsdroid/parsers/tests/test_parser_catchall.py
new file mode 100644
--- /dev/null
+++ b/opsdroid/parsers/tests/test_parser_catchall.py
@@ -0,0 +1,144 @@
+"""Test the opsdroid catch-all parser."""
+
+import logging
+
+import asynctest.mock as amock
+import pytest
+from opsdroid.cli.start import configure_lang
+from opsdroid.core import OpsDroid
+from opsdroid.events import Message, OpsdroidStarted
+from opsdroid.matchers import match_always, match_catchall
+from opsdroid.parsers.catchall import parse_catchall
+
+pytestmark = pytest.mark.asyncio
+
+configure_lang({})
+
+
+async def getMockSkill():
+    async def mockedskill(config, message):
+        pass
+
+    mockedskill.config = {}
+    return mockedskill
+
+
+async def getRaisingMockSkill():
+    async def mockedskill(config, message):
+        raise Exception()
+
+    mockedskill.config = {}
+    return mockedskill
+
+
+async def test_parse_catchall_decorator_parens():
+    with OpsDroid() as opsdroid:
+        mock_skill = await getMockSkill()
+        opsdroid.skills.append(match_catchall()(mock_skill))
+        opsdroid.run_skill = amock.CoroutineMock()
+
+        mock_connector = amock.CoroutineMock()
+        message = Message(
+            text="Hello world",
+            user="user",
+            target="default",
+            connector=mock_connector,
+        )
+
+        await parse_catchall(opsdroid, message)
+
+        assert opsdroid.run_skill.called
+
+
+async def test_parse_catchall_decorate_no_parens():
+    with OpsDroid() as opsdroid:
+        mock_skill = await getMockSkill()
+        opsdroid.skills.append(match_catchall(mock_skill))
+        opsdroid.run_skill = amock.CoroutineMock()
+
+        mock_connector = amock.CoroutineMock()
+        message = Message(
+            text="Hello world",
+            user="user",
+            target="default",
+            connector=mock_connector,
+        )
+
+        await parse_catchall(opsdroid, message)
+
+        assert opsdroid.run_skill.called
+
+
+async def test_parse_catchall_raises(caplog):
+    caplog.set_level(logging.ERROR)
+    with OpsDroid() as opsdroid:
+        mock_skill = await getRaisingMockSkill()
+        mock_skill.config = {"name": "greetings"}
+        opsdroid.skills.append(match_catchall()(mock_skill))
+        assert len(opsdroid.skills) == 1
+
+        mock_connector = amock.MagicMock()
+        mock_connector.send = amock.CoroutineMock()
+        message = Message(
+            text="Hello world",
+            user="user",
+            target="default",
+            connector=mock_connector,
+        )
+
+        await parse_catchall(opsdroid, message)
+        assert "ERROR" in caplog.text
+
+
+async def test_parse_catchall_not_called():
+    with OpsDroid() as opsdroid:
+        mock_skill = await getMockSkill()
+        catchall_skill = amock.CoroutineMock()
+        opsdroid.skills.append(match_always()(mock_skill))
+        opsdroid.skills.append(match_catchall()(catchall_skill))
+        opsdroid.run_skill = amock.CoroutineMock()
+
+        mock_connector = amock.CoroutineMock()
+        message = Message(
+            text="Hello world",
+            user="user",
+            target="default",
+            connector=mock_connector,
+        )
+
+        await parse_catchall(opsdroid, message)
+
+        assert not catchall_skill.called
+
+
+async def test_parse_catchall_messages_only_default():
+    with OpsDroid() as opsdroid:
+        catchall_skill = await getMockSkill()
+        event = OpsdroidStarted()
+        opsdroid.skills.append(match_catchall()(catchall_skill))
+        opsdroid.run_skill = amock.CoroutineMock()
+
+        await parse_catchall(opsdroid, event)
+
+        assert opsdroid.run_skill.called
+
+
+async def test_parse_catchall_messages_only_enabled():
+    with OpsDroid() as opsdroid:
+        catchall_skill = await getMockSkill()
+        event = OpsdroidStarted()
+        opsdroid.skills.append(match_catchall(messages_only=True)(catchall_skill))
+        opsdroid.run_skill = amock.CoroutineMock()
+
+        mock_connector = amock.CoroutineMock()
+        message = Message(
+            text="Hello world",
+            user="user",
+            target="default",
+            connector=mock_connector,
+        )
+
+        await parse_catchall(opsdroid, event)
+        assert not opsdroid.run_skill.called
+        await parse_catchall(opsdroid, message)
+        assert opsdroid.run_skill.called
diff --git a/tests/test_constraints.py b/tests/test_constraints.py
--- a/tests/test_constraints.py
+++ b/tests/test_constraints.py
@@ -31,7 +31,7 @@ async def test_constrain_rooms_constrains(self):
             tasks = await opsdroid.parse(
                 Message(text="Hello", user="user", target="#random", connector=None)
             )
-            self.assertEqual(len(tasks), 2)  # Just match_always and match_event
+            self.assertEqual(len(tasks), 3)  # Just match_always and match_event
 
     async def test_constrain_rooms_skips(self):
         with OpsDroid() as opsdroid, mock.patch("opsdroid.parsers.always.parse_always"):
@@ -55,7 +55,7 @@ async def test_constrain_rooms_inverted(self):
             tasks = await opsdroid.parse(
                 Message(text="Hello", user="user", target="#general", connector=None)
             )
-            self.assertEqual(len(tasks), 2)  # Just match_always and match_event
+            self.assertEqual(len(tasks), 3)  # Just match_always and match_event
 
     async def test_constrain_users_constrains(self):
         with OpsDroid() as opsdroid:
@@ -69,7 +69,7 @@ async def test_constrain_users_constrains(self):
                     text="Hello", user="otheruser", target="#general", connector=None
                 )
             )
-            self.assertEqual(len(tasks), 2)  # Just match_always and match_event
+            self.assertEqual(len(tasks), 3)  # Just match_always and match_event
 
     async def test_constrain_users_skips(self):
         with OpsDroid() as opsdroid:
@@ -93,7 +93,7 @@ async def test_constrain_users_inverted(self):
             tasks = await opsdroid.parse(
                 Message(text="Hello", user="user", target="#general", connector=None)
             )
-            self.assertEqual(len(tasks), 2)  # Just match_always and match_event
+            self.assertEqual(len(tasks), 3)  # Just match_always and match_event
 
     async def test_constrain_connectors_constrains(self):
         with OpsDroid() as opsdroid:
@@ -109,7 +109,7 @@ async def test_constrain_connectors_constrains(self):
                     text="Hello", user="user", target="#random", connector=connector
                 )
             )
-            self.assertEqual(len(tasks), 2)  # Just match_always and match_event
+            self.assertEqual(len(tasks), 3)  # Just match_always and match_event
 
     async def test_constrain_connectors_skips(self):
         with OpsDroid() as opsdroid:
@@ -141,7 +141,7 @@ async def test_constrain_connectors_inverted(self):
                     text="Hello", user="user", target="#general", connector=connector
                 )
             )
-            self.assertEqual(len(tasks), 2)  # Just match_always and match_event
+            self.assertEqual(len(tasks), 3)  # Just match_always and match_event
 
     async def test_constraint_can_be_called_after_skip(self):
         with OpsDroid() as opsdroid:
@@ -160,7 +160,7 @@ async def test_constraint_can_be_called_after_skip(self):
                     text="Hello", user="otheruser", target="#general", connector=None
                 )
             )
-            self.assertEqual(len(tasks), 2)  # Just match_always and match_event
+            self.assertEqual(len(tasks), 3)  # Just match_always and match_event
 
             tasks = await opsdroid.parse(
                 Message(text="Hello", user="user", target="#general", connector=None)
diff --git a/tests/test_core.py b/tests/test_core.py
--- a/tests/test_core.py
+++ b/tests/test_core.py
@@ -377,7 +377,7 @@ async def test_parse_dialogflow(self):
                 "opsdroid.parsers.dialogflow.parse_dialogflow"
             ), amock.patch("opsdroid.parsers.dialogflow.call_dialogflow"):
                 tasks = await opsdroid.parse(message)
-                self.assertEqual(len(tasks), 2)
+                self.assertEqual(len(tasks), 3)
 
                 tasks = await opsdroid.parse(message)
                 self.assertLogs("_LOGGER", "warning")
@@ -399,7 +399,7 @@ async def test_parse_luisai(self):
             )
             with amock.patch("opsdroid.parsers.luisai.parse_luisai"):
                 tasks = await opsdroid.parse(message)
-                self.assertEqual(len(tasks), 2)
+                self.assertEqual(len(tasks), 3)
 
     async def test_parse_rasanlu(self):
         with OpsDroid() as opsdroid:
@@ -417,7 +417,7 @@ async def test_parse_rasanlu(self):
             )
             with amock.patch("opsdroid.parsers.rasanlu.parse_rasanlu"):
                 tasks = await opsdroid.parse(message)
-                self.assertEqual(len(tasks), 2)
+                self.assertEqual(len(tasks), 3)
 
     async def test_parse_sapcai(self):
         with OpsDroid() as opsdroid:
@@ -433,7 +433,7 @@ async def test_parse_sapcai(self):
             )
             with amock.patch("opsdroid.parsers.sapcai.parse_sapcai"):
                 tasks = await opsdroid.parse(message)
-                self.assertEqual(len(tasks), 2)
+                self.assertEqual(len(tasks), 3)
 
     async def test_parse_watson(self):
         with OpsDroid() as opsdroid:
@@ -447,7 +447,7 @@ async def test_parse_watson(self):
             message = Message("Hello world", "user", "default", mock_connector)
             with amock.patch("opsdroid.parsers.watson.parse_watson"):
                 tasks = await opsdroid.parse(message)
-                self.assertEqual(len(tasks), 2)
+                self.assertEqual(len(tasks), 3)
 
     async def test_parse_witai(self):
         with OpsDroid() as opsdroid:
@@ -466,7 +466,7 @@ async def test_parse_witai(self):
             )
             with amock.patch("opsdroid.parsers.witai.parse_witai"):
                 tasks = await opsdroid.parse(message)
-                self.assertEqual(len(tasks), 2)
+                self.assertEqual(len(tasks), 3)
 
     async def test_send_default_one(self):
         with OpsDroid() as opsdroid, amock.patch(