Repository: sopel-irc/sopel

Issue:
Sopel should respond in-kind to triggers sent with STATUSMSG
### Description

Sopel supports responding to a `PRIVMSG` that uses the [`STATUSMSG`](https://modern.ircdocs.horse/#statusmsg-parameter) feature, and used to respond in the same manner, but this behavior has changed and the bot now responds directly in a channel. For instance, a message sent to `+#sopel` would previously cause a response to be sent to `+#sopel`, but now sends a response to `#sopel`.

### Reproduction steps

See below

### Expected behavior

The old behavior makes sense, but may have been a bit of a coincidence. The bot should respond in-kind to match the style of communication that users are using. It would be confusing for instance for non-voiced users to see a response to a command that was sent only to the voiced users in a channel.

### Relevant logs

In the below sample, `terribot` (running Sopel 7.1.9) responds to my messages in-kind, but `testibot` (running b5eba03) responds in the channel

```shell
7:55 -- Msg -> +##terribot-testing: https://butt.holdings/
17:55 -- Msg:+(terribot): [url] Hold on to your butts! | butt.holdings
17:55 <+testibot> [url] Hold on to your butts! | butt.holdings
17:56 -- Msg -> +##terribot-testing: !version
17:56 -- Msg:+(terribot): [version] Sopel v7.1.9 | Python: 3.9.9
17:56 <+testibot> [version] Sopel v8.0.0.dev0 | Python: 3.7.16 | Commit: b5eba03ce74baae36e3456ac938686fb23f5671b
```

### Notes

Tested on Libera.chat which advertises `STATUSMSG=@+`

### Sopel version

master

### Installation method

`pip install`

### Python version

3.7.16

### Operating system

Ubuntu 20.04

### IRCd

solanum-1.0-dev(20221103-ed775e9b)

### Relevant plugins

N/A


Discussion:
This could be considered the "spaghetti squash" I was afraid of in https://github.com/sopel-irc/sopel/pull/2370#pullrequestreview-1237607381

I think the sanest fix would be to include the `Trigger`'s [`status_prefix`](https://github.com/sopel-irc/sopel/blob/b5eba03ce74baae36e3456ac938686fb23f5671b/sopel/trigger.py#L341) in the [destination logic](https://github.com/sopel-irc/sopel/blob/b5eba03ce74baae36e3456ac938686fb23f5671b/sopel/bot.py#L1282-L1283) within `SopelWrapper`. (That's one example, but almost every method has it.)
This appears to be caused by [deliberate stripping of the `STATUSMSG` prefix](https://github.com/sopel-irc/sopel/blob/b5eba03ce74baae36e3456ac938686fb23f5671b/sopel/trigger.py#L251-L252), if I remove that, I get the expected behavior.

> I think the sanest fix would be to include the `Trigger`'s [`status_prefix`](https://github.com/sopel-irc/sopel/blob/b5eba03ce74baae36e3456ac938686fb23f5671b/sopel/trigger.py#L341) in the [destination logic](https://github.com/sopel-irc/sopel/blob/b5eba03ce74baae36e3456ac938686fb23f5671b/sopel/bot.py#L1282-L1283) within `SopelWrapper`

This sounds reasonable to me
> This appears to be caused by [deliberate stripping of the `STATUSMSG` prefix](https://github.com/sopel-irc/sopel/blob/b5eba03ce74baae36e3456ac938686fb23f5671b/sopel/trigger.py#L251-L252)

The prefix must be removed because if it's left there, things like `trigger.sender.is_nick()` return(ed) wrong values for STATUSMSG triggers.

It's correct to consider the status prefix as separate from the Identifier. We just forgot to make use of it later 😅
Oh right...

@SnoopJ could you provide the IRC raw logs for your example? This would be a great base for a non-regression test.

Also, I'd love to fix this bug myself, given that I think it's my fault.
> Also, I'd love to fix this bug myself, given that I think it's my fault.

Fine by me! Below is the speculative patch I mentioned on IRC:

<details><summary>clicky clicky for patch</summary>

```diff
diff --git a/sopel/bot.py b/sopel/bot.py
index 5354602b..310aab73 100644
--- a/sopel/bot.py
+++ b/sopel/bot.py
@@ -1256,6 +1256,10 @@ class SopelWrapper:
     def __setattr__(self, attr, value):
         return setattr(self._bot, attr, value)
 
+    @property
+    def default_destination(self) -> Optional[identifiers.Identifier]:  # actually optional?
+        return (self._trigger.status_prefix or "") + self._trigger.sender  # this isn't properly handling sender = None
+
     def say(self, message, destination=None, max_messages=1, truncation='', trailing=''):
         """Override ``Sopel.say`` to use trigger source by default.
 
@@ -1280,7 +1284,7 @@ class SopelWrapper:
 
         """
         if destination is None:
-            destination = self._trigger.sender
+            destination = self.default_destination
         self._bot.say(self._out_pfx + message, destination, max_messages, truncation, trailing)
 
     def action(self, message, destination=None):
@@ -1298,7 +1302,7 @@ class SopelWrapper:
             :meth:`sopel.bot.Sopel.action`
         """
         if destination is None:
-            destination = self._trigger.sender
+            destination = self.default_destination
         self._bot.action(message, destination)
 
     def notice(self, message, destination=None):
@@ -1316,7 +1320,7 @@ class SopelWrapper:
             :meth:`sopel.bot.Sopel.notice`
         """
         if destination is None:
-            destination = self._trigger.sender
+            destination = self.default_destination
         self._bot.notice(self._out_pfx + message, destination)
 
     def reply(self, message, destination=None, reply_to=None, notice=False):
@@ -1339,7 +1343,7 @@ class SopelWrapper:
             :meth:`sopel.bot.Sopel.reply`
         """
         if destination is None:
-            destination = self._trigger.sender
+            destination = self.default_destination
         if reply_to is None:
             reply_to = self._trigger.nick
         self._bot.reply(message, destination, reply_to, notice)
```
</details>

> @SnoopJ could you provide the IRC raw logs for your example? This would be a great base for a non-regression test.

No problem:

```
2023-03-31 18:58:24,270 <<      '@time=2023-03-31T22:58:24.266Z;account=SnoopJ :SnoopJ!~snoopj@user/snoopj PRIVMSG +##terribot-testing :!version\r\n'
2023-03-31 18:58:24,277 >>      'PRIVMSG ##terribot-testing :[version] Sopel v8.0.0.dev0 | Python: 3.7.16 | Commit: b5eba03ce74baae36e3456ac938686fb23f5671b\r\n'
2023-03-31 18:58:24,316 <<      '@time=2023-03-31T22:58:24.284Z;account=SnoopJ :terribot!~sopel@user/snoopj PRIVMSG +##terribot-testing :[version] Sopel v7.1.9 | Python: 3.9.9\r\n'
2023-03-31 18:58:24,347 <<      '@time=2023-03-31T22:58:24.320Z;account=SnoopJ :testibot!~sopel@user/snoopj PRIVMSG ##terribot-testing :[version] Sopel v8.0.0.dev0 | Python: 3.7.16 | Commit: b5eba03ce74baae36e3456ac938686fb23f5671b\r\n'
```
> `+        return (self._trigger.status_prefix or "") + self._trigger.sender  # this isn't properly handling sender = None`

About this part, I think the best solution is to just `if self._trigger.sender is None: return None`.

If the `sender` was `None`, then there's no chance it will have a STATUSMSG prefix that needs to be preserved.

Resolving patch:
diff --git a/sopel/bot.py b/sopel/bot.py
--- a/sopel/bot.py
+++ b/sopel/bot.py
@@ -1256,6 +1256,35 @@ def __getattr__(self, attr):
     def __setattr__(self, attr, value):
         return setattr(self._bot, attr, value)
 
+    @property
+    def default_destination(self) -> Optional[str]:
+        """Default say/reply destination for the associated Trigger.
+
+        :return: the channel (with status prefix) or nick to send messages to
+
+        This property returns the :class:`str` version of the destination that
+        will be used by default by these methods:
+
+        * :meth:`say`
+        * :meth:`reply`
+        * :meth:`action`
+        * :meth:`notice`
+
+        For a channel, it also ensures that the status-specific prefix is added
+        to the result, so the bot replies with the same status.
+        """
+        if not self._trigger.sender:
+            return None
+
+        # ensure str and not Identifier
+        destination = str(self._trigger.sender)
+
+        # prepend status prefix if it exists
+        if self._trigger.status_prefix:
+            destination = self._trigger.status_prefix + destination
+
+        return destination
+
     def say(self, message, destination=None, max_messages=1, truncation='', trailing=''):
         """Override ``Sopel.say`` to use trigger source by default.
 
@@ -1280,8 +1309,15 @@ def say(self, message, destination=None, max_messages=1, truncation='', trailing
 
         """
         if destination is None:
-            destination = self._trigger.sender
-        self._bot.say(self._out_pfx + message, destination, max_messages, truncation, trailing)
+            destination = self.default_destination
+
+        self._bot.say(
+            self._out_pfx + message,
+            destination,
+            max_messages,
+            truncation,
+            trailing,
+        )
 
     def action(self, message, destination=None):
         """Override ``Sopel.action`` to use trigger source by default.
@@ -1298,7 +1334,8 @@ def action(self, message, destination=None):
             :meth:`sopel.bot.Sopel.action`
         """
         if destination is None:
-            destination = self._trigger.sender
+            destination = self.default_destination
+
         self._bot.action(message, destination)
 
     def notice(self, message, destination=None):
@@ -1316,7 +1353,8 @@ def notice(self, message, destination=None):
             :meth:`sopel.bot.Sopel.notice`
         """
         if destination is None:
-            destination = self._trigger.sender
+            destination = self.default_destination
+
         self._bot.notice(self._out_pfx + message, destination)
 
     def reply(self, message, destination=None, reply_to=None, notice=False):
@@ -1339,9 +1377,11 @@ def reply(self, message, destination=None, reply_to=None, notice=False):
             :meth:`sopel.bot.Sopel.reply`
         """
         if destination is None:
-            destination = self._trigger.sender
+            destination = self.default_destination
+
         if reply_to is None:
             reply_to = self._trigger.nick
+
         self._bot.reply(message, destination, reply_to, notice)
 
     def kick(self, nick, channel=None, message=None):
@@ -1364,6 +1404,8 @@ def kick(self, nick, channel=None, message=None):
                 raise RuntimeError('Error: KICK requires a channel.')
             else:
                 channel = self._trigger.sender
+
         if nick is None:
             raise RuntimeError('Error: KICK requires a nick.')
+
         self._bot.kick(nick, channel, message)
diff --git a/sopel/trigger.py b/sopel/trigger.py
--- a/sopel/trigger.py
+++ b/sopel/trigger.py
@@ -329,6 +329,18 @@ class Trigger(str):
         else:
             # message sent from a channel
 
+    .. important::
+
+        If the message was sent to a `specific status prefix`__, the ``sender``
+        does not include the status prefix. Be sure to use the
+        :attr:`status_prefix` when replying.
+
+        Note that the ``bot`` argument passed to plugin callables is a
+        :class:`~sopel.bot.SopelWrapper` that handles this for the default
+        ``destination`` of the methods it overrides (most importantly,
+        :meth:`~sopel.bot.SopelWrapper.say` &
+        :meth:`~sopel.bot.SopelWrapper.reply`).
+
     .. warning::
 
         The ``sender`` Will be ``None`` for commands that have no implicit
@@ -337,6 +349,7 @@ class Trigger(str):
         The :attr:`COMMANDS_WITH_CONTEXT` attribute lists IRC commands for
         which ``sender`` can be relied upon.
 
+    .. __: https://modern.ircdocs.horse/#statusmsg-parameter
     """
     status_prefix = property(lambda self: self._pretrigger.status_prefix)
     """The prefix used for the :attr:`sender` for status-specific messages.


Test patch:
diff --git a/test/test_bot.py b/test/test_bot.py
--- a/test/test_bot.py
+++ b/test/test_bot.py
@@ -102,6 +102,32 @@ def mockplugin(tmpdir):
 # -----------------------------------------------------------------------------
 # sopel.bot.SopelWrapper
 
+def test_wrapper_default_destination(mockbot, triggerfactory):
+    wrapper = triggerfactory.wrapper(
+        mockbot, ':Test!test@example.com PRIVMSG #channel :test message')
+
+    assert wrapper.default_destination == '#channel'
+
+
+def test_wrapper_default_destination_none(mockbot, triggerfactory):
+    wrapper = triggerfactory.wrapper(
+        mockbot, ':irc.example.com 301 Sopel :I am away.')
+
+    assert wrapper.default_destination is None
+
+
+def test_wrapper_default_destination_statusmsg(mockbot, triggerfactory):
+    mockbot._isupport = mockbot.isupport.apply(
+        STATUSMSG=tuple('+'),
+    )
+
+    wrapper = triggerfactory.wrapper(
+        mockbot, ':Test!test@example.com PRIVMSG +#channel :test message')
+
+    assert wrapper._trigger.sender == '#channel'
+    assert wrapper.default_destination == '+#channel'
+
+
 def test_wrapper_say(mockbot, triggerfactory):
     wrapper = triggerfactory.wrapper(
         mockbot, ':Test!test@example.com PRIVMSG #channel :test message')
@@ -112,6 +138,20 @@ def test_wrapper_say(mockbot, triggerfactory):
     )
 
 
+def test_wrapper_say_statusmsg(mockbot, triggerfactory):
+    mockbot._isupport = mockbot.isupport.apply(
+        STATUSMSG=tuple('+'),
+    )
+
+    wrapper: bot.SopelWrapper = triggerfactory.wrapper(
+        mockbot, ':Test!test@example.com PRIVMSG +#channel :test message')
+    wrapper.say('Hi!')
+
+    assert mockbot.backend.message_sent == rawlist(
+        'PRIVMSG +#channel :Hi!'
+    )
+
+
 def test_wrapper_say_override_destination(mockbot, triggerfactory):
     wrapper = triggerfactory.wrapper(
         mockbot, ':Test!test@example.com PRIVMSG #channel :test message')
@@ -132,6 +172,20 @@ def test_wrapper_notice(mockbot, triggerfactory):
     )
 
 
