From: Christian Heller <c.heller@plomlompom.de>
Date: Tue, 26 Aug 2025 01:25:52 +0000 (+0200)
Subject: Parse isupports to derive proper legal/illegal first chars for nicknames, channel... 
X-Git-Url: https://plomlompom.com/repos/%22https:/validator.w3.org/static/tasks?a=commitdiff_plain;ds=inline;p=ircplom

Parse isupports to derive proper legal/illegal first chars for nicknames, channel names.
---

diff --git a/ircplom/client.py b/ircplom/client.py
index 9a99aa2..46b21cc 100644
--- a/ircplom/client.py
+++ b/ircplom/client.py
@@ -11,8 +11,9 @@ from uuid import uuid4
 # ourselves
 from ircplom.events import (
         AffectiveEvent, CrashingException, ExceptionEvent, QueueMixin)
-from ircplom.irc_conn import (BaseIrcConnection, IrcConnAbortException,
-                              IrcMessage, PORT_SSL)
+from ircplom.irc_conn import (
+    BaseIrcConnection, IrcConnAbortException, IrcMessage, ILLEGAL_NICK_CHARS,
+    ILLEGAL_NICK_FIRSTCHARS, ISUPPORT_DEFAULTS, PORT_SSL)
 
 ClientsDb = dict[str, 'Client']
 
@@ -130,7 +131,6 @@ class ClientQueueMixin(QueueMixin, _ClientIdMixin):
 
 
 _NAMES_DESIRED_SERVER_CAPS = ('sasl',)
-_ILLEGAL_NICK_FIRSTCHARS = '~&@+# '
 
 
 class _MsgTok(Enum):
@@ -487,7 +487,7 @@ class _UpdatingDict:
         self._dict: dict[str, Any] = {}
 
     def __getitem__(self, key: str):
-        return self._dict[key]
+        return self._dict[key] if key in self._dict else None
 
     def __setitem__(self, key: str, val: Any) -> None:
         if isinstance(val, _NickUserHost):
@@ -697,6 +697,26 @@ class _ClientDb(_Db, SharedClientDbFields):
         return not isinstance(getattr(self, key), (bool, int, str, tuple,
                                                    _CompletableStringsList))
 
+    @property
+    def illegal_nick_firstchars(self) -> str:
+        'Calculated from hardcoded constants and .isupports.'
+        return (ILLEGAL_NICK_CHARS + ILLEGAL_NICK_FIRSTCHARS
+                + self.chan_prefixes + self.membership_prefixes)
+
+    @property
+    def chan_prefixes(self) -> str:
+        'Registered possible channel name prefixes.'
+        return self.isupports['CHANTYPES'] or ISUPPORT_DEFAULTS['CHANTYPES']
+
+    @property
+    def membership_prefixes(self) -> str:
+        'Registered possible membership nickname prefixes.'
+        prefix = self.isupports['PREFIX'] or ISUPPORT_DEFAULTS['PREFIX']
+        toks = prefix.split(')', maxsplit=1)
+        assert len(toks) == 2
+        assert toks[0][0] == '('
+        return toks[1]
+
     def user_id(self, query: str | _NickUserHost) -> str:
         'Return user_id for nickname of entire NickUserHost, create if none.'
         nick = query if isinstance(query, str) else query.nick
@@ -852,9 +872,10 @@ class Client(ABC, ClientQueueMixin):
                                    and not set('@!') & set(msg_tok)) else None
             if ex_tok is _MsgTok.CHANNEL:
                 return {'id': msg_tok, 'db': self._db.chan(msg_tok)
-                        } if msg_tok[0] == '#' else None
+                        } if msg_tok[0] in self._db.chan_prefixes else None
             if ex_tok is _MsgTok.NICKNAME:
