From e0e14839c1c8d69a8bedaa9be1e47d487c3666ff Mon Sep 17 00:00:00 2001 From: luk3yx Date: Wed, 14 Dec 2022 13:01:58 +1300 Subject: [PATCH] Try and regain current nick (fixes #26) --- CHANGELOG.md | 13 +++++++ README.md | 19 ++++++----- miniirc.py | 90 +++++++++++++++++++++++++++++++++++++++---------- setup.py | 2 +- test_miniirc.py | 41 ++++++++++++++++------ 5 files changed, 127 insertions(+), 38 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e602384..128c307 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,19 @@ Notes: - This changelog may contain typographical errors, it is still a work-in-progress. +## 1.9.0 - 2022-12-13 + +### Added + + - miniirc will now attempt to regain the originally specified nickname if it + cannot used when connecting. For compatibility, `irc.nick` will return the + current nickname while connected, however changing it will change the + desired nickname. This may change in the future. + +### Changed + + - The current nickname is now obtained from the 001 response after connecting. + ## 1.8.4 - 2022-08-22 ### Changed diff --git a/README.md b/README.md index 84f385d..b50d950 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,7 @@ You don't need to add every argument, and the `ip`, `port`, `nick`, and `channels` arguments should be specified as positional arguments. ```py -irc = miniirc.IRC('irc.example.com', 6697, 'my-bot', ['#my-channel'], ns_identity=('my-bot', 'hunter2'), executor=concurrent.futures.ThreadPoolExecutor()) +irc = miniirc.IRC('irc.example.com', 6697, 'my-bot', ['#my-channel'], ns_identity=('my-bot', 'hunter2'), executor=concurrent.futures.ThreadPoolExecutor(), keepnick=True) ``` If you are not doing anything with the main thread after connecting to IRC, @@ -64,6 +64,7 @@ irc.wait_until_disconnected() | `ping_timeout` | The ping timeout used alongside the above `ping_interval` option, if unspecified will default to `ping_interval`. | | `verify_ssl` | Verifies TLS/SSL certificates. Disabling this is not recommended as it opens the IRC connection up to MiTM attacks. If you have trouble with certificate verification, try running `pip3 install certifi` first. | | `executor` | An instance of `concurrent.futures.ThreadPoolExecutor` to use when running handlers. | +| `keepnick` | If enabled, miniirc will attempt to obtain the original nick like ZNC's *keepnick. `irc.nick` will be the original nickname rather than the current one. | *The only mandatory parameters are `ip`, `port`, and `nick`.* @@ -121,10 +122,10 @@ space however not interpreted as one). | ------------- | -------------------------------------------------------- | | `active_caps` | A `set` of IRCv3 capabilities that have been successfully negotiated with the IRC server. This is empty while disconnected. | | `connected` | A boolean (or `None`), `True` when miniirc is connected, `False` when miniirc is connecting, and `None` when miniirc is not connected. | -| `current_nick` | *New in v1.4.3.* The bot/client's current nickname, currently an alias for `irc.nick`. Do not modify this, and use this instead of `irc.nick` when getting the bot's current nickname for compatibility with miniirc v2.0.0. | +| `current_nick` | The bot/client's current nickname. Do not modify this, and use this instead of `irc.nick` when getting the bot's current nickname. | | `isupport` | A `dict` with values (not necessarily strings) from `ISUPPORT` messages sent to the client. | | `msglen` | The maximum length (in bytes) of messages (including `\r\n`). This is automatically changed if the server supports the `oragono.io/maxline-2` capability. | -| `nick` | The nickname to use when connecting to IRC. Until miniirc v2.0.0, you should only modify this while disconnected, as it is currently automatically updated with nickname changes. | +| `nick` | The nickname to use when connecting to IRC. Until miniirc v2.0.0, you should only use or modify this while disconnected, as it is currently automatically updated with nickname changes. | The following arguments passed to `miniirc.IRC` are also available: `ip`, `port`, `channels`, `ssl`, `ident`, `realname`, `persist`, `connect_modes`, @@ -354,15 +355,15 @@ is still in beta and there will be breaking API changes in the future. ## Deprecations -When miniirc v2.0.0 is released, the following breaking changes will (probably) -be made: +If miniirc v2.0.0 is ever released, the following breaking changes will +(probably) be made: - Internal-only attributes `irc.handlers`, `irc.sock`, and `irc.sendq` (please do not use these) will be renamed. Again, please do not use these. - - `irc.nick` will be the nickname used when connecting to IRC rather than the - current nickname, use `irc.current_nick` for the current nickname (since - v1.4.3). This will stop lots of underscores being automatically appended to - nicknames. + - `irc.nick` will always be the nickname used when connecting to IRC rather + than the current nickname, use `irc.current_nick` for the current nickname + (since v1.4.3). + - The `keepnick` parameter will default to True. - `irc.ns_identity` will be stored as a tuple instead of a string, for example `('username', 'password with spaces')` instead of `'username password with spaces'`. Both formats are currently accepted and diff --git a/miniirc.py b/miniirc.py index dd91ae0..3314fb8 100755 --- a/miniirc.py +++ b/miniirc.py @@ -5,12 +5,12 @@ # © 2018-2022 by luk3yx and other contributors of miniirc. # -import atexit, errno, threading, time, select, socket, ssl, sys, warnings +import atexit, threading, time, select, socket, ssl, sys, warnings # The version string and tuple -ver = __version_info__ = (1, 8, 4) -version = 'miniirc IRC framework v1.8.4' -__version__ = '1.8.4' +ver = __version_info__ = (1, 9, 0) +version = 'miniirc IRC framework v1.9.0' +__version__ = '1.9.0' # __all__ and _default_caps __all__ = ['CmdHandler', 'Handler', 'IRC'] @@ -204,7 +204,17 @@ class IRC: _unhandled_caps = None # This will no longer be an alias in miniirc v2.0.0. - current_nick = property(lambda self: self.nick) + # This is still a property to avoid breaking miniirc_matrix + current_nick = property(lambda self: self._current_nick) + + # For backwards compatibility, irc.nick will return the current nickname. + # However, changing irc.nick will change the desired nickname as well + # TODO: Consider changing what irc.nick does if it won't break anything or + # making desired_nick public + @current_nick.setter + def nick(self, new_nick): + self._desired_nick = new_nick + self._current_nick = new_nick def __init__(self, ip, port, nick, channels=None, *, ssl=None, ident=None, realname=None, persist=True, debug=False, ns_identity=None, @@ -230,6 +240,7 @@ def __init__(self, ip, port, nick, channels=None, *, ssl=None, ident=None, self.ping_interval = ping_interval self.ping_timeout = ping_timeout self.verify_ssl = verify_ssl + self._keepnick_active = False self._executor = executor # Set the NickServ identity @@ -387,14 +398,15 @@ def connect(self): self.sock = ctx.wrap_socket(self.sock, server_hostname=self.ip) # Begin IRCv3 CAP negotiation. + self._current_nick = self._desired_nick self._unhandled_caps = None self.quote('CAP LS 302', force=True) self.quote('USER', self.ident, '0', '*', ':' + self.realname, force=True) - self.quote('NICK', self.nick, force=True) + self.quote('NICK', self._desired_nick, force=True) atexit.register(self.disconnect) self.debug('Starting main loop...') - self._sasl = self._pinged = False + self._sasl = self._pinged = self._keepnick_active = False self._start_main_loop() def _start_main_loop(self): @@ -410,6 +422,7 @@ def disconnect(self, msg=None, *, auto_reconnect=False): self.connected = None self.active_caps.clear() atexit.unregister(self.disconnect) + self._current_nick = self._desired_nick self._unhandled_caps = None try: self.quote('QUIT :' + str(msg or self.quit_message), force=True) @@ -560,6 +573,12 @@ def _main(self): self.debug('Ignored message:', line) del raw + # Attempt to change nicknames every 30 seconds + if (self._keepnick_active and + time.monotonic() > self._last_keepnick_attempt + 30): + self.send('NICK', self._desired_nick, force=True) + self._last_keepnick_attempt = time.monotonic() + def wait_until_disconnected(self, *, _timeout=None): # The main thread may be replaced on reconnects while self._main_thread and self._main_thread.is_alive(): @@ -582,15 +601,27 @@ def _handler(irc, hostmask, args): irc.isupport.clear() irc._unhandled_caps = None irc.debug('Connected!') + + # Update the current nickname and activate keepnick if required + irc._last_keepnick_attempt = time.monotonic() + irc._keepnick_active = args[0] != irc._desired_nick + irc._current_nick = args[0] + + # Apply connection modes if irc.connect_modes: - irc.quote('MODE', irc.nick, irc.connect_modes) + irc.quote('MODE', irc.current_nick, irc.connect_modes) + + # Log into NickServ if required if not irc._sasl and irc.ns_identity: irc.debug('Logging in (no SASL, aww)...') irc.msg('NickServ', 'identify ' + irc.ns_identity) + + # Join channels if irc.channels: irc.debug('*** Joining channels...', irc.channels) irc.quote('JOIN', ','.join(irc.channels)) + # Send any queued messages with irc._send_lock: sendq, irc.sendq = irc.sendq, None if sendq: @@ -613,21 +644,26 @@ def _handler(irc, hostmask, args): def _handler(irc, hostmask, args): if not irc.connected: try: - return int(irc.nick[0]) + return int(irc._current_nick[0]) except ValueError: pass - if len(irc.nick) >= irc.isupport.get('NICKLEN', 20): + if len(irc._current_nick) >= irc.isupport.get('NICKLEN', 20): return - irc.debug('WARNING: The requested nickname', repr(irc.nick), 'is ' - 'invalid. Trying again with', repr(irc.nick + '_') + '...') - irc.nick += '_' - irc.quote('NICK', irc.nick, force=True) + irc.debug('WARNING: The requested nickname', repr(irc._current_nick), + 'is invalid. Trying again with', + repr(irc._current_nick + '_') + '...') + irc._current_nick += '_' + irc.quote('NICK', irc.current_nick, force=True) @Handler('NICK', colon=False) def _handler(irc, hostmask, args): - if hostmask[0].lower() == irc.nick.lower(): - irc.nick = args[-1] + if hostmask[0].lower() == irc._current_nick.lower(): + irc._current_nick = args[-1] + + # Deactivate keepnick if the client has the right nickname + if irc._current_nick.lower() == irc._desired_nick.lower(): + irc._keepnick_active = False @Handler('PRIVMSG', colon=False) @@ -759,8 +795,10 @@ def _handler(irc, hostmask, args): for key in isupport: try: isupport[key] = int(isupport[key]) - if key == 'NICKLEN': - irc.nick = irc.nick[:isupport[key]] + + # Disable keepnick if the nickname is too long + if key == 'NICKLEN' and len(irc._desired_nick) > isupport[key]: + irc._keepnick_active = False except ValueError: if key.endswith('LEN'): remove.add(key) @@ -770,5 +808,21 @@ def _handler(irc, hostmask, args): irc.isupport.update(isupport) +# Attempt to get the current nickname if the user that currently has it quits +@Handler('QUIT', 'NICK') +def _handler(irc, hostmask, args): + if (irc.connected and irc._keepnick_active and + hostmask[0].lower() == irc._desired_nick.lower()): + irc.send('NICK', irc._desired_nick, force=True) + irc._last_keepnick_attempt = time.monotonic() + + +# Stop trying to get the desired nickname if it's invalid or if nick changes +# aren't permitted +@Handler('432', '435', '447') +def _handler(irc, hostmask, args): + irc._keepnick_active = False + + _colon_warning = True del _handler diff --git a/setup.py b/setup.py index 02c2fa7..7cbc68b 100755 --- a/setup.py +++ b/setup.py @@ -7,7 +7,7 @@ setup( name='miniirc', - version='1.8.4', + version='1.9.0', py_modules=['miniirc'], author='luk3yx', description='A lightweight IRC framework.', diff --git a/test_miniirc.py b/test_miniirc.py index 7d91917..659a827 100644 --- a/test_miniirc.py +++ b/test_miniirc.py @@ -1,6 +1,6 @@ #!/bin/false -import collections, functools, miniirc, pathlib, queue, random, re, select, \ - socket, threading, time +import collections, functools, miniirc, pathlib, pytest, queue, random, re, \ + select, socket, threading, time MINIIRC_V2 = miniirc.ver >= (2, 0, 0) if MINIIRC_V2: @@ -241,6 +241,23 @@ def main(self): assert not hasattr(irc, '_main_lock') +def test_nick_backwards_compatibility(): + irc = DummyIRC() + + # Changing nick should update everything + irc.nick = 'test1' + assert irc.current_nick == irc.nick == irc._desired_nick == 'test1' + + # Changing _current_nick should update current_nick and nick + irc._current_nick = 'test2' + assert irc.current_nick == 'test2' + assert irc.nick == 'test2' + assert irc._desired_nick == 'test1' + + irc.disconnect() + assert irc.current_nick == irc.nick == 'test1' + + def test_connection(monkeypatch): irc = err = None @@ -269,12 +286,13 @@ def wrapper(self, *args, **kwargs): 'AUTHENTICATE PLAIN': 'AUTHENTICATE +', 'AUTHENTICATE dGVzdAB0ZXN0AGh1bnRlcjI=': '903', 'CAP END': ( - '001 parameter test :with colon\n' + '001 miniirc-test_ parameter test :with colon\n' '005 * CAP=END :isupport description\n' ), 'USER miniirc-test 0 * :miniirc-test': ':a PRIVMSG miniirc-test :\x01VERSION\x01', - 'NICK miniirc-test': '432', + 'NICK miniirc-test': '433', + 'NICK :miniirc-test': '433', 'NICK miniirc-test_': '', 'NOTICE a :\x01VERSION ' + miniirc.version + '\x01': '005 miniirc-test CTCP=VERSION :are supported by this server', @@ -315,7 +333,7 @@ def send(self, data): msg = msg[:-2] assert msg in fixed_responses if self._recvq is None: - return + raise BrokenPipeError for line in fixed_responses[msg].split('\n'): self._recvq.put(line.encode('utf-8') + random.choice((b'\r', b'\n', b'\r\n', b'\n\r'))) @@ -369,8 +387,8 @@ def fake_select(read, write, err, timeout): try: event = threading.Event() irc = miniirc.IRC('example.com', 6667, 'miniirc-test', - auto_connect=False, ns_identity=('test', 'hunter2'), persist=False, - debug=True) + auto_connect=False, ns_identity=('test', 'hunter2'), + persist=False, debug=True) assert irc.connected is None @irc.Handler('001', colon=False) @@ -380,7 +398,7 @@ def _handle_001(irc, hostmask, args): if 'CAP' in irc.isupport and 'CTCP' in irc.isupport: break time.sleep(0.001) - assert args == ['parameter', 'test', 'with colon'] + assert args == ['miniirc-test_', 'parameter', 'test', 'with colon'] assert irc.isupport == {'CTCP': 'VERSION', 'CAP': 'END'} event.set() @@ -389,11 +407,14 @@ def _handle_001(irc, hostmask, args): if err is not None: raise err assert irc.connected + assert irc._keepnick_active if MINIIRC_V2: assert irc.nick == 'miniirc-test' - assert irc.current_nick == 'miniirc-test_' else: - assert irc.nick == irc.current_nick == 'miniirc-test_' + assert irc._desired_nick == 'miniirc-test' + assert irc._current_nick == 'miniirc-test_' + assert irc.current_nick == 'miniirc-test_' finally: irc.disconnect() socket_event.set() + assert not irc.connected