home · contact · privacy
Refactor IrcMessage handling.
authorChristian Heller <c.heller@plomlompom.de>
Sun, 17 Aug 2025 15:51:33 +0000 (17:51 +0200)
committerChristian Heller <c.heller@plomlompom.de>
Sun, 17 Aug 2025 15:51:33 +0000 (17:51 +0200)
ircplom/client.py
ircplom/irc_conn.py

index 1a818471b7cac925e3bd7fed16f9b954adc4a35b..b45dabe5a1b49d5be01bba2bcb92dbca796f0ea4 100644 (file)
@@ -35,6 +35,31 @@ _NUMERICS_TO_IGNORE = (
 )
 
 
+class _IrcMsg(IrcMessage):
+    'Extends IrcMessage with some conveniences.'
+
+    def match(self, verb: str, len_params=1, len_is_min=False) -> bool:
+        'Test .verb, len(.params).'
+        n_msg_params = len(self.params)
+        return (self.verb == verb
+                and (n_msg_params == len_params
+                     or len_is_min and n_msg_params > len_params))
+
+    def nick_conforms(self, db: '_ClientDb') -> bool:
+        'Test .nick_from_source == db.nickname.'
+        return self.nick_from_source == db.nickname
+
+    def confirm_nick(self, db: '_ClientDb') -> None:
+        'Assume .params[0] confirms nickname.'
+        db.nickname = self.params[0]
+        db.nickname_confirmed = True
+
+    @property
+    def nick_from_source(self) -> str:
+        'Parse .source into user nickname.'
+        return self.source.split('!')[0]
+
+
 class LogScope(Enum):
     'Where log messages should go.'
     ALL = auto()
@@ -84,7 +109,7 @@ class _IrcConnection(BaseIrcConnection, ClientIdMixin):
 
     def _make_recv_event(self, msg: IrcMessage) -> ClientEvent:
         return ClientEvent.affector('handle_msg', client_id=self.client_id
-                                    ).kw(msg=msg)
+                                    ).kw(msg=_IrcMsg.from_raw(msg.raw))
 
     def _on_handled_loop_exception(self, e: IrcConnAbortException
                                    ) -> ClientEvent:
@@ -377,6 +402,15 @@ class _ClientDb(_Db, IrcConnSetup):
                 on_update=lambda k: self._on_update(name, k))
         return self._channels[name]
 
+    def process_isupport(self, params: tuple[str, ...]) -> None:
+        'Process 005 RPL_ISUPPORT params into dictionary updates.'
+        for param in params[1:-1]:
+            toks = param.split('=', maxsplit=1)
+            if toks[0][0] == '-':
+                del self.isupports[toks[0][1:]]
+            else:
+                self.isupports[toks[0]] = toks[1] if len(toks) > 1 else ''
+
 
 class Client(ABC, ClientQueueMixin):
     'Abstracts socket connection, loop over it, and handling messages from it.'
@@ -462,92 +496,76 @@ class Client(ABC, ClientQueueMixin):
         self._log(f'connection broken: {e}', alert=True)
         self.close()
 
-    def handle_msg(self, msg: IrcMessage) -> None:
+    def handle_msg(self, msg: _IrcMsg) -> None:
         'Log msg.raw, then process incoming msg into appropriate client steps.'
         self._log(msg.raw, scope=LogScope.RAW, out=False)
         if _NumericsToConfirmNickname.contain(msg.verb):
-            self._db.nickname = msg.params[0]
-            self._db.nickname_confirmed = True
+            msg.confirm_nick(self._db)
         if _NumericsToIgnore.contain(msg.verb):
             return
