return self._raw
+class _IrcConnAbortException(Exception):
+ pass
+
+
+class _IrcConnection(QueueMixin):
+ 'Collects low-level server-client connection management.'
+
+ def __init__(self, hostname: str, client_id: UUID, **kwargs) -> None:
+ super().__init__(**kwargs)
+ self.client_id = client_id
+ self._socket = socket()
+ self._socket.settimeout(_TIMEOUT_CONNECT)
+ try:
+ self._socket.connect((hostname, _PORT))
+ except (TimeoutError, socket_gaierror) as e:
+ raise _IrcConnAbortException(e) from e
+ self._socket.settimeout(_TIMEOUT_RECV_LOOP)
+ self._recv_loop = Loop(iterator=self._read_lines(), q_out=self.q_out)
+
+ def close(self) -> None:
+ 'Stop recv loop and close socket.'
+ self._recv_loop.stop()
+ self._socket.close()
+
+ def send(self, msg: IrcMessage) -> None:
+ 'Send line-separator-delimited message over socket.'
+ self._socket.sendall(msg.raw.encode('utf-8') + _IRCSPEC_LINE_SEPARATOR)
+
+ def _read_lines(self) -> Iterator[Optional['_RecvEvent']]:
+ assert self._socket is not None
+ bytes_total = b''
+ buffer_linesep = b''
+ while True:
+ try:
+ bytes_new = self._socket.recv(_CONN_RECV_BUFSIZE)
+ except TimeoutError:
+ yield None
+ continue
+ except ConnectionResetError as e:
+ raise e
+ except OSError as e:
+ if e.errno == 9:
+ break
+ raise e
+ if not bytes_new:
+ break
+ for c in bytes_new:
+ c_byted = c.to_bytes()
+ if c not in _IRCSPEC_LINE_SEPARATOR:
+ bytes_total += c_byted
+ buffer_linesep = b''
+ elif c == _IRCSPEC_LINE_SEPARATOR[0]:
+ buffer_linesep = c_byted
+ else:
+ buffer_linesep += c_byted
+ if buffer_linesep == _IRCSPEC_LINE_SEPARATOR:
+ buffer_linesep = b''
+ yield _RecvEvent(client_id=self.client_id,
+ payload=IrcMessage.from_raw(
+ bytes_total.decode('utf-8')))
+ bytes_total = b''
+
+
@dataclass
class ClientIdMixin:
'Collects a Client\'s ID at .client_id.'
def affect(self, target: 'Client') -> None:
target.log(msg='# connected to server', chat=CHAT_GLOB)
target.try_send_cap('LS', ('302',))
- target.send(IrcMessage(verb='USER', params=(getuser(), '0', '*',
- target.realname)))
- target.send(IrcMessage(verb='NICK', params=(target.nickname,)))
+ target.send(IrcMessage(verb='USER',
+ params=(getuser(), '0', '*',
+ target.conn_setup.realname)))
+ target.send(IrcMessage(verb='NICK',
+ params=(target.conn_setup.nickname,)))
@dataclass
'To trigger re-opening of connection.'
def affect(self, target: 'Client') -> None:
- if target.assumed_open:
+ if target.conn:
target.log('# ALERT: reconnection called, but still seem '
'connected, so nothing to do.')
else:
class SendEvent(ClientEvent, PayloadMixin):
'To trigger sending of payload to server.'
payload: IrcMessage
-
- def __init__(self, chat: str = '', **kwargs) -> None:
- super().__init__(**kwargs)
- self._chat = chat
+ chat: str = ''
def affect(self, target: 'Client') -> None:
- target.send(msg=self.payload, chat=self._chat)
+ target.send(msg=self.payload, chat=self.chat)
@dataclass
return listing
+@dataclass
+class IrcConnSetup:
+ 'All we need to know to set up a new Client connection.'
+ hostname: str
+ nickname: str
+ realname: str
+
+
class Client(ABC, ClientQueueMixin):
'Abstracts socket connection, loop over it, and handling messages from it.'
- nick_confirmed: bool
- nickname: str
+ nick_confirmed: bool = False
+ conn: Optional[_IrcConnection] = None
- def __init__(self, hostname: str, nickname: str, realname: str, **kwargs
- ) -> None:
+ def __init__(self, conn_setup: IrcConnSetup, **kwargs) -> None:
super().__init__(**kwargs)
- self._hostname = hostname
- self._socket: Optional[socket] = None
- self._recv_loop: Optional[Loop] = None
+ self.conn_setup = conn_setup
self._cap_neg_states: dict[str, bool] = {}
self.caps: dict[str, ServerCapability] = {}
self.id_ = uuid4()
- self.assumed_open = False
- self.realname = realname
- self.update_login(nick_confirmed=False, nickname=nickname)
+ self.update_login(nick_confirmed=False,
+ nickname=self.conn_setup.nickname)
self.start_connecting()
def start_connecting(self) -> None:
- 'Start thread to initiate connection, from socket to recv loop.'
+ 'Start thread to set up IrcConnection at .conn.'
def connect(self) -> None:
try:
- self._socket = socket()
- self.log(f'# connecting to server {self._hostname} …')
- self._socket.settimeout(_TIMEOUT_CONNECT)
- try:
- self._socket.connect((self._hostname, _PORT))
- except (TimeoutError, socket_gaierror) as e:
- self.log(f'# ALERT: {e}')
- return
- self._socket.settimeout(_TIMEOUT_RECV_LOOP)
- self.assumed_open = True
- self._recv_loop = Loop(iterator=self._read_lines(),
- q_out=self.q_out)
+ self.conn = _IrcConnection(hostname=self.conn_setup.hostname,
+ q_out=self.q_out,
+ client_id=self.id_)
self._cput(_ConnectedEvent)
+ except _IrcConnAbortException as e:
+ self.log(f'# ALERT: {e}')
except Exception as e: # pylint: disable=broad-exception-caught
self._put(ExceptionEvent(e))
def send(self, msg: IrcMessage, chat: str = '') -> None:
'Send line-separator-delimited message over socket.'
- if not (self._socket and self.assumed_open):
+ if not self.conn:
self.log('# ALERT: cannot send, connection seems closed')
return
- self._socket.sendall(msg.raw.encode('utf-8') + _IRCSPEC_LINE_SEPARATOR)
+ self.conn.send(msg)
self.log(msg=f'> {msg.raw}', chat=chat)
self.log(msg=f'=>| {msg.raw}', chat=':raw')
def update_login(self, nick_confirmed: bool, nickname: str = '') -> None:
- 'Manage .nickname, .nick_confirmed – useful for subclass extension.'
- first_run = not hasattr(self, 'nickname')
+ '''Manage conn_setup..nickname, .nick_confirmed.
+
+ (Useful for subclass extension.)
+ '''
+ first_run = not hasattr(self.conn_setup, 'nickname')
prefix = '# nickname'
- if first_run or (nickname and nickname != self.nickname):
- verb = 'set' if first_run else f'changed from "{self.nickname}'
- self.nickname = nickname
- self.log(msg=f'{prefix} {verb} to {nickname}', chat=CHAT_GLOB)
+ if first_run or (nickname and nickname != self.conn_setup.nickname):
+ verb = ('set' if first_run
+ else f'changed from "{self.conn_setup.nickname}"')
+ self.conn_setup.nickname = nickname
+ self.log(msg=f'{prefix} {verb} to "{nickname}"', chat=CHAT_GLOB)
if first_run or nick_confirmed != self.nick_confirmed:
self.nick_confirmed = nick_confirmed
if not first_run:
def close(self) -> None:
'Close both recv Loop and socket.'
- self.log(msg='# disconnected from server', chat=CHAT_GLOB)
- self.assumed_open = False
+ self.log(msg='# disconnecting from server', chat=CHAT_GLOB)
+ if self.conn:
+ self.conn.close()
+ self.conn = None
self.update_login(nick_confirmed=False)
- if self._recv_loop:
- self._recv_loop.stop()
- self._recv_loop = None
- if self._socket:
- self._socket.close()
- self._socket = None
-
- def _read_lines(self) -> Iterator[Optional['_RecvEvent']]:
- assert self._socket is not None
- bytes_total = b''
- buffer_linesep = b''
- while True:
- try:
- bytes_new = self._socket.recv(_CONN_RECV_BUFSIZE)
- except TimeoutError:
- yield None
- continue
- except ConnectionResetError as e:
- raise e
- except OSError as e:
- if e.errno == 9:
- break
- raise e
- if not bytes_new:
- break
- for c in bytes_new:
- c_byted = c.to_bytes()
- if c not in _IRCSPEC_LINE_SEPARATOR:
- bytes_total += c_byted
- buffer_linesep = b''
- elif c == _IRCSPEC_LINE_SEPARATOR[0]:
- buffer_linesep = c_byted
- else:
- buffer_linesep += c_byted
- if buffer_linesep == _IRCSPEC_LINE_SEPARATOR:
- buffer_linesep = b''
- yield _RecvEvent(client_id=self.id_,
- payload=bytes_total.decode('utf-8'))
- bytes_total = b''
@dataclass
class _RecvEvent(ClientEvent, PayloadMixin):
- payload: str
+ payload: IrcMessage
def affect(self, target: Client) -> None:
- msg = IrcMessage.from_raw(self.payload)
- target.log(f'<-| {self.payload}', ':raw')
- if msg.verb == 'PING':
- target.send(IrcMessage(verb='PONG', params=(msg.params[0],)))
- elif msg.verb == 'ERROR':
+ target.log(f'<-| {self.payload.raw}', ':raw')
+ if self.payload.verb == 'PING':
+ target.send(IrcMessage(verb='PONG',
+ params=(self.payload.params[0],)))
+ elif self.payload.verb == 'ERROR':
target.close()
- elif msg.verb in {'001', 'NICK'}:
- target.update_login(nickname=msg.params[0], nick_confirmed=True)
- elif msg.verb == 'PRIVMSG':
- target.log(msg=str(msg.params), chat=msg.source)
- elif msg.verb == 'CAP':
- if msg.params[1] in {'LS', 'LIST'}:
- target.collect_caps(msg.params[1:])
- elif msg.params[1] == {'ACK', 'NAK'}:
- cap_names = msg.params[-1].split()
+ elif self.payload.verb in {'001', 'NICK'}:
+ target.update_login(nickname=self.payload.params[0],
+ nick_confirmed=True)
+ elif self.payload.verb == 'PRIVMSG':
+ target.log(msg=str(self.payload.params), chat=self.payload.source)
+ elif self.payload.verb == 'CAP':
+ if self.payload.params[1] in {'LS', 'LIST'}:
+ target.collect_caps(self.payload.params[1:])
+ elif self.payload.params[1] == {'ACK', 'NAK'}:
+ cap_names = self.payload.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'
+ target.caps[cap_name].enabled = (self.payload.params[1]
+ == 'ACK')
if target.cap_neg_done('LIST'):
target.try_send_cap('END')
if not target.cap_neg('printing'):