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))