)
+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()
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:
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.'
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