From e4fb64aebaf542122c70a8f3a49061386a00b0ca Mon Sep 17 00:00:00 2001 From: James Lu Date: Sun, 6 Dec 2015 17:40:13 -0800 Subject: [PATCH] Document more parts of the core - comments are cool right --- classes.py | 202 +++++++++++++++++++++++++++++++++++--------------- conf.py | 15 ++++ coreplugin.py | 4 +- log.py | 8 ++ utils.py | 7 ++ world.py | 4 +- 6 files changed, 179 insertions(+), 61 deletions(-) diff --git a/classes.py b/classes.py index 41d26b2..ed60716 100644 --- a/classes.py +++ b/classes.py @@ -1,3 +1,12 @@ +""" +classes.py - Base classes for PyLink IRC Services. + +This module contains the base classes used by PyLink, including threaded IRC +connections and objects used to represent IRC servers, users, and channels. + +Here be dragons. +""" + import threading from random import randint import time @@ -20,55 +29,14 @@ class ProtocolError(Exception): ### Internal classes (users, servers, channels) class Irc(): - def initVars(self): - self.sid = self.serverdata["sid"] - self.botdata = self.conf['bot'] - self.pingfreq = self.serverdata.get('pingfreq') or 30 - self.pingtimeout = self.pingfreq * 2 - - self.connected.clear() - self.aborted.clear() - self.pseudoclient = None - self.lastping = time.time() - - # Internal variable to set the place the last command was called (in PM - # or in a channel), used by fantasy command support. - self.called_by = None - - # Server, channel, and user indexes to be populated by our protocol module - self.servers = {self.sid: IrcServer(None, self.serverdata['hostname'], - internal=True, desc=self.serverdata.get('serverdesc') - or self.botdata['serverdesc'])} - self.users = {} - self.channels = defaultdict(IrcChannel) - # Sets flags such as whether to use halfops, etc. The default RFC1459 - # modes are implied. - self.cmodes = {'op': 'o', 'secret': 's', 'private': 'p', - 'noextmsg': 'n', 'moderated': 'm', 'inviteonly': 'i', - 'topiclock': 't', 'limit': 'l', 'ban': 'b', - 'voice': 'v', 'key': 'k', - # Type A, B, and C modes - '*A': 'b', - '*B': 'k', - '*C': 'l', - '*D': 'imnpstr'} - self.umodes = {'invisible': 'i', 'snomask': 's', 'wallops': 'w', - 'oper': 'o', - '*A': '', '*B': '', '*C': 's', '*D': 'iow'} - - # This max nick length starts off as the config value, but may be - # overwritten later by the protocol module if such information is - # received. Note that only some IRCds (InspIRCd) give us nick length - # during link, so it is still required that the config value be set! - self.maxnicklen = self.serverdata['maxnicklen'] - self.prefixmodes = {'o': '@', 'v': '+'} - - # Uplink SID (filled in by protocol module) - self.uplink = None - self.start_ts = int(time.time()) + """Base IRC object for PyLink.""" def __init__(self, netname, proto, conf): - # Initialize some variables + """ + Initializes an IRC object. This takes 3 variables: the network name + (a string), the name of the protocol module to use for this connection, + and a configuration object. + """ self.name = netname.lower() self.conf = conf self.serverdata = conf['servers'][netname] @@ -92,19 +60,99 @@ class Irc(): self.connection_thread.start() self.pingTimer = None + def initVars(self): + """ + (Re)sets an IRC object to its default state. This should be called when + an IRC object is first created, and on every reconnection to a network. + """ + self.sid = self.serverdata["sid"] + self.botdata = self.conf['bot'] + self.pingfreq = self.serverdata.get('pingfreq') or 30 + self.pingtimeout = self.pingfreq * 2 + + self.connected.clear() + self.aborted.clear() + self.pseudoclient = None + self.lastping = time.time() + + # Internal variable to set the place the last command was called (in PM + # or in a channel), used by fantasy command support. + self.called_by = None + + # Intialize the server, channel, and user indexes to be populated by + # our protocol module. For the server index, we can add ourselves right + # now. + self.servers = {self.sid: IrcServer(None, self.serverdata['hostname'], + internal=True, desc=self.serverdata.get('serverdesc') + or self.botdata['serverdesc'])} + self.users = {} + self.channels = defaultdict(IrcChannel) + + # This sets the list of supported channel and user modes: the default + # RFC1459 modes are implied. Named modes are used here to make + # protocol-independent code easier to write, as mode chars vary by + # IRCd. + # Protocol modules should add to and/or replace this with what their + # protocol supports. This can be a hardcoded list or something + # negotiated on connect, depending on the nature of their protocol. + self.cmodes = {'op': 'o', 'secret': 's', 'private': 'p', + 'noextmsg': 'n', 'moderated': 'm', 'inviteonly': 'i', + 'topiclock': 't', 'limit': 'l', 'ban': 'b', + 'voice': 'v', 'key': 'k', + # This fills in the type of mode each mode character is. + # A-type modes are list modes (i.e. bans, ban exceptions, etc.), + # B-type modes require an argument to both set and unset, + # but there can only be one value at a time + # (i.e. cmode +k). + # C-type modes require an argument to set but not to unset + # (one sets "+l limit" and # "-l"), + # and D-type modes take no arguments at all. + '*A': 'b', + '*B': 'k', + '*C': 'l', + '*D': 'imnpstr'} + self.umodes = {'invisible': 'i', 'snomask': 's', 'wallops': 'w', + 'oper': 'o', + '*A': '', '*B': '', '*C': 's', '*D': 'iow'} + + # This max nick length starts off as the config value, but may be + # overwritten later by the protocol module if such information is + # received. Note that only some IRCds (InspIRCd) give us nick length + # during link, so it is still required that the config value be set! + self.maxnicklen = self.serverdata['maxnicklen'] + + # Defines a list of supported prefix modes. + self.prefixmodes = {'o': '@', 'v': '+'} + + # Defines the uplink SID (to be filled in by protocol module). + self.uplink = None + self.start_ts = int(time.time()) + def connect(self): + """ + Runs the connect loop for the IRC object. This is usually called by + __init__ in a separate thread to allow multiple concurrent connections. + """ while True: self.initVars() ip = self.serverdata["ip"] port = self.serverdata["port"] checks_ok = True try: + # Set the socket type (IPv6 or IPv4). stype = socket.AF_INET6 if self.serverdata.get("ipv6") else socket.AF_INET + + # Creat the socket. self.socket = socket.socket(stype) self.socket.setblocking(0) - # Initial connection timeout is a lot smaller than the timeout after - # we've connected; this is intentional. + + # Set the connection timeouts. Initial connection timeout is a + # lot smaller than the timeout after we've connected; this is + # intentional. self.socket.settimeout(self.pingfreq) + + # Enable SSL if set to do so. This requires a valid keyfile and + # certfile to be present. self.ssl = self.serverdata.get('ssl') if self.ssl: log.info('(%s) Attempting SSL for this connection...', self.name) @@ -122,20 +170,26 @@ class Irc(): '"ssl_keyfile" set correctly?', self.name) checks_ok = False - else: + else: # SSL was misconfigured, abort. log.error('(%s) SSL certfile/keyfile was not set ' 'correctly, aborting... ', self.name) checks_ok = False + log.info("Connecting to network %r on %s:%s", self.name, ip, port) self.socket.connect((ip, port)) self.socket.settimeout(self.pingtimeout) + # If SSL was enabled, optionally verify the certificate + # fingerprint for some added security. I don't bother to check + # the entire certificate for validity, since most IRC networks + # self-sign their certificates anyways. if self.ssl and checks_ok: peercert = self.socket.getpeercert(binary_form=True) sha1fp = hashlib.sha1(peercert).hexdigest() expected_fp = self.serverdata.get('ssl_fingerprint') if expected_fp: if sha1fp != expected_fp: + # SSL Fingerprint doesn't match; break. log.error('(%s) Uplink\'s SSL certificate ' 'fingerprint (SHA1) does not match the ' 'one configured: expected %r, got %r; ' @@ -153,21 +207,30 @@ class Irc(): sha1fp) if checks_ok: + # All our checks passed, get the protocol module to connect + # and run the listen loop. self.proto.connect() self.spawnMain() log.info('(%s) Starting ping schedulers....', self.name) self.schedulePing() log.info('(%s) Server ready; listening for data.', self.name) self.run() - else: + else: # Configuration error :( log.error('(%s) A configuration error was encountered ' 'trying to set up this connection. Please check' ' your configuration file and try again.', self.name) except (socket.error, ProtocolError, ConnectionError) as e: + # self.run() or the protocol module it called raised an + # exception, meaning we've disconnected! log.warning('(%s) Disconnected from IRC: %s: %s', self.name, type(e).__name__, str(e)) + + # The run() loop above was broken, meaning we've disconnected. self._disconnect() + + # If autoconnect is enabled, loop back to the start. Otherwise, + # return and stop. autoconnect = self.serverdata.get('autoconnect') log.debug('(%s) Autoconnect delay set to %s seconds.', self.name, autoconnect) if autoconnect is not None and autoconnect >= 1: @@ -178,7 +241,10 @@ class Irc(): return def callCommand(self, source, text): - """Calls a PyLink bot command.""" + """ + Calls a PyLink bot command. source is the caller's UID, and text is the + full, unparsed text of the message. + """ cmd_args = text.strip().split(' ') cmd = cmd_args[0].lower() cmd_args = cmd_args[1:] @@ -209,10 +275,11 @@ class Irc(): self.callHooks([source, cmd, {'target': target, 'text': text}]) def reply(self, text, notice=False, source=None): - """Replies to the last caller in context.""" + """Replies to the last caller in the right context (channel or PM).""" self.msg(self.called_by, text, notice=notice, source=source) def _disconnect(self): + """Handle disconnects from the remote server.""" log.debug('(%s) Canceling pingTimer at %s due to _disconnect() call', self.name, time.time()) self.connected.clear() try: @@ -225,9 +292,12 @@ class Irc(): def disconnect(self): """Closes the IRC connection.""" - self.aborted.set() + self.aborted.set() # This will cause run() to abort. def run(self): + """Main IRC loop which listens for messages.""" + # Some magic below cause this to work, though anything that's + # not encoded in UTF-8 doesn't work very well. buf = b"" data = b"" while not self.aborted.is_set(): @@ -265,6 +335,7 @@ class Irc(): self.callHooks(hook_args) def callHooks(self, hook_args): + """Calls a hook function with the given hook args.""" numeric, command, parsed_args = hook_args # Always make sure TS is sent. if 'ts' not in parsed_args: @@ -294,6 +365,7 @@ class Irc(): continue def send(self, data): + """Sends raw text to the uplink server.""" # Safeguard against newlines in input!! Otherwise, each line gets # treated as a separate command, which is particularly nasty. data = data.replace('\n', ' ') @@ -306,6 +378,7 @@ class Irc(): log.debug("(%s) Dropping message %r; network isn't connected!", self.name, stripped_data) def schedulePing(self): + """Schedules periodic pings in a loop.""" self.proto.pingServer() self.pingTimer = threading.Timer(self.pingfreq, self.schedulePing) self.pingTimer.daemon = True @@ -313,6 +386,7 @@ class Irc(): log.debug('(%s) Ping scheduled at %s', self.name, time.time()) def spawnMain(self): + """Spawns the main PyLink client.""" nick = self.botdata.get('nick') or 'PyLink' ident = self.botdata.get('ident') or 'pylink' host = self.serverdata["hostname"] @@ -331,6 +405,7 @@ class Irc(): return "" % self.name class IrcUser(): + """PyLink IRC user class.""" def __init__(self, nick, ts, uid, ident='null', host='null', realname='PyLink dummy client', realhost='null', ip='0.0.0.0', manipulatable=False): @@ -348,16 +423,17 @@ class IrcUser(): self.channels = set() self.away = '' - # Whether the client should be marked as manipulatable - # (i.e. we are allowed to play with it using bots.py's commands). - # For internal services clients, this should always be False. + # This sets whether the client should be marked as manipulatable. + # Plugins like bots.py's commands should take caution against + # manipulating these "protected" clients, to prevent desyncs and such. + # For "serious" service clients, this should always be False. self.manipulatable = manipulatable def __repr__(self): return repr(self.__dict__) class IrcServer(): - """PyLink IRC Server class. + """PyLink IRC server class. uplink: The SID of this IrcServer instance's uplink. This is set to None for the main PyLink PseudoServer! @@ -374,29 +450,37 @@ class IrcServer(): return repr(self.__dict__) class IrcChannel(): + """PyLink IRC channel class.""" def __init__(self): + # Initialize variables, such as the topic, user list, TS, who's opped, etc. self.users = set() self.modes = {('n', None), ('t', None)} self.topic = '' self.ts = int(time.time()) - self.topicset = False self.prefixmodes = {'ops': set(), 'halfops': set(), 'voices': set(), 'owners': set(), 'admins': set()} + # Determines whether a topic has been set here or not. Protocol modules + # should set this. + self.topicset = False + def __repr__(self): return repr(self.__dict__) def removeuser(self, target): + """Removes a user from a channel.""" for s in self.prefixmodes.values(): s.discard(target) self.users.discard(target) def deepcopy(self): + """Returns a deep copy of the channel object.""" return deepcopy(self) ### FakeIRC classes, used for test cases class FakeIRC(Irc): + """Fake IRC object used for unit tests.""" def connect(self): self.messages = [] self.hookargs = [] diff --git a/conf.py b/conf.py index 2a6f89a..35007ae 100644 --- a/conf.py +++ b/conf.py @@ -1,3 +1,18 @@ +""" +conf.py - PyLink configuration core. + +This module is used to access the complete configuration for the current +PyLink instance. It will load the config on first import, taking the +configuration file name from the first command-line argument, but defaulting +to 'config.yml' if this isn't given. + +If world.testing is set to True, it will return a preset testing configuration +instead. + +This module also provides simple checks for validating and loading YAML-format +configurations from arbitrary files. +""" + import yaml import sys from collections import defaultdict diff --git a/coreplugin.py b/coreplugin.py index 078d6b4..3e6286e 100644 --- a/coreplugin.py +++ b/coreplugin.py @@ -1,4 +1,6 @@ -# coreplugin.py - Implements core PyLink functions as a plugin +""" +coreplugin.py - Implements core PyLink functions as a plugin. +""" import gc import sys diff --git a/log.py b/log.py index 67a84d7..f36ee1d 100644 --- a/log.py +++ b/log.py @@ -1,3 +1,11 @@ +""" +log.py - PyLink logging module. + +This module contains the logging portion of the PyLink framework. Plugins can +access the global logger object by importing "log" from this module +(from log import log). +""" + import logging import sys import os diff --git a/utils.py b/utils.py index 7df6511..0c171ea 100644 --- a/utils.py +++ b/utils.py @@ -1,3 +1,10 @@ +""" +utils.py - PyLink utilities module. + +This module contains various utility functions related to IRC and/or the PyLink +framework. +""" + import string import re import inspect diff --git a/world.py b/world.py index fde2be9..abfcb91 100644 --- a/world.py +++ b/world.py @@ -1,4 +1,6 @@ -# world.py: global state variables go here +""" +world.py: Stores global state variables for PyLink. +""" from collections import defaultdict import threading