-        if msg.verb in {'JOIN', 'PART'} and len(msg.params) >= 1:
-            scope = LogScope.CHAT
+        if msg.match('005', 2, True):  # RPL_ISUPPORT
+            self._db.process_isupport(msg.params[1:-1])
+        elif msg.match('353', 4):  # RPL_NAMREPLY
+            for user in msg.params[3].split():
+                self._db.chan(msg.params[2]).append_completable('users', user)
+        elif msg.match('366', 3):  # RPL_ENDOFNAMES
+            self._db.chan(msg.params[1]).declare_complete('users')
+        elif msg.match('372', 2):  # RPL_MOTD
+            self._db.append_completable('motd', msg.params[1])
+        elif msg.match('376', 2):  # RPL_ENDOFMOTD
+            self._db.declare_complete('motd')
+        elif msg.match('396', 3):  # RPL_VISIBLEHOST
+            # '@'-split because <https://defs.ircdocs.horse/defs/numerics>
+            # claims: "<hostname> can also be in the form <user@hostname>"
+            self._db.client_host = msg.params[1].split('@')[-1]
+        elif msg.match('903', 2):  # RPL_SASLSUCESS
+            self._log('SASL auth succeeded')
+            self._caps.end_negotiation()
+        elif msg.match('904', 2):  # ERR_SASLFAIL
+            self._log('SASL auth failed', alert=True)
+            self._caps.end_negotiation()
+        elif msg.match('AUTHENTICATE') and msg.params[0] == '+':
+            auth = b64encode((self._db.nickname + '\0'
+                              + self._db.nickname + '\0'
+                              + self._db.password
+                              ).encode('utf-8')).decode('utf-8')
+            self.send(IrcMessage('AUTHENTICATE', (auth,)))
+        elif msg.match('CAP', len_is_min=True)\
+                and self._caps.process_msg(msg.params[1:])\
+                and self._db.caps.has('sasl')\
+                and 'PLAIN' in self._db.caps['sasl'].data.split(','):
+            if self._db.password:
+                self._log('trying to authenticate via SASL/plain')
+                self.send(IrcMessage('AUTHENTICATE', ('PLAIN',)))
+            else:
+                self._caps.end_negotiation()
+        elif msg.match('ERROR'):
+            self.close()
+        elif msg.match('MODE', 2) and msg.params[0] == self._db.nickname:
+            self._db.user_modes = msg.params[1]
+        elif msg.match('NICK') and msg.nick_conforms(self._db):
+            msg.confirm_nick(self._db)
+        elif msg.match('NOTICE', 2) or msg.match('PRIVMSG', 2):
+            scope = LogScope.CHAT if '!' in msg.source else LogScope.SERVER
+            self._log(msg.params[-1], scope=scope, channel=msg.params[0],
+                      out=False, as_notice=msg.verb == 'NOTICE',
+                      sender=msg.nick_from_source)
+        elif msg.match('PART', len_is_min=True) or msg.match('JOIN'):
             channel = msg.params[0]
             log_msg = f'{msg.nick_from_source} {msg.verb.lower()}s {channel}'
