'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
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,)))
**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
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
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:
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')