Skip to content

Commit

Permalink
Merge pull request #2244 from sopel-irc/plugin.allow_bots
Browse files Browse the repository at this point in the history
loader, plugin, plugins.rules: add `allow_bots` decorator
  • Loading branch information
dgw authored Feb 4, 2022
2 parents 6e56fc6 + 834f546 commit e223044
Show file tree
Hide file tree
Showing 6 changed files with 144 additions and 2 deletions.
26 changes: 26 additions & 0 deletions docs/source/plugin/anatomy.rst
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,32 @@ Example::
A rule with rate-limiting can return :const:`sopel.plugin.NOLIMIT` to let the
user try again after a failed command, e.g. if a required argument is missing.

Bypassing restrictions
----------------------

By default, a :term:`Rule` will not trigger on messages from Sopel itself,
other users that are flagged as bots, or users who are
:ref:`ignored <Ignore User>` or :ref:`rate-limited <Rate limiting>`. In
certain cases, it might be desirable to bypass these defaults using one or
more of these decorators:

* :func:`sopel.plugin.allow_bots`: the rule will accept events from other
users who are flagged as bots (like Sopel itself)
* :func:`sopel.plugin.echo`: the rule will accept Sopel's own output (e.g.
from calls to :func:`bot.say() <sopel.bot.Sopel.say>`)
* :func:`sopel.plugin.unblockable`: the rule will ignore rate-limiting or
nick/host blocks and always process the event

For example, Sopel itself uses the :func:`sopel.plugin.unblockable` decorator
to track joins/parts from everyone, always, so plugins can *always* access
data about any user in any channel.

.. important::

The :func:`sopel.plugin.echo` decorator will send *anything* Sopel says
(that matches the rule) to the decorated callable, *including output from
the decorated callable*. Be careful not to create a feedback loop.

Rule labels
-----------

Expand Down
1 change: 1 addition & 0 deletions sopel/loader.py
Original file line number Diff line number Diff line change
Expand Up @@ -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', '')
Expand Down
30 changes: 30 additions & 0 deletions sopel/plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
# decorators
'action_command',
'action_commands',
'allow_bots',
'command',
'commands',
'ctcp',
Expand Down Expand Up @@ -463,13 +464,42 @@ 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 allows a function to opt into receiving these events.
.. __: 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]:
"""Decorate a function to specify that it should receive echo messages.
This decorator can be used to listen in on the messages that Sopel is
sending and react accordingly.
.. important::
The decorated callable will receive *all* matching messages that Sopel
sends, including output from the same callable. Take care to avoid
creating feedback loops when using this feature.
"""
def add_attribute(function):
function.echo = True
Expand Down
29 changes: 27 additions & 2 deletions sopel/plugins/rules.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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', ''),
Expand Down Expand Up @@ -847,6 +865,7 @@ def __init__(self,
handler=None,
events=None,
intents=None,
allow_bots=False,
allow_echo=False,
threaded=True,
output_prefix=None,
Expand All @@ -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
Expand Down Expand Up @@ -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):
Expand All @@ -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

Expand Down
39 changes: 39 additions & 0 deletions test/plugins/test_plugins_rules.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}[email protected] 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 = ':[email protected] JOIN #sopel'
pretrigger = trigger.PreTrigger(mockbot.nick, line)
Expand Down
21 changes: 21 additions & 0 deletions test/test_plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down

0 comments on commit e223044

Please sign in to comment.