From: Christian Heller <c.heller@plomlompom.de>
Date: Mon, 25 Aug 2025 10:45:20 +0000 (+0200)
Subject: Store user identities independently from nicknames.
X-Git-Url: https://plomlompom.com/repos/%22https:/validator.w3.org/test?a=commitdiff_plain;h=HEAD;p=ircplom

Store user identities independently from nicknames.
---

diff --git a/ircplom/client.py b/ircplom/client.py
index 77dedef..78243f1 100644
--- a/ircplom/client.py
+++ b/ircplom/client.py
@@ -2,11 +2,12 @@
 # built-ins
 from abc import ABC, abstractmethod
 from base64 import b64encode
-from dataclasses import dataclass, InitVar
+from dataclasses import dataclass, asdict as dc_asdict, InitVar
 from enum import Enum, auto
 from getpass import getuser
 from threading import Thread
 from typing import Any, Callable, NamedTuple, Optional, Self
+from uuid import uuid4
 # ourselves
 from ircplom.events import (
         AffectiveEvent, CrashingException, ExceptionEvent, QueueMixin)
@@ -15,6 +16,7 @@ from ircplom.irc_conn import (BaseIrcConnection, IrcConnAbortException,
 
 ClientsDb = dict[str, 'Client']
 _NAMES_DESIRED_SERVER_CAPS = ('sasl',)
+_ILLEGAL_NICK_FIRSTCHARS = '~&@+# '
 
 
 class _MsgTok(Enum):
@@ -43,19 +45,19 @@ _EXPECTATIONS: list[_MsgParseExpectation] = []
 _EXPECTATIONS += [
     _MsgParseExpectation(_MsgTok.SERVER,
                          '001',  # RPL_WELCOME
-                         ((_MsgTok.NICKNAME, 'set_db_attr:nickname'),
+                         ((_MsgTok.NICKNAME, 'set_me_attr:nick'),
                           _MsgTok.ANY)),
     _MsgParseExpectation(_MsgTok.SERVER,
                          '002',  # RPL_YOURHOST
-                         ((_MsgTok.NICKNAME, 'set_db_attr:nickname'),
+                         ((_MsgTok.NICKNAME, 'set_me_attr:nick'),
                           _MsgTok.ANY)),
     _MsgParseExpectation(_MsgTok.SERVER,
                          '003',  # RPL_CREATED
-                         ((_MsgTok.NICKNAME, 'set_db_attr:nickname'),
+                         ((_MsgTok.NICKNAME, 'set_me_attr:nick'),
                           _MsgTok.ANY)),
     _MsgParseExpectation(_MsgTok.SERVER,
                          '004',  # RPL_MYINFO
-                         ((_MsgTok.NICKNAME, 'set_db_attr:nickname'),
+                         ((_MsgTok.NICKNAME, 'set_me_attr:nick'),
                           _MsgTok.ANY,
                           _MsgTok.ANY,
                           _MsgTok.ANY,
@@ -63,54 +65,54 @@ _EXPECTATIONS += [
                           _MsgTok.ANY)),
     _MsgParseExpectation(_MsgTok.SERVER,
                          '250',  # RPL_STATSDLINE / RPL_STATSCONN
-                         ((_MsgTok.NICKNAME, 'set_db_attr:nickname'),
+                         ((_MsgTok.NICKNAME, 'set_me_attr:nick'),
                           _MsgTok.ANY)),
     _MsgParseExpectation(_MsgTok.SERVER,
                          '251',  # RPL_LUSERCLIENT
-                         ((_MsgTok.NICKNAME, 'set_db_attr:nickname'),
+                         ((_MsgTok.NICKNAME, 'set_me_attr:nick'),
                           _MsgTok.ANY)),
     _MsgParseExpectation(_MsgTok.SERVER,
                          '252',  # RPL_LUSEROP
-                         ((_MsgTok.NICKNAME, 'set_db_attr:nickname'),
+                         ((_MsgTok.NICKNAME, 'set_me_attr:nick'),
                           _MsgTok.ANY,
                           _MsgTok.ANY)),
     _MsgParseExpectation(_MsgTok.SERVER,
                          '253',  # RPL_LUSERUNKNOWN
-                         ((_MsgTok.NICKNAME, 'set_db_attr:nickname'),
+                         ((_MsgTok.NICKNAME, 'set_me_attr:nick'),
                           _MsgTok.ANY,
                           _MsgTok.ANY)),
     _MsgParseExpectation(_MsgTok.SERVER,
                          '254',  # RPL_LUSERCHANNELS
-                         ((_MsgTok.NICKNAME, 'set_db_attr:nickname'),
+                         ((_MsgTok.NICKNAME, 'set_me_attr:nick'),
                           _MsgTok.ANY,
                           _MsgTok.ANY)),
     _MsgParseExpectation(_MsgTok.SERVER,
                          '255',  # RPL_LUSERME
-                         ((_MsgTok.NICKNAME, 'set_db_attr:nickname'),
+                         ((_MsgTok.NICKNAME, 'set_me_attr:nick'),
                           _MsgTok.ANY)),
     _MsgParseExpectation(_MsgTok.SERVER,
                          '265',  # RPL_LOCALUSERS
-                         ((_MsgTok.NICKNAME, 'set_db_attr:nickname'),
+                         ((_MsgTok.NICKNAME, 'set_me_attr:nick'),
                           _MsgTok.ANY)),
     _MsgParseExpectation(_MsgTok.SERVER,
-                         '265',  # RPL_GLOBALUSERS
-                         ((_MsgTok.NICKNAME, 'set_db_attr:nickname'),
+                         '265',  # RPL_LOCALUSERS
+                         ((_MsgTok.NICKNAME, 'set_me_attr:nick'),
                           _MsgTok.ANY,
                           _MsgTok.ANY,
                           _MsgTok.ANY)),
     _MsgParseExpectation(_MsgTok.SERVER,
-                         '266',
-                         ((_MsgTok.NICKNAME, 'set_db_attr:nickname'),
+                         '266',  # RPL_GLOBALUSERS
+                         ((_MsgTok.NICKNAME, 'set_me_attr:nick'),
                           _MsgTok.ANY)),
     _MsgParseExpectation(_MsgTok.SERVER,
-                         '266',
-                         ((_MsgTok.NICKNAME, 'set_db_attr:nickname'),
+                         '266',  # RPL_GLOBALUSERS
+                         ((_MsgTok.NICKNAME, 'set_me_attr:nick'),
                           _MsgTok.ANY,
                           _MsgTok.ANY,
                           _MsgTok.ANY)),
     _MsgParseExpectation(_MsgTok.SERVER,
                          '375',  # RPL_MOTDSTART already implied by 1st 372
-                         ((_MsgTok.NICKNAME, 'set_db_attr:nickname'),
+                         ((_MsgTok.NICKNAME, 'set_me_attr:nick'),
                           _MsgTok.ANY)),
 ]
 
@@ -118,22 +120,22 @@ _EXPECTATIONS += [
 _EXPECTATIONS += [
     _MsgParseExpectation(_MsgTok.SERVER,
                          '005',  # RPL_ISUPPORT
-                         ((_MsgTok.NICKNAME, 'set_db_attr:nickname'),
+                         ((_MsgTok.NICKNAME, 'set_me_attr:nick'),
                           (_MsgTok.ANY, ':isupports'),
                           _MsgTok.ANY),  # comment
                          idx_into_list=1),
     _MsgParseExpectation(_MsgTok.SERVER,
                          '372',  # RPL_MOTD
-                         ((_MsgTok.NICKNAME, 'set_db_attr:nickname'),
+                         ((_MsgTok.NICKNAME, 'set_me_attr:nick'),
                           (_MsgTok.ANY, ':line'))),
     _MsgParseExpectation(_MsgTok.SERVER,
                          '376',  # RPL_ENDOFMOTD
-                         ((_MsgTok.NICKNAME, 'set_db_attr:nickname'),
+                         ((_MsgTok.NICKNAME, 'set_me_attr:nick'),
                           _MsgTok.ANY)),  # comment
     _MsgParseExpectation(_MsgTok.SERVER,
                          '396',  # RPL_VISIBLEHOST
-                         ((_MsgTok.NICKNAME, 'set_db_attr:nickname'),
-                          (_MsgTok.SERVER, 'set_db_attr:client_host'),
+                         ((_MsgTok.NICKNAME, 'set_me_attr:nick'),
+                          (_MsgTok.SERVER, 'set_me_attr:host'),
                           _MsgTok.ANY)),  # comment
 ]
 
@@ -141,18 +143,17 @@ _EXPECTATIONS += [
 _EXPECTATIONS += [
     _MsgParseExpectation(_MsgTok.SERVER,
                          '900',  # RPL_LOGGEDIN
-                         ((_MsgTok.NICKNAME, 'set_db_attr:nickname'),
-                          (_MsgTok.NICK_USER_HOST,
-                           'set_db_attr:_nick_user_host'),
+                         ((_MsgTok.NICKNAME, 'set_me_attr:nick'),
+                          (_MsgTok.NICK_USER_HOST, 'set_me_attr:nickuserhost'),
                           (_MsgTok.ANY, 'set_db_attr:sasl_account'),
                           _MsgTok.ANY)),  # comment
     _MsgParseExpectation(_MsgTok.SERVER,
                          '903',  # RPL_SASLSUCCESS
-                         ((_MsgTok.NICKNAME, 'set_db_attr:nickname'),
+                         ((_MsgTok.NICKNAME, 'set_me_attr:nick'),
                           (_MsgTok.ANY, 'set_db_attr:sasl_auth_state'))),
     _MsgParseExpectation(_MsgTok.SERVER,
                          '904',  # ERR_SASLFAIL
-                         ((_MsgTok.NICKNAME, 'set_db_attr:nickname'),
+                         ((_MsgTok.NICKNAME, 'set_me_attr:nick'),
                           (_MsgTok.ANY, 'set_db_attr:sasl_auth_state'))),
     _MsgParseExpectation(_MsgTok.NONE,
                          'AUTHENTICATE',
@@ -163,12 +164,12 @@ _EXPECTATIONS += [
 _EXPECTATIONS += [
     _MsgParseExpectation(_MsgTok.SERVER,
                          'CAP',
-                         ((_MsgTok.NICKNAME, 'set_db_attr:nickname'),
+                         ((_MsgTok.NICKNAME, 'set_me_attr:nick'),
                           ('NEW', ':subverb'),
                           (_MsgTok.LIST, ':items'))),
     _MsgParseExpectation(_MsgTok.SERVER,
                          'CAP',
-                         ((_MsgTok.NICKNAME, 'set_db_attr:nickname'),
+                         ((_MsgTok.NICKNAME, 'set_me_attr:nick'),
                           ('DEL', ':subverb'),
                           (_MsgTok.LIST, ':items'))),
     _MsgParseExpectation(_MsgTok.SERVER,
@@ -178,7 +179,7 @@ _EXPECTATIONS += [
                           (_MsgTok.LIST, ':items'))),
     _MsgParseExpectation(_MsgTok.SERVER,
                          'CAP',
-                         ((_MsgTok.NICKNAME, 'set_db_attr:nickname'),
+                         ((_MsgTok.NICKNAME, 'set_me_attr:nick'),
                           ('ACK', ':subverb'),
                           (_MsgTok.LIST, ':items'))),
     _MsgParseExpectation(_MsgTok.SERVER,
@@ -188,7 +189,7 @@ _EXPECTATIONS += [
                           (_MsgTok.LIST, ':items'))),
     _MsgParseExpectation(_MsgTok.SERVER,
                          'CAP',
-                         ((_MsgTok.NICKNAME, 'set_db_attr:nickname'),
+                         ((_MsgTok.NICKNAME, 'set_me_attr:nick'),
                           ('NAK', ':subverb'),
                           (_MsgTok.LIST, ':items'))),
     _MsgParseExpectation(_MsgTok.SERVER,
@@ -204,12 +205,12 @@ _EXPECTATIONS += [
                           (_MsgTok.LIST, ':items'))),
     _MsgParseExpectation(_MsgTok.SERVER,
                          'CAP',
-                         ((_MsgTok.NICKNAME, 'set_db_attr:nickname'),
+                         ((_MsgTok.NICKNAME, 'set_me_attr:nick'),
                           ('LS', ':subverb'),
                           (_MsgTok.LIST, ':items'))),
     _MsgParseExpectation(_MsgTok.SERVER,
                          'CAP',
-                         ((_MsgTok.NICKNAME, 'set_db_attr:nickname'),
+                         ((_MsgTok.NICKNAME, 'set_me_attr:nick'),
                           ('LS', ':subverb'),
                           ('*', ':tbc'),
                           (_MsgTok.LIST, ':items'))),
@@ -227,12 +228,12 @@ _EXPECTATIONS += [
                           (_MsgTok.LIST, ':items'))),
     _MsgParseExpectation(_MsgTok.SERVER,
                          'CAP',
-                         ((_MsgTok.NICKNAME, 'set_db_attr:nickname'),
+                         ((_MsgTok.NICKNAME, 'set_me_attr:nick'),
                           ('LIST', ':subverb'),
                           (_MsgTok.LIST, ':items'))),
     _MsgParseExpectation(_MsgTok.SERVER,
                          'CAP',
-                         ((_MsgTok.NICKNAME, 'set_db_attr:nickname'),
+                         ((_MsgTok.NICKNAME, 'set_me_attr:nick'),
                           ('LIST', ':subverb'),
                           ('*', ':tbc'),
                           (_MsgTok.LIST, ':items'))),
@@ -243,34 +244,34 @@ _EXPECTATIONS += [
     _MsgParseExpectation(_MsgTok.SERVER,
                          '432',  # ERR_ERRONEOUSNICKNAME
                          ('*',
-                          _MsgTok.NICKNAME,
+                          _MsgTok.NICKNAME,  # no need to re-use the bad one
                           _MsgTok.ANY)),  # comment
     _MsgParseExpectation(_MsgTok.SERVER,
                          '432',  # ERR_ERRONEOUSNICKNAME
-                         ((_MsgTok.NICKNAME, 'set_db_attr:nickname'),
-                          _MsgTok.NICKNAME,
+                         ((_MsgTok.NICKNAME, 'set_me_attr:nick'),
+                          _MsgTok.NICKNAME,  # no need to re-use the bad one
                           _MsgTok.ANY)),  # comment
     _MsgParseExpectation(_MsgTok.SERVER,
                          '433',  # ERR_NICKNAMEINUSE
-                         (_MsgTok.NICKNAME,
+                         (_MsgTok.NICKNAME,  # we rather go for incrementation
                           (_MsgTok.NICKNAME, ':used'),
                           _MsgTok.ANY)),  # comment
     _MsgParseExpectation((_MsgTok.NICK_USER_HOST, ':named'),
                          'NICK',
-                         ((_MsgTok.NICKNAME, ':nickname'),)),
+                         ((_MsgTok.NICKNAME, ':nick'),)),
 ]
 
 # joining/leaving
 _EXPECTATIONS += [
     _MsgParseExpectation(_MsgTok.SERVER,
                          '353',  # RPL_NAMREPLY
-                         ((_MsgTok.NICKNAME, 'set_db_attr:nickname'),
+                         ((_MsgTok.NICKNAME, 'set_me_attr:nick'),
                           '=',
                           (_MsgTok.CHANNEL, ':channel'),
                           (_MsgTok.LIST, ':names'))),
     _MsgParseExpectation(_MsgTok.SERVER,
                          '366',  # RPL_ENDOFNAMES
-                         ((_MsgTok.NICKNAME, 'set_db_attr:nickname'),
+                         ((_MsgTok.NICKNAME, 'set_me_attr:nick'),
                           (_MsgTok.CHANNEL, ':channel'),
                           _MsgTok.ANY)),  # comment
     _MsgParseExpectation((_MsgTok.NICK_USER_HOST, ':joiner'),
@@ -285,7 +286,7 @@ _EXPECTATIONS += [
 _EXPECTATIONS += [
     _MsgParseExpectation(_MsgTok.SERVER,
                          '401',  # ERR_NOSUCKNICK
-                         ((_MsgTok.NICKNAME, 'set_db_attr:nickname'),
+                         ((_MsgTok.NICKNAME, 'set_me_attr:nick'),
                           (_MsgTok.NICKNAME, ':target'),
                           _MsgTok.ANY)),  # comment
     _MsgParseExpectation(_MsgTok.SERVER,
@@ -294,21 +295,21 @@ _EXPECTATIONS += [
                           (_MsgTok.ANY, ':message'))),
     _MsgParseExpectation(_MsgTok.SERVER,
                          'NOTICE',
-                         ((_MsgTok.NICKNAME, 'set_db_attr:nickname'),
+                         ((_MsgTok.NICKNAME, 'set_me_attr:nick'),
                           (_MsgTok.ANY, ':message'))),
-    _MsgParseExpectation((_MsgTok.NICK_USER_HOST, ':sender'),
+    _MsgParseExpectation((_MsgTok.NICK_USER_HOST, 'set_user:sender'),
                          'NOTICE',
-                         ((_MsgTok.NICKNAME, 'set_db_attr:nickname'),
+                         ((_MsgTok.NICKNAME, 'set_me_attr:nick'),
                           (_MsgTok.ANY, ':message'))),
-    _MsgParseExpectation((_MsgTok.NICK_USER_HOST, ':sender'),
+    _MsgParseExpectation((_MsgTok.NICK_USER_HOST, 'set_user:sender'),
                          'NOTICE',
                          ((_MsgTok.CHANNEL, ':channel'),
                           (_MsgTok.ANY, ':message'))),
-    _MsgParseExpectation((_MsgTok.NICK_USER_HOST, ':sender'),
+    _MsgParseExpectation((_MsgTok.NICK_USER_HOST, 'set_user:sender'),
                          'PRIVMSG',
-                         ((_MsgTok.NICKNAME, 'set_db_attr:nickname'),
+                         ((_MsgTok.NICKNAME, 'set_me_attr:nick'),
                           (_MsgTok.ANY, ':message'))),
-    _MsgParseExpectation((_MsgTok.NICK_USER_HOST, ':sender'),
+    _MsgParseExpectation((_MsgTok.NICK_USER_HOST, 'set_user:sender'),
                          'PRIVMSG',
                          ((_MsgTok.CHANNEL, ':channel'),
                           (_MsgTok.ANY, ':message'))),
@@ -319,13 +320,13 @@ _EXPECTATIONS += [
     _MsgParseExpectation(_MsgTok.NONE,
                          'ERROR',
                          ((_MsgTok.ANY, 'set_db_attr:connection_state'),)),
-    _MsgParseExpectation(_MsgTok.NICKNAME,
+    _MsgParseExpectation((_MsgTok.NICK_USER_HOST, 'set_me_attr:nickuserhost'),
                          'MODE',
-                         ((_MsgTok.NICKNAME, 'set_db_attr:nickname'),
+                         ((_MsgTok.NICKNAME, 'set_me_attr:nick'),
                           (_MsgTok.ANY, 'set_db_attr:user_modes'))),
-    _MsgParseExpectation(_MsgTok.NICK_USER_HOST,
+    _MsgParseExpectation(_MsgTok.NICKNAME,
                          'MODE',
-                         ((_MsgTok.NICKNAME, 'set_db_attr:nickname'),
+                         ((_MsgTok.NICKNAME, 'set_me_attr:nick'),
                           (_MsgTok.ANY, 'set_db_attr:user_modes'))),
     _MsgParseExpectation(_MsgTok.NONE,
                          'PING',
@@ -400,6 +401,11 @@ class _UpdatingDict:
     def __init__(self) -> None:
         self._dict: dict[str, Any] = {}
 
+    @property
+    def keys(self) -> tuple[str, ...]:
+        'Keys of item registrations.'
+        return tuple(self._dict.keys())
+
     def set_on_update(self, name: str, on_update: Callable) -> None:
         'Caller of on_update with path= set to name.'
         self._on_update = lambda k: on_update(name, k)
@@ -417,6 +423,8 @@ class _UpdatingDict:
         return self._dict[key]
 
     def __setitem__(self, key: str, val: Any) -> None:
+        if isinstance(val, _NickUserHost):
+            val.set_on_update(lambda: self._on_update(key))
         self._dict[key] = val
         self._on_update(key)
 
@@ -647,7 +655,7 @@ class _Db(Db):
 
 
 class _ChannelDb(_Db):
-    _completable_users: _CompletableStringsList
+    _completable_user_ids: _CompletableStringsList
     # topic: str
     # channel_modes: str
 
@@ -655,18 +663,39 @@ class _ChannelDb(_Db):
 class SharedClientDbFields(IrcConnSetup):
     'API for fields shared directly in name and type with TUI.'
     connection_state: str
-    client_host: str
-    nickname: str
     sasl_account: str
     sasl_auth_state: str
     user_modes: str
-    username: str
+    users: Any
+    _channels: dict[str, Any]
+
+    def _purge_users(self) -> None:
+        to_keep = {'me'}
+        for chan in self._channels.values():
+            to_keep |= set(chan.user_ids)
+        for user_id in [id_ for id_ in self.users.keys if id_ not in to_keep]:
+            del self.users[user_id]
 
+    def chans_of_user(self, user_id: str) -> tuple[str, ...]:
+        'Return names of channels user of user_id currently participates in.'
+        return tuple(k for k, v in self._channels.items()
+                     if user_id in v.user_ids)
 
-class _NickUserHost(NamedTuple):
-    nick: str
-    user: str
-    host: str
+
+@dataclass
+class NickUserHost:
+    'Combination of nickname, username on host, and host.'
+    nick: str = '?'
+    user: str = '?'
+    host: str = '?'
+
+    def copy(self) -> Self:
+        'Produce copy not subject to later attribute changes on original.'
+        return self.__class__(**dc_asdict(self))
+
+
+class _NickUserHost(NickUserHost):
+    _on_update: Callable
 
     def __str__(self) -> str:
         return f'{self.nick}!{self.user}@{self.host}'
@@ -680,31 +709,54 @@ class _NickUserHost(NamedTuple):
         assert len(toks) == 3
         return cls(*toks)
 
+    def set_on_update(self, on_update: Callable) -> None:
+        'Caller of on_update with path= set to name.'
+        self._on_update = on_update
+
+    def __setattr__(self, key: str, value: Any) -> None:
+        if key == 'nickuserhost' and isinstance(value, _NickUserHost):
+            self.nick = value.nick
+            self.user = value.user
+            self.host = value.host
+        else:
+            super().__setattr__(key, value)
+            if key != '_on_update' and hasattr(self, '_on_update'):
+                self._on_update()
+
 
 class _ClientDb(_Db, SharedClientDbFields):
     caps: _UpdatingDict
     isupports: _UpdatingDict
+    users: _UpdatingDict
     _completable_motd: _CompletableStringsList
     _channels: dict[str, _ChannelDb]
 
-    def __init__(self, **kwargs) -> None:
-        super().__init__(**kwargs)
-        self._types['_nick_user_host'] = _NickUserHost
-
-    def __setattr__(self, key: str, value) -> None:
-        super().__setattr__(key, value)
-        if key == 'nickname':
-            self.nick_wanted = value
-
-    @property
-    def _nick_user_host(self) -> _NickUserHost:
-        return _NickUserHost(self.nickname, self.username, self.client_host)
-
-    @_nick_user_host.setter
-    def _nick_user_host(self, nick_user_host: _NickUserHost) -> None:
-        self.nickname = nick_user_host.nick
-        self.username = nick_user_host.user
-        self.client_host = nick_user_host.host
+    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
+        matches = [id_ for id_ in self.users.keys
+                   if self.users[id_].nick == nick]
+        assert len(matches) < 2
+        id_ = matches[0] if matches else str(uuid4())
+        if isinstance(query, _NickUserHost):
+            self.users[id_] = query
+        elif not matches:
+            self.users[id_] = _NickUserHost(query)
+        return id_
+
+    def remove_user_from_channel(self, user_id: str, chan_name: str) -> None:
+        'Remove user from channel, check that user deleted if that was last.'
+        self.chan(chan_name).remove_completable('user_ids', user_id, True)
+        if user_id == 'me':
+            self.del_chan(chan_name)
+        self._purge_users()
+
+    def remove_user(self, user_id: str) -> tuple[str, ...]:
+        'Run remove_user_from_channel on all channels user is in.'
+        affected_chans = self.chans_of_user(user_id)
+        for chan_name in affected_chans:
+            self.remove_user_from_channel(user_id, chan_name)
+        return affected_chans
 
     def needs_arg(self, key: str) -> bool:
         'Reply if attribute of key may reasonably be addressed without an arg.'
@@ -718,6 +770,7 @@ class _ClientDb(_Db, SharedClientDbFields):
     def del_chan(self, name: str) -> None:
         'Remove DB for channel of name.'
         del self._channels[name]
+        self._purge_users()
         self._on_update(name)
 
     def chan(self, name: str) -> _ChannelDb:
@@ -727,10 +780,6 @@ class _ClientDb(_Db, SharedClientDbFields):
                 on_update=lambda k: self._on_update(name, k))
         return self._channels[name]
 
-    def chans_of_user(self, user: str) -> dict[str, _ChannelDb]:
-        'Return part of channels dictionary for channels user is currently in.'
-        return {k: v for k, v in self._channels.items() if user in v.users}
-
 
 class Client(ABC, ClientQueueMixin):
     'Abstracts socket connection, loop over it, and handling messages from it.'
@@ -741,7 +790,7 @@ class Client(ABC, ClientQueueMixin):
         self.client_id = conn_setup.hostname
         super().__init__(client_id=self.client_id, **kwargs)
         self._db = _ClientDb(on_update=self._on_update)
-        self._db.username = getuser()
+        self._db.users['me'] = _NickUserHost('?', getuser(), '?')
         self._caps = _CapsManager(self.send, self._db.caps)
         for k in conn_setup.__annotations__:
             setattr(self._db, k, getattr(conn_setup, k))
@@ -776,8 +825,9 @@ class Client(ABC, ClientQueueMixin):
         assert self.conn is not None
         self._db.connection_state = 'connected'
         self._caps.start_negotation()
-        self.send(IrcMessage(verb='USER', params=(self._db.username, '0', '*',
-                                                  self._db.realname)))
+        self.send(IrcMessage(verb='USER', params=(
+            self._db.users['me'].user.lstrip('~'),
+            '0', '*', self._db.realname)))
         self.send(IrcMessage(verb='NICK', params=(self._db.nick_wanted,)))
 
     @abstractmethod
@@ -812,7 +862,7 @@ class Client(ABC, ClientQueueMixin):
         for name in self._db.chan_names:
             self._db.del_chan(name)
         self._db.isupports.clear()
-        self._db.nickname = ''
+        self._db.users['me'].nick = '?'
         self._db.sasl_auth_state = ''
 
     def on_handled_loop_exception(self, e: IrcConnAbortException) -> None:
@@ -846,7 +896,8 @@ class Client(ABC, ClientQueueMixin):
                 return {'id': msg_tok, 'db': self._db.chan(msg_tok)
                         } if msg_tok[0] == '#' else None
             if ex_tok is _MsgTok.NICKNAME:
-                return msg_tok if msg_tok[0] not in '~&@%+# ' else None
+                return (msg_tok if msg_tok[0] not in _ILLEGAL_NICK_FIRSTCHARS
+                        else None)
             if ex_tok is _MsgTok.NICK_USER_HOST:
                 try:
                     return _NickUserHost.from_str(msg_tok)
@@ -876,8 +927,8 @@ class Client(ABC, ClientQueueMixin):
             for idx, ex_tok in enumerate(ex_tok_fields):
                 ex_tok, key = ((ex_tok[0], ex_tok[1])
                                if isinstance(ex_tok, tuple) else (ex_tok, ''))
-                task, key = key.split(':', maxsplit=1) if key else ('', '')
-                if task:
+                tasks_, key = key.split(':', maxsplit=1) if key else ('', '')
+                for task in tasks_.split(','):
                     tasks[task] = tasks.get(task, []) + [key]
                 to_return[key] = param_match(ex_tok, msg_tok_fields[idx])
                 if to_return[key] is None:
@@ -898,6 +949,10 @@ class Client(ABC, ClientQueueMixin):
             for arg in args:
                 if task == 'set_db_attr':
                     setattr(self._db, arg, ret[arg])
+                if task == 'set_me_attr':
+                    setattr(self._db.users['me'], arg, ret[arg])
+                if task == 'set_user':
+                    self._db.user_id(ret[arg])
         if ret['verb'] == '005':   # RPL_ISUPPORT
             for item in ret['isupports']:
                 toks = item.split('=', maxsplit=1)
@@ -907,11 +962,11 @@ class Client(ABC, ClientQueueMixin):
                     self._db.isupports[toks[0]] = (toks[1] if len(toks) > 1
                                                    else '')
         elif ret['verb'] == '353':  # RPL_NAMREPLY
-            for name in ret['names']:
-                ret['channel']['db'].append_completable('users',
-                                                        name.lstrip('~&@%+'))
+            for id_ in [self._db.user_id(name.lstrip(_ILLEGAL_NICK_FIRSTCHARS))
+                        for name in ret['names']]:
+                ret['channel']['db'].append_completable('user_ids', id_)
         elif ret['verb'] == '366':  # RPL_ENDOFNAMES
-            ret['channel']['db'].declare_complete('users')
+            ret['channel']['db'].declare_complete('user_ids')
         elif ret['verb'] == '372':  # RPL_MOTD
             self._db.append_completable('motd', ret['line'])
         elif ret['verb'] == '376':  # RPL_ENDOFMOTD
@@ -921,7 +976,7 @@ class Client(ABC, ClientQueueMixin):
                       target=ret['target'], alert=True)
         elif ret['verb'] == '432':  # ERR_ERRONEOUSNICKNAME
             alert = 'nickname refused for bad format'
-            if 'nickname' not in ret:
+            if 'nick' not in ret:
                 alert += ', giving up'
                 self.close()
             self._log(alert, alert=True)
@@ -948,40 +1003,31 @@ class Client(ABC, ClientQueueMixin):
                     self._caps.end_negotiation()
         elif ret['verb'] == 'ERROR':
             self.close()
-        elif ret['verb'] == 'JOIN' and ret['joiner'].nick != self._db.nickname:
+        elif ret['verb'] == 'JOIN' and ret['joiner'] != self._db.users['me']:
             ret['channel']['db'].append_completable(
-                    'users', ret['joiner'].nick, True)
+                    'user_ids', self._db.user_id(ret['joiner']), True)
         elif ret['verb'] == 'NICK':
-            if ret['named'].nick == self._db.nickname:
-                self._db.nickname = ret['nickname']
-            else:
-                for id_, ch in self._db.chans_of_user(ret['named'].nick
-                                                      ).items():
-                    ch.remove_completable('users', ret['named'].nick, True)
-                    ch.append_completable('users', ret['nickname'], True)
-                    self._log(f'{ret["named"]} becomes {ret["nickname"]}',
-                              scope=LogScope.CHAT, target=id_)
+            user_id = self._db.user_id(ret['named'])
+            self._db.users[user_id].nick = ret['nick']
+            if user_id == 'me':
+                self._db.nick_wanted = ret['nick']
         elif ret['verb'] in {'NOTICE', 'PRIVMSG'}:
             kw: dict[str, bool | str | LogScope] = {
                     'as_notice': msg.verb == 'NOTICE'}
             if 'sender' in ret:  # not just server message
                 kw |= {'sender': ret['sender'].nick, 'scope': LogScope.CHAT,
-                       'target': (ret['sender'].nick if 'nickname' in ret
+                       'target': (ret['sender'].nick if 'nick' in ret
                                   else ret['channel']['id'])}
             self._log(ret['message'], out=False, **kw)
         elif ret['verb'] == 'PART':
-            if ret['parter'].nick == self._db.nickname:
-                self._db.del_chan(ret['channel']['id'])
-            else:
-                ret['channel']['db'].remove_completable(
-                        'users', ret['parter'].nick, True)
+            self._db.remove_user_from_channel(self._db.user_id(ret['parter']),
+                                              ret['channel']['id'])
         elif ret['verb'] == 'PING':
             self.send(IrcMessage(verb='PONG', params=(ret['reply'],)))
         elif ret['verb'] == 'QUIT':
-            for id_, ch in self._db.chans_of_user(ret['quitter'].nick).items():
-                ch.remove_completable('users', ret['quitter'].nick, True)
+            for chan in self._db.remove_user(self._db.user_id(ret['quitter'])):
                 self._log(f'{ret["quitter"]} quits: {ret["message"]}',
-                          LogScope.CHAT, target=id_)
+                          LogScope.CHAT, target=chan)
 
 
 @dataclass
diff --git a/ircplom/client_tui.py b/ircplom/client_tui.py
index 2d48c5c..920dc35 100644
--- a/ircplom/client_tui.py
+++ b/ircplom/client_tui.py
@@ -9,7 +9,7 @@ from ircplom.tui_base import (BaseTui, PromptWidget, TuiEvent, Window,
 from ircplom.irc_conn import IrcMessage
 from ircplom.client import (
         Client, ClientQueueMixin, Db, IrcConnSetup, LogScope, NewClientEvent,
-        ServerCapability, SharedClientDbFields)
+        NickUserHost, ServerCapability, SharedClientDbFields)
 
 CMD_SHORTCUTS['disconnect'] = 'window.disconnect'
 CMD_SHORTCUTS['join'] = 'window.join'
@@ -23,7 +23,7 @@ _LOG_PREFIX_SERVER = '$'
 _LOG_PREFIX_OUT = '>'
 _LOG_PREFIX_IN = '<'
 
-_DbType = bool | int | str | tuple[str, ...]
+_DbType = bool | int | str | tuple[str, ...] | NickUserHost
 
 
 class _ClientWindow(Window, ClientQueueMixin):
@@ -103,8 +103,7 @@ class _ChatWindow(_ClientWindow):
 
     def set_prompt_prefix(self) -> None:
         'Look up relevant DB data to update prompt prefix.'
-        retrieval = self._get_nick_data()
-        self.prompt.set_prefix_data(*retrieval)
+        self.prompt.set_prefix_data(self._get_nick_data())
 
     def cmd__chat(self, msg: str) -> None:
         'PRIVMSG to target identified by .chatname.'
@@ -160,18 +159,18 @@ class _Db(Db):
 
 
 class _ChannelDb(_Db):
-    users: tuple[str, ...]
+    user_ids: tuple[str, ...]
 
     def set_and_check_for_change(self, update: _Update
                                  ) -> bool | dict[str, tuple[str, ...]]:
         if isinstance(getattr(self, update.path), dict):
             return self._set_and_check_for_dict(update)
-        if update.path == 'users':
+        if update.path == 'user_ids':
             assert isinstance(update.value, tuple)
-            d = {'joins': tuple(user for user in update.value
-                                if user not in self.users),
-                 'parts': tuple(user for user in self.users
-                                if user not in update.value)}
+            d = {'joins': tuple(user_id for user_id in update.value
+                                if user_id not in self.user_ids),
+                 'parts': tuple(user_id for user_id in self.user_ids
+                                if user_id not in update.value)}
             return d if super().set_and_check_for_change(update) else False
         return super().set_and_check_for_change(update)
 
@@ -180,21 +179,30 @@ class _TuiClientDb(_Db, SharedClientDbFields):
     caps: dict[str, str]
     isupports: dict[str, str]
     motd: tuple[str]
+    users: dict[str, NickUserHost]
     _channels: dict[str, _ChannelDb]
 
     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:
             chan_name = update.path
             if update.value is None and not update.arg:
                 del self._channels[chan_name]
-                return True
-            update.path = update.arg
-            update.arg = ''
-            return self.chan(chan_name).set_and_check_for_change(update)
-        if isinstance(getattr(self, update.path), dict):
-            return self._set_and_check_for_dict(update)
-        return super().set_and_check_for_change(update)
+                result = True
+            else:
+                update.path = update.arg
+                update.arg = ''
+                result = self.chan(chan_name).set_and_check_for_change(update)
+                if isinstance(result, dict):
+                    for key, user_ids in result.items():
+                        result[key] = tuple(self.users[user_id].nick
+                                            for user_id in user_ids)
+        elif isinstance(getattr(self, update.path), dict):
+            result = self._set_and_check_for_dict(update)
+        else:
+            result = super().set_and_check_for_change(update)
+        return result
 
     def chan(self, name: str) -> _ChannelDb:
         'Produce DB for channel of name – pre-existing, or newly created.'
@@ -220,7 +228,7 @@ class _ClientWindowsManager:
             kwargs['win_cls'] = (_ChannelWindow if chatname[0] == '#'
                                  else _ChatWindow)
             kwargs['chatname'] = chatname
-            kwargs['get_nick_data'] = lambda: (self.db.nickname,)
+            kwargs['get_nick_data'] = lambda: self.db.users['me'].nick
         win = self._tui_new_window(**kwargs)
         self.windows += [win]
         return win
@@ -242,8 +250,10 @@ class _ClientWindowsManager:
         if 'out' in kwargs and scope != LogScope.SERVER:
             first_char = _LOG_PREFIX_OUT if kwargs['out'] else _LOG_PREFIX_IN
             if scope == LogScope.CHAT:
-                sender_label = ' [' + (self.db.nickname if kwargs['out']
-                                       else kwargs['sender']) + ']'
+                sender_label = (
+                        ' [' + (self.db.users['me'].nick if kwargs['out']
+                                else kwargs['sender'])
+                        + ']')
         if kwargs.get('as_notice', False):
             first_char *= 3
         prefix = f'{first_char}{sender_label}'
@@ -295,9 +305,9 @@ class ClientTui(BaseTui):
             if scope == LogScope.SERVER:
                 return [m.window(LogScope.SERVER), m.window(LogScope.RAW)]
             if scope == LogScope.CHAT:
-                chatname = (
-                        kwargs['target'] if kwargs['target'] != m.db.nickname
-                        else kwargs['sender'])
+                chatname = (kwargs['target']
+                            if kwargs['target'] != m.db.users['me'].nick
+                            else kwargs['sender'])
                 return [m.window(LogScope.CHAT, chatname=chatname)]
             return [m.window(scope)]
         return super()._log_target_wins(**kwargs)
@@ -394,5 +404,7 @@ class _ClientKnowingTui(Client):
                             display += f' ({value.data})'
         elif (not is_chan) and not self._db.needs_arg(path):
             value = getattr(self._db, path)
+        if isinstance(value, NickUserHost):
+            value = value.copy()
         self._client_tui_trigger('update_db', update=_Update(
             path, arg, value, display))