-                return (msg_tok if msg_tok[0] not in _ILLEGAL_NICK_FIRSTCHARS
+                return (msg_tok
+                        if msg_tok[0] not in self._db.illegal_nick_firstchars
                         else None)
             if ex_tok is _MsgTok.NICK_USER_HOST:
                 try:
@@ -919,7 +940,7 @@ class Client(ABC, ClientQueueMixin):
                     self._db.isupports.set_from_eq_str(str(item))
         elif ret['verb'] == '353':  # RPL_NAMREPLY
             for user_id in [
-                    self._db.user_id(name.lstrip(_ILLEGAL_NICK_FIRSTCHARS))
+                    self._db.user_id(name.lstrip(self._db.membership_prefixes))
                     for name in ret['names']]:
                 ret['channel']['db'].add_user(user_id)
         elif ret['verb'] == '372':  # RPL_MOTD
diff --git a/ircplom/client_tui.py b/ircplom/client_tui.py
index 3792fa6..5664df2 100644
--- a/ircplom/client_tui.py
+++ b/ircplom/client_tui.py
@@ -6,7 +6,7 @@ from typing import Callable, Optional, Sequence
 # ourselves
 from ircplom.tui_base import (BaseTui, PromptWidget, TuiEvent, Window,
                               CMD_SHORTCUTS)
-from ircplom.irc_conn import IrcMessage
+from ircplom.irc_conn import IrcMessage, ISUPPORT_DEFAULTS
 from ircplom.client import (
         Client, ClientQueueMixin, Db, IrcConnSetup, LogScope, NewClientEvent,
         NickUserHost, ServerCapability, SharedChannelDbFields,
@@ -130,11 +130,6 @@ class _Update:
         if not self.display:
             self.display = str(self.value)
 
-    @property
-    def is_chan(self) -> bool:
-        'Return if .path points to a _ChannelDb.'
-        return self.path[0] == '#'
-
 
 class _Db(Db):
 
@@ -185,7 +180,7 @@ class _TuiClientDb(_Db, SharedClientDbFields):
     def set_and_check_for_change(self, update: _Update
                                  ) -> bool | dict[str, tuple[str, ...]]:
         result: bool | dict[str, tuple[str, ...]] = False
-        if update.is_chan:
+        if self.is_chan_name(update.path):
             chan_name = update.path
             if update.value is None and not update.arg:
                 del self._channels[chan_name]
@@ -204,6 +199,11 @@ class _TuiClientDb(_Db, SharedClientDbFields):
             result = super().set_and_check_for_change(update)
         return result
 
+    def is_chan_name(self, name: str) -> bool:
+        'Tests name to match CHANTYPES prefixes.'
+        return name[0] in self.isupports.get('CHANTYPES',
+                                             ISUPPORT_DEFAULTS['CHANTYPES'])
+
     def chan(self, name: str) -> _ChannelDb:
         'Produce DB for channel of name – pre-existing, or newly created.'
         if name not in self._channels:
@@ -261,12 +261,13 @@ class _ClientWindowsManager:
 
     def update_db(self, update: _Update) -> bool:
         'Apply update to .db, and if changing anything, log and trigger.'
-        scope = (LogScope.CHAT if update.is_chan
+        is_chan_update = self.db.is_chan_name(update.path)
+        scope = (LogScope.CHAT if is_chan_update
                  else (LogScope.ALL if update.path == 'connection_state'
                        else LogScope.SERVER))
         verb = 'cleared' if update.value is None else 'changed to:'
         what = f'{update.path}:{update.arg}' if update.arg else update.path
-        log_kwargs = {'target': update.path} if update.is_chan else {}
+        log_kwargs = {'target': update.path} if is_chan_update else {}
         result = self.db.set_and_check_for_change(update)
         if result is False:
             return False
@@ -388,7 +389,7 @@ class _ClientKnowingTui(Client):
 
     def _on_update(self, path: str, arg: str = '') -> None:
         value: Optional[_DbType] = None
-        is_chan = path[0] == '#'
+        is_chan = path[0] in self._db.chan_prefixes
         display = ''
         if arg:
             if is_chan and path in self._db.chan_names:
diff --git a/ircplom/irc_conn.py b/ircplom/irc_conn.py
index 5e09c1a..add211d 100644
--- a/ircplom/irc_conn.py
+++ b/ircplom/irc_conn.py
@@ -13,6 +13,12 @@ _TIMEOUT_RECV_LOOP = 0.1
 _TIMEOUT_CONNECT = 5
 _CONN_RECV_BUFSIZE = 1024
 
+ILLEGAL_NICK_CHARS = ' ,*?!@'
+ILLEGAL_NICK_FIRSTCHARS = ':$'
+ISUPPORT_DEFAULTS = {
+    'CHANTYPES': '#&',
+    'PREFIX': '(ov)@+'
+}
 _IRCSPEC_LINE_SEPARATOR = b'\r\n'
 _IRCSPEC_TAG_ESCAPES = ((r'\:', ';'),
                         (r'\s', ' '),