Repository: sopel-irc/sopel

Issue:
STATUSMSG not handled in `Identifier.is_nick()`
### Description
Freenode adds a @ to the channel name in a +mz channel or +z channel where the user is quieted.

This means #channel becomes @#channel and the it's impossible to use trigger.sender to kick a user, for example, from the channel based on what they said.

### Reproduction steps
1. Create a +mz channel on Freenode and set log_raw to True
2. op your bot have a user who can only speak to ops say something.
3. You will see the channel is recorded as @#channel

### Expected behavior
The @ is dropped from trigger.sender (but maybe something like trigger.was_moderated is added to indicate it)

(PS: I can attempt it tonight)

### Logs
```
2020-06-05 22:47:01,739 << :test-lta!uid348912@gateway/web/irccloud.com/x-zbfyoyydyhcwvgmu PRIVMSG @#miraheze-overflow :sing and dance with me
```

### Environment
- Sopel `.version`: 7.0.1 & 7.0.4 tested
- Sopel installed via: pip (both)
- Python version: 3.5.3 (both)
- Operating system: Debian Stretch (9.7) for .1 & Debian 4.9.144-3 (2019-02-02) x86_64 for .4
- IRCd `/version`: Freenode (https://freenode.net/news/seven-118)


Discussion:
Going to leave these here for whoever first decides to work on implementing this (could be me, could be someone else): https://modern.ircdocs.horse/#statusmsg-parameter & https://modern.ircdocs.horse/#privmsg-message

Resolving patch:
diff --git a/sopel/irc/__init__.py b/sopel/irc/__init__.py
--- a/sopel/irc/__init__.py
+++ b/sopel/irc/__init__.py
@@ -322,6 +322,7 @@ def on_message(self, message: str) -> None:
             message,
             url_schemes=self.settings.core.auto_url_schemes,
             identifier_factory=self.make_identifier,
+            statusmsg_prefixes=self.isupport.get('STATUSMSG'),
         )
         if all(
             cap not in self.enabled_capabilities
@@ -371,6 +372,7 @@ def on_message_sent(self, raw: str) -> None:
                 ":{0}!{1}@{2} {3}".format(self.nick, self.user, host, raw),
                 url_schemes=self.settings.core.auto_url_schemes,
                 identifier_factory=self.make_identifier,
+                statusmsg_prefixes=self.isupport.get('STATUSMSG'),
             )
             self.dispatch(pretrigger)
 
diff --git a/sopel/trigger.py b/sopel/trigger.py
--- a/sopel/trigger.py
+++ b/sopel/trigger.py
@@ -169,6 +169,7 @@ def __init__(
         line: str,
         url_schemes: Optional[Sequence] = None,
         identifier_factory: IdentifierFactory = identifiers.Identifier,
+        statusmsg_prefixes: Tuple[str, ...] = tuple(),
     ):
         self.make_identifier = identifier_factory
         line = line.strip('\r\n')
