diff --git a/sopel/loader.py b/sopel/loader.py
index 8233d1f98f..dd6a070697 100644
--- a/sopel/loader.py
+++ b/sopel/loader.py
@@ -58,6 +58,7 @@ def clean_callable(func, config):
         # confusing to other code (and a waste of memory) for jobs.
         return
 
+    func.allow_bots = getattr(func, 'allow_bots', False)
     func.echo = getattr(func, 'echo', False)
     func.priority = getattr(func, 'priority', 'medium')
     func.output_prefix = getattr(func, 'output_prefix', '')
diff --git a/sopel/plugin.py b/sopel/plugin.py
index da9cb58233..76b667a499 100644
--- a/sopel/plugin.py
+++ b/sopel/plugin.py
@@ -24,6 +24,7 @@
     # decorators
     'action_command',
     'action_commands',
+    'allow_bots',
     'command',
     'commands',
     'ctcp',
@@ -463,6 +464,28 @@ def add_attribute(function):
     return add_attribute
 
 
+def allow_bots(
+    function: typing.Optional[typing.Any] = None,
+) -> typing.Union[typing.Any, typing.Callable]:
+    """Decorate a function to specify that it should receive events from bots.
+
+    On networks implementing the `Bot Mode specification`__, messages and
+    other events from other clients that have identified themselves as bots
+    will be tagged as such, and Sopel will ignore them by default. This
+    decorator allow opting in for a function to receive these events anyway.
+
+    .. __: https://ircv3.net/specs/extensions/bot-mode
+    """
+    def add_attribute(function):
+        function.allow_bots = True
+        return function
+
+    # hack to allow both @allow_bots and @allow_bots() to work
+    if callable(function):
+        return add_attribute(function)
+    return add_attribute
+
+
 def echo(
     function: typing.Optional[typing.Any] = None,
 ) -> typing.Union[typing.Any, typing.Callable]:
diff --git a/sopel/plugins/rules.py b/sopel/plugins/rules.py
index 73e57ceeea..bfc9f9689a 100644
--- a/sopel/plugins/rules.py
+++ b/sopel/plugins/rules.py
@@ -627,6 +627,23 @@ def match_intent(self, intent) -> bool:
         :rtype: bool
         """
 
+    @abc.abstractmethod
+    def allow_bots(self) -> bool:
+        """Tell if the rule should match bot commands.
+
+        :return: ``True`` when the rule allows bot commands,
+                 ``False`` otherwise
+
+        A "bot command" is any IRC protocol command or numeric that has been
+        tagged as ``bot`` (or ``draft/bot``) by the IRC server.
+
+        .. seealso::
+
+            The `IRCv3 Bot Mode specification`__.
+
+        .. __: https://ircv3.net/specs/extensions/bot-mode
+        """
+
     @abc.abstractmethod
     def allow_echo(self) -> bool:
         """Tell if the rule should match echo messages.
@@ -758,6 +775,7 @@ def kwargs_from_callable(cls, handler):
             'priority': getattr(handler, 'priority', PRIORITY_MEDIUM),
             'events': getattr(handler, 'event', []),
             'intents': getattr(handler, 'intents', []),
+            'allow_bots': getattr(handler, 'allow_bots', False),
             'allow_echo': getattr(handler, 'echo', False),
             'threaded': getattr(handler, 'thread', True),
             'output_prefix': getattr(handler, 'output_prefix', ''),
@@ -847,6 +865,7 @@ def __init__(self,
                  handler=None,
                  events=None,
                  intents=None,
+                 allow_bots=False,
                  allow_echo=False,
                  threaded=True,
                  output_prefix=None,
@@ -867,6 +886,7 @@ def __init__(self,
         # filters
         self._events = events or ['PRIVMSG']
         self._intents = intents or []
+        self._allow_bots = bool(allow_bots)
         self._allow_echo = bool(allow_echo)
 
         # execution
@@ -976,8 +996,10 @@ def match_preconditions(self, bot, pretrigger):
         return (
             self.match_event(event) and
             self.match_intent(intent) and
-            (not is_bot_message or (is_echo_message and self.allow_echo())) and
-            (not is_echo_message or self.allow_echo())
+            (
+                (not is_bot_message or self.allow_bots()) or
+                (is_echo_message and self.allow_echo())
+            ) and (not is_echo_message or self.allow_echo())
         )
 
     def parse(self, text):
@@ -998,6 +1020,9 @@ def match_intent(self, intent):
             for regex in self._intents
         ))
 
+    def allow_bots(self):
+        return self._allow_bots
+
     def allow_echo(self):
         return self._allow_echo
 
diff --git a/test/plugins/test_plugins_rules.py b/test/plugins/test_plugins_rules.py
index 517d8f822c..18a4b26fd0 100644
--- a/test/plugins/test_plugins_rules.py
+++ b/test/plugins/test_plugins_rules.py
@@ -687,6 +687,45 @@ def test_rule_match_privmsg_echo(mockbot):
     assert match.group(0) == 'Hi!'
 
 
+@pytest.mark.parametrize(
+    'is_bot, allow_bots, is_echo, allow_echo, should_match',
+    [
+        (True, True, True, True, True),
+        (True, True, True, False, False),
+        (True, True, False, True, True),
+        (True, True, False, False, True),
+        (True, False, True, True, True),
+        (True, False, True, False, False),
+        (True, False, False, True, False),
+        (True, False, False, False, False),
+        (False, True, True, True, True),
+        (False, True, True, False, False),
+        (False, True, False, True, True),
+        (False, True, False, False, True),
+        (False, False, True, True, True),
+        (False, False, True, False, False),
+        (False, False, False, True, True),
+        (False, False, False, False, True),
+    ])
+def test_rule_match_privmsg_echo_and_bot_tag(
+    is_bot, allow_bots, is_echo, allow_echo, should_match, mockbot
+):
+    line = '{tags}:{nick}!user@example.com PRIVMSG #sopel :Hi!'.format(
+        tags='@bot ' if is_bot else '',
+        nick=mockbot.nick if is_echo else 'SomeUser',
+    )
+    pretrigger = trigger.PreTrigger(mockbot.nick, line)
+    regex = re.compile(r'.*')
+
+    rule = rules.Rule([regex], allow_bots=allow_bots, allow_echo=allow_echo)
+    matches = list(rule.match(mockbot, pretrigger))
+
+    if should_match:
+        assert len(matches) == 1, 'This combination should match the Rule'
+    else:
+        assert not matches, 'This combination should not match the Rule'
+
+
 def test_rule_match_join(mockbot):
     line = ':Foo!foo@example.com JOIN #sopel'
     pretrigger = trigger.PreTrigger(mockbot.nick, line)
diff --git a/test/test_plugin.py b/test/test_plugin.py
index 6015aedb50..d4427b10d6 100644
--- a/test/test_plugin.py
+++ b/test/test_plugin.py
@@ -13,6 +13,27 @@
 """
 
 
+def test_allow_bots():
+    # test decorator with parentheses
+    @plugin.allow_bots()
+    def mock(bot, trigger, match):
+        return True
+    assert mock.allow_bots is True
+
+    # test decorator without parentheses
+    @plugin.allow_bots
+    def mock(bot, trigger, match):
+        return True
+    assert mock.allow_bots is True
+
+    # test without decorator
+    def mock(bot, trigger, match):
+        return True
+    # on undecorated callables, the attr only exists after the loader loads them
+    # so this cannot `assert mock.allow_bots is False` here
+    assert not hasattr(mock, 'allow_bots')
+
+
 def test_find():
     @plugin.find('.*')
     def mock(bot, trigger, match):