home · contact · privacy
Further re-organize CAP LS/LIST parsing. master
authorChristian Heller <c.heller@plomlompom.de>
Mon, 28 Jul 2025 02:56:07 +0000 (04:56 +0200)
committerChristian Heller <c.heller@plomlompom.de>
Mon, 28 Jul 2025 02:56:07 +0000 (04:56 +0200)
ircplom/irc_conn.py

index ef7728d9f86064849e3b2011722b84673ddfed42..31466936f780089a5363e4dca7a8e3dc95feb1cc 100644 (file)
@@ -1,6 +1,7 @@
 'IRC server connection management.'
 # built-ins
 from abc import ABC, abstractmethod
+from dataclasses import dataclass
 from getpass import getuser
 from socket import socket, gaierror as socket_gaierror
 from threading import Thread
@@ -152,7 +153,7 @@ class _ConnectedEvent(ClientEvent):
 
     def affect(self, target: 'Client') -> None:
         target.log(msg='# connected to server', chat=CHAT_GLOB)
-        target.send(IrcMessage(verb='CAP', params=('LS', '302')))
+        target.try_send_cap('LS', ('302',))
         target.send(IrcMessage(verb='USER', params=(getuser(), '0', '*',
                                                     target.realname)))
         target.send(IrcMessage(verb='NICK', params=(target.nickname,)))
@@ -190,6 +191,21 @@ class ClientQueueMixin(QueueMixin):
                               **kwargs))
 
 
+@dataclass
+class ServerCapability:
+    'Store data collectable via CAPS LS/LIST/NEW.'
+    enabled: bool
+    data: str
+
+    def str_for_log(self, name: str) -> str:
+        'Optimized for Client.log per-line listing.'
+        listing = '+' if self.enabled else '-'
+        listing += f' {name}'
+        if self.data:
+            listing += f' ({self.data})'
+        return listing
+
+
 class Client(ABC, ClientQueueMixin):
     'Abstracts socket connection, loop over it, and handling messages from it.'
     nick_confirmed: bool
@@ -201,8 +217,8 @@ class Client(ABC, ClientQueueMixin):
         self._hostname = hostname
         self._socket: Optional[socket] = None
         self._recv_loop: Optional[Loop] = None
-        self.cap_neg_state: set[str] = set()
-        self.caps: dict[str, tuple[bool, str]] = {}
+        self._cap_neg_states: dict[str, bool] = {}
+        self.caps: dict[str, ServerCapability] = {}
         self.id_ = uuid4()
         self.assumed_open = False
         self.realname = realname
@@ -232,31 +248,47 @@ class Client(ABC, ClientQueueMixin):
 
         Thread(target=connect, daemon=True, args=(self,)).start()
 
+    def cap_neg_done(self, negotiation_step: str) -> bool:
+        'Whether negotiation_step is registered as finished.'
+        return self._cap_neg_states.get(negotiation_step, False)
+
+    def cap_neg(self, negotiation_step: str) -> bool:
+        'Whether negotiation_step is registered at all (started or finished).'
+        return negotiation_step in self._cap_neg_states
+
+    def cap_neg_set(self, negotiation_step: str, done: bool = False) -> None:
+        'Declare negotiation_step started, or (if done) finished.'
+        self._cap_neg_states[negotiation_step] = done
+
+    def try_send_cap(self, *params, key_fused: bool = False) -> None:
+        'Run CAP command with params, handle cap neg. state.'
+        neg_state_key = ':'.join(params) if key_fused else params[0]
+        if self.cap_neg(neg_state_key):
+            return
+        self.send(IrcMessage(verb='CAP', params=params))
+        self.cap_neg_set(neg_state_key)
+
     def collect_caps(self, params: tuple[str, ...]) -> None:
         'Record available and enabled server capabilities.'
         verb = params[0]
         items = params[-1].strip().split()
         is_final_line = params[1] != '*'
-        doneness = f'{verb} done'
-        waiting = f'{verb} wait'
-        if doneness in self.cap_neg_state:
+        if self.cap_neg_done(verb):
             if verb == 'LS':
                 self.caps.clear()
             else:
-                for name in self.caps:
-                    self.caps[name] = (False, self.caps[name][1])
-            self.cap_neg_state.remove(doneness)
-        if waiting not in self.cap_neg_state:
-            self.cap_neg_state.add(waiting)
+                for cap in self.caps.values():
+                    cap.enabled = False
+            self.cap_neg_set(verb)
         for item in items:
             if verb == 'LS':
                 splitted = item.split('=', maxsplit=1)
-                self.caps[splitted[0]] = (False, ''.join(splitted[1:]))
+                self.caps[splitted[0]] = ServerCapability(
+                        enabled=False, data=''.join(splitted[1:]))
             else:
-                self.caps[item] = (True, self.caps[item][1])
+                self.caps[item].enabled = True
         if is_final_line:
-            self.cap_neg_state.remove(waiting)
-            self.cap_neg_state.add(doneness)
+            self.cap_neg_set(verb, done=True)
 
     @abstractmethod
     def log(self, msg: str, chat: str = '') -> None:
@@ -351,14 +383,21 @@ class _RecvEvent(ClientEvent, PayloadMixin):
         elif msg.verb == 'CAP':
             if msg.params[1] in {'LS', 'LIST'}:
                 target.collect_caps(msg.params[1:])
-            if ('LIST done' in target.cap_neg_state
-                    and 'listed' not in target.cap_neg_state):
-                target.send(IrcMessage(verb='CAP', params=('END',)))
-                target.log('# available server capabilities (enabled: "+"):')
-                for cap_name, config in target.caps.items():
-                    target.log(f'# {"+" if config[0] else "-"} {cap_name}'
-                               + (f' ({config[1]})' if config[1] else ''))
-                target.cap_neg_state.add('listed')
-            elif ('LS done' in target.cap_neg_state
-                    and 'LIST wait' not in target.cap_neg_state):
-                target.send(IrcMessage(verb='CAP', params=('LIST',)))
+            elif msg.params[1] == {'ACK', 'NAK'}:
+                cap_names = msg.params[-1].split()
+                for cap_name in cap_names:
+                    target.cap_neg_set(f'REQ:{cap_name}', done=True)
+                    target.caps[cap_name].enabled = msg.params[1] == 'ACK'
+            if target.cap_neg_done('LIST'):
+                target.try_send_cap('END')
+                if not target.cap_neg('printing'):
+                    target.log('# server capabilities (enabled: "+"):')
+                    for cap_name, cap in target.caps.items():
+                        target.log('# ' + cap.str_for_log(cap_name))
+                    target.cap_neg_set('printing', done=True)
+            elif target.cap_neg_done('LS'):
+                for cap_name in ('server-time', 'account-tag', 'sasl'):
+                    if (cap_name in target.caps
+                            and (not target.caps[cap_name].enabled)):
+                        target.try_send_cap('REQ', cap_name, key_fused=True)
+                target.try_send_cap('LIST')