""" 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 collections import collections.abc import functools import hashlib import ipaddress import queue import re import socket import ssl import string import textwrap import threading import time from . import __version__, conf, selectdriver, structures, utils, world from .log import log, PyLinkChannelLogger from .utils import ProtocolError # Compatibility with PyLink 1.x __all__ = ['ChannelState', 'User', 'UserMapping', 'PyLinkNetworkCore', 'PyLinkNetworkCoreWithUtils', 'IRCNetwork', 'Server', 'Channel', 'PUIDGenerator'] QUEUE_FULL = queue.Full ### Internal classes (users, servers, channels) class ChannelState(structures.IRCCaseInsensitiveDict): """ A dictionary storing channels case insensitively. Channel objects are initialized on access. """ def __getitem__(self, key): key = self._keymangle(key) if key not in self._data: log.debug('(%s) ChannelState: creating new channel %s in memory', self._irc.name, key) self._data[key] = newchan = Channel(self._irc, key) return newchan return self._data[key] class TSObject(): """Base class for classes containing a type-normalized timestamp.""" def __init__(self, *args, **kwargs): self._ts = int(time.time()) @property def ts(self): return self._ts @ts.setter def ts(self, value): if (not isinstance(value, int)) and (not isinstance(value, float)): log.warning('TSObject: Got bad type for TS, converting from %s to int', type(value), stack_info=True) value = int(value) self._ts = value class User(TSObject): """PyLink IRC user class.""" def __init__(self, irc, nick, ts, uid, server, ident='null', host='null', realname='PyLink dummy client', realhost='null', ip='0.0.0.0', manipulatable=False, opertype='IRC Operator'): super().__init__() self._nick = nick self.lower_nick = irc.to_lower(nick) self.ts = ts self.uid = uid self.ident = ident self.host = host self.realhost = realhost self.ip = ip self.realname = realname self.modes = set() # Tracks user modes self.server = server self._irc = irc # Tracks PyLink identification status self.account = '' # Tracks oper type (for display only) self.opertype = opertype # Tracks external services identification status self.services_account = '' # Tracks channels the user is in self.channels = structures.IRCCaseInsensitiveSet(self._irc) # Tracks away message status self.away = '' # 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 # Cloaked host for IRCds that use it self.cloaked_host = None # Stores service bot name if applicable self.service = None @property def nick(self): return self._nick @nick.setter def nick(self, newnick): oldnick = self.lower_nick self._nick = newnick self.lower_nick = self._irc.to_lower(newnick) # Update the irc.users bynick index: if oldnick in self._irc.users.bynick: # Remove existing value -> key mappings. self._irc.users.bynick[oldnick].remove(self.uid) # Remove now-empty keys as well. if not self._irc.users.bynick[oldnick]: del self._irc.users.bynick[oldnick] # Update the new nick. self._irc.users.bynick.setdefault(self.lower_nick, []).append(self.uid) def get_fields(self): """ Returns all template/substitution-friendly fields for the User object in a read-only dictionary. """ fields = self.__dict__.copy() # These don't really make sense in text substitutions for field in ('manipulatable', '_irc', 'channels', 'modes'): del fields[field] # Swap SID and server name for convenience fields['sid'] = self.server try: fields['server'] = self._irc.get_friendly_name(self.server) except KeyError: pass # Keep it as is (i.e. as the SID) if grabbing the server name fails # Network name fields['netname'] = self._irc.name # Add the nick attribute; this isn't in __dict__ because it's a property fields['nick'] = self._nick return fields def __repr__(self): return 'User(%s/%s)' % (self.uid, self.nick) IrcUser = User # Bidirectional dict based off https://stackoverflow.com/a/21894086 class UserMapping(collections.abc.MutableMapping, structures.CopyWrapper): """ A mapping storing User objects by UID, as well as UIDs by nick via the 'bynick' attribute """ def __init__(self, irc, data=None): if data is not None: assert isinstance(data, dict) self._data = data else: self._data = {} self.bynick = collections.defaultdict(list) self._irc = irc def __getitem__(self, key): return self._data[key] def __setitem__(self, key, userobj): assert hasattr(userobj, 'lower_nick'), "Cannot add object without lower_nick attribute to UserMapping" if key in self._data: log.warning('(%s) Attempting to replace User object for %r: %r -> %r', self._irc.name, key, self._data.get(key), userobj) self._data[key] = userobj self.bynick.setdefault(userobj.lower_nick, []).append(key) def __delitem__(self, key): # Remove this entry from the bynick index if self[key].lower_nick in self.bynick: self.bynick[self[key].lower_nick].remove(key) if not self.bynick[self[key].lower_nick]: del self.bynick[self[key].lower_nick] del self._data[key] # Generic container methods. XXX: consider abstracting this out in structures? def __repr__(self): return "%s(%s)" % (self.__class__.__name__, self._data) def __iter__(self): return iter(self._data) def __len__(self): return len(self._data) def __contains__(self, key): return self._data.__contains__(key) def __copy__(self): return self.__class__(self._irc, data=self._data.copy()) class PyLinkNetworkCore(structures.CamelCaseToSnakeCase): """Base IRC object for PyLink.""" def __init__(self, netname): self.loghandlers = [] self.name = netname self.conf = conf.conf if not hasattr(self, 'sid'): self.sid = None # serverdata may be overridden as a property on some protocols if netname in conf.conf['servers'] and not hasattr(self, 'serverdata'): self.serverdata = conf.conf['servers'][netname] self.protoname = self.__class__.__module__.split('.')[-1] # Remove leading pylinkirc.protocols. # Protocol stuff self.casemapping = 'rfc1459' self.hook_map = {} # Lists required conf keys for the server block. self.conf_keys = {'ip', 'port', 'hostname', 'sid', 'sidrange', 'protocol', 'sendpass', 'recvpass'} # Defines a set of PyLink protocol capabilities self.protocol_caps = set() # These options depend on self.serverdata from above to be set. self.encoding = None self.connected = threading.Event() self._aborted = threading.Event() self._aborted_send = threading.Event() self._reply_lock = threading.RLock() # Sets the multiplier for autoconnect delay (grows with time). self.autoconnect_active_multiplier = 1 self.was_successful = False self._init_vars() def log_setup(self): """ Initializes any channel loggers defined for the current network. """ try: channels = conf.conf['logging']['channels'][self.name] except (KeyError, TypeError): # Not set up; just ignore. return log.debug('(%s) Setting up channel logging to channels %r', self.name, channels) # Only create handlers if they haven't already been set up. if not self.loghandlers: if not isinstance(channels, dict): log.warning('(%s) Got invalid channel logging configuration %r; are your indentation ' 'and block commenting consistent?', self.name, channels) return for channel, chandata in channels.items(): # Fetch the log level for this channel block. level = None if isinstance(chandata, dict): level = chandata.get('loglevel') else: log.warning('(%s) Got invalid channel logging pair %r: %r; are your indentation ' 'and block commenting consistent?', self.name, filename, config) handler = PyLinkChannelLogger(self, channel, level=level) self.loghandlers.append(handler) log.addHandler(handler) def _init_vars(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.encoding = self.serverdata.get('encoding') or 'utf-8' # Tracks the main PyLink client's UID. self.pseudoclient = None # Internal variable to set the place and caller of the last command (in PM # or in a channel), used by fantasy command support. self.called_by = None self.called_in = None # Intialize the server, channel, and user indexes to be populated by # our protocol module. self.servers = {} self.users = UserMapping(self) # Two versions of the channels index exist in PyLink 2.0, and they are joined together # - irc._channels which implicitly creates channels on access (mostly used # in protocol modules) # - irc.channels which does not (recommended for use by plugins) self._channels = ChannelState(self) self.channels = structures.IRCCaseInsensitiveDict(self, data=self._channels._data) # 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': 'imnpst'} self.umodes = {'invisible': 'i', 'snomask': 's', 'wallops': 'w', 'oper': 'o', '*A': '', '*B': '', '*C': '', '*D': 'iosw'} # Acting extbans such as +b m:n!u@h on InspIRCd self.extbans_acting = {} # Matching extbans such as R:account on InspIRCd and $a:account on TS6. self.extbans_matching = {} # This max nick length starts off as the config value, but may be # overwritten later by the protocol module if such information is # received. It defaults to 30. self.maxnicklen = self.serverdata.get('maxnicklen', 30) # 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()) # Set up channel logging for the network self.log_setup() def __repr__(self): return "<%s object for network %r>" % (self.__class__.__name__, self.name) ## Stubs def validate_server_conf(self): return def connect(self): raise NotImplementedError def disconnect(self): raise NotImplementedError ## General utility functions def call_hooks(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: parsed_args['ts'] = int(time.time()) hook_cmd = command hook_map = self.hook_map # If the hook name is present in the protocol module's hook_map, then we # should set the hook name to the name that points to instead. # For example, plugins will read SETHOST as CHGHOST, EOS (end of sync) # as ENDBURST, etc. if command in hook_map: hook_cmd = hook_map[command] # However, individual handlers can also return a 'parse_as' key to send # their payload to a different hook. An example of this is "/join 0" # being interpreted as leaving all channels (PART). hook_cmd = parsed_args.get('parse_as') or hook_cmd log.debug('(%s) Raw hook data: [%r, %r, %r] received from %s handler ' '(calling hook %s)', self.name, numeric, hook_cmd, parsed_args, command, hook_cmd) # Iterate over registered hook functions, catching errors accordingly. for hook_pair in world.hooks[hook_cmd].copy(): hook_func = hook_pair[1] try: log.debug('(%s) Calling hook function %s from plugin "%s"', self.name, hook_func, hook_func.__module__) retcode = hook_func(self, numeric, command, parsed_args) if retcode is False: log.debug('(%s) Stopping hook loop for %r (command=%r)', self.name, hook_func, command) break except Exception: # We don't want plugins to crash our servers... log.exception('(%s) Unhandled exception caught in hook %r from plugin "%s"', self.name, hook_func, hook_func.__module__) log.error('(%s) The offending hook data was: %s', self.name, hook_args) continue def call_command(self, source, text): """ Calls a PyLink bot command. source is the caller's UID, and text is the full, unparsed text of the message. """ world.services['pylink'].call_cmd(self, source, text) def msg(self, target, text, notice=None, source=None, loopback=True, wrap=True): """Handy function to send messages/notices to clients. Source is optional, and defaults to the main PyLink client if not specified.""" if not text: return if not (source or self.pseudoclient): # No explicit source set and our main client wasn't available; abort. return source = source or self.pseudoclient.uid def _msg(text): if notice: self.notice(source, target, text) cmd = 'PYLINK_SELF_NOTICE' else: self.message(source, target, text) cmd = 'PYLINK_SELF_PRIVMSG' # Determines whether we should send a hook for this msg(), to forward things like services # replies across relay. if loopback: self.call_hooks([source, cmd, {'target': target, 'text': text}]) # Optionally wrap the text output. if wrap: for line in self.wrap_message(source, target, text): _msg(line) else: _msg(text) def _reply(self, text, notice=None, source=None, private=None, force_privmsg_in_private=False, loopback=True, wrap=True): """ Core of the reply() function - replies to the last caller in the right context (channel or PM). """ if private is None: # Allow using private replies as the default, if no explicit setting was given. private = conf.conf['pylink'].get("prefer_private_replies") # Private reply is enabled, or the caller was originally a PM if private or (self.called_in in self.users): if not force_privmsg_in_private: # For private replies, the default is to override the notice=True/False argument, # and send replies as notices regardless. This is standard behaviour for most # IRC services, but can be disabled if force_privmsg_in_private is given. notice = True target = self.called_by else: target = self.called_in self.msg(target, text, notice=notice, source=source, loopback=loopback, wrap=wrap) def reply(self, *args, **kwargs): """ Replies to the last caller in the right context (channel or PM). This function wraps around _reply() and can be monkey-patched in a thread-safe manner to temporarily redirect plugin output to another target. """ with self._reply_lock: self._reply(*args, **kwargs) def error(self, text, **kwargs): """Replies with an error to the last caller in the right context (channel or PM).""" # This is a stub to alias error to reply self.reply("Error: %s" % text, **kwargs) ## Configuration-based lookup functions. def version(self): """ Returns a detailed version string including the PyLink daemon version, the protocol module in use, and the server hostname. """ fullversion = 'PyLink-%s. %s :[protocol:%s, encoding:%s]' % (__version__, self.hostname(), self.protoname, self.encoding) return fullversion def hostname(self): """ Returns the server hostname used by PyLink on the given server. """ return self.serverdata.get('hostname', world.fallback_hostname) def get_full_network_name(self): """ Returns the full network name (as defined by the "netname" option), or the short network name if that isn't defined. """ return self.serverdata.get('netname', self.name) def get_service_option(self, servicename, option, default=None, global_option=None): """ Returns the value of the requested service bot option on the current network, or the global value if it is not set for this network. This function queries and returns: 1) If present, the value of the config option servers::::_