+def test_wrapper_notice_statusmsg(mockbot, triggerfactory):
+    mockbot._isupport = mockbot.isupport.apply(
+        STATUSMSG=tuple('+'),
+    )
+
+    wrapper: bot.SopelWrapper = triggerfactory.wrapper(
+        mockbot, ':Test!test@example.com PRIVMSG +#channel :test message')
+    wrapper.notice('Hi!')
+
+    assert mockbot.backend.message_sent == rawlist(
+        'NOTICE +#channel :Hi!'
+    )
+
+
 def test_wrapper_notice_override_destination(mockbot, triggerfactory):
     wrapper = triggerfactory.wrapper(
         mockbot, ':Test!test@example.com PRIVMSG #channel :test message')
@@ -152,6 +206,20 @@ def test_wrapper_action(mockbot, triggerfactory):
     )
 
 
+def test_wrapper_action_statusmsg(mockbot, triggerfactory):
+    mockbot._isupport = mockbot.isupport.apply(
+        STATUSMSG=tuple('+'),
+    )
+
+    wrapper: bot.SopelWrapper = triggerfactory.wrapper(
+        mockbot, ':Test!test@example.com PRIVMSG +#channel :test message')
+    wrapper.action('Hi!')
+
+    assert mockbot.backend.message_sent == rawlist(
+        'PRIVMSG +#channel :\x01ACTION Hi!\x01'
+    )
+
+
 def test_wrapper_action_override_destination(mockbot, triggerfactory):
     wrapper = triggerfactory.wrapper(
         mockbot, ':Test!test@example.com PRIVMSG #channel :test message')
@@ -172,6 +240,20 @@ def test_wrapper_reply(mockbot, triggerfactory):
     )
 
 
+def test_wrapper_reply_statusmsg(mockbot, triggerfactory):
+    mockbot._isupport = mockbot.isupport.apply(
+        STATUSMSG=tuple('+'),
+    )
+
+    wrapper: bot.SopelWrapper = triggerfactory.wrapper(
+        mockbot, ':Test!test@example.com PRIVMSG +#channel :test message')
+    wrapper.reply('Hi!')
+
+    assert mockbot.backend.message_sent == rawlist(
+        'PRIVMSG +#channel :Test: Hi!'
+    )
+
+
 def test_wrapper_reply_override_destination(mockbot, triggerfactory):
     wrapper = triggerfactory.wrapper(
         mockbot, ':Test!test@example.com PRIVMSG #channel :test message')