@@ -243,9 +244,13 @@ def __init__(
         # If we have arguments, the first one is *usually* the sender,
         # most numerics and certain general events (e.g. QUIT) excepted
         target: Optional[identifiers.Identifier] = None
+        status_prefix: Optional[str] = None
 
         if self.args and self.event in COMMANDS_WITH_CONTEXT:
-            target = self.make_identifier(self.args[0])
+            raw_target = self.args[0]
+            if statusmsg_prefixes and raw_target[0] in statusmsg_prefixes:
+                status_prefix, raw_target = raw_target[0], raw_target[1:]
+            target = self.make_identifier(raw_target)
 
             # Unless we're messaging the bot directly, in which case that
             # second arg will be our bot's name.
@@ -253,6 +258,7 @@ def __init__(
                 target = self.nick
 
         self.sender = target
+        self.status_prefix = status_prefix
 
         # Parse CTCP
         if self.event == 'PRIVMSG' or self.event == 'NOTICE':
@@ -331,6 +337,16 @@ class Trigger(str):
         The :attr:`COMMANDS_WITH_CONTEXT` attribute lists IRC commands for
         which ``sender`` can be relied upon.
 
+    """
+    status_prefix = property(lambda self: self._pretrigger.status_prefix)
+    """The prefix used for the :attr:`sender` for status-specific messages.
+
+    :type: Optional[str]
+
+    This will be ``None`` by default. If a message is sent to a channel, it may
+    have a status prefix for status-specific messages. In that case, the prefix
+    is removed from the channel's identifier (see :attr:`sender`) and saved to
+    this attribute.
     """
     time = property(lambda self: self._pretrigger.time)
     """When the message was received.


Test patch:
diff --git a/sopel/tests/factories.py b/sopel/tests/factories.py
--- a/sopel/tests/factories.py
+++ b/sopel/tests/factories.py
@@ -118,6 +118,7 @@ def __call__(
             raw,
             url_schemes=url_schemes,
             identifier_factory=mockbot.make_identifier,
+            statusmsg_prefixes=mockbot.isupport.get('STATUSMSG'),
         )
         return trigger.Trigger(mockbot.settings, pretrigger, match)
 
diff --git a/sopel/tests/pytest_plugin.py b/sopel/tests/pytest_plugin.py
--- a/sopel/tests/pytest_plugin.py
+++ b/sopel/tests/pytest_plugin.py
@@ -90,7 +90,12 @@ def test(configfactory, botfactory, ircfactory):
         # TODO enable message tags
         full_message = ':{} PRIVMSG {} :{}'.format(hostmask, sender, msg)
         pretrigger = trigger.PreTrigger(
-            mockbot.nick, full_message, url_schemes=url_schemes)
+            mockbot.nick,
+            full_message,
+            url_schemes=url_schemes,
+            identifier_factory=mockbot.make_identifier,
+            statusmsg_prefixes=mockbot.isupport.get('STATUSMSG'),
+        )
         test_trigger = trigger.Trigger(mockbot.settings, pretrigger, match)
         pattern = re.compile(r'^%s: ' % re.escape(mockbot.nick))
 
@@ -116,6 +121,8 @@ def isnt_ignored(value):
                     mockbot.nick,
                     message.decode('utf-8'),
                     url_schemes=url_schemes,
+                    identifier_factory=mockbot.make_identifier,
+                    statusmsg_prefixes=mockbot.isupport.get('STATUSMSG'),
                 )
                 for message in wrapper.backend.message_sent
             )
diff --git a/test/test_trigger.py b/test/test_trigger.py
--- a/test/test_trigger.py
+++ b/test/test_trigger.py
@@ -46,6 +46,7 @@ def test_basic_pretrigger(nick):
     assert pretrigger.user == 'foo'
     assert pretrigger.host == 'example.com'
     assert pretrigger.sender == '#Sopel'
+    assert pretrigger.status_prefix is None
 
 
 def test_pm_pretrigger(nick):
@@ -62,6 +63,37 @@ def test_pm_pretrigger(nick):
     assert pretrigger.user == 'foo'
     assert pretrigger.host == 'example.com'
     assert pretrigger.sender == Identifier('Foo')
+    assert pretrigger.status_prefix is None
+
+
+def test_statusmsg_pretrigger(nick):
+    line = ':Foo!foo@example.com PRIVMSG @#Sopel :Hello, world'
+    pretrigger = PreTrigger(nick, line, statusmsg_prefixes=tuple('@'))
+    assert pretrigger.tags == {}
+    assert pretrigger.hostmask == 'Foo!foo@example.com'
+    assert pretrigger.line == line
+    assert pretrigger.args == ['@#Sopel', 'Hello, world']
+    assert pretrigger.text == 'Hello, world'
+    assert pretrigger.plain == 'Hello, world'
+    assert pretrigger.event == 'PRIVMSG'
+    assert pretrigger.nick == Identifier('Foo')
+    assert pretrigger.user == 'foo'
+    assert pretrigger.host == 'example.com'
+    assert pretrigger.sender == '#Sopel'
+    assert pretrigger.status_prefix == '@'
+
+
+def test_statusmsg_custom_prefix_pretrigger(nick):
+    line = ':Foo!foo@example.com PRIVMSG £#Sopel :Hello, world'
+    pretrigger = PreTrigger(nick, line, statusmsg_prefixes=('@', '£'))
+    assert pretrigger.sender == '#Sopel'
+    assert pretrigger.status_prefix == '£'
+
+    # no statusmsg_prefixes set (use default)
+    line = ':Foo!foo@example.com PRIVMSG £#Sopel :Hello, world'
+    pretrigger = PreTrigger(nick, line)
+    assert pretrigger.sender == '£#Sopel'
+    assert pretrigger.status_prefix is None
 
 
 def test_quit_pretrigger(nick):
@@ -78,6 +110,7 @@ def test_quit_pretrigger(nick):
     assert pretrigger.user == 'foo'
     assert pretrigger.host == 'example.com'
     assert pretrigger.sender is None
+    assert pretrigger.status_prefix is None
 
 
 def test_quit_pretrigger_empty(nick):
