###############################################################################
#
# Copyright 2011-2012 Pants Developers (see AUTHORS.txt)
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
###############################################################################
###############################################################################
# Imports
###############################################################################
import logging
import re
from pants.stream import Stream
###############################################################################
# Logging
###############################################################################
log = logging.getLogger(__name__)
###############################################################################
# Constants
###############################################################################
__all__ = ('BaseIRC','IRCClient')
COMMAND = re.compile(r"((?::.+? )?)(.+?) (.*)")
NETMASK = re.compile(
r":(?:(?:([^!\s]+)!)?([^@\s]+)@)?([A-Za-z0-9-/:?`[_^{|}\\\]\.]+)")
ARGS = re.compile(r"(?:^|(?<= ))(:.*|[^ ]+)")
CTCP = re.compile(r"([\x00\n\r\x10])")
unCTCP = re.compile(r"\x10([0nr\x10])")
CODECS = ('utf-8','iso-8859-1','cp1252')
#])" # this is here to correct syntax highlighting in textmate... remove it!!!
###############################################################################
# BaseIRC Class
###############################################################################
[docs]class BaseIRC(Stream):
"""
The IRC protocol, implemented over a Pants :class:`~pants.stream.Stream`.
The goal with this is to create a lightweight IRC class that can serve as
either a server or a client. As such, it doesn't implement a lot of logic
in favor of providing a robust base.
The BaseIRC class can receive and send IRC commands, and automatically
respond to certain commands such as PING.
This class extends :class:`~pants.stream.Stream`, and as such has the same
:func:`~pants.stream.Stream.connect` and :func:`~pants.stream.Stream.listen`
functions.
"""
def __init__(self, encoding='utf-8', **kwargs):
Stream.__init__(self, **kwargs)
# Set our prefix to an empty string. This is prepended to all sent
# commands, and useful for servers.
self.prefix = ''
self.encoding = encoding
# Read lines at once.
self.read_delimiter = '\n'
##### Public Event Handlers ###############################################
[docs] def irc_close(self):
"""
Placeholder.
This method is called whenever the IRC instance becomes disconnected
from the remote client or server.
"""
pass
[docs] def irc_command(self, command, args, nick, user, host):
"""
Placeholder.
This method is called whenever a command is received from the other
side and successfully parsed as an IRC command.
========= ============
Argument Description
========= ============
command The received command.
args A list of the arguments following the command.
nick The nick of the user that sent the command, if applicable, or an empty string.
user The username of the user that sent the command, if applicable, or an empty string.
host The host of the user that sent the command, the host of the server that sent the command, or an empty string if no host was supplied.
========= ============
"""
pass
[docs] def irc_connect(self):
"""
Placeholder.
This method is called when the IRC instance has successfully connected
to the remote client or server.
"""
pass
##### I/O Methods #########################################################
[docs] def message(self, destination, message, _ctcpQuote=True, _prefix=None):
"""
Send a message to the given nick or channel.
============ ======== ============
Argument Default Description
============ ======== ============
destination The nick or channel to send the message to.
message The text of the message to be sent.
_ctcpQuote True *Optional.* If True, the message text will be quoted for CTCP before being sent.
_prefix None *Optional.* A string that, if provided, will be prepended to the command string before it's sent to the server.
============ ======== ============
"""
if _ctcpQuote:
message = ctcpQuote(message)
self.send_command("PRIVMSG", destination, message, _prefix=_prefix)
[docs] def notice(self, destination, message, _ctcpQuote=True, _prefix=None):
"""
Send a NOTICE to the specified destination.
=========== ======== ============
Argument Default Description
=========== ======== ============
destination The nick or channel to send the NOTICE to.
message The text of the NOTICE to be sent.
_ctcpQuote True *Optional.* If True, the message text will be quoted for CTCP before being sent.
_prefix None *Optional.* A string that, if provided, will be prepended to the command string before it's sent to the server.
=========== ======== ============
"""
if _ctcpQuote:
message = ctcpQuote(message)
self.send_command("NOTICE", destination, message, _prefix=_prefix)
[docs] def quit(self, reason=None, _prefix=None):
"""
Send a QUIT message, with an optional reason.
========= ======== ============
Argument Default Description
========= ======== ============
reason None *Optional.* The reason for quitting that will be displayed to other users.
_prefix None *Optional.* A string that, if provided, will be prepended to the command string before it's sent to the server.
========= ======== ============
"""
if not reason:
reason = "pants.contrib.irc -- http://www.pantsweb.org/"
self.send_command("QUIT", reason, _prefix=_prefix)
[docs] def send_command(self, command, *args, **kwargs):
"""
Send a command to the remote endpoint.
========= ======== ============
Argument Default Description
========= ======== ============
command The command to send.
\*args *Optional.* A list of arguments to send with the command.
_prefix None *Optional.* A string that, if provided, will be prepended to the command string before it's sent to the server.
========= ======== ============
"""
if args:
args = list(args)
for i in xrange(len(args)):
arg = args[i]
if not isinstance(arg, basestring):
args[i] = str(arg)
if not args[-1].startswith(':'):
args[-1] = ':%s' % args[-1]
out = '%s %s\r\n' % (command, ' '.join(args))
else:
out = '%s\r\n' % command
if '_prefix' in kwargs and kwargs['_prefix']:
out = '%s %s' % (kwargs['_prefix'], out)
elif self.prefix:
out = '%s %s' % (self.prefix, out)
# Send it.
log.debug('\x1B[0;32m>> %s\x1B[0m' % out.rstrip())
self.write(out.encode(self.encoding))
##### Internal Event Handlers #############################################
def on_command(self, command, args, nick, user, host):
"""
Placeholder.
Performs any logic that has to be performed upon receiving a command,
then calls irc_command.
Arguments are identical to irc_command.
"""
if hasattr(self, 'irc_command_%s' % command):
getattr(self, 'irc_command_%s' % command)(
command, args, nick, user, host)
else:
self.irc_command(command, args, nick, user, host)
def on_connect(self):
"""
Placeholder.
Performs any logic that has to be performed at connect, then calls
self.irc_connect.
"""
self.irc_connect()
def on_close(self):
"""
Placeholder.
Performs any logic that has to be performed at disconnect, then calls
self.irc_close.
"""
self.irc_close()
def on_read(self, data):
"""
Read the available data, parse the command, and call an event for it.
"""
data = data.strip('\r\n')
if not data:
return
log.debug('\x1B[0;31m<< %s\x1B[0m' % repr(data)[1:-1])
# Decode it straight away.
data = decode(data)
try:
prefix, command, raw = COMMAND.match(data).groups()
except Exception:
log.warning('Invalid IRC command %r.' % data)
return
if prefix:
nick, user, host = NETMASK.match(prefix).groups()
if not nick and not '.' in host:
nick = host
host = ''
else:
nick, user, host = '', '', ''
args = ARGS.findall(raw)
if args:
if args[-1].startswith(':'):
args[-1] = args[-1][1:]
# If it's PING, handle it.
if command == 'PING':
self.send_command('PONG', *args)
return
# Handle the command.
self.on_command(command, args, nick, user, host)
###############################################################################
# IRCClient Class & Channel Class
###############################################################################
[docs]class Channel(object):
"""
An IRC channel's representation, for keeping track of users and the topic
and stuff.
"""
__slots__ = ('name', 'users','topic','topic_setter','topic_time')
def __init__(self, name):
self.name = name
self.users = []
self.topic = None
self.topic_setter = None
self.topic_time = 0
[docs]class IRCClient(BaseIRC):
"""
An IRC client, written in Pants, based on :class:`~pants.contrib.irc.BaseIRC`.
This implements rather more logic, and keeps track of what server it's
connected to, its nick, and what channels it's in.
"""
def __init__(self, encoding='utf-8', **kwargs):
BaseIRC.__init__(self, encoding=encoding, **kwargs)
# Internal State Stuff
self._channels = {}
self._joining = []
self._nick = None
self._port = 6667
self._server = None
self._user = None
self._realname = None
# External Stuff
self.password = None
##### Properties ##########################################################
@property
def nick(self):
"""
This instance's current nickname on the server it's connected to, or
the nickname it will attempt to acquire when connecting.
"""
return self._nick
@nick.setter
def nick(self, val):
if not self.connected:
self._nick = val
else:
self.send_command("NICK", val)
@property
def port(self):
"""
The port this instance is connected to on the remote server, or the
port it will attempt to connect to.
"""
return self._port
@port.setter
def port(self, val):
if self.connected or self.connecting:
raise IOError('Cannot change while connected to server.')
self._port = val
@property
def realname(self):
"""
The real name this instance will report to the server when connecting.
"""
return self._realname
@realname.setter
def realname(self, val):
if self.connected or self.connecting:
raise IOError('Cannot change while connected to server.')
self._realname = val
@property
def server(self):
"""
The server this instance is connected to, or will attempt to connect to.
"""
return self._server
@server.setter
def server(self, val):
if self.connected or self.connecting:
raise IOError('Cannot change while connected to server.')
self._server = val
@property
def user(self):
"""
The user name this instance will report to the server when connecting.
"""
return self._user
@user.setter
def user(self, val):
if self.connected or self.connecting:
raise IOError('Cannot change while connected to server.')
self._user = val
##### General Methods #####################################################
[docs] def channel(self, name):
"""
Retrieve a Channel object for the channel ``name``, or None if we're
not in that channel.
"""
return self._channels.get(name, None)
[docs] def connect(self, server=None, port=None):
"""
Connect to the server.
========= ============
Argument Description
========= ============
server The host to connect to.
port The port to connect to on the remote server.
========= ============
"""
if not self.connected and not self.connecting:
if server:
self._server = server
if port:
self._port = port
Stream.connect(self, (self._server, self._port))
##### I/O Methods #########################################################
[docs] def join(self, channel):
"""
Join the specified channel.
========= ============
Argument Description
========= ============
channel The name of the channel to join.
========= ============
"""
if channel in self._channels or channel in self._joining:
return
self._joining.append(channel)
self.send_command("JOIN", channel)
[docs] def part(self, channel, reason=None, force=False):
"""
Leave the specified channel.
========= ======== ============
Argument Default Description
========= ======== ============
channel The channel to leave.
reason None *Optional.* The reason why.
force False *Optional.* Don't ensure the client is actually *in* the named channel before sending ``PART``.
========= ======== ============
"""
if not force and not channel in self._channels:
return
args = [channel]
if reason:
args.append(reason)
self.send_command("PART", *args)
##### Public Event Handlers ###############################################
[docs] def irc_ctcp(self, nick, message, user, host):
"""
Placeholder.
This method is called when the bot receives a CTCP message, which
could, in theory, be anywhere in a PRIVMSG... annoyingly enough.
========= ============
Argument Description
========= ============
nick The nick of the user that sent the CTCP message, or an empty string if no nick is available.
message The full CTCP message.
user The username of the user that sent the CTCP message, or an empty string if no username is available.
host The host of the user that sent the CTCP message, or an empty string if no host is available.
========= ============
"""
pass
[docs] def irc_join(self, channel, nick, user, host):
"""
Placeholder.
This method is called when a user enters a channel. That also means
that this function is called whenever this IRC client successfully
joins a channel.
========= ============
Argument Description
========= ============
channel The channel a user has joined.
nick The nick of the user that joined the channel.
user The username of the user that joined the channel.
host The host of the user that joined the channel.
========= ============
"""
pass
[docs] def irc_message_channel(self, channel, message, nick, user, host):
"""
Placeholder.
This method is called when the client receives a message from a channel.
========= ============
Argument Description
========= ============
channel The channel the message was received in.
message The text of the message.
nick The nick of the user that sent the message.
user The username of the user that sent the message.
host The host of the user that sent the message.
========= ============
"""
pass
[docs] def irc_message_private(self, nick, message, user, host):
"""
Placeholder.
This method is called when the client receives a message from a user.
========= ============
Argument Description
========= ============
nick The nick of the user that sent the message.
message The text of the message.
user The username of the user that sent the message.
host The host of the user that sent the message.
========= ============
"""
pass
[docs] def irc_nick_changed(self, nick):
"""
Placeholder.
This method is called when the client's nick on the network is changed
for any reason.
========= ============
Argument Description
========= ============
nick The client's new nick.
========= ============
"""
pass
[docs] def irc_part(self, channel, reason, nick, user, host):
"""
Placeholder.
This method is called when a leaves enters a channel. That also means
that this function is called whenever this IRC client leaves a
channel.
========= ============
Argument Description
========= ============
channel The channel that the user has left.
reason The provided reason message, or an empty string if there is no message.
nick The nick of the user that left the channel.
user The username of the user that left the channel.
host The host of the user that left the channel.
========= ============
"""
pass
[docs] def irc_topic_changed(self, channel, topic):
"""
Placeholder.
This method is called when the topic of a channel changes.
========= ============
Argument Description
========= ============
channel The channel which had its topic changed.
topic The channel's new topic.
========= ============
"""
pass
##### IRC Command Handlers ################################################
def irc_command_004(self, command, args, nick, user, host):
""" 004 - Registered
Syntax:
004 server ver usermode chanmode
The 004 command is sent once we've registered successfully with the
server and can proceed to do normal IRC things.
"""
# Check our nick.
n = args[0]
if n != self._nick:
self._nick = n
self.irc_nick_changed(n)
self.irc_connect()
def irc_command_332(self, command, args, nick, user, host):
""" 332 - Channel Topic
Syntax:
332 channel :topic
"""
chan, topic = args[-2:]
if chan in self._channels:
self._channels[chan].topic = topic
self.irc_topic_changed(chan, topic)
def irc_command_333(self, command, args, nick, user, host):
""" 333 - Channel Topic (Extended)
Syntax:
333 channel nickname time
"""
chan, nickname, time = args[-3:]
if chan in self._channels:
self._channels[chan].topic_setter = nickname
try:
self._channels[chan].topic_time = int(time)
except ValueError:
pass
def irc_command_353(self, command, args, nick, user, host):
""" 353 - Users in Channel
Syntax:
353 = channel: names
"""
chan, names = args[-2:]
if chan in self._channels:
for name in names.split(' '):
while name[0] in '@+':
name = name[1:]
if not name in self._channels[chan].users:
self._channels[chan].users.append(name)
def irc_command_JOIN(self, command, args, nick, user, host):
"""
Received whenever a user, including ourself, joins a channel.
"""
chan = args[0]
if nick == self._nick:
if chan in self._joining:
self._joining.remove(chan)
if not chan in self._channels:
self._channels[chan] = Channel(chan)
if chan in self._channels:
name = nick
while name[0] in '@+':
name = name[1:]
if not name in self._channels[chan].users:
self._channels[chan].users.append(name)
self.irc_join(chan, nick, user, host)
def irc_command_PART(self, command, args, nick, user, host):
"""
Received whenever a user, including ourself, leaves a channel.
"""
chan = args[0]
if nick == self._nick:
if chan in self._joining:
self._joining.remove(chan)
if chan in self._channels:
del self._channels[chan]
if chan in self._channels:
name = nick
while name[0] in '@+':
name = name[1:]
if name in self._channels[chan].users:
self._channels[chan].users.remove(name)
if len(args) < 2:
args.append('')
self.irc_part(chan, args[1], nick, user, host)
def irc_command_PRIVMSG(self, command, args, nick, user, host):
"""
The PRIVMSG command is the heart of IRC communications. This method
will call either irc_message_channel, or irc_message_private depending
on the recipient of the privmsg.
"""
msg = args[1]
while msg:
ind = msg.find('\x01')
if ind == -1:
ind = len(msg)
if ind > 0:
message = msg[:ind]
msg = msg[ind:]
if args[0] == self._nick:
self.irc_message_private(nick, message, user, host)
else:
self.irc_message_channel(
args[0], message, nick, user, host)
if msg:
msg = msg[1:]
ind = msg.find('\x01')
if ind == -1:
continue
message = msg[:ind]
msg = msg[ind+1:]
self.irc_ctcp(nick, message, user, host)
##### Internal Event Handlers #############################################
def on_connect(self):
"""
We're connected, so send the login stuff.
"""
if self.password:
self.send_command("PASS", self.password)
self.send_command("NICK", self._nick or 'PantsIRC')
# Our user and realname.
self.send_command("USER",
self._user or 'PantsIRC',
0, '*',
self._realname or 'pants.contrib.irc'
)
# And now, we wait. Don't raise irc_connect until we get a message
# letting us know our connection was accepted.
def on_close(self):
"""
We've been disconnected.
"""
self._channels = {}
self._joining = []
self.irc_close()
###############################################################################
# Helper Functions
###############################################################################
[docs]def ctcpQuote(message):
"""
Low-level quote a message, adhering to the CTCP guidelines.
"""
return CTCP.sub(_ctcpQuoter, message)
def _ctcpQuoter(match):
m = match.group(1)
if m == '\x00':
return '\x100'
elif m == '\n':
return '\x10n'
elif m == '\r':
return '\x10r'
elif m == '\x10':
return '\x10\x10'
else:
return m
[docs]def ctcpUnquote(message):
"""
Low-level unquote a message, adhering to the CTCP guidelines.
"""
return unCTCP.sub(_ctcpUnquoter, message)
def _ctcpUnquoter(match):
m = match.group(1)
if m == '0':
return '\x00'
elif m == 'n':
return '\n'
elif m == 'r':
return '\r'
elif m == '\x10':
return '\x10'
else:
return m
def decode(data):
for codec in CODECS:
try:
return data.decode(codec)
except UnicodeDecodeError:
continue
return data.decode('utf-8', 'ignore')