From 9ae3e85200da3a28b318904279c3a42ca05e7d6a Mon Sep 17 00:00:00 2001 From: Tony Young Date: Fri, 17 Apr 2015 19:37:32 -0700 Subject: [PATCH] Add support for User and Channel models (closes #15). This is extremely backwards-incompatible with v0.8 as it changes many interfaces for interacting with user and channel information, namely: - All previous dictionary fields have been converted to attributes, e.g. user['username'] is now user.username. This extends to results from WHOIS (WhoisInfo) and WHOWAS (WhowasInfo). - Instead of nicknames and channel names, User and Channel models are now passed to on_* hooks (BREAKING). - _sync_user has been completely removed and replaced with _parse_and_sync_user (and, to some extent, _get_or_create_user). Everything that made use of _parse_user now also synchronizes with the user database for consistency (may be breaking). - A new metaclass, ClientMeta, has been introduced to allow dynamic composition of the User and Channel classes on the feature classes (internal only). - User/Channel objects have the message() method when RFC1459 support is active (feature). --- pydle/client.py | 93 ++++--- pydle/features/account.py | 42 ++- pydle/features/ctcp.py | 10 +- pydle/features/ircv3_1/ircv3_1.py | 43 ++-- pydle/features/ircv3_2/ircv3_2.py | 7 +- pydle/features/ircv3_2/monitor.py | 8 +- pydle/features/isupport.py | 24 +- pydle/features/rfc1459/client.py | 411 ++++++++++++++++-------------- pydle/features/tls.py | 7 +- pydle/features/whox.py | 20 +- pydle/models.py | 27 ++ tests/test_client_channels.py | 4 +- tests/test_client_users.py | 54 ++-- 13 files changed, 391 insertions(+), 359 deletions(-) create mode 100644 pydle/models.py diff --git a/pydle/client.py b/pydle/client.py index 1b6f38f..3dd5c12 100644 --- a/pydle/client.py +++ b/pydle/client.py @@ -7,6 +7,7 @@ from . import async from . import connection +from . import models from . import protocol __all__ = [ 'Error', 'AlreadyInChannel', 'NotInChannel', 'BasicClient' ] @@ -29,7 +30,20 @@ def __init__(self, channel): self.channel = channel -class BasicClient: +class ClientMeta(type): + @staticmethod + def _compose(name, cls, bases): + return type(name, tuple({base for base in bases if not issubclass(cls, base)}) + (cls,), {}) + + def __new__(cls, name, bases, attrs): + if "User" in attrs: + attrs["User"] = cls._compose("_User", attrs["User"], [base.User for base in bases]) + if "Channel" in attrs: + attrs["Channel"] = cls._compose("_Channel", attrs["Channel"], [base.Channel for base in bases]) + return super().__new__(cls, name, bases, attrs) + + +class BasicClient(metaclass=ClientMeta): """ Base IRC client class. This class on its own is not complete: in order to be able to run properly, _has_message, _parse_message and _create_message have to be overloaded. @@ -39,11 +53,13 @@ class BasicClient: RECONNECT_DELAYED = True RECONNECT_DELAYS = [0, 5, 10, 30, 120, 600] + User = models.User + Channel = models.Channel + def __init__(self, nickname, fallback_nicknames=[], username=None, realname=None, **kwargs): """ Create a client. """ self._nicknames = [nickname] + fallback_nicknames - self.username = username or nickname.lower() - self.realname = realname or nickname + self.user = models.User(self, nickname, realname or nickname, username or nickname.lower()) self.eventloop = None self.own_eventloop = True self._reset_connection_attributes() @@ -52,6 +68,18 @@ def __init__(self, nickname, fallback_nicknames=[], username=None, realname=None if kwargs: self.logger.warning('Unused arguments: %s', ', '.join(kwargs.keys())) + @property + def nickname(self): + return self.user.nickname + + @property + def username(self): + return self.user.username + + @property + def realname(self): + return self.user.realname + def _reset_attributes(self): """ Reset attributes. """ # Record-keeping. @@ -61,7 +89,6 @@ def _reset_attributes(self): # Low-level data stuff. self._last_data_received = time.time() self._receive_buffer = b'' - self._pending = {} self._handler_top_level = False self._ping_checker_handle = None @@ -69,7 +96,7 @@ def _reset_attributes(self): self.logger = logging.getLogger(__name__) # Public connection attributes. - self.nickname = DEFAULT_NICKNAME + self.user.nickname = DEFAULT_NICKNAME self.network = None def _reset_connection_attributes(self): @@ -164,42 +191,34 @@ def _check_ping_timeout(self): ## Internal database management. def _create_channel(self, channel): - self.channels[channel] = { - 'users': set(), - } + self.channels[channel] = self.Channel(self, channel) def _destroy_channel(self, channel): # Copy set to prevent a runtime error when destroying the user. - for user in set(self.channels[channel]['users']): + for user in set(self.channels[channel].users): self._destroy_user(user, channel) del self.channels[channel] def _create_user(self, nickname): - # Servers are NOT users. - if not nickname or '.' in nickname: - return + self.users[nickname] = self.User(self, nickname) - self.users[nickname] = { - 'nickname': nickname, - 'username': None, - 'realname': None, - 'hostname': None - } - - def _sync_user(self, nick, metadata): - # Create user in database. - if nick not in self.users: - self._create_user(nick) - if nick not in self.users: - return + def _get_or_create_user(self, nickname): + if nickname not in self.users: + self._create_user(nickname) - self.users[nick].update(metadata) + return self.users[nickname] + + def _get_or_create_channel(self, name): + if name not in self.channels: + self._create_channel(name) + + return self.channels[name] def _rename_user(self, user, new): if user in self.users: self.users[new] = self.users[user] - self.users[new]['nickname'] = new + self.users[new].nickname = new del self.users[user] else: self._create_user(new) @@ -208,9 +227,9 @@ def _rename_user(self, user, new): for ch in self.channels.values(): # Rename user in channel list. - if user in ch['users']: - ch['users'].discard(user) - ch['users'].add(new) + if user in ch.users: + ch.users.discard(user) + ch.users.add(new) def _destroy_user(self, nickname, channel=None): if channel: @@ -220,11 +239,11 @@ def _destroy_user(self, nickname, channel=None): for ch in channels: # Remove from nicklist. - ch['users'].discard(nickname) + ch.users.discard(nickname) # If we're not in any common channels with the user anymore, we have no reliable way to keep their info up-to-date. # Remove the user. - if not channel or not any(nickname in ch['users'] for ch in self.channels.values()): + if not channel or not any(nickname in ch.users for ch in self.channels.values()): del self.users[nickname] def _parse_user(self, data): @@ -232,11 +251,11 @@ def _parse_user(self, data): raise NotImplementedError() def _format_user_mask(self, nickname): - user = self.users.get(nickname, { "nickname": nickname, "username": "*", "hostname": "*" }) - return self._format_host_mask(user['nickname'], user['username'] or '*', user['hostname'] or '*') - - def _format_host_mask(self, nick, user, host): - return '{n}!{u}@{h}'.format(n=nick, u=user, h=host) + if nickname in self.users: + user = self.users[nickname] + else: + user = self.User(self, nickname) + return user.hostmask ## IRC helpers. diff --git a/pydle/features/account.py b/pydle/features/account.py index 4c7dbb6..c2611b0 100644 --- a/pydle/features/account.py +++ b/pydle/features/account.py @@ -6,18 +6,12 @@ class AccountSupport(rfc1459.RFC1459Support): ## Internal. - def _create_user(self, nickname): - super()._create_user(nickname) - if nickname in self.users: - self.users[nickname].update({ - 'account': None, - 'identified': False - }) - def _rename_user(self, user, new): super()._rename_user(user, new) # Unset account info. - self._sync_user(new, { 'account': None, 'identified': False }) + user = self.users[new] + user.account = None + user.identified = False ## IRC API. @@ -27,8 +21,9 @@ def whois(self, nickname): # Add own info. if nickname in self._whois_info: - self._whois_info[nickname].setdefault('account', None) - self._whois_info[nickname].setdefault('identified', False) + whois_info = self._whois_info[nickname] + whois_info.account = None + whois_info.identified = False return future @@ -38,24 +33,23 @@ def whois(self, nickname): def on_raw_307(self, message): """ WHOIS: User has identified for this nickname. (Anope) """ target, nickname = message.params[:2] - info = { - 'identified': True - } if nickname in self.users: - self._sync_user(nickname, info) - if nickname in self._pending['whois']: - self._whois_info[nickname].update(info) + user = self._get_or_create_user(nickname) + user.identified = True + if nickname in self._pending_whois: + whois_info = self._whois_info[nickname] + whois_info.identified = True def on_raw_330(self, message): """ WHOIS account name (Atheme). """ target, nickname, account = message.params[:3] - info = { - 'account': account, - 'identified': True - } if nickname in self.users: - self._sync_user(nickname, info) - if nickname in self._pending['whois']: - self._whois_info[nickname].update(info) + user = self._get_or_create_user(nickname) + user.account = nickname + user.identified = True + if nickname in self._pending_whois: + whois_info = self._whois_info[nickname] + whois_info.account = account + whois_info.identified = True diff --git a/pydle/features/ctcp.py b/pydle/features/ctcp.py index 0412f42..e2098ff 100644 --- a/pydle/features/ctcp.py +++ b/pydle/features/ctcp.py @@ -60,29 +60,27 @@ def ctcp_reply(self, target, query, response): def on_raw_privmsg(self, message): """ Modify PRIVMSG to redirect CTCP messages. """ - nick, metadata = self._parse_user(message.source) + user = self._parse_and_sync_user(message.source) target, msg = message.params if is_ctcp(msg): - self._sync_user(nick, metadata) type, contents = parse_ctcp(msg) # Find dedicated handler if it exists. attr = 'on_ctcp_' + pydle.protocol.identifierify(type) if hasattr(self, attr): - getattr(self, attr)(nick, target, contents) + getattr(self, attr)(user.nickname, target, contents) # Invoke global handler. - self.on_ctcp(nick, target, type, contents) + self.on_ctcp(user.nickname, target, type, contents) else: super().on_raw_privmsg(message) def on_raw_notice(self, message): """ Modify NOTICE to redirect CTCP messages. """ - nick, metadata = self._parse_user(message.source) + user = self._parse_and_sync_user(message.source) target, msg = message.params if is_ctcp(msg): - self._sync_user(nick, metadata) type, response = parse_ctcp(msg) # Find dedicated handler if it exists. diff --git a/pydle/features/ircv3_1/ircv3_1.py b/pydle/features/ircv3_1/ircv3_1.py index 014a516..2c92a41 100644 --- a/pydle/features/ircv3_1/ircv3_1.py +++ b/pydle/features/ircv3_1/ircv3_1.py @@ -1,5 +1,6 @@ ## ircv3_1.py # IRCv3.1 full spec support. +from pydle import models from pydle.features import account, tls from . import sasl @@ -9,9 +10,19 @@ NO_ACCOUNT = '*' + +class IRCv3_1User(models.User): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.account = None + self.identified = False + + class IRCv3_1Support(sasl.SASLSupport, account.AccountSupport, tls.TLSSupport): """ Support for IRCv3.1's base and optional extensions. """ + User = IRCv3_1User + ## IRC callbacks. def on_capability_account_notify_available(self): @@ -42,46 +53,34 @@ def on_raw_account(self, message): if 'account-notify' not in self._capabilities or not self._capabilities['account-notify']: return - nick, metadata = self._parse_user(message.source) + user = self._parse_and_sync_user(message.source) account = message.params[0] - - if nick not in self.users: - return - - self._sync_user(nick, metadata) - if account == NO_ACCOUNT: - self._sync_user(nick, { 'account': None, 'identified': False }) - else: - self._sync_user(nick, { 'account': account, 'identified': True }) + if account != NO_ACCOUNT: + user.account = account + user.identified = True def on_raw_away(self, message): """ Process AWAY messages. """ if 'away-notify' not in self._capabilities or not self._capabilities['away-notify']: return - nick, metadata = self._parse_user(message.source) - if nick not in self.users: - return - - self._sync_user(nick, metadata) - self.users[nick]['away'] = len(message.params) > 0 - self.users[nick]['away_message'] = message.params[0] if len(message.params) > 0 else None + user = self._parse_and_syn_user(message.source) + user.away_message = message.params[0] if len(message.params) > 0 else None def on_raw_join(self, message): """ Process extended JOIN messages. """ if 'extended-join' in self._capabilities and self._capabilities['extended-join']: - nick, metadata = self._parse_user(message.source) + user = self._parse_and_sync_user(message.source) channels, account, realname = message.params - self._sync_user(nick, metadata) - # Emit a fake join message. fakemsg = self._create_message('JOIN', channels, source=message.source) super().on_raw_join(fakemsg) if account == NO_ACCOUNT: account = None - self.users[nick]['account'] = account - self.users[nick]['realname'] = realname + user.account = account + user.identified = user.account is not None + user.realname = realname else: super().on_raw_join(message) diff --git a/pydle/features/ircv3_2/ircv3_2.py b/pydle/features/ircv3_2/ircv3_2.py index 66e383b..65e9db8 100644 --- a/pydle/features/ircv3_2/ircv3_2.py +++ b/pydle/features/ircv3_2/ircv3_2.py @@ -42,8 +42,5 @@ def on_raw_chghost(self, message): return # Update user and host. - metadata = { - 'username': message.params[0], - 'hostname': message.params[1] - } - self._sync_user(nick, metadata) + user = self._get_or_create_user(nick) + user.username, user.hostname = message.params[:2] diff --git a/pydle/features/ircv3_2/monitor.py b/pydle/features/ircv3_2/monitor.py index 287ef97..78fb7a0 100644 --- a/pydle/features/ircv3_2/monitor.py +++ b/pydle/features/ircv3_2/monitor.py @@ -21,16 +21,16 @@ def _destroy_user(self, nickname, channel=None, monitor_override=False): for ch in channels: # Remove from nicklist. - ch['users'].discard(nickname) + ch.users.discard(nickname) # Remove from statuses. for status in self._nickname_prefixes.values(): - if status in ch['modes'] and nickname in ch['modes'][status]: - ch['modes'][status].remove(nickname) + if status in ch.modes and nickname in ch.modes[status]: + ch.modes[status].remove(nickname) # If we're not in any common channels with the user anymore, we have no reliable way to keep their info up-to-date. # Remove the user. - if (monitor_override or not self.is_monitoring(nickname)) and (not channel or not any(nickname in ch['users'] for ch in self.channels.values())): + if (monitor_override or not self.is_monitoring(nickname)) and (not channel or not any(nickname in ch.users for ch in self.channels.values())): del self.users[nickname] diff --git a/pydle/features/isupport.py b/pydle/features/isupport.py index 16e9548..4b3dee8 100644 --- a/pydle/features/isupport.py +++ b/pydle/features/isupport.py @@ -3,6 +3,8 @@ # See: http://tools.ietf.org/html/draft-hardy-irc-isupport-00 import collections import pydle.protocol + +from pydle import models from pydle.features import rfc1459 __all__ = [ 'ISUPPORTSupport' ] @@ -13,9 +15,22 @@ INVITE_EXCEPT_MODE = 'I' +class ISUPPORTChannel(models.Channel): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + if 'EXCEPTS' in self.client._isupport: + self.except_list = None + + if 'INVEX' in self.client._isupport: + self.invite_except_list = None + + class ISUPPORTSupport(rfc1459.RFC1459Support): """ ISUPPORT support. """ + Channel = ISUPPORTChannel + ## Internal overrides. def _reset_attributes(self): @@ -24,15 +39,6 @@ def _reset_attributes(self): self._extban_types = [] self._extban_prefix = None - def _create_channel(self, channel): - """ Create channel with optional ban and invite exception lists. """ - super()._create_channel(channel) - if 'EXCEPTS' in self._isupport: - self.channels[channel]['exceptlist'] = None - if 'INVEX' in self._isupport: - self.channels[channel]['inviteexceptlist'] = None - - ## Command handlers. def on_raw_005(self, message): diff --git a/pydle/features/rfc1459/client.py b/pydle/features/rfc1459/client.py index fc81099..65ee8ac 100644 --- a/pydle/features/rfc1459/client.py +++ b/pydle/features/rfc1459/client.py @@ -5,16 +5,86 @@ import copy import ipaddress +from pydle import models from pydle.async import Future from pydle.client import BasicClient, NotInChannel, AlreadyInChannel from . import parsing from . import protocol +class RFC1459User(models.User): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.away_message = None + + @property + def away(self): + return self.away_message is not None + + def message(self, message): + self.client.message(self.nickname, message) + + +class RFC1459Channel(models.Channel): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.modes = {} + self.topic = None + self.topic_by = None + self.topic_set = None + self.created = None + self.password = None + self.banlist = None + self.public = True + + def message(self, message): + self.client.message(self.name, message) + + +class WhoisInfo: + def __init__(self): + self.user = None + self.oper = False + self.idle = 0 + self.away_message = None + self.server = None + self.server_info = None + self.channels = set() + + @property + def away(self): + return self.away_message is not None + + @property + def nickname(self): + return self.user.nickname + + @property + def username(self): + return self.user.username + + @property + def realname(self): + return self.user.realname + + @property + def hostname(self): + return self.user.hostname + + +class WhowasInfo: + def __init__(self): + self.server = None + self.server_info = None + + class RFC1459Support(BasicClient): """ Basic RFC1459 client. """ DEFAULT_QUIT_MESSAGE = 'Quitting' + User = RFC1459User + Channel = RFC1459Channel + ## Internals. def _reset_attributes(self): @@ -48,8 +118,8 @@ def _reset_attributes(self): self._attempt_nicknames = self._nicknames[:] # Info. - self._pending['whois'] = {} - self._pending['whowas'] = {} + self._pending_whois = {} + self._pending_whowas = {} self._whois_info = {} self._whowas_info = {} @@ -63,36 +133,15 @@ def _reset_connection_attributes(self): super()._reset_connection_attributes() self.password = None - def _create_channel(self, channel): - super()._create_channel(channel) - self.channels[channel].update({ - 'modes': {}, - 'topic': None, - 'topic_by': None, - 'topic_set': None, - 'created': None, - 'password': None, - 'banlist': None, - 'public': True - }) - - def _create_user(self, nickname): - super()._create_user(nickname) - if nickname in self.users: - self.users[nickname].update({ - 'away': False, - 'away_message': None, - }) - def _rename_user(self, user, new): super()._rename_user(user, new) # Rename in mode lists, too. for ch in self.channels.values(): for mode in self._nickname_prefixes.values(): - if mode in ch['modes'] and user in ch['modes'][mode]: - ch['modes'][mode].remove(user) - ch['modes'][mode].append(new) + if mode in ch.modes and user in ch.modes[mode]: + ch.modes[mode].remove(user) + ch.modes[mode].append(new) def _destroy_user(self, user, channel): if channel: @@ -103,33 +152,39 @@ def _destroy_user(self, user, channel): # Remove user from status list too. for ch in channels: for status in self._nickname_prefixes.values(): - if status in ch['modes'] and nickname in ch['modes'][status]: - ch['modes'][status].remove(nickname) + if status in ch.modes and nickname in ch.modes[status]: + ch.modes[status].remove(nickname) def _parse_user(self, data): - if data: - nickname, username, host = parsing.parse_user(data) - - metadata = {} - metadata['nickname'] = nickname - if username: - metadata['username'] = username - if host: - metadata['hostname'] = host - else: - return None, {} - return nickname, metadata + if data is None: + return None + return parsing.parse_user(data) def _parse_user_modes(self, user, modes, current=None): if current is None: - current = self.users[user]['modes'] + current = self.users[user].modes return parsing.parse_modes(modes, current, behaviour=self._user_modes_behaviour) def _parse_channel_modes(self, channel, modes, current=None): if current is None: - current = self.channels[channel]['modes'] + current = self.channels[channel].modes return parsing.parse_modes(modes, current, behaviour=self._channel_modes_behaviour) + def _parse_and_sync_user(self, data): + if data is None: + return None + + nickname, username, hostname = self._parse_user(data) + + # Servers are NOT users, but we're willing to return something sensible for it. + if "." in nickname: + return models.Server(nickname) + + user = self._get_or_create_user(nickname) + user.username = username + user.hostname = hostname + return user + def _format_host_range(self, host, range, allow_everything=False): # IPv4? try: @@ -220,7 +275,7 @@ def _registration_completed(self, message): self.connection.throttle = True target = message.params[0] - fakemsg = self._create_message('NICK', target, source=self.nickname) + fakemsg = self._create_message('NICK', target, source=self.user.nickname) self.on_raw_nick(fakemsg) @@ -289,7 +344,7 @@ def ban(self, channel, target, range=0): 1+ means ban that many 'degrees' (up to 3 for IP addresses) of the host for range bans. """ if target in self.users: - host = self.users[target]['hostname'] + host = self.users[target].hostname else: host = target @@ -303,7 +358,7 @@ def unban(self, channel, target, range=0): See ban documentation for the range parameter. """ if target in self.users: - host = self.users[target]['hostname'] + host = self.users[target].hostname else: host = target @@ -331,15 +386,14 @@ def cycle(self, channel): if not self.in_channel(channel): raise NotInChannel(channel) - password = self.channels[channel]['password'] + password = self.channels[channel].password self.part(channel) self.join(channel, password) def message(self, target, message): """ Message channel or user. """ - hostmask = self._format_user_mask(self.nickname) # Leeway. - chunklen = protocol.MESSAGE_LENGTH_LIMIT - len('{hostmask} PRIVMSG {target} :'.format(hostmask=hostmask, target=target)) - 25 + chunklen = protocol.MESSAGE_LENGTH_LIMIT - len('{hostmask} PRIVMSG {target} :'.format(hostmask=self.user.hostmask, target=target)) - 25 for line in message.replace('\r', '').split('\n'): for chunk in chunkify(line, chunklen): @@ -347,9 +401,8 @@ def message(self, target, message): def notice(self, target, message): """ Notice channel or user. """ - hostmask = self._format_user_mask(self.nickname) # Leeway. - chunklen = protocol.MESSAGE_LENGTH_LIMIT - len('{hostmask} NOTICE {target} :'.format(hostmask=hostmask, target=target)) - 25 + chunklen = protocol.MESSAGE_LENGTH_LIMIT - len('{hostmask} NOTICE {target} :'.format(hostmask=self.user.hostmask, target=target)) - 25 for line in message.replace('\r', '').split('\n'): for chunk in chunkify(line, chunklen): @@ -400,19 +453,14 @@ def whois(self, nickname): result.set_result(None) return result - if nickname not in self._pending['whois']: + if nickname not in self._pending_whois: self.rawmsg('WHOIS', nickname) - self._whois_info[nickname] = { - 'oper': False, - 'idle': 0, - 'away': False, - 'away_message': None - } + self._whois_info[nickname] = WhoisInfo() # Create a future for when the WHOIS requests succeeds. - self._pending['whois'][nickname] = Future() + self._pending_whois[nickname] = Future() - return self._pending['whois'][nickname] + return self._pending_whois[nickname] def whowas(self, nickname): """ @@ -427,14 +475,14 @@ def whowas(self, nickname): result.set_result(None) return result - if nickname not in self._pending['whowas']: + if nickname not in self._pending_whowas: self.rawmsg('WHOWAS', nickname) - self._whowas_info[nickname] = {} + self._whowas_info[nickname] = WhowasInfo() # Create a future for when the WHOWAS requests succeeds. - self._pending['whowas'][nickname] = Future() + self._pending_whowas[nickname] = Future() - return self._pending['whowas'][nickname] + return self._pending_whowas[nickname] ## IRC helpers. @@ -535,22 +583,20 @@ def on_raw_error(self, message): def on_raw_invite(self, message): """ INVITE command. """ - nick, metadata = self._parse_user(message.source) - self._sync_user(nick, metadata) + user = self._parse_and_sync_user(message.source) target, channel = message.params - target, metadata = self._parse_user(target) + target, _, _ = self._parse_user(target) - if self.is_same_nick(self.nickname, target): - self.on_invite(channel, nick) + if self.is_same_nick(self.user.nickname, target): + self.on_invite(self._get_or_create_channel(channel), user) def on_raw_join(self, message): """ JOIN command. """ - nick, metadata = self._parse_user(message.source) - self._sync_user(nick, metadata) + user = self._parse_and_sync_user(message.source) channels = message.params[0].split(',') - if self.is_same_nick(self.nickname, nick): + if self.is_same_nick(self.user.nickname, user.nickname): # Add to our channel list, we joined here. for channel in channels: if not self.in_channel(channel): @@ -562,15 +608,14 @@ def on_raw_join(self, message): # Add user to channel user list. for channel in channels: if self.in_channel(channel): - self.channels[channel]['users'].add(nick) + self.channels[channel].users.add(user.nickname) for channel in channels: - self.on_join(channel, nick) + self.on_join(self.channels[channel], user) def on_raw_kick(self, message): """ KICK command. """ - kicker, kickermeta = self._parse_user(message.source) - self._sync_user(kicker, kickermeta) + kicker = self._parse_and_sync_user(message.source) if len(message.params) > 2: channels, targets, reason = message.params @@ -581,108 +626,101 @@ def on_raw_kick(self, message): channels = channels.split(',') targets = targets.split(',') - for channel, target in itertools.product(channels, targets): - target, targetmeta = self._parse_user(target) - self._sync_user(target, targetmeta) + for channel, target_user in itertools.product(channels, targets): + target = self._parse_and_sync_user(target_user) - if self.is_same_nick(target, self.nickname): + if self.is_same_nick(target.nickname, self.user.nickname): self._destroy_channel(channel) else: # Update nick list on channel. if self.in_channel(channel): - self._destroy_user(target, channel) + self._destroy_user(target.nickname, channel) - self.on_kick(channel, target, kicker, reason) + self.on_kick(self._get_or_create_channel(channel), target, kicker, reason) def on_raw_kill(self, message): """ KILL command. """ - by, bymeta = self._parse_user(message.source) - target, targetmeta = self._parse_user(message.params[0]) + by = self._parse_and_sync_user(message.source) + target = self._parse_and_sync_user(message.params[0]) reason = message.params[1] - self._sync_user(target, targetmeta) - if by in self.users: - self._sync_user(by, bymeta) - self.on_kill(target, by, reason) - if self.is_same_nick(self.nickname, target): + if self.is_same_nick(self.user.nickname, target): self.disconnect(expected=False) else: self._destroy_user(target) def on_raw_mode(self, message): """ MODE command. """ - nick, metadata = self._parse_user(message.source) + user = self._parse_and_sync_user(message.source) target, modes = message.params[0], message.params[1:] - self._sync_user(nick, metadata) if self.is_channel(target): if self.in_channel(target): # Parse modes. - self.channels[target]['modes'] = self._parse_channel_modes(target, modes) + channel = self.channels[target] + channel.modes = self._parse_channel_modes(target, modes) - self.on_mode_change(target, modes, nick) + self.on_mode_change(channel, modes, user) else: - target, targetmeta = self._parse_user(target) - self._sync_user(target, targetmeta) + self._parse_and_sync_user(target) # Update own modes. - if self.is_same_nick(self.nickname, nick): - self._mode = self._parse_user_modes(nick, modes, current=self._mode) + if self.is_same_nick(self.user.nickname, user.nickname): + self._mode = self._parse_user_modes(user.nickname, modes, current=self._mode) self.on_user_mode_change(modes) def on_raw_nick(self, message): """ NICK command. """ - nick, metadata = self._parse_user(message.source) + user = self._parse_and_sync_user(message.source) new = message.params[0] - self._sync_user(nick, metadata) # Acknowledgement of nickname change: set it internally, too. # Alternatively, we were force nick-changed. Nothing much we can do about it. - if self.is_same_nick(self.nickname, nick): - self.nickname = new + if self.is_same_nick(self.user.nickname, user.nickname): + self.user.nickname = new # Go through all user lists and replace. - self._rename_user(nick, new) + self._rename_user(user.nickname, new) # Call handler. - self.on_nick_change(nick, new) + self.on_nick_change(user, new) def on_raw_notice(self, message): """ NOTICE command. """ - nick, metadata = self._parse_user(message.source) + user = self._parse_and_sync_user(message.source) target, message = message.params - self._sync_user(nick, metadata) - - self.on_notice(target, nick, message) if self.is_channel(target): - self.on_channel_notice(target, nick, message) + channel = self._get_or_create_channel(target) + self.on_notice(channel, user, message) + self.on_channel_notice(channel, user, message) else: - self.on_private_notice(nick, message) + self.on_notice(self.user, user, message) + self.on_private_notice(user, message) def on_raw_part(self, message): """ PART command. """ - nick, metadata = self._parse_user(message.source) + user = self._parse_and_sync_user(message.source) channels = message.params[0].split(',') if len(message.params) > 1: reason = message.params[1] else: reason = None - self._sync_user(nick, metadata) - if self.is_same_nick(self.nickname, nick): + if self.is_same_nick(self.user.nickname, user.nickname): # We left the channel. Remove from channel list. :( for channel in channels: if self.in_channel(channel): + old_channel = self.channels[channel] self._destroy_channel(channel) - self.on_part(channel, nick, reason) + self.on_part(old_channel, user, reason) else: # Someone else left. Remove them. for channel in channels: - self._destroy_user(nick, channel) - self.on_part(channel, nick, reason) + self._destroy_user(user.nickname, channel) + self.on_part(self.channels[channel], user, reason) def on_raw_ping(self, message): """ PING command. """ @@ -691,49 +729,47 @@ def on_raw_ping(self, message): def on_raw_privmsg(self, message): """ PRIVMSG command. """ - nick, metadata = self._parse_user(message.source) + user = self._parse_and_sync_user(message.source) target, message = message.params - self._sync_user(nick, metadata) - - self.on_message(target, nick, message) if self.is_channel(target): - self.on_channel_message(target, nick, message) + channel = self._get_or_create_channel(target) + self.on_message(channel, user, message) + self.on_channel_message(channel, user, message) else: - self.on_private_message(nick, message) + self.on_message(self.user, user, message) + self.on_private_message(user, message) def on_raw_quit(self, message): """ QUIT command. """ - nick, metadata = self._parse_user(message.source) + user = self._parse_and_sync_user(message.source) - self._sync_user(nick, metadata) if message.params: reason = message.params[0] else: reason = None - self.on_quit(nick, reason) + self.on_quit(user, reason) # Remove user from database. - if not self.is_same_nick(self.nickname, nick): - self._destroy_user(nick) + if not self.is_same_nick(self.user.nickname, user.nickname): + self._destroy_user(user.nickname) # Else, we quit. elif self.connected: self.disconnect(expected=True) def on_raw_topic(self, message): """ TOPIC command. """ - setter, settermeta = self._parse_user(message.source) + setter = self._parse_and_sync_user(message.source) target, topic = message.params - self._sync_user(setter, settermeta) - # Update topic in our own channel list. if self.in_channel(target): - self.channels[target]['topic'] = topic - self.channels[target]['topic_by'] = setter - self.channels[target]['topic_set'] = datetime.datetime.now() + channel = self.channels[target] + channel.topic = topic + channel.topic_by = setter.nickname + channel.topic_set = datetime.datetime.now() - self.on_topic_change(target, topic, setter) + self.on_topic_change(channel, topic, setter) ## Numeric responses. @@ -767,95 +803,83 @@ def on_raw_004(self, message): def on_raw_301(self, message): """ User is away. """ nickname, message = message.params[0] - info = { - 'away': True, - 'away_message': message - } if nickname in self.users: - self._sync_user(nickname, info) - if nickname in self._pending['whois']: - self._whois_info[nickname].update(info) + user = self.users[nickname] + user.away_message = message + if nickname in self._pending_whois: + whois_info = self._whois_info[nickname] + whois_info.away_message = message def on_raw_311(self, message): """ WHOIS user info. """ target, nickname, username, hostname, _, realname = message.params - info = { - 'username': username, - 'hostname': hostname, - 'realname': realname - } - self._sync_user(nickname, info) - if nickname in self._pending['whois']: - self._whois_info[nickname].update(info) + user = self._get_or_create_user(nickname) + user.username = username + user.hostname = hostname + user.realname = realname + + if nickname in self._pending_whois: + whois_info = self._whois_info[nickname] + whois_info.user = user def on_raw_312(self, message): """ WHOIS server info. """ target, nickname, server, serverinfo = message.params - info = { - 'server': server, - 'server_info': serverinfo - } - if nickname in self._pending['whois']: - self._whois_info[nickname].update(info) - if nickname in self._pending['whowas']: - self._whowas_info[nickname].update(info) + if nickname in self._pending_whois: + whois_info = self._whois_info[nickname] + whois_info.server = server + whois_info.server_info = serverinfo + if nickname in self._pending_whowas: + whowas_info = self._whowas_info[nickname] + whowas_info.server = server + whowas_info.server_info = serverinfo def on_raw_313(self, message): """ WHOIS operator info. """ target, nickname = message.params[:2] - info = { - 'oper': True - } - if nickname in self._pending['whois']: - self._whois_info[nickname].update(info) + if nickname in self._pending_whois: + whois_info = self._whois_info[nickname] + whois_info.oper = True def on_raw_314(self, message): """ WHOWAS user info. """ target, nickname, username, hostname, _, realname = message.params - info = { - 'username': username, - 'hostname': hostname, - 'realname': realname - } - if nickname in self._pending['whowas']: - self._whowas_info[nickname].update(info) + if nickname in self._pending_whowas: + whowas_info = self._whowas_info[nickname] + whowas_info.username = username + whowas_info.hostname = hostname + whowas_info.realname = realname on_raw_315 = BasicClient._ignored # End of /WHO list. def on_raw_317(self, message): """ WHOIS idle time. """ target, nickname, idle_time = message.params[:3] - info = { - 'idle': int(idle_time), - } - if nickname in self._pending['whois']: - self._whois_info[nickname].update(info) + if nickname in self._pending_whois: + self._whois_info[nickname].idle = int(idle_time) def on_raw_318(self, message): """ End of /WHOIS list. """ target, nickname = message.params[:2] # Mark future as done. - if nickname in self._pending['whois']: - future = self._pending['whois'].pop(nickname) + if nickname in self._pending_whois: + future = self._pending_whois.pop(nickname) future.set_result(self._whois_info[nickname]) def on_raw_319(self, message): """ WHOIS active channels. """ target, nickname, channels = message.params[:3] channels = { channel.lstrip() for channel in channels.strip().split(' ') } - info = { - 'channels': channels - } - if nickname in self._pending['whois']: - self._whois_info[nickname].update(info) + if nickname in self._pending_whois: + self._whois_info[nickname].channels = channels def on_raw_324(self, message): """ Channel mode. """ @@ -864,7 +888,7 @@ def on_raw_324(self, message): if not self.in_channel(channel): return - self.channels[channel]['modes'] = self._parse_channel_modes(channel, modes) + self.channels[channel].modes = self._parse_channel_modes(channel, modes) def on_raw_329(self, message): """ Channel creation time. """ @@ -872,7 +896,7 @@ def on_raw_329(self, message): if not self.in_channel(channel): return - self.channels[channel]['created'] = datetime.datetime.fromtimestamp(int(timestamp)) + self.channels[channel].created = datetime.datetime.fromtimestamp(int(timestamp)) def on_raw_332(self, message): """ Current topic on channel join. """ @@ -880,7 +904,7 @@ def on_raw_332(self, message): if not self.in_channel(channel): return - self.channels[channel]['topic'] = topic + self.channels[channel].topic = topic def on_raw_333(self, message): """ Topic setter and time on channel join. """ @@ -889,8 +913,8 @@ def on_raw_333(self, message): return # No need to sync user since this is most likely outdated info. - self.channels[channel]['topic_by'] = self._parse_user(setter)[0] - self.channels[channel]['topic_set'] = datetime.datetime.fromtimestamp(int(timestamp)) + self.channels[channel].topic_by = self._parse_user(setter)[0] + self.channels[channel].topic_set = datetime.datetime.fromtimestamp(int(timestamp)) def on_raw_353(self, message): """ Response to /NAMES. """ @@ -900,9 +924,9 @@ def on_raw_353(self, message): # Set channel visibility. if visibility == protocol.PUBLIC_CHANNEL_SIGIL: - self.channels[channel]['public'] = True + self.channels[channel].public = True elif visibility in (protocol.PRIVATE_CHANNEL_SIGIL, protocol.SECRET_CHANNEL_SIGIL): - self.channels[channel]['public'] = False + self.channels[channel].public = False # Update channel user list. for entry in names.split(): @@ -910,8 +934,7 @@ def on_raw_353(self, message): # Make entry safe for _parse_user(). safe_entry = entry.lstrip(''.join(self._nickname_prefixes.keys())) # Parse entry and update database. - nick, metadata = self._parse_user(safe_entry) - self._sync_user(nick, metadata) + user = self._parse_and_sync_user(safe_entry) # Get prefixes. prefixes = set(entry.replace(safe_entry, '')) @@ -923,12 +946,12 @@ def on_raw_353(self, message): statuses.append(status) # Add user to user list. - self.channels[channel]['users'].add(nick) + self.channels[channel].users.add(user.nickname) # And to channel modes.. for status in statuses: - if status not in self.channels[channel]['modes']: - self.channels[channel]['modes'][status] = [] - self.channels[channel]['modes'][status].append(nick) + if status not in self.channels[channel].modes: + self.channels[channel].modes[status] = [] + self.channels[channel].modes[status].append(user.nickname) on_raw_366 = BasicClient._ignored # End of /NAMES list. @@ -953,8 +976,8 @@ def on_raw_401(self, message): nickname = message.params[1] # Remove nickname from whois requests if it involves one of ours. - if nickname in self._pending['whois']: - future = self._pending['whois'].pop(nickname) + if nickname in self._pending_whois: + future = self._pending_whois.pop(nickname) future.set_result(None) del self._whois_info[nickname] diff --git a/pydle/features/tls.py b/pydle/features/tls.py index f2a08cd..98d478e 100644 --- a/pydle/features/tls.py +++ b/pydle/features/tls.py @@ -65,7 +65,7 @@ def whois(self, nickname): # Add field that determines if the target user is connected over TLS. if nickname in self._whois_info: - self._whois_info[nickname].setdefault('secure', False) + self._whois_info[nickname].secure = False return future @@ -92,12 +92,9 @@ def on_raw_670(self, message): def on_raw_671(self, message): """ WHOIS: user is connected securely. """ target, nickname = message.params[:2] - info = { - 'secure': True - } if nickname in self._whois_info: - self._whois_info[nickname].update(info) + self._whois_info[nickname].secure = True def on_raw_691(self, message): """ Error setting up TLS server-side. """ diff --git a/pydle/features/whox.py b/pydle/features/whox.py index 2596e7e..2892585 100644 --- a/pydle/features/whox.py +++ b/pydle/features/whox.py @@ -13,10 +13,10 @@ class WHOXSupport(isupport.ISUPPORTSupport, account.AccountSupport): def on_raw_join(self, message): """ Override JOIN to send WHOX. """ super().on_raw_join(message) - nick, metadata = self._parse_user(message.source) + user = self._parse_and_sync_user(message.source) channels = message.params[0].split(',') - if self.is_same_nick(self.nickname, nick): + if self.is_same_nick(self.nickname, user.nickname): # We joined. if 'WHOX' in self._isupport and self._isupport['WHOX']: # Get more relevant channel info thanks to WHOX. @@ -33,14 +33,8 @@ def on_raw_354(self, message): return # Great. Extract relevant information. - metadata = { - 'nickname': message.params[4], - 'username': message.params[2], - 'realname': message.params[6], - 'hostname': message.params[3], - } - if message.params[5] != NO_ACCOUNT: - metadata['identified'] = True - metadata['account'] = message.params[5] - - self._sync_user(metadata['nickname'], metadata) + user = self._get_or_create_user(message.params[4]) + user.username = message.params[2] + user.realname = message.params[6] + user.hostname = message.params[3] + user.account = message.params[5] if message.params[5] != NO_ACCOUNT else None diff --git a/pydle/models.py b/pydle/models.py new file mode 100644 index 0000000..d2dc604 --- /dev/null +++ b/pydle/models.py @@ -0,0 +1,27 @@ +class User: + def __init__(self, client, nickname, realname=None, username=None, hostname=None): + self.client = client + self.nickname = nickname + self.realname = realname + self.username = username + self.hostname = hostname + + @property + def name(self): + return self.nickname + + @property + def hostmask(self): + return '{n}!{u}@{h}'.format(n=self.nickname, u=self.username or '*', h=self.hostname or '*') + + +class Channel: + def __init__(self, client, name): + self.client = client + self.name = name + self.users = set() + + +class Server: + def __init__(self, name): + self.name = name diff --git a/tests/test_client_channels.py b/tests/test_client_channels.py index 5aab225..196b68a 100644 --- a/tests/test_client_channels.py +++ b/tests/test_client_channels.py @@ -24,7 +24,7 @@ def test_client_is_channel(server, client): def test_channel_creation(server, client): client._create_channel('#pydle') assert '#pydle' in client.channels - assert client.channels['#pydle']['users'] == set() + assert client.channels['#pydle'].users == set() @with_client() def test_channel_destruction(server, client): @@ -36,7 +36,7 @@ def test_channel_destruction(server, client): def test_channel_user_destruction(server, client): client._create_channel('#pydle') client._create_user('WiZ') - client.channels['#pydle']['users'].add('WiZ') + client.channels['#pydle'].users.add('WiZ') client._destroy_channel('#pydle') assert '#pydle' not in client.channels diff --git a/tests/test_client_users.py b/tests/test_client_users.py index b534507..e5982b0 100644 --- a/tests/test_client_users.py +++ b/tests/test_client_users.py @@ -13,12 +13,7 @@ def test_client_same_nick(server, client): def test_user_creation(server, client): client._create_user('WiZ') assert 'WiZ' in client.users - assert client.users['WiZ']['nickname'] == 'WiZ' - -@with_client() -def test_user_invalid_creation(server, client): - client._create_user('irc.fbi.gov') - assert 'irc.fbi.gov' not in client.users + assert client.users['WiZ'].nickname == 'WiZ' @with_client() @@ -28,7 +23,7 @@ def test_user_renaming(server, client): assert 'WiZ' not in client.users assert 'jilles' in client.users - assert client.users['jilles']['nickname'] == 'jilles' + assert client.users['jilles'].nickname == 'jilles' @with_client() def test_user_renaming_creation(server, client): @@ -37,22 +32,16 @@ def test_user_renaming_creation(server, client): assert 'WiZ' in client.users assert 'null' not in client.users -@with_client() -def test_user_renaming_invalid_creation(server, client): - client._rename_user('null', 'irc.fbi.gov') - - assert 'irc.fbi.gov' not in client.users - assert 'null' not in client.users @with_client() def test_user_renaming_channel_users(server, client): client._create_user('WiZ') client._create_channel('#lobby') - client.channels['#lobby']['users'].add('WiZ') + client.channels['#lobby'].users.add('WiZ') client._rename_user('WiZ', 'jilles') - assert 'WiZ' not in client.channels['#lobby']['users'] - assert 'jilles' in client.channels['#lobby']['users'] + assert 'WiZ' not in client.channels['#lobby'].users + assert 'jilles' in client.channels['#lobby'].users @with_client() @@ -66,53 +55,42 @@ def test_user_deletion(server, client): def test_user_channel_deletion(server, client): client._create_channel('#lobby') client._create_user('WiZ') - client.channels['#lobby']['users'].add('WiZ') + client.channels['#lobby'].users.add('WiZ') client._destroy_user('WiZ', '#lobby') assert 'WiZ' not in client.users - assert client.channels['#lobby']['users'] == set() + assert client.channels['#lobby'].users == set() @with_client() def test_user_channel_incomplete_deletion(server, client): client._create_channel('#lobby') client._create_channel('#foo') client._create_user('WiZ') - client.channels['#lobby']['users'].add('WiZ') - client.channels['#foo']['users'].add('WiZ') + client.channels['#lobby'].users.add('WiZ') + client.channels['#foo'].users.add('WiZ') client._destroy_user('WiZ', '#lobby') assert 'WiZ' in client.users - assert client.channels['#lobby']['users'] == set() + assert client.channels['#lobby'].users == set() @with_client() -def test_user_synchronization(server, client): - client._create_user('WiZ') - client._sync_user('WiZ', { 'hostname': 'og.irc.developer' }) - - assert client.users['WiZ']['hostname'] == 'og.irc.developer' - -@with_client() -def test_user_synchronization_creation(server, client): - client._sync_user('WiZ', {}) +def test_user_get_or_create(server, client): + client._get_or_create_user('WiZ') assert 'WiZ' in client.users -@with_client() -def test_user_invalid_synchronization(server, client): - client._sync_user('irc.fbi.gov', {}) - assert 'irc.fbi.gov' not in client.users - @with_client() def test_user_mask_format(server, client): client._create_user('WiZ') + wiz = client.users['WiZ'] assert client._format_user_mask('WiZ') == 'WiZ!*@*' - client._sync_user('WiZ', { 'username': 'wiz' }) + wiz.username = 'wiz' assert client._format_user_mask('WiZ') == 'WiZ!wiz@*' - client._sync_user('WiZ', { 'hostname': 'og.irc.developer' }) + wiz.hostname = 'og.irc.developer' assert client._format_user_mask('WiZ') == 'WiZ!wiz@og.irc.developer' - client._sync_user('WiZ', { 'username': None }) + wiz.username = None assert client._format_user_mask('WiZ') == 'WiZ!*@og.irc.developer'