-        match msg.verb:
-            case '005' if len(msg.params) > 2:  # RPL_ISUPPORT
-                for param in msg.params[1:-1]:
-                    toks = param.split('=', maxsplit=1)
-                    if toks[0][0] == '-':
-                        del self._db.isupports[toks[0][1:]]
-                    else:
-                        self._db.isupports[toks[0]] = (
-                                toks[1] if len(toks) > 1 else '')
-            case '353' if len(msg.params) == 4:  # RPL_NAMREPLY
-                for user in msg.params[3].split():
-                    self._db.chan(msg.params[2]
-                                  ).append_completable('users', user)
-            case '366' if len(msg.params) == 3:  # RPL_ENDOFNAMES
-                self._db.chan(msg.params[1]).declare_complete('users')
-            case '372' if len(msg.params) == 2:  # RPL_MOTD
-                self._db.append_completable('motd', msg.params[1])
-            case '376' if len(msg.params) == 2:  # RPL_ENDOFMOTD
-                self._db.declare_complete('motd')
-            case '396' if len(msg.params) == 3:  # RPL_VISIBLEHOST
-                # '@'-split because <https://defs.ircdocs.horse/defs/numerics>
-                # claims: "<hostname> can also be in the form <user@hostname>"
-                self._db.client_host = msg.params[1].split('@')[-1]
-            case '903' | '904' if len(msg.params) == 2:  # RPL_SASLSUCESS
-                alert = msg.verb == '904'                # ERR_SASLFAIL
-                self._log(f'SASL auth {"failed" if alert else "succeeded"}',
-                          alert=alert)
-                self._caps.end_negotiation()
-            case 'AUTHENTICATE' if msg.params == ('+',):
-                auth = b64encode((self._db.nickname + '\0' +
-                                  self._db.nickname + '\0' +
-                                  self._db.password
-                                  ).encode('utf-8')).decode('utf-8')
-                self.send(IrcMessage('AUTHENTICATE', (auth,)))
-            case 'CAP' if len(msg.params) > 1:
-                if (self._caps.process_msg(msg.params[1:])
-                        and self._db.caps.has('sasl')
-                        and (sasl_caps := self._db.caps['sasl'])
-                        and ('PLAIN' in sasl_caps.data.split(','))):
-                    if self._db.password:
-                        self._log('trying to authenticate via SASL/plain')
-                        self.send(IrcMessage('AUTHENTICATE', ('PLAIN',)))
-                    else:
-                        self._caps.end_negotiation()
-            case 'ERROR' if len(msg.params) == 1:
-                self.close()
-            case 'JOIN' if len(msg.params) == 1:
-                if msg.nick_from_source != self._db.nickname:
-                    self._db.chan(channel).users.append(msg.nick_from_source)
-                self._log(log_msg, scope=scope, channel=channel)
-            case 'MODE' if (len(msg.params) == 2
-                            and msg.params[0] == self._db.nickname):
-                self._db.user_modes = msg.params[1]
-            case 'NICK' if (len(msg.params) == 1
-                            and msg.nick_from_source == self._db.nickname):
-                self._db.nickname = msg.params[0]
-                self._db.nickname_confirmed = True
-            case 'NOTICE' | 'PRIVMSG' if len(msg.params) == 2:
-                scope = LogScope.CHAT if '!' in msg.source else LogScope.SERVER
-                self._log(msg.params[-1], scope=scope, channel=msg.params[0],
-                          out=False, as_notice=msg.verb == 'NOTICE',
-                          sender=msg.nick_from_source)
-            case 'PART' if len(msg.params) >= 1:
-                if len(msg.params) > 1:
-                    log_msg += f': {msg.params[1]}'
-                self._log(log_msg, scope=scope, channel=channel)
-                if msg.nick_from_source == self._db.nickname:
-                    self._db.del_chan(channel)
-                else:
-                    self._db.chan(channel).users.remove(msg.nick_from_source)
-            case 'PING' if len(msg.params) == 1:
-                self.send(IrcMessage(verb='PONG', params=(msg.params[0],)))
-            case _:
-                self._log(f'PLEASE IMPLEMENT HANDLER FOR: {msg.raw}')
+            if msg.match('PART', 2, True):
+                log_msg += f': {msg.params[1]}'
+            self._log(log_msg, scope=LogScope.CHAT, channel=channel)
+            if msg.verb == 'JOIN':
+                self._db.chan(channel).users.append(msg.nick_from_source)
+            elif msg.nick_conforms(self._db):
+                self._db.del_chan(channel)
+            else:
+                self._db.chan(channel).users.remove(msg.nick_from_source)
+        elif msg.match('PING'):
+            self.send(IrcMessage(verb='PONG', params=(msg.params[0],)))
+        else:
+            self._log(f'PLEASE IMPLEMENT HANDLER FOR: {msg.raw}')
 
 
 @dataclass
index 197c733362b8cc312c011a48ec5f180ad43aac62..5e09c1aa7133357111a699f879d366316421c096 100644 (file)
@@ -98,11 +98,6 @@ class IrcMessage:
         msg._raw = raw_msg
         return msg
 
-    @property
-    def nick_from_source(self) -> str:
-        'Parse .source into user nickname.'
-        return self.source.split('!')[0]
-
     @property
     def raw(self) -> str:
         'Return raw message code – create from known fields if necessary.'