@@ -95,6 +128,7 @@ def test_quit_pretrigger_empty(nick):
     assert pretrigger.user == 'foo'
     assert pretrigger.host == 'example.com'
     assert pretrigger.sender is None
+    assert pretrigger.status_prefix is None
 
 
 def test_away_pretrigger(nick):
@@ -111,6 +145,7 @@ def test_away_pretrigger(nick):
     assert pretrigger.user == 'foo'
     assert pretrigger.host == 'example.com'
     assert pretrigger.sender is None
+    assert pretrigger.status_prefix is None
 
 
 def test_away_pretrigger_back(nick):
@@ -128,6 +163,7 @@ def test_away_pretrigger_back(nick):
     assert pretrigger.user == 'foo'
     assert pretrigger.host == 'example.com'
     assert pretrigger.sender is None
+    assert pretrigger.status_prefix is None
 
 
 def test_join_pretrigger(nick):
@@ -144,6 +180,7 @@ def test_join_pretrigger(nick):
     assert pretrigger.user == 'foo'
     assert pretrigger.host == 'example.com'
     assert pretrigger.sender == Identifier('#Sopel')
+    assert pretrigger.status_prefix is None
 
 
 def test_tags_pretrigger(nick):
@@ -162,6 +199,7 @@ def test_tags_pretrigger(nick):
     assert pretrigger.user == 'foo'
     assert pretrigger.host == 'example.com'
     assert pretrigger.sender == '#Sopel'
+    assert pretrigger.status_prefix is None
 
 
 def test_intents_pretrigger(nick):
@@ -179,6 +217,7 @@ def test_intents_pretrigger(nick):
     assert pretrigger.user == 'foo'
     assert pretrigger.host == 'example.com'
     assert pretrigger.sender == '#Sopel'
+    assert pretrigger.status_prefix is None
 
 
 def test_unusual_pretrigger(nick):
@@ -193,6 +232,7 @@ def test_unusual_pretrigger(nick):
     assert pretrigger.plain == ''
     assert pretrigger.event == 'PING'
     assert pretrigger.sender is None
+    assert pretrigger.status_prefix is None
 
 
 def test_ctcp_intent_pretrigger(nick):
@@ -210,6 +250,7 @@ def test_ctcp_intent_pretrigger(nick):
     assert pretrigger.user == 'foo'
     assert pretrigger.host == 'example.com'
     assert pretrigger.sender == Identifier('Foo')
+    assert pretrigger.status_prefix is None
 
 
 def test_ctcp_data_pretrigger(nick):
@@ -227,6 +268,7 @@ def test_ctcp_data_pretrigger(nick):
     assert pretrigger.user == 'foo'
     assert pretrigger.host == 'example.com'
     assert pretrigger.sender == Identifier('Foo')
+    assert pretrigger.status_prefix is None
 
 
 def test_ctcp_action_pretrigger(nick):
@@ -244,6 +286,7 @@ def test_ctcp_action_pretrigger(nick):
     assert pretrigger.user == 'foo'
     assert pretrigger.host == 'example.com'
     assert pretrigger.sender == '#Sopel'
+    assert pretrigger.status_prefix is None
 
 
 def test_ctcp_action_trigger(nick, configfactory):
@@ -289,6 +332,7 @@ def test_ircv3_extended_join_pretrigger(nick):
     assert pretrigger.user == 'foo'
     assert pretrigger.host == 'example.com'
     assert pretrigger.sender == Identifier('#Sopel')
+    assert pretrigger.status_prefix is None
 
 
 def test_ircv3_extended_join_trigger(nick, configfactory):
@@ -376,3 +420,16 @@ def test_ircv3_server_time_trigger(nick, configfactory):
     line = '@time=2016-01-09T04:20 :Foo!foo@example.com PRIVMSG #Sopel :Hello, world'
     pretrigger = PreTrigger(nick, line)
     assert pretrigger.time is not None
+
+
+def test_statusmsg_trigger(nick, configfactory):
+    line = ':Foo!foo@example.com PRIVMSG @#channel :text message'
+    pretrigger = PreTrigger(nick, line, statusmsg_prefixes=tuple('@'))
+    config = configfactory('default.cfg', TMP_CONFIG)
+    fakematch = re.match('.*', line)
+
+    trigger = Trigger(config, pretrigger, fakematch)
+
+    assert trigger.sender == '#channel'
+    assert trigger.sender == Identifier('#channel')
+    assert trigger.status_prefix == '@'