Skip to content

Commit

Permalink
Add support for User and Channel models (closes #15).
Browse files Browse the repository at this point in the history
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).
  • Loading branch information
Tony Young committed Apr 18, 2015
1 parent ac2c7de commit 9ae3e85
Show file tree
Hide file tree
Showing 13 changed files with 391 additions and 359 deletions.
93 changes: 56 additions & 37 deletions pydle/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@

from . import async
from . import connection
from . import models
from . import protocol

__all__ = [ 'Error', 'AlreadyInChannel', 'NotInChannel', 'BasicClient' ]
Expand All @@ -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.
Expand All @@ -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()
Expand All @@ -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.
Expand All @@ -61,15 +89,14 @@ 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

# Misc.
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):
Expand Down Expand Up @@ -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)
Expand All @@ -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:
Expand All @@ -220,23 +239,23 @@ 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):
""" Parse user and return nickname, metadata tuple. """
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.
Expand Down
42 changes: 18 additions & 24 deletions pydle/features/account.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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

Expand All @@ -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
10 changes: 4 additions & 6 deletions pydle/features/ctcp.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
43 changes: 21 additions & 22 deletions pydle/features/ircv3_1/ircv3_1.py
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -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):
Expand Down Expand Up @@ -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)
7 changes: 2 additions & 5 deletions pydle/features/ircv3_2/ircv3_2.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Loading

0 comments on commit 9ae3e85

Please sign in to comment.