Source code for hanyuu.ircbot.irclib.session

"""
A high level session object to the lower level irclib.connection module.
"""
from __future__ import unicode_literals
from __future__ import print_function
from __future__ import absolute_import

from . import utils
from . import connection
from . import dcc

from . import logger
logger = logger.getChild('session')

import time
import select
import bisect
import collections
import re
import weakref

# TODO: move this somewhere else
DEBUG = 0



#: All the high level events that we can register to.
#: Low level events that aren't on this list can be registered to as well,
#: but they will not be parsed.
high_level_events = ['connect',
                     'text',
                     'join',
                     'part',
                     'kick',
                     'quit',
                     'mode',
                     'umode',
                     'topic',
                     'invite',
                     'ctcp',
                     'ctcpreply',
                     'action',
                     'nick',
                     'raw'
                     ]

[docs]class Session: """Class that handles one or several IRC server connections. When a Session object has been instantiated, it can be used to create Connection objects that represent the IRC connections. The responsibility of the Session object is to provide a high-level event-driven framework for the connections and to keep the connections alive. It runs a select loop to poll each connection's TCP socket and hands over the sockets with incoming data for processing by the corresponding connection. It then encapsulates the low level IRC events generated by the Connection objects into higher level versions. """ def __init__(self, encoding='utf-8', handle_ctcp=True): """Constructor for :class:`Session` objects. :param encoding: The encoding that we should treat the incoming data as. :param handle_ctcp: If this is True, the Session will respond to common CTCP commands like VERSION and PING on its own. It will still generate high level events. See :meth:`process_once` for information on how to run the Session object. """ self.connections = [] self.socket_map = weakref.WeakKeyDictionary() self.delayed_commands = [] # list of tuples in the format (time, function, arguments) self.encoding = encoding self.handle_ctcp = handle_ctcp # CTCP response values #: Used to respond to CTCP VERSION messages. self.ctcp_version = "Hanyuu IRC Lib 1.3" #: Used to respond to CTCP SOURCE messages. self.ctcp_source = "https://github.com/R-a-dio/Hanyuu-sama/"
[docs] def server(self): """Creates and returns a :class:`connection.ServerConnection` object.""" c = connection.ServerConnection(self) self.connections.append(c) return c
[docs] def process_data(self, sockets): """Called when there is more data to read on connection sockets. :param sockets: A list of socket objects to be processed. .. seealso: :meth:`process_once` """ for s in sockets: self.socket_map[s].process_data()
[docs] def process_timeout(self): """This is called to process any delayed commands that are registered to the Session object. .. seealso:: :meth:`process_once` """ t = time.time() while self.delayed_commands: if t >= self.delayed_commands[0][0]: self.delayed_commands[0][1](*self.delayed_commands[0][2]) del self.delayed_commands[0] else: break
def _send_once(self): """This method will send data to the servers from the message queue at a limited rate. The default is 2500 bytes per 1.3 seconds. This value cannot currently be changed. .. warning:: This method is internal and should not be called manually. """ for c in self.connections: try: delta = time.time() - c.last_time except (AttributeError): continue c.last_time = time.time() c.send_time += delta if c.send_time >= 1.3: c.send_time = 0 c.sent_bytes = 0 while not c.message_queue.empty(): if c.sent_bytes <= 2500: message = c.message_queue.get() try: if c.ssl: c.send_raw_instant(message) else: c.send_raw_instant(message) except (AttributeError): c.reconnect() c.sent_bytes += len(message.encode('utf-8')) if DEBUG: logger.debug("TO SERVER:" + message) else: break
[docs] def process_once(self, timeout=0): """Process data from connections once. :param timeout: How long the select() call should wait if no data is available. This method should be called periodically to check and process incoming and outgoing data, if there is any. It calls :meth:`process_data`, :meth:`_send_once` and :meth:`process_timeout`. It will also examine when we last received data from the server; if it exceeds a specified time limit, the Session assumes that we have lost connection to the server and will attempt to reconnect us. If calling it manually seems boring, look at the :meth:`process_forever` method. """ sockets = map(lambda x: x._get_socket(), self.connections) sockets = filter(lambda x: x != None, sockets) if sockets: (i, o, e) = select.select(sockets, [], [], timeout) # Process incoming data self.process_data(i) else: time.sleep(timeout) _current_time = time.time() for connection in self.connections: try: _difference = _current_time - connection._last_ping except (AttributeError): continue if (_difference >= 260.0): logger.info("No data in the past 260 seconds, disconnect") connection.reconnect("Ping timeout: 260 seconds") # Send outgoing data self._send_once() # Check delayed calls self.process_timeout()
[docs] def process_forever(self, timeout=0.2): """Run an infinite loop, processing data from connections. This method repeatedly calls :meth:`process_once`. :param timeout: Parameter to pass to process_once. """ while 1: self.process_once(timeout)
[docs] def disconnect_all(self, message=""): """Disconnects all connections. :param message: The quit message to send to servers. """ for c in self.connections: c.disconnect(message)
[docs] def execute_at(self, at, function, arguments=()): """Execute a function at a specified time. :param at: Time to execute at (standard \"time_t\" time). :param function: The function to call. :param arguments: Arguments to give the function. """ self.execute_delayed(at-time.time(), function, arguments)
[docs] def execute_delayed(self, delay, function, arguments=()): """Execute a function after a specified time. :param delay: How many seconds to wait. :param function: The function to call. :param arguments: Arguments to give the function. """ bisect.insort(self.delayed_commands, (delay+time.time(), function, arguments))
[docs] def dcc(self, dcctype="chat", dccinfo=(None, 0)): """Creates and returns a :class:`dcc.DCCConnection` object. :param dcctype: "chat" for DCC CHAT connections or "raw" for DCC SEND (or other DCC types). If "chat", incoming data will be split in newline-separated chunks. If "raw", incoming data is not touched. """ c = dcc.DCCConnection(self, dcctype, dccinfo) self.connections.append(c) return c
[docs] def register_socket(self, socket, conn): """Internal method used to map the sockets on :class:`connection.Connection` to the connections themselves.""" self.socket_map[socket] = conn
def _handle_event(self, server, event): """Internal event handler. Receives events from :class:`connection.Connection` and converts them into high level events, then dispatches them to event handlers. """ # PONG any incoming PING event if event.eventtype == 'ping': self._ping_ponger(server, event) # Should we handle the common CTCP events? if self.handle_ctcp and event.eventtype == 'ctcp': try: self._ctcp_handler(server, event) except: logger.exception('Error in CTCP handler') # Preparse MODE events, we want them separate in high level if event.eventtype in ['mode', 'umode']: modes = server._parse_modes(' '.join(event.argument)) # do we have more than 1 mode? split and rehandle if len(modes) > 1: for sign, mode, param in modes: # if the parameter is empty, make it blank # otherwise the joining breaks later on if not param: param = '' new_event = connection.Event(event.eventtype, event.source, event.target, [sign+mode, param]) # Reraise the individual events as low level self._handle_event(server, new_event) # If we had to preparse, end here return # Rebuild the low level event into a high level one high_event = HighEvent.from_low_event(server, event) handlers = Session.handlers command = high_event.command channel = high_event.channel nickname = high_event.nickname message = high_event.message if channel: channel = channel.lower() if nickname: nickname = nickname.name.lower() for function, events, channels, nicks, modes, regex in handlers.values(): # command is guaranteed to exist, no need to do .lower in advance if events and command.lower() not in events: continue if channels and channel not in channels: continue if nicks and nickname not in nicks: continue if channel and nickname and modes != '': # If the triggering nick does not have any of the needed modes if not server.hasanymodes(channel, nickname, modes): # Don't trigger the handler continue if message and regex: if not regex.match(message): continue # If we get here, that means we met all the requirements for # triggering this handler try: function(high_event) except: logger.exception('Exception in IRC handler') def _remove_connection(self, connection): """Removes a connection from the connection list.""" self.connections.remove(connection) def _ping_ponger(self, connection, event): """Internal responder to PING events.""" connection._last_ping = time.time() connection.pong(event.target) def _ctcp_handler(self, server, event): """Internal handler of CTCP events. Responds to VERSION, PING, TIME and SOURCE. The attributes :attr:`self.ctcp_version` and :attr:`ctcp_source` can be used to customize the responses of their respective CTCPs. """ ctcp = event.argument[0] parameters = event.argument[1:] source = event.source if '!' in source: # Source is a userhost, we need to split it source = utils.nm_to_n(source) if ctcp == 'VERSION': server.ctcp_reply(source, 'VERSION ' + self.ctcp_version) elif ctcp == 'PING': # a ping ctcp has the caller's time in the argument ping_time = event.argument[1] server.ctcp_reply(source, 'PING ' + ping_time) elif ctcp == 'TIME': the_time = time.localtime() time_str = time.strftime("%a %b %d %Y %H:%M:%S", the_time) server.ctcp_reply(source, 'TIME ' + time_str) elif ctcp == 'SOURCE': server.ctcp_reply(source, 'SOURCE ' + self.ctcp_source) #: Global high level event handler container.
Session.handlers = {}
[docs]class HighEvent(object): """ A abstracted event of the IRC library. """ def __init__(self, server, command, nickname, channel, message): super(HighEvent, self).__init__() self.command = command self.nickname = nickname self.server = server self.channel = channel self.message = message @classmethod
[docs] def from_low_event(cls, server, low_event): """Generates a high level event from a low level one.""" command = low_event.eventtype # We supply the source and server already to reduce code repetition. # Just use it as the HighEvent constructor but with partial applied. creator = lambda *args, **kwargs: cls(server, command, *args, **kwargs) if command == 'welcome': # We treat this as a "connected" event # The name of the server we are connected to server_name = low_event.source # Our nickname - this might be different than the one we wanted! nickname = Nickname(low_event.target, nickname_only=True) # The welcome message message = low_event.argument[0] event = creator(nickname, None, message) event.command = 'connect' event.server_name = server_name return event elif command == 'nick': # A nickname change. old_nickname = Nickname(low_event.source) # We cheat here by using the original host and replacing the # name attribute with our new nickname. new_nickname = Nickname(low_event.source) new_nickname.name = low_event.target event = creator(old_nickname, None, None) event.new_nickname = new_nickname return event elif command in ["pubmsg", "pubnotice"]: # A channel message nickname = Nickname(low_event.source) channel = low_event.target message = low_event.argument[0] event = creator(nickname, channel, message) event.text_command = command event.command = 'text' return event elif command in ["privmsg", "privnotice"]: # Private message # The target is set to our own nickname in privmsg. nickname = Nickname(low_event.source) message = low_event.argument[0] event = creator(nickname, None, message) event.text_command = command event.command = 'text' return event elif command == 'ctcp': # A CTCP to us. # Same as privmsg/notice the target is our own nickname nickname = Nickname(low_event.source) # The irclib splits off the first space delimited word for us. # This is the CTCP command name ctcp = low_event.argument[0] # The things behind the command are then indexed behind it. message = ' '.join(low_event.argument[1:]) event = creator(nickname, None, message) event.ctcp = ctcp return event elif command == 'action': # ACTION CTCP are parsed differently than others (for some reason) nickname = Nickname(low_event.source) # The target is present in an ACTION # However, this may be our nick; discard in that case channel = low_event.target if not server.is_channel(channel): channel = None # Message is in the argument message = low_event.argument[0] event = creator(nickname, channel, message) return event elif command == 'ctcpreply': # A CTCP reply. # Same as privmsg/notice the target is our own nickname nickname = Nickname(low_event.source) # The irclib splits off the first space delimited word for us. # This is the CTCP command name ctcp = low_event.argument[0] # The things behind the command are then indexed behind it. message = ' '.join(low_event.argument[1:]) event = creator(nickname, None, message) event.ctcp = ctcp return event elif command == 'quit': # A quit from an user. nickname = Nickname(low_event.source) message = low_event.argument[0] return creator(nickname, None, message) elif command == 'join': # Someone joining our channel nickname = Nickname(low_event.source) channel = low_event.target return creator(nickname, channel, None) elif command == 'part': # Someone leaving our channel nickname = Nickname(low_event.source) channel = low_event.target return creator(nickname, channel, None) elif command == 'kick': # Someone forcibly leaving our channel. # The person kicking here kicker = Nickname(low_event.source) # The person being kicked target = Nickname(low_event.argument[0], nickname_only=True) # The reason given by the kicker reason = low_event.argument[1] # The channel this all went wrong in! channel = low_event.target event = creator(target, channel, reason) event.kicker = kicker return event elif command == 'invite': # Someone has invited us to a channel. # The inviter nickname = Nickname(low_event.source) # Target contains our nickname # First argument is the channel we were invited to channel = low_event.argument[0] return creator(nickname, channel, None) elif command in ['mode', 'umode']: # Mode change in the channel # The nickname that set the mode mode_setter = Nickname(low_event.source) # Simple channel channel = low_event.target # ServerConnection._parse_modes returns a list of tuples with # (operation, mode, param) # HOWEVER, we preparse the modes, so we (preferably) only want the # first one. Let's make sure we can still get all of them, though event = creator(mode_setter, channel, None) modes = server._parse_modes(' '.join(low_event.argument)) if len(modes) > 1: event.modes = modes else: event.modes = modes[0] return event elif command in ['topic', 'currenttopic', 'notopic']: # Any message that tells us what the topic is. # The channel that had its topic set. if command == 'currenttopic': channel = low_event.argument[0] else: channel = low_event.target # The person who set the topic. # If this isn't a topic command, there is no setter topic_setter = None if command == 'topic': setter = Nickname(low_event.source) # The argument contains the topic string # Treat notopic as empty string topic = '' if command == 'currenttopic': topic = ' '.join(low_event.argument[1:]) elif command == 'topic': topic = low_event.argument[0] event = creator(topic_setter, channel, topic) event.command = 'topic' return event elif command == 'all_raw_messages': # This event contains all messages, unparsed server_name = low_event.source event = creator(None, None, low_event.argument[0]) event.command = 'raw' return event # The event was not high level: thus, it's not raw, but simply unparsed # You will probably be able to register to these, but they won't have # much use event = creator(None, None, low_event.argument[0]) event.source = low_event.source event.target = low_event.target return event
[docs]class Nickname(object): """ A simple class that represents a nickname on IRC. Contains information such as actual nickname, hostmask and more. """ def __init__(self, host, nickname_only=False): """ The constructor really just expects the raw host send by IRC servers. It parses this for you into segments. if `nickname_only` is set to True it expects a bare nickname unicode object to be used as nickname and nothing more. """ super(Nickname, self).__init__() if nickname_only: self.name = host else: self.name = utils.nm_to_n(host) self.host = host
[docs]def event_handler(events, channels=[], nicks=[], modes='', regex=''): """ The decorator for high level event handlers. By decorating a function with this, the function is registered in the global :class:`Session` event handler list, :attr:`Session.handlers`. :param events: The events that the handler should subscribe to. This can be both a string and a list; if a string is provided, it will be added as a single element in a list of events. This rule applies to `channels` and `nicks` as well. :param channels: The channels that the events should trigger on. Given an empty list, all channels will trigger the event. :param nicks: The nicknames that this handler should trigger for. Given an empty list, all nicknames will trigger the event. :param modes: The required channel modes that are needed to trigger this event. If an empty mode string is specified, no modes are needed to trigger the event. :param regex: The event will only be triggered if the :attr:`HighEvent.message` matches the specified regex. If no regex is specified, any :attr:`HighEvent.message` will do. """ Handler = collections.namedtuple('Handler', ['handler', 'events', 'channels', 'nicks', 'modes', 'regex']) # If you think the type checking here is wrong, please fix it, # i have no idea what i'm doing if not isinstance(events, list): events = [events] if not isinstance(channels, list): channels = [channels] if not isinstance(nicks, list): nicks = [nicks] if not isinstance(modes, str) and not isinstance(modes, unicode): raise TypeError('invalid type for mode string: {}'.format(modes)) if not isinstance(regex, str) and not isinstance(regex, unicode): raise TypeError('invalid type for regex: {}'.format(regex)) for event in events: if not isinstance(event, str) and not isinstance(event, unicode): raise TypeError('invalid type for event name: {}'.format(event)) for channel in channels: if not isinstance(channel, str) and not isinstance(channel, unicode): raise TypeError('invalid type for channel name: {}'.format(channel)) for nick in nicks: if not isinstance(nick, str) and not isinstance(nick, unicode): raise TypeError('invalid type for nickname: {}'.format(nick)) # we don't care about cases, just lower events = map(lambda e: e.lower(), events) channels = map(lambda c: c.lower(), channels) nicks= map(lambda n: n.lower(), nicks) # Compile the regex in advance if regex != '': cregex = re.compile(regex, re.I) else: cregex = None def decorator(fn): handler = Handler(fn, events, channels, nicks, modes, cregex) Session.handlers[fn.__module__ + ":" + fn.__name__] = handler return fn return decorator

Project Versions

This Page