Repository: sopel-irc/sopel

Issue:
Plugin API: Allow declaring output prefix
This has come up enough times in just my own work on modules (most recently sopel-wolfram & sopel-github) that I think it's time to open a proper issue.

I think it would be useful for Sopel's module API to support declaring an output prefix, such that any calls to `bot.say()` or `bot.reply()` automatically get prefixed with the module's choice of string. (`bot.msg()` and other lower-level calls, maybe not. I'm still thinking about that.)

Why? It would eliminate having to hard-code, for example, `[GitHub]` (see sopel-irc/sopel-github#9) or `[W|A]` all over the place. The prefix string would be defined only once.

Modules can, of course, roll their own version of this. But just like with #1346 (proposing the addition of `web.unquote()` to mirror `web.quote()`), there are some things that happen often enough across modules to warrant inclusion in Sopel's library. I think prefixing the output is one of those things.

Obviously the idea that this API functionality should exist is the easy part. The hard part is figuring out how it should work, but there's plenty of time between now and when Sopel 6.6.0 drops to think, plus all the time between then and Sopel 7 (when I'd like to actually release this) to iterate on it.

Considerations I've thought of so far:

  * Should square brackets be added automatically, for consistency, or should modules be allowed to define the entire prefix string? Including the trailing space between it and the rest of the message?
  * If prefixes get bracket-wrapped automatically, should there be an override mechanism?
  * Where should this be set up? The module `setup()` function is a logical place, I guess, though it would mean setting the prefix would need to be a function of the `bot` object. Hmm…

(As I said, the idea is the easy part!)


Discussion:
This feature requires the bot to know, when it triggers a rule or a command callable, which output prefix should be used for this callable.

And then, you need to be able to activate/deactivate it for a given plugin and/or callable, to take it into account when building your message (because of the limited length you have).

I'd suggest a decorator, that could be used on a per-callable basis, like that:

``` python
from sopel import module

@module.command('echo')
@module.output_prefix('[e]')
def echo_message(bot, trigger):
    bot.say('echo')
    bot.say('ech')
    bot.say('ec')
    bot.say('e')
```

The output would be:

```
<Exirel> .echo
<Sopel> [e] echo
<Sopel> [e] ech
<Sopel> [e] ec
<Sopel> [e] e
```

I don't think the decorator should assume a space after the prefix, but other than that, 👍.

Resolving patch:
diff --git a/sopel/bot.py b/sopel/bot.py
--- a/sopel/bot.py
+++ b/sopel/bot.py
@@ -717,7 +717,8 @@ def dispatch(self, pretrigger):
                         continue
 
                     # call triggered function
-                    wrapper = SopelWrapper(self, trigger)
+                    wrapper = SopelWrapper(
+                        self, trigger, output_prefix=func.output_prefix)
                     if func.thread:
                         targs = (func, wrapper, trigger)
                         t = threading.Thread(target=self.call, args=targs)
@@ -984,18 +985,24 @@ class SopelWrapper(object):
     :type sopel: :class:`~sopel.bot.Sopel`
     :param trigger: IRC Trigger line
     :type trigger: :class:`sopel.trigger.Trigger`
+    :param string output_prefix: prefix for messages sent through this wrapper
+                                 (e.g. plugin tag)
 
     This wrapper will be used to call Sopel's triggered commands and rules as
     their ``bot`` argument. It acts as a proxy to :meth:`send messages<say>` to
     the sender (either a channel or in a private message) and even to
     :meth:`reply to someone<reply>` in a channel.
     """
-    def __init__(self, sopel, trigger):
+    def __init__(self, sopel, trigger, output_prefix=''):
+        if not output_prefix:
+            # Just in case someone passes in False, None, etc.
+            output_prefix = ''
         # The custom __setattr__ for this class sets the attribute on the
         # original bot object. We don't want that for these, so we set them
         # with the normal __setattr__.
         object.__setattr__(self, '_bot', sopel)
         object.__setattr__(self, '_trigger', trigger)
+        object.__setattr__(self, '_out_pfx', output_prefix)
 
     def __dir__(self):
         classattrs = [attr for attr in self.__class__.__dict__
@@ -1021,7 +1028,7 @@ def say(self, message, destination=None, max_messages=1):
         """
         if destination is None:
             destination = self._trigger.sender
-        self._bot.say(message, destination, max_messages)
+        self._bot.say(self._out_pfx + message, destination, max_messages)
 
     def action(self, message, destination=None):
         """Override ``Sopel.action`` to send action to sender
@@ -1049,7 +1056,7 @@ def notice(self, message, destination=None):
         """
         if destination is None:
             destination = self._trigger.sender
-        self._bot.notice(message, destination)
+        self._bot.notice(self._out_pfx + message, destination)
 
     def reply(self, message, destination=None, reply_to=None, notice=False):
         """Override ``Sopel.reply`` to reply to someone
diff --git a/sopel/loader.py b/sopel/loader.py
--- a/sopel/loader.py
+++ b/sopel/loader.py
@@ -53,6 +53,7 @@ def clean_callable(func, config):
     func.rate = getattr(func, 'rate', 0)
     func.channel_rate = getattr(func, 'channel_rate', 0)
     func.global_rate = getattr(func, 'global_rate', 0)
+    func.output_prefix = getattr(func, 'output_prefix', '')
 
     if not hasattr(func, 'event'):
         func.event = ['PRIVMSG']
diff --git a/sopel/module.py b/sopel/module.py
--- a/sopel/module.py
+++ b/sopel/module.py
@@ -21,6 +21,7 @@
     'intent',
     'interval',
     'nickname_commands',
+    'output_prefix',
     'priority',
     'rate',
     'require_admin',
@@ -674,3 +675,21 @@ def __call__(self, func):
         }
         func.example.append(record)
         return func
+
+
+def output_prefix(prefix):
+    """Decorate a function to add a prefix on its output.
+
+    :param str prefix: the prefix to add (must include trailing whitespace if
+                       desired; Sopel does not assume it should add anything)
+
+    Prefix will be added to text sent through:
+
+    * :meth:`bot.say <sopel.bot.SopelWrapper.say>`
+    * :meth:`bot.notice <sopel.bot.SopelWrapper.notice>`
+
+    """
+    def add_attribute(function):
+        function.output_prefix = prefix
+        return function
+    return add_attribute


Test patch:
diff --git a/test/test_module.py b/test/test_module.py
--- a/test/test_module.py
+++ b/test/test_module.py
@@ -343,3 +343,11 @@ def test_example(bot, trigger):
     def mock(bot, trigger, match=None):
         return True
     assert mock(bot, trigger) is True
+
+
+def test_output_prefix():
+    @module.commands('mock')
+    @module.output_prefix('[MOCK] ')
+    def mock(bot, trigger, match):
+        return True
+    assert mock.output_prefix == '[MOCK] '