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).

Oh, and now the contents parameter of ctcp() actually does something.
  • Loading branch information
Tony Young committed Jun 8, 2015
1 parent b7c771a commit 9ac5d8e
Show file tree
Hide file tree
Showing 13 changed files with 445 additions and 360 deletions.
102 changes: 65 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,29 @@ def __init__(self, channel):
self.channel = channel


class BasicClient:
class ClientType(type):
def _compose(c, name, field):
# We traverse the class's method resolution order to create the user and channel model classes.
# There is an assumption that the model classes for users and channels follow the same inheritance hierarchy as the client class, and we can avoid calling featurize().
seen = set()
bases = []

for mro_cls in c.mro():
inner = getattr(mro_cls, field, None)
if inner is not None and inner not in seen:
bases.append(inner)
seen.add(inner)

return type("{name}[{bases}]".format(name=name, bases=', '.join(base.__name__ for base in bases)), tuple(bases), {})

def __new__(cls, name, bases, attrs):
c = super().__new__(cls, name, bases, attrs)
c.User = cls._compose(c, "User", "USER_MODEL")
c.Channel = cls._compose(c, "Channel", "CHANNEL_MODEL")
return c


class BasicClient(metaclass=ClientType):
"""
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 +62,13 @@ class BasicClient:
RECONNECT_DELAYED = True
RECONNECT_DELAYS = [0, 5, 10, 30, 120, 600]

USER_MODEL = models.User
CHANNEL_MODEL = 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 = self.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 +77,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 +98,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 @@ -167,42 +203,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_user(self, nickname):
if nickname not in self.users:
self._create_user(nickname)

self.users[nick].update(metadata)
return self.users[nickname]

def _get_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 @@ -211,9 +239,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 @@ -223,23 +251,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
53 changes: 29 additions & 24 deletions pydle/features/account.py
Original file line number Diff line number Diff line change
@@ -1,23 +1,28 @@
## account.py
# Account system support.
from pydle import models
from pydle.features import rfc1459


class AccountUser(models.User):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.account = None
self.identified = False


class AccountSupport(rfc1459.RFC1459Support):

## Internal.
USER_MODEL = AccountUser

def _create_user(self, nickname):
super()._create_user(nickname)
if nickname in self.users:
self.users[nickname].update({
'account': None,
'identified': False
})
## Internal.

def _rename_user(self, user, new):
super()._rename_user(user, new)
# Unset account info.
self._sync_user(new, { 'account': None, 'identified': False })
user = self._get_user(new)
user.account = None
user.identified = False


## IRC API.
Expand All @@ -27,8 +32,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 +44,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_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_user(nickname)
user.account = account
user.identified = True
if nickname in self._pending_whois:
whois_info = self._whois_info[nickname]
whois_info.account = account
whois_info.identified = True
56 changes: 49 additions & 7 deletions pydle/features/ctcp.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
## ctcp.py
# Client-to-Client-Protocol (CTCP) support.
import pydle.protocol

from pydle import models
from pydle.features import rfc1459

__all__ = [ 'CTCPSupport' ]
Expand All @@ -10,9 +12,38 @@
CTCP_ESCAPE_CHAR = '\x16'


class CTCPUser(models.User):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)

def ctcp(self, query, contents=None):
""" Send a CTCP request to the user. """
self.client.ctcp(self.nickname, query, contents)

def ctcp_reply(self, query, contents=None):
""" Send a CTCP reply to the user. """
self.client.ctcp_reply(self.nickname, query, response)


class CTCPChannel(models.Channel):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)

def ctcp(self, query, contents=None):
""" Send a CTCP request to the channel. """
self.client.ctcp(self.name, query, contents)

def ctcp_reply(self, query, contents=None):
""" Send a CTCP reply to the channel. """
self.client.ctcp_reply(self.name, query, response)


class CTCPSupport(rfc1459.RFC1459Support):
""" Support for CTCP messages. """

USER_MODEL = CTCPUser
CHANNEL_MODEL = CTCPChannel

## Callbacks.

def on_ctcp(self, by, target, what, contents):
Expand Down Expand Up @@ -46,7 +77,10 @@ def ctcp(self, target, query, contents=None):
if self.is_channel(target) and not self.in_channel(target):
raise client.NotInChannel(target)

self.message(target, construct_ctcp(query, contents))
if contents is None:
contents = []

self.message(target, construct_ctcp(query, *contents))

def ctcp_reply(self, target, query, response):
""" Send a CTCP reply to a target. """
Expand All @@ -60,29 +94,37 @@ 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 self.is_channel(target):
target = self._get_channel(target)
else:
target = self._get_user(target)

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, target, contents)
# Invoke global handler.
self.on_ctcp(nick, target, type, contents)
self.on_ctcp(user, 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 self.is_channel(target):
target = self._get_channel(target)
else:
target = self._get_user(target)

if is_ctcp(msg):
self._sync_user(nick, metadata)
type, response = parse_ctcp(msg)

# Find dedicated handler if it exists.
Expand Down
Loading

0 comments on commit 9ac5d8e

Please sign in to comment.