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):