diff --git a/classes.py b/classes.py index 0b7b8dc..dcd02c5 100644 --- a/classes.py +++ b/classes.py @@ -1,9 +1,263 @@ import threading from random import randint +import time +import socket +import threading +import ssl +from collections import defaultdict +import hashlib from log import log -import main -import time +from conf import conf +import world + +### Exceptions + +class ProtocolError(Exception): + pass + +### Internal classes (users, servers, channels) + +class Irc(): + def initVars(self): + self.pseudoclient = None + self.connected = threading.Event() + self.lastping = time.time() + # Server, channel, and user indexes to be populated by our protocol module + self.servers = {self.sid: IrcServer(None, self.serverdata['hostname'], internal=True)} + 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()) + + # UID generators, for servers that need it + self.uidgen = {} + + def __init__(self, netname, proto): + # Initialize some variables + self.name = netname.lower() + self.conf = conf + self.serverdata = conf['servers'][netname] + self.sid = self.serverdata["sid"] + self.botdata = conf['bot'] + self.proto = proto + self.pingfreq = self.serverdata.get('pingfreq') or 30 + self.pingtimeout = self.pingfreq * 2 + + self.initVars() + + if world.testing: + # HACK: Don't thread if we're running tests. + self.connect() + else: + self.connection_thread = threading.Thread(target = self.connect) + self.connection_thread.start() + self.pingTimer = None + + def connect(self): + ip = self.serverdata["ip"] + port = self.serverdata["port"] + while True: + self.initVars() + checks_ok = True + try: + self.socket = socket.socket() + self.socket.setblocking(0) + # Initial connection timeout is a lot smaller than the timeout after + # we've connected; this is intentional. + self.socket.settimeout(self.pingfreq) + self.ssl = self.serverdata.get('ssl') + if self.ssl: + log.info('(%s) Attempting SSL for this connection...', self.name) + certfile = self.serverdata.get('ssl_certfile') + keyfile = self.serverdata.get('ssl_keyfile') + if certfile and keyfile: + try: + self.socket = ssl.wrap_socket(self.socket, + certfile=certfile, + keyfile=keyfile) + except OSError: + log.exception('(%s) Caught OSError trying to ' + 'initialize the SSL connection; ' + 'are "ssl_certfile" and ' + '"ssl_keyfile" set correctly?', + self.name) + checks_ok = False + else: + 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 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: + log.error('(%s) Uplink\'s SSL certificate ' + 'fingerprint (SHA1) does not match the ' + 'one configured: expected %r, got %r; ' + 'disconnecting...', self.name, + expected_fp, sha1fp) + checks_ok = False + else: + log.info('(%s) Uplink SSL certificate fingerprint ' + '(SHA1) verified: %r', self.name, sha1fp) + else: + log.info('(%s) Uplink\'s SSL certificate fingerprint ' + 'is %r. You can enhance the security of your ' + 'link by specifying this in a "ssl_fingerprint"' + ' option in your server block.', self.name, + sha1fp) + + if checks_ok: + self.proto.connect(self) + 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: + 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: + log.warning('(%s) Disconnected from IRC: %s: %s', + self.name, type(e).__name__, str(e)) + self.disconnect() + autoconnect = self.serverdata.get('autoconnect') + log.debug('(%s) Autoconnect delay set to %s seconds.', self.name, autoconnect) + if autoconnect is not None and autoconnect >= 0: + log.info('(%s) Going to auto-reconnect in %s seconds.', self.name, autoconnect) + time.sleep(autoconnect) + else: + return + + def disconnect(self): + log.debug('(%s) Canceling pingTimer at %s due to disconnect() call', self.name, time.time()) + self.connected.clear() + try: + self.socket.close() + self.pingTimer.cancel() + except: # Socket timed out during creation; ignore + pass + # Internal hook signifying that a network has disconnected. + self.callHooks([None, 'PYLINK_DISCONNECT', {}]) + + def run(self): + buf = b"" + data = b"" + while True: + data = self.socket.recv(2048) + buf += data + if self.connected.is_set() and not data: + log.warning('(%s) No data received and self.connected is set; disconnecting!', self.name) + return + elif (time.time() - self.lastping) > self.pingtimeout: + log.warning('(%s) Connection timed out.', self.name) + return + while b'\n' in buf: + line, buf = buf.split(b'\n', 1) + line = line.strip(b'\r') + # TODO: respect other encodings? + line = line.decode("utf-8", "replace") + log.debug("(%s) <- %s", self.name, line) + hook_args = None + try: + hook_args = self.proto.handle_events(self, line) + except Exception: + log.exception('(%s) Caught error in handle_events, disconnecting!', self.name) + return + # Only call our hooks if there's data to process. Handlers that support + # hooks will return a dict of parsed arguments, which can be passed on + # to plugins and the like. For example, the JOIN handler will return + # something like: {'channel': '#whatever', 'users': ['UID1', 'UID2', + # 'UID3']}, etc. + if hook_args is not None: + self.callHooks(hook_args) + + def callHooks(self, 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.proto.hook_map + # Handlers can 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). + if command in hook_map: + hook_cmd = hook_map[command] + hook_cmd = parsed_args.get('parse_as') or hook_cmd + log.debug('Parsed args %r received from %s handler (calling hook %s)', parsed_args, command, hook_cmd) + # Iterate over hooked functions, catching errors accordingly + for hook_func in world.command_hooks[hook_cmd]: + try: + log.debug('Calling function %s', hook_func) + hook_func(self, numeric, command, parsed_args) + except Exception: + # We don't want plugins to crash our servers... + log.exception('Unhandled exception caught in %r' % hook_func) + continue + + def send(self, data): + # Safeguard against newlines in input!! Otherwise, each line gets + # treated as a separate command, which is particularly nasty. + data = data.replace('\n', ' ') + data = data.encode("utf-8") + b"\n" + stripped_data = data.decode("utf-8").strip("\n") + log.debug("(%s) -> %s", self.name, stripped_data) + try: + self.socket.send(data) + except (OSError, AttributeError): + log.debug("(%s) Dropping message %r; network isn't connected!", self.name, stripped_data) + + def schedulePing(self): + self.proto.pingServer(self) + self.pingTimer = threading.Timer(self.pingfreq, self.schedulePing) + self.pingTimer.daemon = True + self.pingTimer.start() + log.debug('(%s) Ping scheduled at %s', self.name, time.time()) + + def spawnMain(self): + nick = self.botdata.get('nick') or 'PyLink' + ident = self.botdata.get('ident') or 'pylink' + host = self.serverdata["hostname"] + log.info('(%s) Connected! Spawning main client %s.', self.name, nick) + olduserobj = self.pseudoclient + self.pseudoclient = self.proto.spawnClient(self, nick, ident, host, modes={("+o", None)}) + for chan in self.serverdata['channels']: + self.proto.joinClient(self, self.pseudoclient.uid, chan) + # PyLink internal hook called when spawnMain is called and the + # contents of Irc().pseudoclient change. + self.callHooks([self.sid, 'PYLINK_SPAWNMAIN', {'olduser': olduserobj}]) class IrcUser(): def __init__(self, nick, ts, uid, ident='null', host='null', @@ -36,7 +290,7 @@ class IrcServer(): """ def __init__(self, uplink, name, internal=False): self.uplink = uplink - self.users = [] + self.users = set() self.internal = internal self.name = name.lower() def __repr__(self): @@ -45,7 +299,7 @@ class IrcServer(): class IrcChannel(): def __init__(self): self.users = set() - self.modes = set() + self.modes = {('n', None), ('t', None)} self.topic = '' self.ts = int(time.time()) self.topicset = False @@ -60,33 +314,9 @@ class IrcChannel(): s.discard(target) self.users.discard(target) -class ProtocolError(Exception): - pass +### FakeIRC classes, used for test cases -global testconf -testconf = {'bot': - { - 'nick': 'PyLink', - 'user': 'pylink', - 'realname': 'PyLink Service Client', - 'loglevel': 'DEBUG', - }, - 'servers': - {'unittest': - { - 'ip': '0.0.0.0', - 'port': 7000, - 'recvpass': "abcd", - 'sendpass': "abcd", - 'protocol': "null", - 'hostname': "pylink.unittest", - 'sid': "9PY", - 'channels': ["#pylink"], - }, - }, - } - -class FakeIRC(main.Irc): +class FakeIRC(Irc): def connect(self): self.messages = [] self.hookargs = [] @@ -100,7 +330,10 @@ class FakeIRC(main.Irc): def run(self, data): """Queues a message to the fake IRC server.""" log.debug('<- ' + data) - self.proto.handle_events(self, data) + hook_args = self.proto.handle_events(self, data) + if hook_args is not None: + self.hookmsgs.append(hook_args) + self.callHooks(hook_args) def send(self, data): self.messages.append(data) @@ -132,13 +365,13 @@ class FakeIRC(main.Irc): self.hookmsgs = [] return hookmsgs - @staticmethod - def dummyhook(irc, source, command, parsed_args): - """Dummy function to bind to hooks.""" - irc.hookmsgs.append(parsed_args) - class FakeProto(): """Dummy protocol module for testing purposes.""" + def __init__(self): + self.hook_map = {} + self.casemapping = 'rfc1459' + self.__name__ = 'FakeProto' + @staticmethod def handle_events(irc, data): pass diff --git a/conf.py b/conf.py index 8a102b5..9ee2d81 100644 --- a/conf.py +++ b/conf.py @@ -1,19 +1,53 @@ import yaml import sys +from collections import defaultdict -global confname -try: - # Get the config name from the command line, falling back to config.yml - # if not given. - fname = sys.argv[1] - confname = fname.split('.', 1)[0] -except IndexError: - # confname is used for logging and PID writing, so that each - # instance uses its own files. fname is the actual name of the file - # we load. - confname = 'pylink' - fname = 'config.yml' +import world -with open(fname, 'r') as f: - global conf - conf = yaml.load(f) +global testconf +testconf = {'bot': + { + 'nick': 'PyLink', + 'user': 'pylink', + 'realname': 'PyLink Service Client', + # Suppress logging in the test output for the most part. + 'loglevel': 'CRITICAL', + 'serverdesc': 'PyLink unit tests' + }, + 'servers': + # Wildcard defaultdict! This means that + # any network name you try will work and return + # this basic template: + defaultdict(lambda: { + 'ip': '0.0.0.0', + 'port': 7000, + 'recvpass': "abcd", + 'sendpass': "chucknorris", + 'protocol': "null", + 'hostname': "pylink.unittest", + 'sid': "9PY", + 'channels': ["#pylink"], + 'maxnicklen': 20 + }) + } +if world.testing: + conf = testconf + confname = 'testconf' +else: + try: + # Get the config name from the command line, falling back to config.yml + # if not given. + fname = sys.argv[1] + confname = fname.split('.', 1)[0] + except IndexError: + # confname is used for logging and PID writing, so that each + # instance uses its own files. fname is the actual name of the file + # we load. + confname = 'pylink' + fname = 'config.yml' + with open(fname, 'r') as f: + try: + conf = yaml.load(f) + except Exception as e: + print('ERROR: Failed to load config from %r: %s: %s' % (fname, type(e).__name__, e)) + sys.exit(4) diff --git a/coreplugin.py b/coreplugin.py index 5a04eda..0a01d9c 100644 --- a/coreplugin.py +++ b/coreplugin.py @@ -2,6 +2,7 @@ import utils from log import log +import world # Handle KILLs sent to the PyLink client and respawn def handle_kill(irc, source, command, args): @@ -24,18 +25,17 @@ def handle_commands(irc, source, command, args): cmd_args = text.split(' ') cmd = cmd_args[0].lower() cmd_args = cmd_args[1:] - try: - func = utils.bot_commands[cmd] - except KeyError: - utils.msg(irc, source, 'Unknown command %r.' % cmd) - return - try: - log.info('(%s) Calling command %r for %s', irc.name, cmd, utils.getHostmask(irc, source)) - func(irc, source, cmd_args) - except Exception as e: - log.exception('Unhandled exception caught in command %r', cmd) - utils.msg(irc, source, 'Uncaught exception in command %r: %s: %s' % (cmd, type(e).__name__, str(e))) + if cmd not in world.bot_commands: + utils.msg(irc, source, 'Error: Unknown command %r.' % cmd) return + log.info('(%s) Calling command %r for %s', irc.name, cmd, utils.getHostmask(irc, source)) + for func in world.bot_commands[cmd]: + try: + func(irc, source, cmd_args) + except Exception as e: + log.exception('Unhandled exception caught in command %r', cmd) + utils.msg(irc, source, 'Uncaught exception in command %r: %s: %s' % (cmd, type(e).__name__, str(e))) + return utils.add_hook(handle_commands, 'PRIVMSG') # Handle WHOIS queries, for IRCds that send them across servers (charybdis, UnrealIRCd; NOT InspIRCd). @@ -75,7 +75,13 @@ def handle_whois(irc, source, command, args): # 313: sends a string denoting the target's operator privilege, # only if they have umode +o. if ('o', None) in user.modes: - f(irc, server, 313, source, "%s :is an IRC Operator" % nick) + if hasattr(user, 'opertype'): + opertype = user.opertype.replace("_", " ") + else: + opertype = "IRC Operator" + # Let's be gramatically correct. + n = 'n' if opertype[0].lower() in 'aeiou' else '' + f(irc, server, 313, source, "%s :is a%s %s" % (nick, n, opertype)) # 379: RPL_WHOISMODES, used by UnrealIRCd and InspIRCd. # Only show this to opers! if sourceisOper: @@ -84,7 +90,7 @@ def handle_whois(irc, source, command, args): # idle time, so we simply return 0. # <- 317 GL GL 15 1437632859 :seconds idle, signon time f(irc, server, 317, source, "%s 0 %s :seconds idle, signon time" % (nick, user.ts)) - for func in utils.whois_handlers: + for func in world.whois_handlers: # Iterate over custom plugin WHOIS handlers. They return a tuple # or list with two arguments: the numeric, and the text to send. try: diff --git a/main.py b/main.py index 776cc72..3b99c8f 100755 --- a/main.py +++ b/main.py @@ -2,258 +2,17 @@ import imp import os -import socket -import time import sys -from collections import defaultdict -import threading -import ssl -import hashlib -from log import log +# This must be done before conf imports, so we get the real conf instead of testing one. +import world +world.testing = False + import conf +from log import log import classes -import utils import coreplugin -class Irc(): - - def initVars(self): - self.pseudoclient = None - self.connected = threading.Event() - self.lastping = time.time() - # Server, channel, and user indexes to be populated by our protocol module - self.servers = {self.sid: classes.IrcServer(None, self.serverdata['hostname'], internal=True)} - self.users = {} - self.channels = defaultdict(classes.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()) - - # UID generators, for servers that need it - self.uidgen = {} - - def __init__(self, netname, proto, conf): - # Initialize some variables - self.name = netname.lower() - self.conf = conf - self.serverdata = conf['servers'][netname] - self.sid = self.serverdata["sid"] - self.botdata = conf['bot'] - self.proto = proto - self.pingfreq = self.serverdata.get('pingfreq') or 30 - self.pingtimeout = self.pingfreq * 2 - - self.initVars() - - self.connection_thread = threading.Thread(target = self.connect) - self.connection_thread.start() - self.pingTimer = None - - def connect(self): - ip = self.serverdata["ip"] - port = self.serverdata["port"] - while True: - self.initVars() - checks_ok = True - try: - self.socket = socket.socket() - self.socket.setblocking(0) - # Initial connection timeout is a lot smaller than the timeout after - # we've connected; this is intentional. - self.socket.settimeout(self.pingfreq) - self.ssl = self.serverdata.get('ssl') - if self.ssl: - log.info('(%s) Attempting SSL for this connection...', self.name) - certfile = self.serverdata.get('ssl_certfile') - keyfile = self.serverdata.get('ssl_keyfile') - if certfile and keyfile: - try: - self.socket = ssl.wrap_socket(self.socket, - certfile=certfile, - keyfile=keyfile) - except OSError: - log.exception('(%s) Caught OSError trying to ' - 'initialize the SSL connection; ' - 'are "ssl_certfile" and ' - '"ssl_keyfile" set correctly?', - self.name) - checks_ok = False - else: - 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 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: - log.error('(%s) Uplink\'s SSL certificate ' - 'fingerprint (SHA1) does not match the ' - 'one configured: expected %r, got %r; ' - 'disconnecting...', self.name, - expected_fp, sha1fp) - checks_ok = False - else: - log.info('(%s) Uplink SSL certificate fingerprint ' - '(SHA1) verified: %r', self.name, sha1fp) - else: - log.info('(%s) Uplink\'s SSL certificate fingerprint ' - 'is %r. You can enhance the security of your ' - 'link by specifying this in a "ssl_fingerprint"' - ' option in your server block.', self.name, - sha1fp) - - if checks_ok: - self.proto.connect(self) - 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: - 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, classes.ProtocolError, ConnectionError) as e: - log.warning('(%s) Disconnected from IRC: %s: %s', - self.name, type(e).__name__, str(e)) - self.disconnect() - autoconnect = self.serverdata.get('autoconnect') - log.debug('(%s) Autoconnect delay set to %s seconds.', self.name, autoconnect) - if autoconnect is not None and autoconnect >= 0: - log.info('(%s) Going to auto-reconnect in %s seconds.', self.name, autoconnect) - time.sleep(autoconnect) - else: - return - - def disconnect(self): - log.debug('(%s) Canceling pingTimer at %s due to disconnect() call', self.name, time.time()) - self.connected.clear() - try: - self.socket.close() - self.pingTimer.cancel() - except: # Socket timed out during creation; ignore - pass - # Internal hook signifying that a network has disconnected. - self.callHooks([None, 'PYLINK_DISCONNECT', {}]) - - def run(self): - buf = b"" - data = b"" - while True: - data = self.socket.recv(2048) - buf += data - if self.connected.is_set() and not data: - log.warning('(%s) No data received and self.connected is set; disconnecting!', self.name) - return - elif (time.time() - self.lastping) > self.pingtimeout: - log.warning('(%s) Connection timed out.', self.name) - return - while b'\n' in buf: - line, buf = buf.split(b'\n', 1) - line = line.strip(b'\r') - # TODO: respect other encodings? - line = line.decode("utf-8", "replace") - log.debug("(%s) <- %s", self.name, line) - hook_args = None - try: - hook_args = self.proto.handle_events(self, line) - except Exception: - log.exception('(%s) Caught error in handle_events, disconnecting!', self.name) - return - # Only call our hooks if there's data to process. Handlers that support - # hooks will return a dict of parsed arguments, which can be passed on - # to plugins and the like. For example, the JOIN handler will return - # something like: {'channel': '#whatever', 'users': ['UID1', 'UID2', - # 'UID3']}, etc. - if hook_args is not None: - self.callHooks(hook_args) - - def callHooks(self, 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.proto.hook_map - # Handlers can 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). - if command in hook_map: - hook_cmd = hook_map[command] - hook_cmd = parsed_args.get('parse_as') or hook_cmd - log.debug('Parsed args %r received from %s handler (calling hook %s)', parsed_args, command, hook_cmd) - # Iterate over hooked functions, catching errors accordingly - for hook_func in utils.command_hooks[hook_cmd]: - try: - log.debug('Calling function %s', hook_func) - hook_func(self, numeric, command, parsed_args) - except Exception: - # We don't want plugins to crash our servers... - log.exception('Unhandled exception caught in %r' % hook_func) - continue - - def send(self, data): - # Safeguard against newlines in input!! Otherwise, each line gets - # treated as a separate command, which is particularly nasty. - data = data.replace('\n', ' ') - data = data.encode("utf-8") + b"\n" - stripped_data = data.decode("utf-8").strip("\n") - log.debug("(%s) -> %s", self.name, stripped_data) - try: - self.socket.send(data) - except (OSError, AttributeError): - log.debug("(%s) Dropping message %r; network isn't connected!", self.name, stripped_data) - - def schedulePing(self): - self.proto.pingServer(self) - self.pingTimer = threading.Timer(self.pingfreq, self.schedulePing) - self.pingTimer.daemon = True - self.pingTimer.start() - log.debug('(%s) Ping scheduled at %s', self.name, time.time()) - - def spawnMain(self): - nick = self.botdata.get('nick') or 'PyLink' - ident = self.botdata.get('ident') or 'pylink' - host = self.serverdata["hostname"] - log.info('(%s) Connected! Spawning main client %s.', self.name, nick) - olduserobj = self.pseudoclient - self.pseudoclient = self.proto.spawnClient(self, nick, ident, host, modes={("+o", None)}) - for chan in self.serverdata['channels']: - self.proto.joinClient(self, self.pseudoclient.uid, chan) - # PyLink internal hook called when spawnMain is called and the - # contents of Irc().pseudoclient change. - self.callHooks([self.sid, 'PYLINK_SPAWNMAIN', {'olduser': olduserobj}]) - if __name__ == '__main__': log.info('PyLink starting...') if conf.conf['login']['password'] == 'changeme': @@ -267,7 +26,7 @@ if __name__ == '__main__': # Import plugins first globally, because they can listen for events # that happen before the connection phase. - utils.plugins.append(coreplugin) + world.plugins.append(coreplugin) to_load = conf.conf['plugins'] plugins_folder = [os.path.join(os.getcwd(), 'plugins')] # Here, we override the module lookup and import the plugins @@ -276,7 +35,7 @@ if __name__ == '__main__': try: moduleinfo = imp.find_module(plugin, plugins_folder) pl = imp.load_source(plugin, moduleinfo[1]) - utils.plugins.append(pl) + world.plugins.append(pl) except ImportError as e: if str(e) == ('No module named %r' % plugin): log.error('Failed to load plugin %r: The plugin could not be found.', plugin) @@ -299,7 +58,7 @@ if __name__ == '__main__': log.critical('Failed to load protocol module: ImportError: %s', protoname, str(e)) sys.exit(2) else: - utils.networkobjects[network] = Irc(network, proto, conf.conf) - utils.started.set() - log.info("loaded plugins: %s", utils.plugins) + world.networkobjects[network] = classes.Irc(network, proto) + world.started.set() + log.info("loaded plugins: %s", world.plugins) diff --git a/plugins/admin.py b/plugins/admin.py index a834222..e9ce07b 100644 --- a/plugins/admin.py +++ b/plugins/admin.py @@ -1,47 +1,22 @@ # admin.py: PyLink administrative commands import sys import os -import inspect sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) import utils from log import log -class NotAuthenticatedError(Exception): - pass - -def checkauthenticated(irc, source): - lastfunc = inspect.stack()[1][3] - if not irc.users[source].identified: - log.warning('(%s) Access denied for %s calling %r', irc.name, - utils.getHostmask(irc, source), lastfunc) - raise NotAuthenticatedError("You are not authenticated!") - -def _exec(irc, source, args): - """ - - Admin-only. Executes in the current PyLink instance. - \x02**WARNING: THIS CAN BE DANGEROUS IF USED IMPROPERLY!**\x02""" - checkauthenticated(irc, source) - args = ' '.join(args) - if not args.strip(): - utils.msg(irc, source, 'No code entered!') - return - log.info('(%s) Executing %r for %s', irc.name, args, utils.getHostmask(irc, source)) - exec(args, globals(), locals()) -utils.add_cmd(_exec, 'exec') - @utils.add_cmd def spawnclient(irc, source, args): """ Admin-only. Spawns the specified PseudoClient on the PyLink server. Note: this doesn't check the validity of any fields you give it!""" - checkauthenticated(irc, source) + utils.checkAuthenticated(irc, source, allowOper=False) try: nick, ident, host = args[:3] except ValueError: - utils.msg(irc, source, "Error: not enough arguments. Needs 3: nick, user, host.") + utils.msg(irc, source, "Error: Not enough arguments. Needs 3: nick, user, host.") return irc.proto.spawnClient(irc, nick, ident, host) @@ -50,17 +25,17 @@ def quit(irc, source, args): """ [] Admin-only. Quits the PyLink client with nick , if one exists.""" - checkauthenticated(irc, source) + utils.checkAuthenticated(irc, source, allowOper=False) try: nick = args[0] except IndexError: - utils.msg(irc, source, "Error: not enough arguments. Needs 1-2: nick, reason (optional).") + utils.msg(irc, source, "Error: Not enough arguments. Needs 1-2: nick, reason (optional).") return if irc.pseudoclient.uid == utils.nickToUid(irc, nick): - utils.msg(irc, source, "Error: cannot quit the main PyLink PseudoClient!") + utils.msg(irc, source, "Error: Cannot quit the main PyLink PseudoClient!") return u = utils.nickToUid(irc, nick) - quitmsg = ' '.join(args[1:]) or 'Client quit' + quitmsg = ' '.join(args[1:]) or 'Client Quit' irc.proto.quitClient(irc, u, quitmsg) irc.callHooks([u, 'PYLINK_ADMIN_QUIT', {'text': quitmsg, 'parse_as': 'QUIT'}]) @@ -68,14 +43,14 @@ def joinclient(irc, source, args): """ ,[], etc. Admin-only. Joins , the nick of a PyLink client, to a comma-separated list of channels.""" - checkauthenticated(irc, source) + utils.checkAuthenticated(irc, source, allowOper=False) try: nick = args[0] clist = args[1].split(',') if not clist: raise IndexError except IndexError: - utils.msg(irc, source, "Error: not enough arguments. Needs 2: nick, comma separated list of channels.") + utils.msg(irc, source, "Error: Not enough arguments. Needs 2: nick, comma separated list of channels.") return u = utils.nickToUid(irc, nick) for channel in clist: @@ -93,12 +68,12 @@ def nick(irc, source, args): """ Admin-only. Changes the nick of , a PyLink client, to .""" - checkauthenticated(irc, source) + utils.checkAuthenticated(irc, source, allowOper=False) try: nick = args[0] newnick = args[1] except IndexError: - utils.msg(irc, source, "Error: not enough arguments. Needs 2: nick, newnick.") + utils.msg(irc, source, "Error: Not enough arguments. Needs 2: nick, newnick.") return u = utils.nickToUid(irc, nick) if newnick in ('0', u): @@ -114,13 +89,13 @@ def part(irc, source, args): """ ,[],... [] Admin-only. Parts , the nick of a PyLink client, from a comma-separated list of channels.""" - checkauthenticated(irc, source) + utils.checkAuthenticated(irc, source, allowOper=False) try: nick = args[0] clist = args[1].split(',') reason = ' '.join(args[2:]) except IndexError: - utils.msg(irc, source, "Error: not enough arguments. Needs 2: nick, comma separated list of channels.") + utils.msg(irc, source, "Error: Not enough arguments. Needs 2: nick, comma separated list of channels.") return u = utils.nickToUid(irc, nick) for channel in clist: @@ -135,14 +110,14 @@ def kick(irc, source, args): """ [] Admin-only. Kicks from via , where is the nick of a PyLink client.""" - checkauthenticated(irc, source) + utils.checkAuthenticated(irc, source, allowOper=False) try: nick = args[0] channel = args[1] target = args[2] reason = ' '.join(args[3:]) except IndexError: - utils.msg(irc, source, "Error: not enough arguments. Needs 3-4: source nick, channel, target, reason (optional).") + utils.msg(irc, source, "Error: Not enough arguments. Needs 3-4: source nick, channel, target, reason (optional).") return u = utils.nickToUid(irc, nick) or nick targetu = utils.nickToUid(irc, target) @@ -155,38 +130,19 @@ def kick(irc, source, args): irc.proto.kickClient(irc, u, channel, targetu, reason) irc.callHooks([u, 'PYLINK_ADMIN_KICK', {'channel': channel, 'target': targetu, 'text': reason, 'parse_as': 'KICK'}]) -@utils.add_cmd -def showuser(irc, source, args): - """ - - Admin-only. Shows information about .""" - checkauthenticated(irc, source) - try: - target = args[0] - except IndexError: - utils.msg(irc, source, "Error: not enough arguments. Needs 1: nick.") - return - u = utils.nickToUid(irc, target) - if u is None: - utils.msg(irc, source, 'Error: unknown user %r' % target) - return - s = ['\x02%s\x02: %s' % (k, v) for k, v in sorted(irc.users[u].__dict__.items())] - s = 'Information on user \x02%s\x02: %s' % (target, '; '.join(s)) - utils.msg(irc, source, s) - @utils.add_cmd def showchan(irc, source, args): """ Admin-only. Shows information about .""" - checkauthenticated(irc, source) + utils.checkAuthenticated(irc, source, allowOper=False) try: channel = args[0].lower() except IndexError: - utils.msg(irc, source, "Error: not enough arguments. Needs 1: channel.") + utils.msg(irc, source, "Error: Not enough arguments. Needs 1: channel.") return if channel not in irc.channels: - utils.msg(irc, source, 'Error: unknown channel %r' % channel) + utils.msg(irc, source, 'Error: Unknown channel %r.' % channel) return s = ['\x02%s\x02: %s' % (k, v) for k, v in sorted(irc.channels[channel].__dict__.items())] s = 'Information on channel \x02%s\x02: %s' % (channel, '; '.join(s)) @@ -197,14 +153,14 @@ def mode(irc, source, args): """ Admin-only. Sets modes on from , where is either the nick of a PyLink client, or the SID of a PyLink server.""" - checkauthenticated(irc, source) + utils.checkAuthenticated(irc, source, allowOper=False) try: modesource, target, modes = args[0], args[1], args[2:] except IndexError: - utils.msg(irc, source, 'Error: not enough arguments. Needs 3: source nick, target, modes to set.') + utils.msg(irc, source, 'Error: Not enough arguments. Needs 3: source nick, target, modes to set.') return if not modes: - utils.msg(irc, source, "Error: no modes given to set!") + utils.msg(irc, source, "Error: No modes given to set!") return parsedmodes = utils.parseModes(irc, target, modes) targetuid = utils.nickToUid(irc, target) @@ -226,25 +182,25 @@ def msg(irc, source, args): """ Admin-only. Sends message from , where is the nick of a PyLink client.""" - checkauthenticated(irc, source) + utils.checkAuthenticated(irc, source, allowOper=False) try: msgsource, target, text = args[0], args[1], ' '.join(args[2:]) except IndexError: - utils.msg(irc, source, 'Error: not enough arguments. Needs 3: source nick, target, text.') + utils.msg(irc, source, 'Error: Not enough arguments. Needs 3: source nick, target, text.') return sourceuid = utils.nickToUid(irc, msgsource) if not sourceuid: - utils.msg(irc, source, 'Error: unknown user %r' % msgsource) + utils.msg(irc, source, 'Error: Unknown user %r.' % msgsource) return if not utils.isChannel(target): real_target = utils.nickToUid(irc, target) if real_target is None: - utils.msg(irc, source, 'Error: unknown user %r' % target) + utils.msg(irc, source, 'Error: Unknown user %r.' % target) return else: real_target = target if not text: - utils.msg(irc, source, 'Error: no text given.') + utils.msg(irc, source, 'Error: No text given.') return irc.proto.messageClient(irc, sourceuid, real_target, text) irc.callHooks([sourceuid, 'PYLINK_ADMIN_MSG', {'target': real_target, 'text': text, 'parse_as': 'PRIVMSG'}]) diff --git a/plugins/commands.py b/plugins/commands.py index 44968e3..fb89abc 100644 --- a/plugins/commands.py +++ b/plugins/commands.py @@ -1,11 +1,13 @@ # commands.py: base PyLink commands import sys import os +from time import ctime sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) import utils from conf import conf from log import log +import world @utils.add_cmd def status(irc, source, args): @@ -27,7 +29,7 @@ def identify(irc, source, args): try: username, password = args[0], args[1] except IndexError: - utils.msg(irc, source, 'Error: not enough arguments.') + utils.msg(irc, source, 'Error: Not enough arguments.') return # Usernames are case-insensitive, passwords are NOT. if username.lower() == conf['login']['user'].lower() and password == conf['login']['password']: @@ -35,9 +37,9 @@ def identify(irc, source, args): irc.users[source].identified = realuser utils.msg(irc, source, 'Successfully logged in as %s.' % realuser) log.info("(%s) Successful login to %r by %s.", - irc.name, username, utils.getHostmask(irc, source)) + irc.name, username, utils.getHostmask(irc, source)) else: - utils.msg(irc, source, 'Incorrect credentials.') + utils.msg(irc, source, 'Error: Incorrect credentials.') u = irc.users[source] log.warning("(%s) Failed login to %r from %s.", irc.name, username, utils.getHostmask(irc, source)) @@ -46,8 +48,12 @@ def listcommands(irc, source, args): """takes no arguments. Returns a list of available commands PyLink has to offer.""" - cmds = list(utils.bot_commands.keys()) + cmds = list(world.bot_commands.keys()) cmds.sort() + for idx, cmd in enumerate(cmds): + nfuncs = len(world.bot_commands[cmd]) + if nfuncs > 1: + cmds[idx] = '%s(x%s)' % (cmd, nfuncs) utils.msg(irc, source, 'Available commands include: %s' % ', '.join(cmds)) utils.msg(irc, source, 'To see help on a specific command, type \x02help \x02.') utils.add_cmd(listcommands, 'list') @@ -62,20 +68,60 @@ def help(irc, source, args): except IndexError: # No argument given, just return 'list' output listcommands(irc, source, args) return - try: - func = utils.bot_commands[command] - except KeyError: - utils.msg(irc, source, 'Error: no such command %r.' % command) + if command not in world.bot_commands: + utils.msg(irc, source, 'Error: Unknown command %r.' % command) return else: - doc = func.__doc__ - if doc: - lines = doc.split('\n') - # Bold the first line, which usually just tells you what - # arguments the command takes. - lines[0] = '\x02%s %s\x02' % (command, lines[0]) - for line in lines: - utils.msg(irc, source, line.strip()) - else: - utils.msg(irc, source, 'Error: Command %r doesn\'t offer any help.' % command) - return + funcs = world.bot_commands[command] + if len(funcs) > 1: + utils.msg(irc, source, 'The following \x02%s\x02 plugins bind to the \x02%s\x02 command: %s' + % (len(funcs), command, ', '.join([func.__module__ for func in funcs]))) + for func in funcs: + doc = func.__doc__ + mod = func.__module__ + if doc: + lines = doc.split('\n') + # Bold the first line, which usually just tells you what + # arguments the command takes. + lines[0] = '\x02%s %s\x02 (plugin: %r)' % (command, lines[0], mod) + for line in lines: + utils.msg(irc, source, line.strip()) + else: + utils.msg(irc, source, "Error: Command %r (from plugin %r) " + "doesn't offer any help." % (command, mod)) + return + +@utils.add_cmd +def showuser(irc, source, args): + """ + + Shows information about .""" + try: + target = args[0] + except IndexError: + utils.msg(irc, source, "Error: Not enough arguments. Needs 1: nick.") + return + u = utils.nickToUid(irc, target) or target + # Only show private info if the person is calling 'showuser' on themselves, + # or is an oper. + verbose = utils.isOper(irc, source) or u == source + if u not in irc.users: + utils.msg(irc, source, 'Error: Unknown user %r.' % target) + return + + f = lambda s: utils.msg(irc, source, s) + userobj = irc.users[u] + f('Information on user \x02%s\x02 (%s@%s): %s' % (userobj.nick, userobj.ident, + userobj.host, userobj.realname)) + sid = utils.clientToServer(irc, u) + serverobj = irc.servers[sid] + ts = userobj.ts + f('\x02Home server\x02: %s (%s); \x02Signon time:\x02 %s (%s)' % \ + (serverobj.name, sid, ctime(float(ts)), ts)) + if verbose: + f('\x02Protocol UID\x02: %s; \x02PyLink identification\x02: %s' % \ + (u, userobj.identified)) + f('\x02User modes\x02: %s' % utils.joinModes(userobj.modes)) + f('\x02Real host\x02: %s; \x02IP\x02: %s; \x02Away status\x02: %s' % \ + (userobj.realhost, userobj.ip, userobj.away or '\x1D(not set)\x1D')) + f('\x02Channels\x02: %s' % (' '.join(userobj.channels).strip() or '\x1D(none)\x1D')) diff --git a/plugins/exec.py b/plugins/exec.py new file mode 100755 index 0000000..aa19204 --- /dev/null +++ b/plugins/exec.py @@ -0,0 +1,21 @@ +# exec.py: Provides an 'exec' command to execute raw code +import sys +import os +sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +import utils +from log import log + +def _exec(irc, source, args): + """ + + Admin-only. Executes in the current PyLink instance. + \x02**WARNING: THIS CAN BE DANGEROUS IF USED IMPROPERLY!**\x02""" + utils.checkAuthenticated(irc, source, allowOper=False) + args = ' '.join(args) + if not args.strip(): + utils.msg(irc, source, 'No code entered!') + return + log.info('(%s) Executing %r for %s', irc.name, args, utils.getHostmask(irc, source)) + exec(args, globals(), locals()) +utils.add_cmd(_exec, 'exec') diff --git a/plugins/relay.py b/plugins/relay.py index e3f1d65..286956e 100644 --- a/plugins/relay.py +++ b/plugins/relay.py @@ -13,6 +13,7 @@ from expiringdict import ExpiringDict import utils from log import log from conf import confname +import world dbname = "pylinkrelay" if confname != 'pylink': @@ -28,11 +29,11 @@ def relayWhoisHandlers(irc, target): orig = getLocalUser(irc, target) if orig: network, remoteuid = orig - remotenick = utils.networkobjects[network].users[remoteuid].nick + remotenick = world.networkobjects[network].users[remoteuid].nick return [320, "%s :is a remote user connected via PyLink Relay. Home " "network: %s; Home nick: %s" % (user.nick, network, remotenick)] -utils.whois_handlers.append(relayWhoisHandlers) +world.whois_handlers.append(relayWhoisHandlers) def normalizeNick(irc, netname, nick, separator=None, uid=''): separator = separator or irc.serverdata.get('separator') or "/" @@ -94,7 +95,7 @@ def loadDB(): db = {} def exportDB(reschedule=False): - scheduler = utils.schedulers.get('relaydb') + scheduler = world.schedulers.get('relaydb') if reschedule and scheduler: scheduler.enter(30, 1, exportDB, argument=(True,)) log.debug("Relay: exporting links database to %s", dbname) @@ -110,7 +111,7 @@ def save(irc, source, args): exportDB() utils.msg(irc, source, 'Done.') else: - utils.msg(irc, source, 'Error: you are not authenticated!') + utils.msg(irc, source, 'Error: You are not authenticated!') return def getPrefixModes(irc, remoteirc, channel, user): @@ -149,16 +150,48 @@ def getRemoteUser(irc, remoteirc, user, spawnIfMissing=True): host = userobj.host[:64] realname = userobj.realname modes = getSupportedUmodes(irc, remoteirc, userobj.modes) + opertype = '' + if ('o', None) in userobj.modes: + if hasattr(userobj, 'opertype'): + # InspIRCd's special OPERTYPE command; this is mandatory + # and setting of umode +/-o will fail unless this + # is used instead. This also sets an oper type for + # the user, which is used in WHOIS, etc. + + # If an opertype exists for the user, add " (remote)" + # for the relayed clone, so that it shows in whois. + # Janus does this too. :) + # OPERTYPE uses underscores instead of spaces, FYI. + log.debug('(%s) relay.getRemoteUser: setting OPERTYPE of client for %r to %s', + irc.name, user, userobj.opertype) + opertype = userobj.opertype + '_(remote)' + else: + opertype = 'IRC_Operator_(remote)' + # Set hideoper on remote opers, to prevent inflating + # /lusers and various /stats + hideoper_mode = remoteirc.umodes.get('hideoper') + if hideoper_mode: + modes.append((hideoper_mode, None)) u = remoteirc.proto.spawnClient(remoteirc, nick, ident=ident, host=host, realname=realname, - modes=modes, ts=userobj.ts).uid + modes=modes, ts=userobj.ts, + opertype=opertype).uid remoteirc.users[u].remote = (irc.name, user) + remoteirc.users[u].opertype = opertype away = userobj.away if away: remoteirc.proto.awayClient(remoteirc, u, away) relayusers[(irc.name, user)][remoteirc.name] = u return u +def handle_operup(irc, numeric, command, args): + newtype = args['text'] + '_(remote)' + for netname, user in relayusers[(irc.name, numeric)].items(): + log.debug('(%s) relay.handle_opertype: setting OPERTYPE of %s/%s to %s', irc.name, user, netname, newtype) + remoteirc = world.networkobjects[netname] + remoteirc.users[user].opertype = newtype +utils.add_hook(handle_operup, 'PYLINK_CLIENT_OPERED') + def getLocalUser(irc, user, targetirc=None): """ [] @@ -179,7 +212,7 @@ def getLocalUser(irc, user, targetirc=None): # If targetirc is given, we'll return simply the UID of the user on the # target network, if it exists. Otherwise, we'll return a tuple # with the home network name and the original user's UID. - sourceobj = utils.networkobjects.get(remoteuser[0]) + sourceobj = world.networkobjects.get(remoteuser[0]) if targetirc and sourceobj: if remoteuser[0] == targetirc.name: # The user we found's home network happens to be the one being @@ -230,7 +263,7 @@ def initializeChannel(irc, channel): remotenet, remotechan = link if remotenet == irc.name: continue - remoteirc = utils.networkobjects.get(remotenet) + remoteirc = world.networkobjects.get(remotenet) if remoteirc is None: continue rc = remoteirc.channels[remotechan] @@ -255,12 +288,12 @@ def handle_join(irc, numeric, command, args): return ts = args['ts'] users = set(args['users']) - relayJoins(irc, channel, users, ts) + relayJoins(irc, channel, users, ts, burst=False) utils.add_hook(handle_join, 'JOIN') def handle_quit(irc, numeric, command, args): for netname, user in relayusers[(irc.name, numeric)].copy().items(): - remoteirc = utils.networkobjects[netname] + remoteirc = world.networkobjects[netname] remoteirc.proto.quitClient(remoteirc, user, args['text']) del relayusers[(irc.name, numeric)] utils.add_hook(handle_quit, 'QUIT') @@ -274,7 +307,7 @@ utils.add_hook(handle_squit, 'SQUIT') def handle_nick(irc, numeric, command, args): for netname, user in relayusers[(irc.name, numeric)].items(): - remoteirc = utils.networkobjects[netname] + remoteirc = world.networkobjects[netname] newnick = normalizeNick(remoteirc, irc.name, args['newnick'], uid=user) if remoteirc.users[user].nick != newnick: remoteirc.proto.nickClient(remoteirc, user, newnick) @@ -288,7 +321,7 @@ def handle_part(irc, numeric, command, args): return for channel in channels: for netname, user in relayusers[(irc.name, numeric)].copy().items(): - remoteirc = utils.networkobjects[netname] + remoteirc = world.networkobjects[netname] remotechan = findRemoteChan(irc, remoteirc, channel) if remotechan is None: continue @@ -324,7 +357,7 @@ def handle_privmsg(irc, numeric, command, args): return if utils.isChannel(target): for netname, user in relayusers[(irc.name, numeric)].items(): - remoteirc = utils.networkobjects[netname] + remoteirc = world.networkobjects[netname] real_target = findRemoteChan(irc, remoteirc, target) if not real_target: continue @@ -343,11 +376,11 @@ def handle_privmsg(irc, numeric, command, args): # on the remote network, and we won't have anything to send our # messages from. if homenet not in remoteusers.keys(): - utils.msg(irc, numeric, 'Error: you must be in a common channel ' + utils.msg(irc, numeric, 'Error: You must be in a common channel ' 'with %r in order to send messages.' % \ irc.users[target].nick, notice=True) return - remoteirc = utils.networkobjects[homenet] + remoteirc = world.networkobjects[homenet] user = getRemoteUser(irc, remoteirc, numeric, spawnIfMissing=False) if notice: remoteirc.proto.noticeClient(remoteirc, user, real_target, text) @@ -367,7 +400,7 @@ def handle_kick(irc, source, command, args): if relay is None or target == irc.pseudoclient.uid: return origuser = getLocalUser(irc, target) - for name, remoteirc in utils.networkobjects.items(): + for name, remoteirc in world.networkobjects.items(): if irc.name == name or not remoteirc.connected.is_set(): continue remotechan = findRemoteChan(irc, remoteirc, channel) @@ -400,14 +433,14 @@ def handle_kick(irc, source, command, args): # Join the kicked client back with its respective modes. irc.proto.sjoinServer(irc, irc.sid, channel, [(modes, target)]) if kicker in irc.users: - log.info('(%s) Blocked KICK (reason %r) from %s to relay client %s/%s on %s.', + log.info('(%s) Relay claim: Blocked KICK (reason %r) from %s to relay client %s/%s on %s.', irc.name, args['text'], irc.users[source].nick, remoteirc.users[real_target].nick, remoteirc.name, channel) utils.msg(irc, kicker, "This channel is claimed; your kick to " "%s has been blocked because you are not " "(half)opped." % channel, notice=True) else: - log.info('(%s) Blocked KICK (reason %r) from server %s to relay client %s/%s on %s.', + log.info('(%s) Relay claim: Blocked KICK (reason %r) from server %s to relay client %s/%s on %s.', irc.name, args['text'], irc.servers[source].name, remoteirc.users[real_target].nick, remoteirc.name, channel) return @@ -458,7 +491,7 @@ def handle_chgclient(irc, source, command, args): text = args['newgecos'] if field: for netname, user in relayusers[(irc.name, target)].items(): - remoteirc = utils.networkobjects[netname] + remoteirc = world.networkobjects[netname] try: remoteirc.proto.updateClient(remoteirc, user, field, text) except NotImplementedError: # IRCd doesn't support changing the field we want @@ -579,30 +612,38 @@ def getSupportedUmodes(irc, remoteirc, modes): else: log.debug("(%s) getSupportedUmodes: skipping mode (%r, %r) because " "the remote network (%s)'s IRCd (%s) doesn't support it.", - irc.name, modechar, arg, remoteirc.name, irc.proto.__name__) + irc.name, modechar, arg, remoteirc.name, + remoteirc.proto.__name__) return supported_modes def handle_mode(irc, numeric, command, args): target = args['target'] modes = args['modes'] - for name, remoteirc in utils.networkobjects.items(): + for name, remoteirc in world.networkobjects.items(): if irc.name == name or not remoteirc.connected.is_set(): continue if utils.isChannel(target): relayModes(irc, remoteirc, numeric, target, modes) else: + # Set hideoper on remote opers, to prevent inflating + # /lusers and various /stats + hideoper_mode = remoteirc.umodes.get('hideoper') modes = getSupportedUmodes(irc, remoteirc, modes) + if hideoper_mode: + if ('+o', None) in modes: + modes.append(('+%s' % hideoper_mode, None)) + elif ('-o', None) in modes: + modes.append(('-%s' % hideoper_mode, None)) remoteuser = getRemoteUser(irc, remoteirc, target, spawnIfMissing=False) - if remoteuser is None: - continue - remoteirc.proto.modeClient(remoteirc, remoteuser, remoteuser, modes) + if remoteuser and modes: + remoteirc.proto.modeClient(remoteirc, remoteuser, remoteuser, modes) utils.add_hook(handle_mode, 'MODE') def handle_topic(irc, numeric, command, args): channel = args['channel'] topic = args['topic'] - for name, remoteirc in utils.networkobjects.items(): + for name, remoteirc in world.networkobjects.items(): if irc.name == name or not remoteirc.connected.is_set(): continue @@ -628,7 +669,7 @@ def handle_kill(irc, numeric, command, args): # We don't allow killing over the relay, so we must respawn the affected # client and rejoin it to its channels. del relayusers[realuser][irc.name] - remoteirc = utils.networkobjects[realuser[0]] + remoteirc = world.networkobjects[realuser[0]] for remotechan in remoteirc.channels.copy(): localchan = findRemoteChan(remoteirc, irc, remotechan) if localchan: @@ -637,7 +678,7 @@ def handle_kill(irc, numeric, command, args): client = getRemoteUser(remoteirc, irc, realuser[1]) irc.proto.sjoinServer(irc, irc.sid, localchan, [(modes, client)]) if userdata and numeric in irc.users: - log.info('(%s) Blocked KILL (reason %r) from %s to relay client %s/%s.', + log.info('(%s) Relay claim: Blocked KILL (reason %r) from %s to relay client %s/%s.', irc.name, args['text'], irc.users[numeric].nick, remoteirc.users[realuser[1]].nick, realuser[0]) utils.msg(irc, numeric, "Your kill to %s has been blocked " @@ -645,7 +686,7 @@ def handle_kill(irc, numeric, command, args): " users over the relay at this time." % \ userdata.nick, notice=True) else: - log.info('(%s) Blocked KILL (reason %r) from server %s to relay client %s/%s.', + log.info('(%s) Relay claim: Blocked KILL (reason %r) from server %s to relay client %s/%s.', irc.name, args['text'], irc.servers[numeric].name, remoteirc.users[realuser[1]].nick, realuser[0]) # Target user was local. @@ -670,7 +711,7 @@ def isRelayClient(irc, user): return False def relayJoins(irc, channel, users, ts, burst=True): - for name, remoteirc in utils.networkobjects.items(): + for name, remoteirc in world.networkobjects.items(): queued_users = [] if name == irc.name or not remoteirc.connected.is_set(): # Don't relay things to their source network... @@ -710,7 +751,7 @@ def relayJoins(irc, channel, users, ts, burst=True): remoteirc.proto.joinClient(remoteirc, queued_users[0][1], remotechan) def relayPart(irc, channel, user): - for name, remoteirc in utils.networkobjects.items(): + for name, remoteirc in world.networkobjects.items(): if name == irc.name or not remoteirc.connected.is_set(): # Don't relay things to their source network... continue @@ -730,7 +771,7 @@ def removeChannel(irc, channel): if irc is None: return if channel not in map(str.lower, irc.serverdata['channels']): - irc.proto.partClient(irc, irc.pseudoclient.uid, channel) + irc.proto.partClient(irc, irc.pseudoclient.uid, channel, 'Channel delinked.') relay = findRelay((irc.name, channel)) if relay: for user in irc.channels[channel].users.copy(): @@ -756,16 +797,16 @@ def create(irc, source, args): try: channel = utils.toLower(irc, args[0]) except IndexError: - utils.msg(irc, source, "Error: not enough arguments. Needs 1: channel.") + utils.msg(irc, source, "Error: Not enough arguments. Needs 1: channel.") return if not utils.isChannel(channel): - utils.msg(irc, source, 'Error: invalid channel %r.' % channel) + utils.msg(irc, source, 'Error: Invalid channel %r.' % channel) return if source not in irc.channels[channel].users: - utils.msg(irc, source, 'Error: you must be in %r to complete this operation.' % channel) + utils.msg(irc, source, 'Error: You must be in %r to complete this operation.' % channel) return if not utils.isOper(irc, source): - utils.msg(irc, source, 'Error: you must be opered in order to complete this operation.') + utils.msg(irc, source, 'Error: You must be opered in order to complete this operation.') return db[(irc.name, channel)] = {'claim': [irc.name], 'links': set(), 'blocked_nets': set()} initializeChannel(irc, channel) @@ -779,24 +820,24 @@ def destroy(irc, source, args): try: channel = utils.toLower(irc, args[0]) except IndexError: - utils.msg(irc, source, "Error: not enough arguments. Needs 1: channel.") + utils.msg(irc, source, "Error: Not enough arguments. Needs 1: channel.") return if not utils.isChannel(channel): - utils.msg(irc, source, 'Error: invalid channel %r.' % channel) + utils.msg(irc, source, 'Error: Invalid channel %r.' % channel) return if not utils.isOper(irc, source): - utils.msg(irc, source, 'Error: you must be opered in order to complete this operation.') + utils.msg(irc, source, 'Error: You must be opered in order to complete this operation.') return entry = (irc.name, channel) if entry in db: for link in db[entry]['links']: - removeChannel(utils.networkobjects.get(link[0]), link[1]) + removeChannel(world.networkobjects.get(link[0]), link[1]) removeChannel(irc, channel) del db[entry] utils.msg(irc, source, 'Done.') else: - utils.msg(irc, source, 'Error: no such relay %r exists.' % channel) + utils.msg(irc, source, 'Error: No such relay %r exists.' % channel) return @utils.add_cmd @@ -809,7 +850,7 @@ def link(irc, source, args): channel = utils.toLower(irc, args[1]) remotenet = args[0].lower() except IndexError: - utils.msg(irc, source, "Error: not enough arguments. Needs 2-3: remote netname, channel, local channel name (optional).") + utils.msg(irc, source, "Error: Not enough arguments. Needs 2-3: remote netname, channel, local channel name (optional).") return try: localchan = utils.toLower(irc, args[2]) @@ -817,33 +858,33 @@ def link(irc, source, args): localchan = channel for c in (channel, localchan): if not utils.isChannel(c): - utils.msg(irc, source, 'Error: invalid channel %r.' % c) + utils.msg(irc, source, 'Error: Invalid channel %r.' % c) return if source not in irc.channels[localchan].users: - utils.msg(irc, source, 'Error: you must be in %r to complete this operation.' % localchan) + utils.msg(irc, source, 'Error: You must be in %r to complete this operation.' % localchan) return if not utils.isOper(irc, source): - utils.msg(irc, source, 'Error: you must be opered in order to complete this operation.') + utils.msg(irc, source, 'Error: You must be opered in order to complete this operation.') return - if remotenet not in utils.networkobjects: - utils.msg(irc, source, 'Error: no network named %r exists.' % remotenet) + if remotenet not in world.networkobjects: + utils.msg(irc, source, 'Error: No network named %r exists.' % remotenet) return localentry = findRelay((irc.name, localchan)) if localentry: - utils.msg(irc, source, 'Error: channel %r is already part of a relay.' % localchan) + utils.msg(irc, source, 'Error: Channel %r is already part of a relay.' % localchan) return try: entry = db[(remotenet, channel)] except KeyError: - utils.msg(irc, source, 'Error: no such relay %r exists.' % channel) + utils.msg(irc, source, 'Error: No such relay %r exists.' % channel) return else: if irc.name in entry['blocked_nets']: - utils.msg(irc, source, 'Error: access denied (network is banned from linking to this channel).') + utils.msg(irc, source, 'Error: Access denied (network is banned from linking to this channel).') return for link in entry['links']: if link[0] == irc.name: - utils.msg(irc, source, "Error: remote channel '%s%s' is already" + utils.msg(irc, source, "Error: Remote channel '%s%s' is already" " linked here as %r." % (remotenet, channel, link[1])) return @@ -860,17 +901,17 @@ def delink(irc, source, args): try: channel = utils.toLower(irc, args[0]) except IndexError: - utils.msg(irc, source, "Error: not enough arguments. Needs 1-2: channel, remote netname (optional).") + utils.msg(irc, source, "Error: Not enough arguments. Needs 1-2: channel, remote netname (optional).") return try: remotenet = args[1].lower() except IndexError: remotenet = None if not utils.isOper(irc, source): - utils.msg(irc, source, 'Error: you must be opered in order to complete this operation.') + utils.msg(irc, source, 'Error: You must be opered in order to complete this operation.') return if not utils.isChannel(channel): - utils.msg(irc, source, 'Error: invalid channel %r.' % channel) + utils.msg(irc, source, 'Error: Invalid channel %r.' % channel) return entry = findRelay((irc.name, channel)) if entry: @@ -884,18 +925,18 @@ def delink(irc, source, args): else: for link in db[entry]['links'].copy(): if link[0] == remotenet: - removeChannel(utils.networkobjects.get(remotenet), link[1]) + removeChannel(world.networkobjects.get(remotenet), link[1]) db[entry]['links'].remove(link) else: removeChannel(irc, channel) db[entry]['links'].remove((irc.name, channel)) utils.msg(irc, source, 'Done.') else: - utils.msg(irc, source, 'Error: no such relay %r.' % channel) + utils.msg(irc, source, 'Error: No such relay %r.' % channel) def initializeAll(irc): - log.debug('(%s) initializeAll: waiting for utils.started', irc.name) - utils.started.wait() + log.debug('(%s) initializeAll: waiting for world.started', irc.name) + world.started.wait() for chanpair, entrydata in db.items(): network, channel = chanpair initializeChannel(irc, channel) @@ -905,7 +946,7 @@ def initializeAll(irc): def main(): loadDB() - utils.schedulers['relaydb'] = scheduler = sched.scheduler() + world.schedulers['relaydb'] = scheduler = sched.scheduler() scheduler.enter(30, 1, exportDB, argument=(True,)) # Thread this because exportDB() queues itself as part of its # execution, in order to get a repeating loop. @@ -937,7 +978,7 @@ def handle_save(irc, numeric, command, args): # It's one of our relay clients; try to fix our nick to the next # available normalized nick. remotenet, remoteuser = realuser - remoteirc = utils.networkobjects[remotenet] + remoteirc = world.networkobjects[remotenet] nick = remoteirc.users[remoteuser].nick # Limit how many times we can attempt to fix our nick, to prevent # floods and such. @@ -963,7 +1004,7 @@ def linked(irc, source, args): """takes no arguments. Returns a list of channels shared across the relay.""" - networks = list(utils.networkobjects.keys()) + networks = list(world.networkobjects.keys()) networks.remove(irc.name) s = 'Connected networks: \x02%s\x02 %s' % (irc.name, ' '.join(networks)) utils.msg(irc, source, s) @@ -978,7 +1019,7 @@ def linked(irc, source, args): def handle_away(irc, numeric, command, args): for netname, user in relayusers[(irc.name, numeric)].items(): - remoteirc = utils.networkobjects[netname] + remoteirc = world.networkobjects[netname] remoteirc.proto.awayClient(remoteirc, user, args['text']) utils.add_hook(handle_away, 'AWAY') @@ -989,15 +1030,37 @@ def handle_spawnmain(irc, numeric, command, args): initializeAll(irc) utils.add_hook(handle_spawnmain, 'PYLINK_SPAWNMAIN') +def handle_invite(irc, source, command, args): + target = args['target'] + channel = args['channel'] + if isRelayClient(irc, target): + remotenet, remoteuser = getLocalUser(irc, target) + remoteirc = world.networkobjects[remotenet] + remotechan = findRemoteChan(irc, remoteirc, channel) + remotesource = getRemoteUser(irc, remoteirc, source, spawnIfMissing=False) + if remotesource is None: + utils.msg(irc, source, 'Error: You must be in a common channel ' + 'with %s to invite them to channels.' % \ + irc.users[target].nick, + notice=True) + elif remotechan is None: + utils.msg(irc, source, 'Error: You cannot invite someone to a ' + 'channel not on their network!', + notice=True) + else: + remoteirc.proto.inviteClient(remoteirc, remotesource, remoteuser, + remotechan) +utils.add_hook(handle_invite, 'INVITE') + @utils.add_cmd def linkacl(irc, source, args): """ALLOW|DENY|LIST Allows blocking / unblocking certain networks from linking to a relay, based on a blacklist. LINKACL LIST returns a list of blocked networks for a channel, while the ALLOW and DENY subcommands allow manipulating this blacklist.""" - missingargs = "Error: not enough arguments. Needs 2-3: subcommand (ALLOW/DENY/LIST), channel, remote network (for ALLOW/DENY)." + missingargs = "Error: Not enough arguments. Needs 2-3: subcommand (ALLOW/DENY/LIST), channel, remote network (for ALLOW/DENY)." if not utils.isOper(irc, source): - utils.msg(irc, source, 'Error: you must be opered in order to complete this operation.') + utils.msg(irc, source, 'Error: You must be opered in order to complete this operation.') return try: cmd = args[0].lower() @@ -1006,11 +1069,11 @@ def linkacl(irc, source, args): utils.msg(irc, source, missingargs) return if not utils.isChannel(channel): - utils.msg(irc, source, 'Error: invalid channel %r.' % channel) + utils.msg(irc, source, 'Error: Invalid channel %r.' % channel) return relay = findRelay((irc.name, channel)) if not relay: - utils.msg(irc, source, 'Error: no such relay %r exists.' % channel) + utils.msg(irc, source, 'Error: No such relay %r exists.' % channel) return if cmd == 'list': s = 'Blocked networks for \x02%s\x02: \x02%s\x02' % (channel, ', '.join(db[relay]['blocked_nets']) or '(empty)') @@ -1029,8 +1092,44 @@ def linkacl(irc, source, args): try: db[relay]['blocked_nets'].remove(remotenet) except KeyError: - utils.msg(irc, source, 'Error: network %r is not on the blacklist for %r.' % (remotenet, channel)) + utils.msg(irc, source, 'Error: Network %r is not on the blacklist for %r.' % (remotenet, channel)) else: utils.msg(irc, source, 'Done.') else: - utils.msg(irc, source, 'Error: unknown subcommand %r: valid ones are ALLOW, DENY, and LIST.' % cmd) + utils.msg(irc, source, 'Error: Unknown subcommand %r: valid ones are ALLOW, DENY, and LIST.' % cmd) + +@utils.add_cmd +def showuser(irc, source, args): + """ + + Shows relay data about user . This is intended to be used alongside the 'commands' plugin, which provides a 'showuser' command with more general information.""" + try: + target = args[0] + except IndexError: + # No errors here; showuser from the commands plugin already does this + # for us. + return + u = utils.nickToUid(irc, target) + if u: + try: + userpair = getLocalUser(irc, u) or (irc.name, u) + remoteusers = relayusers[userpair].items() + except KeyError: + pass + else: + nicks = [] + if remoteusers: + nicks.append('%s (home network): \x02%s\x02' % (userpair[0], + world.networkobjects[userpair[0]].users[userpair[1]].nick)) + for r in remoteusers: + remotenet, remoteuser = r + remoteirc = world.networkobjects[remotenet] + nicks.append('%s: \x02%s\x02' % (remotenet, remoteirc.users[remoteuser].nick)) + utils.msg(irc, source, "\x02Relay nicks\x02: %s" % ', '.join(nicks)) + relaychannels = [] + for ch in irc.users[u].channels: + relay = findRelay((irc.name, ch)) + if relay: + relaychannels.append(''.join(relay)) + if relaychannels and (utils.isOper(irc, source) or u == source): + utils.msg(irc, source, "\x02Relay channels\x02: %s" % ' '.join(relaychannels)) diff --git a/protocols/inspircd.py b/protocols/inspircd.py index 11240ef..f0122c9 100644 --- a/protocols/inspircd.py +++ b/protocols/inspircd.py @@ -39,7 +39,7 @@ def spawnClient(irc, nick, ident='null', host='null', realhost=None, modes=set() u = irc.users[uid] = IrcUser(nick, ts, uid, ident=ident, host=host, realname=realname, realhost=realhost, ip=ip) utils.applyModes(irc, uid, modes) - irc.servers[server].users.append(uid) + irc.servers[server].users.add(uid) _send(irc, server, "UID {uid} {ts} {nick} {realhost} {host} {ident} {ip}" " {ts} {modes} + :{realname}".format(ts=ts, host=host, nick=nick, ident=ident, uid=uid, @@ -138,7 +138,7 @@ def removeClient(irc, numeric): log.debug('Removing client %s from irc.users', numeric) del irc.users[numeric] log.debug('Removing client %s from irc.servers[%s]', numeric, sid) - irc.servers[sid].users.remove(numeric) + irc.servers[sid].users.discard(numeric) def quitClient(irc, numeric, reason): """ @@ -195,6 +195,7 @@ def _operUp(irc, target, opertype=None): otype = 'IRC_Operator' log.debug('(%s) Sending OPERTYPE from %s to oper them up.', irc.name, target) + userobj.opertype = otype _send(irc, target, 'OPERTYPE %s' % otype) def _sendModes(irc, numeric, target, modes, ts=None): @@ -449,7 +450,7 @@ def handle_uid(irc, numeric, command, args): parsedmodes = utils.parseModes(irc, uid, [args[8], args[9]]) log.debug('Applying modes %s for %s', parsedmodes, uid) utils.applyModes(irc, uid, parsedmodes) - irc.servers[numeric].users.append(uid) + irc.servers[numeric].users.add(uid) return {'uid': uid, 'ts': ts, 'nick': nick, 'realhost': realhost, 'host': host, 'ident': ident, 'ip': ip} def handle_quit(irc, numeric, command, args): @@ -706,7 +707,10 @@ def handle_opertype(irc, numeric, command, args): omode = [('+o', None)] irc.users[numeric].opertype = opertype = args[0] utils.applyModes(irc, numeric, omode) - return {'target': numeric, 'modes': omode, 'text': opertype} + # OPERTYPE is essentially umode +o and metadata in one command; + # we'll call that too. + irc.callHooks([numeric, 'PYLINK_CLIENT_OPERED', {'text': opertype}]) + return {'target': numeric, 'modes': omode} def handle_fident(irc, numeric, command, args): # :70MAAAAAB FHOST test diff --git a/protocols/ts6.py b/protocols/ts6.py index 6beadcb..5cff23e 100644 --- a/protocols/ts6.py +++ b/protocols/ts6.py @@ -42,7 +42,7 @@ def spawnClient(irc, nick, ident='null', host='null', realhost=None, modes=set() u = irc.users[uid] = IrcUser(nick, ts, uid, ident=ident, host=host, realname=realname, realhost=realhost, ip=ip) utils.applyModes(irc, uid, modes) - irc.servers[server].users.append(uid) + irc.servers[server].users.add(uid) _send(irc, server, "EUID {nick} 1 {ts} {modes} {ident} {host} {ip} {uid} " "{realhost} * :{realname}".format(ts=ts, host=host, nick=nick, ident=ident, uid=uid, @@ -469,7 +469,10 @@ def handle_euid(irc, numeric, command, args): parsedmodes = utils.parseModes(irc, uid, [modes]) log.debug('Applying modes %s for %s', parsedmodes, uid) utils.applyModes(irc, uid, parsedmodes) - irc.servers[numeric].users.append(uid) + irc.servers[numeric].users.add(uid) + if ('o', None) in parsedmodes: + otype = 'Server_Administrator' if ('a', None) in parsedmodes else 'IRC_Operator' + irc.callHooks([uid, 'PYLINK_CLIENT_OPERED', {'text': otype}]) return {'uid': uid, 'ts': ts, 'nick': nick, 'realhost': realhost, 'host': host, 'ident': ident, 'ip': ip} def handle_uid(irc, numeric, command, args): @@ -502,6 +505,17 @@ def handle_tmode(irc, numeric, command, args): ts = int(args[0]) return {'target': channel, 'modes': changedmodes, 'ts': ts} +def handle_mode(irc, numeric, command, args): + # <- :70MAAAAAA MODE 70MAAAAAA -i+xc + target = args[0] + modestrings = args[1:] + changedmodes = utils.parseModes(irc, numeric, modestrings) + utils.applyModes(irc, target, changedmodes) + if ('+o', None) in changedmodes: + otype = 'Server_Administrator' if ('a', None) in irc.users[target].modes else 'IRC_Operator' + irc.callHooks([target, 'PYLINK_CLIENT_OPERED', {'text': otype}]) + return {'target': target, 'modes': changedmodes} + def handle_events(irc, data): # TS6 messages: # :42X COMMAND arg1 arg2 :final long arg diff --git a/runtests.py b/runtests.py new file mode 100755 index 0000000..5b64f8b --- /dev/null +++ b/runtests.py @@ -0,0 +1,21 @@ +#!/usr/bin/env python3 + +import unittest +import glob +import os +import sys + +runner = unittest.TextTestRunner(verbosity=2) +fails = [] +suites = [] + +# Yay, import hacks! +sys.path.append(os.path.join(os.getcwd(), 'tests')) +for testfile in glob.glob('tests/test_*.py'): + # Strip the tests/ and .py extension: tests/test_whatever.py => test_whatever + module = testfile.replace('.py', '').replace('tests/', '') + module = __import__(module) + suites.append(unittest.defaultTestLoader.loadTestsFromModule(module)) + +testsuite = unittest.TestSuite(suites) +runner.run(testsuite) diff --git a/tests/test_coreplugin.py b/tests/test_coreplugin.py new file mode 100644 index 0000000..4480297 --- /dev/null +++ b/tests/test_coreplugin.py @@ -0,0 +1,35 @@ +#!/usr/bin/env python + +import inspircd +import unittest +import world +import coreplugin + +import tests_common + +world.testing = inspircd + +class CorePluginTestCase(tests_common.PluginTestCase): + @unittest.skip("Test doesn't work yet.") + def testKillRespawn(self): + self.irc.run(':9PY KILL {u} :test'.format(u=self.u)) + hooks = self.irc.takeHooks() + + # Make sure we're respawning our PseudoClient when its killed + print(hooks) + spmain = [h for h in hooks if h[1] == 'PYLINK_SPAWNMAIN'] + self.assertTrue(spmain, 'PYLINK_SPAWNMAIN hook was never sent!') + + msgs = self.irc.takeMsgs() + commands = self.irc.takeCommands(msgs) + self.assertIn('UID', commands) + self.assertIn('FJOIN', commands) + + # Also make sure that we're updating the irc.pseudoclient field + self.assertNotEqual(self.irc.pseudoclient.uid, spmain[0]['olduser']) + + def testKickrejoin(self): + self.proto.kickClient(self.irc, self.u, '#pylink', self.u, 'test') + msgs = self.irc.takeMsgs() + commands = self.irc.takeCommands(msgs) + self.assertIn('FJOIN', commands) diff --git a/tests/test_fakeirc.py b/tests/test_fakeirc.py index 95bc800..c69c604 100644 --- a/tests/test_fakeirc.py +++ b/tests/test_fakeirc.py @@ -1,15 +1,13 @@ import sys import os sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) -from log import log import classes import unittest -# Yes, we're going to even test the testing classes. Testception? I think so. class TestFakeIRC(unittest.TestCase): def setUp(self): - self.irc = classes.FakeIRC('unittest', classes.FakeProto(), classes.testconf) + self.irc = classes.FakeIRC('unittest', classes.FakeProto()) def testFakeIRC(self): self.irc.run('this should do nothing') diff --git a/tests/test_proto_inspircd.py b/tests/test_proto_inspircd.py index 01eac64..c8b8c08 100644 --- a/tests/test_proto_inspircd.py +++ b/tests/test_proto_inspircd.py @@ -2,31 +2,30 @@ import sys import os sys.path += [os.getcwd(), os.path.join(os.getcwd(), 'protocols')] import unittest -from collections import defaultdict import inspircd import classes -import utils -import coreplugin +import world -class TestProtoInspIRCd(unittest.TestCase): - def setUp(self): - self.irc = classes.FakeIRC('unittest', inspircd, classes.testconf) - self.proto = self.irc.proto - self.sdata = self.irc.serverdata - # This is to initialize ourself as an internal PseudoServer, so we can spawn clients - self.proto.connect(self.irc) - self.u = self.irc.pseudoclient.uid - self.maxDiff = None - utils.command_hooks = defaultdict(list) +import tests_common + +world.testing = inspircd + +class InspIRCdTestCase(tests_common.CommonProtoTestCase): + def testCheckRecvpass(self): + # Correct recvpass here. + self.irc.run('SERVER somehow.someday abcd 0 0AL :Somehow Server - McMurdo Station, Antarctica') + # Incorrect recvpass here; should raise ProtocolError. + self.assertRaises(classes.ProtocolError, self.irc.run, 'SERVER somehow.someday BADPASS 0 0AL :Somehow Server - McMurdo Station, Antarctica') def testConnect(self): + self.proto.connect(self.irc) initial_messages = self.irc.takeMsgs() commands = self.irc.takeCommands(initial_messages) - # SERVER pylink.unittest abcd 0 9PY :PyLink Service - serverline = 'SERVER %s %s 0 %s :PyLink Service' % ( - self.sdata['hostname'], self.sdata['sendpass'], self.sdata['sid']) + serverline = 'SERVER %s %s 0 %s :%s' % ( + self.sdata['hostname'], self.sdata['sendpass'], self.sdata['sid'], + self.irc.botdata['serverdesc']) self.assertIn(serverline, initial_messages) self.assertIn('BURST', commands) self.assertIn('ENDBURST', commands) @@ -34,88 +33,12 @@ class TestProtoInspIRCd(unittest.TestCase): self.assertIn('UID', commands) self.assertIn('FJOIN', commands) - def testCheckRecvpass(self): - # Correct recvpass here. - self.irc.run('SERVER somehow.someday abcd 0 0AL :Somehow Server - McMurdo Station, Antarctica') - # Incorrect recvpass here; should raise ProtocolError. - self.assertRaises(classes.ProtocolError, self.irc.run, 'SERVER somehow.someday BADPASS 0 0AL :Somehow Server - McMurdo Station, Antarctica') - - def testSpawnClient(self): - u = self.proto.spawnClient(self.irc, 'testuser3', 'moo', 'hello.world').uid - # Check the server index and the user index - self.assertIn(u, self.irc.servers[self.irc.sid].users) - self.assertIn(u, self.irc.users) - # Raise ValueError when trying to spawn a client on a server that's not ours - self.assertRaises(ValueError, self.proto.spawnClient, self.irc, 'abcd', 'user', 'dummy.user.net', server='44A') - # Unfilled args should get placeholder fields and not error. - self.proto.spawnClient(self.irc, 'testuser4') - - def testJoinClient(self): - u = self.u - self.proto.joinClient(self.irc, u, '#Channel') - self.assertIn(u, self.irc.channels['#channel'].users) - # Non-existant user. - self.assertRaises(LookupError, self.proto.joinClient, self.irc, '9PYZZZZZZ', '#test') - - def testPartClient(self): - u = self.u - self.proto.joinClient(self.irc, u, '#channel') - self.proto.partClient(self.irc, u, '#channel') - self.assertNotIn(u, self.irc.channels['#channel'].users) - - def testQuitClient(self): - u = self.proto.spawnClient(self.irc, 'testuser3', 'moo', 'hello.world').uid - self.proto.joinClient(self.irc, u, '#channel') - self.assertRaises(LookupError, self.proto.quitClient, self.irc, '9PYZZZZZZ', 'quit reason') - self.proto.quitClient(self.irc, u, 'quit reason') - self.assertNotIn(u, self.irc.channels['#channel'].users) - self.assertNotIn(u, self.irc.users) - self.assertNotIn(u, self.irc.servers[self.irc.sid].users) - - def testKickClient(self): - target = self.proto.spawnClient(self.irc, 'soccerball', 'soccerball', 'abcd').uid - self.proto.joinClient(self.irc, target, '#pylink') - self.assertIn(self.u, self.irc.channels['#pylink'].users) - self.assertIn(target, self.irc.channels['#pylink'].users) - self.proto.kickClient(self.irc, self.u, '#pylink', target, 'Pow!') - self.assertNotIn(target, self.irc.channels['#pylink'].users) - - def testNickClient(self): - self.proto.nickClient(self.irc, self.u, 'NotPyLink') - self.assertEqual('NotPyLink', self.irc.users[self.u].nick) - - def testModeClient(self): - testuser = self.proto.spawnClient(self.irc, 'testcakes') - self.irc.takeMsgs() - self.proto.modeClient(self.irc, self.u, testuser.uid, [('+i', None), ('+w', None)]) - self.assertEqual({('i', None), ('w', None)}, testuser.modes) - - self.proto.modeClient(self.irc, self.u, '#pylink', [('+s', None), ('+l', '30')]) - self.assertEqual({('s', None), ('l', '30')}, self.irc.channels['#pylink'].modes) - - cmds = self.irc.takeCommands(self.irc.takeMsgs()) - self.assertEqual(cmds, ['MODE', 'FMODE']) - def testSpawnServer(self): - # Incorrect SID length - self.assertRaises(Exception, self.proto.spawnServer, self.irc, 'subserver.pylink', '34Q0') - self.proto.spawnServer(self.irc, 'subserver.pylink', '34Q') - # Duplicate server name - self.assertRaises(Exception, self.proto.spawnServer, self.irc, 'Subserver.PyLink', '34Z') - # Duplicate SID - self.assertRaises(Exception, self.proto.spawnServer, self.irc, 'another.Subserver.PyLink', '34Q') - self.assertIn('34Q', self.irc.servers) + super(InspIRCdTestCase, self).testSpawnServer() # Are we bursting properly? self.assertIn(':34Q ENDBURST', self.irc.takeMsgs()) - def testSpawnClientOnServer(self): - self.proto.spawnServer(self.irc, 'subserver.pylink', '34Q') - u = self.proto.spawnClient(self.irc, 'person1', 'person', 'users.overdrive.pw', server='34Q') - # We're spawning clients on the right server, hopefully... - self.assertIn(u.uid, self.irc.servers['34Q'].users) - self.assertNotIn(u.uid, self.irc.servers[self.irc.sid].users) - - def testSquit(self): + def testHandleSQuit(self): # Spawn a messy network map, just because! self.proto.spawnServer(self.irc, 'level1.pylink', '34P') self.proto.spawnServer(self.irc, 'level2.pylink', '34Q', uplink='34P') @@ -136,18 +59,6 @@ class TestProtoInspIRCd(unittest.TestCase): self.assertNotIn('34Q', self.irc.servers) self.assertNotIn('34Z', self.irc.servers) - def testRSquit(self): - u = self.proto.spawnClient(self.irc, 'person1', 'person', 'users.overdrive.pw') - u.identified = 'admin' - self.proto.spawnServer(self.irc, 'level1.pylink', '34P') - self.irc.run(':%s RSQUIT level1.pylink :some reason' % self.u) - # No SQUIT yet, since the 'PyLink' client isn't identified - self.assertNotIn('SQUIT', self.irc.takeCommands(self.irc.takeMsgs())) - # The one we just spawned however, is. - self.irc.run(':%s RSQUIT level1.pylink :some reason' % u.uid) - self.assertIn('SQUIT', self.irc.takeCommands(self.irc.takeMsgs())) - self.assertNotIn('34P', self.irc.servers) - def testHandleServer(self): self.irc.run('SERVER whatever.net abcd 0 10X :something') self.assertIn('10X', self.irc.servers) @@ -157,124 +68,122 @@ class TestProtoInspIRCd(unittest.TestCase): self.assertEqual('test.server', self.irc.servers['0AL'].name) def testHandleUID(self): - self.irc.run('SERVER whatever.net abcd 0 10X :something') self.irc.run(':10X UID 10XAAAAAB 1429934638 GL 0::1 hidden-7j810p.9mdf.lrek.0000.0000.IP gl 0::1 1429934638 +Wioswx +ACGKNOQXacfgklnoqvx :realname') self.assertIn('10XAAAAAB', self.irc.servers['10X'].users) - self.assertIn('10XAAAAAB', self.irc.users) u = self.irc.users['10XAAAAAB'] self.assertEqual('GL', u.nick) + expected = {'uid': '10XAAAAAB', 'ts': '1429934638', 'nick': 'GL', + 'realhost': '0::1', 'ident': 'gl', 'ip': '0::1', + 'host': 'hidden-7j810p.9mdf.lrek.0000.0000.IP'} + hookdata = self.irc.takeHooks()[0][-1] + self.assertEqual(hookdata, expected) + def testHandleKill(self): self.irc.takeMsgs() # Ignore the initial connect messages - utils.add_hook(self.irc.dummyhook, 'KILL') - olduid = self.irc.pseudoclient.uid - self.irc.run(':{u} KILL {u} :killed'.format(u=olduid)) + self.u = self.irc.pseudoclient.uid + self.irc.run(':{u} KILL {u} :killed'.format(u=self.u)) msgs = self.irc.takeMsgs() commands = self.irc.takeCommands(msgs) - hookdata = self.irc.takeHooks()[0] - del hookdata['ts'] - self.assertEqual({'target': olduid, 'text': 'killed'}, hookdata) - # Make sure we're respawning our PseudoClient when its killed - self.assertIn('UID', commands) - self.assertIn('FJOIN', commands) - # Also make sure that we're updating the irc.pseudoclient field - self.assertNotEqual(self.irc.pseudoclient.uid, olduid) + hookdata = self.irc.takeHooks()[0][-1] + self.assertEqual(hookdata['target'], self.u) + self.assertEqual(hookdata['text'], 'killed') + self.assertNotIn(self.u, self.irc.users) def testHandleKick(self): self.irc.takeMsgs() # Ignore the initial connect messages - utils.add_hook(self.irc.dummyhook, 'KICK') self.irc.run(':{u} KICK #pylink {u} :kicked'.format(u=self.irc.pseudoclient.uid)) - hookdata = self.irc.takeHooks()[0] - del hookdata['ts'] - self.assertEqual({'target': self.u, 'text': 'kicked', 'channel': '#pylink'}, hookdata) + hookdata = self.irc.takeHooks()[0][-1] + self.assertEqual(hookdata['target'], self.u) + self.assertEqual(hookdata['text'], 'kicked') + self.assertEqual(hookdata['channel'], '#pylink') - # Ditto above - msgs = self.irc.takeMsgs() - commands = self.irc.takeCommands(msgs) - self.assertIn('FJOIN', commands) - - def testHandleFjoinUsers(self): + def testHandleFJoinUsers(self): self.irc.run(':10X FJOIN #Chat 1423790411 + :,10XAAAAAA ,10XAAAAAB') self.assertEqual({'10XAAAAAA', '10XAAAAAB'}, self.irc.channels['#chat'].users) - # self.assertIn('10XAAAAAB', self.irc.channels['#chat'].users) + self.assertIn('#chat', self.irc.users['10XAAAAAA'].channels) # Sequential FJOINs must NOT remove existing users self.irc.run(':10X FJOIN #Chat 1423790412 + :,10XAAAAAC') # Join list can be empty too, in the case of permanent channels with 0 users. self.irc.run(':10X FJOIN #Chat 1423790413 +nt :') - def testHandleFjoinModes(self): + def testHandleFJoinModes(self): self.irc.run(':10X FJOIN #Chat 1423790411 +nt :,10XAAAAAA ,10XAAAAAB') self.assertEqual({('n', None), ('t', None)}, self.irc.channels['#chat'].modes) # Sequential FJOINs must NOT remove existing modes self.irc.run(':10X FJOIN #Chat 1423790412 + :,10XAAAAAC') self.assertEqual({('n', None), ('t', None)}, self.irc.channels['#chat'].modes) - def testHandleFjoinModesWithArgs(self): + def testHandleFJoinModesWithArgs(self): self.irc.run(':10X FJOIN #Chat 1423790414 +nlks 10 t0psekrit :,10XAAAAAA ,10XAAAAAB') self.assertEqual({('n', None), ('s', None), ('l', '10'), ('k', 't0psekrit')}, self.irc.channels['#chat'].modes) - def testHandleFjoinPrefixes(self): + def testHandleFJoinPrefixes(self): self.irc.run(':10X FJOIN #Chat 1423790418 +nt :ov,10XAAAAAA v,10XAAAAAB ,10XAAAAAC') self.assertEqual({('n', None), ('t', None)}, self.irc.channels['#chat'].modes) self.assertEqual({'10XAAAAAA', '10XAAAAAB', '10XAAAAAC'}, self.irc.channels['#chat'].users) self.assertIn('10XAAAAAA', self.irc.channels['#chat'].prefixmodes['ops']) self.assertEqual({'10XAAAAAA', '10XAAAAAB'}, self.irc.channels['#chat'].prefixmodes['voices']) - def testHandleFjoinHook(self): - utils.add_hook(self.irc.dummyhook, 'JOIN') + def testHandleFJoinHook(self): self.irc.run(':10X FJOIN #PyLink 1423790418 +ls 10 :ov,10XAAAAAA v,10XAAAAAB ,10XAAAAAC') - hookdata = self.irc.takeHooks()[0] + hookdata = self.irc.takeHooks()[0][-1] expected = {'modes': [('+l', '10'), ('+s', None)], 'channel': '#pylink', 'users': ['10XAAAAAA', '10XAAAAAB', '10XAAAAAC'], 'ts': 1423790418} self.assertEqual(expected, hookdata) - def testHandleFmode(self): - self.irc.run(':10X FJOIN #pylink 1423790411 +n :o,10XAAAAAA ,10XAAAAAB') - utils.add_hook(self.irc.dummyhook, 'MODE') + def testHandleFMode(self): self.irc.run(':70M FMODE #pylink 1423790412 +ikl herebedragons 100') - self.assertEqual({('i', None), ('k', 'herebedragons'), ('l', '100'), ('n', None)}, self.irc.channels['#pylink'].modes) + self.assertEqual({('i', None), ('k', 'herebedragons'), ('l', '100')}, self.irc.channels['#pylink'].modes) self.irc.run(':70M FMODE #pylink 1423790413 -ilk+m herebedragons') - self.assertEqual({('m', None), ('n', None)}, self.irc.channels['#pylink'].modes) + self.assertEqual({('m', None)}, self.irc.channels['#pylink'].modes) hookdata = self.irc.takeHooks() - expected = [{'target': '#pylink', 'modes': [('+i', None), ('+k', 'herebedragons'), ('+l', '100')], 'ts': 1423790412}, - {'target': '#pylink', 'modes': [('-i', None), ('-l', None), ('-k', 'herebedragons'), ('+m', None)], 'ts': 1423790413}] + expected = [['70M', 'FMODE', {'target': '#pylink', 'modes': + [('+i', None), ('+k', 'herebedragons'), + ('+l', '100')], 'ts': 1423790412} + ], + ['70M', 'FMODE', {'target': '#pylink', 'modes': + [('-i', None), ('-l', None), + ('-k', 'herebedragons'), ('+m', None)], + 'ts': 1423790413}] + ] self.assertEqual(expected, hookdata) - def testHandleFmodeWithPrefixes(self): - self.irc.run(':70M FJOIN #pylink 1423790411 +n :o,10XAAAAAA ,10XAAAAAB') - utils.add_hook(self.irc.dummyhook, 'MODE') + def testHandleFModeWithPrefixes(self): + self.irc.run(':70M FJOIN #pylink 123 +n :o,10XAAAAAA ,10XAAAAAB') # Prefix modes are stored separately, so they should never show up in .modes self.assertNotIn(('o', '10XAAAAAA'), self.irc.channels['#pylink'].modes) self.assertEqual({'10XAAAAAA'}, self.irc.channels['#pylink'].prefixmodes['ops']) - self.irc.run(':70M FMODE #pylink 1423790412 +lot 50 %s' % self.u) + self.irc.run(':70M FMODE #pylink 123 +lot 50 %s' % self.u) self.assertIn(self.u, self.irc.channels['#pylink'].prefixmodes['ops']) modes = {('l', '50'), ('n', None), ('t', None)} self.assertEqual(modes, self.irc.channels['#pylink'].modes) - self.irc.run(':70M FMODE #pylink 1423790413 -o %s' % self.u) + self.irc.run(':70M FMODE #pylink 123 -o %s' % self.u) self.assertEqual(modes, self.irc.channels['#pylink'].modes) self.assertNotIn(self.u, self.irc.channels['#pylink'].prefixmodes['ops']) # Test hooks hookdata = self.irc.takeHooks() - expected = [{'target': '#pylink', 'modes': [('+l', '50'), ('+o', '9PYAAAAAA'), ('+t', None)], 'ts': 1423790412}, - {'target': '#pylink', 'modes': [('-o', '9PYAAAAAA')], 'ts': 1423790413}] + expected = [['70M', 'FJOIN', {'channel': '#pylink', 'ts': 123, 'modes': [('+n', None)], + 'users': ['10XAAAAAA', '10XAAAAAB']}], + ['70M', 'FMODE', {'target': '#pylink', 'modes': [('+l', '50'), ('+o', '9PYAAAAAA'), ('+t', None)], 'ts': 123}], + ['70M', 'FMODE', {'target': '#pylink', 'modes': [('-o', '9PYAAAAAA')], 'ts': 123}]] self.assertEqual(expected, hookdata) - def testFmodeRemovesOldParams(self): - utils.add_hook(self.irc.dummyhook, 'MODE') + def testHandleFModeRemovesOldParams(self): self.irc.run(':70M FMODE #pylink 1423790412 +l 50') self.assertEqual({('l', '50')}, self.irc.channels['#pylink'].modes) self.irc.run(':70M FMODE #pylink 1423790412 +l 30') self.assertEqual({('l', '30')}, self.irc.channels['#pylink'].modes) hookdata = self.irc.takeHooks() - expected = [{'target': '#pylink', 'modes': [('+l', '50')], 'ts': 1423790412}, - {'target': '#pylink', 'modes': [('+l', '30')], 'ts': 1423790412}] + expected = [['70M', 'FMODE', {'target': '#pylink', 'modes': [('+l', '50')], 'ts': 1423790412}], + ['70M', 'FMODE', {'target': '#pylink', 'modes': [('+l', '30')], 'ts': 1423790412}]] self.assertEqual(expected, hookdata) - def testFjoinResetsTS(self): + def testHandleFJoinResetsTS(self): curr_ts = self.irc.channels['#pylink'].ts self.irc.run(':70M FJOIN #pylink 5 + :') self.assertEqual(self.irc.channels['#pylink'].ts, 5) @@ -285,87 +194,60 @@ class TestProtoInspIRCd(unittest.TestCase): def testHandleTopic(self): self.irc.connect() - utils.add_hook(self.irc.dummyhook, 'TOPIC') self.irc.run(':9PYAAAAAA TOPIC #PyLink :test') self.assertEqual(self.irc.channels['#pylink'].topic, 'test') - hookdata = self.irc.takeHooks()[0] - # Setter is a nick here, not an UID - this is to be consistent - # with FTOPIC above, which sends the nick/prefix of the topic setter. - self.assertTrue(utils.isNick(hookdata.get('setter'))) + hookdata = self.irc.takeHooks()[0][-1] self.assertEqual(type(hookdata['ts']), int) self.assertEqual(hookdata['topic'], 'test') self.assertEqual(hookdata['channel'], '#pylink') - def testMsgHooks(self): + def testHandleMessages(self): for m in ('NOTICE', 'PRIVMSG'): - utils.add_hook(self.irc.dummyhook, m) self.irc.run(':70MAAAAAA %s #dev :afasfsa' % m) - hookdata = self.irc.takeHooks()[0] - del hookdata['ts'] - self.assertEqual({'target': '#dev', 'text': 'afasfsa'}, hookdata) + hookdata = self.irc.takeHooks()[0][-1] + self.assertEqual(hookdata['target'], '#dev') + self.assertEqual(hookdata['text'], 'afasfsa') def testHandlePart(self): - utils.add_hook(self.irc.dummyhook, 'PART') + hookdata = self.irc.takeHooks() self.irc.run(':9PYAAAAAA PART #pylink') - hookdata = self.irc.takeHooks()[0] - del hookdata['ts'] - self.assertEqual({'channel': '#pylink', 'text': ''}, hookdata) - - def testUIDHook(self): - utils.add_hook(self.irc.dummyhook, 'UID') - # Create the server so we won't KeyError on processing UID - self.irc.run('SERVER whatever. abcd 0 10X :Whatever Server - Hellas Planitia, Mars') - self.irc.run(':10X UID 10XAAAAAB 1429934638 GL 0::1 ' - 'hidden-7j810p.9mdf.lrek.0000.0000.IP gl 0::1 1429934638 ' - '+Wioswx +ACGKNOQXacfgklnoqvx :realname') - expected = {'uid': '10XAAAAAB', 'ts': '1429934638', 'nick': 'GL', - 'realhost': '0::1', 'ident': 'gl', 'ip': '0::1', - 'host': 'hidden-7j810p.9mdf.lrek.0000.0000.IP'} - hookdata = self.irc.takeHooks()[0] - self.assertEqual(hookdata, expected) + hookdata = self.irc.takeHooks()[0][-1] + self.assertEqual(hookdata['channels'], ['#pylink']) + self.assertEqual(hookdata['text'], '') def testHandleQuit(self): - utils.add_hook(self.irc.dummyhook, 'QUIT') - self.irc.run('SERVER whatever. abcd 0 10X :Whatever Server - Hellas Planitia, Mars') - self.irc.run(':10X UID 10XAAAAAB 1429934638 GL 0::1 ' - 'hidden-7j810p.9mdf.lrek.0000.0000.IP gl 0::1 1429934638 ' - '+Wioswx +ACGKNOQXacfgklnoqvx :realname') + self.irc.takeHooks() self.irc.run(':10XAAAAAB QUIT :Quit: quit message goes here') - hookdata = self.irc.takeHooks()[0] - del hookdata['ts'] - self.assertEqual(hookdata, {'text': 'Quit: quit message goes here'}) + hookdata = self.irc.takeHooks()[0][-1] + self.assertEqual(hookdata['text'], 'Quit: quit message goes here') self.assertNotIn('10XAAAAAB', self.irc.users) self.assertNotIn('10XAAAAAB', self.irc.servers['10X'].users) def testHandleServer(self): - utils.add_hook(self.irc.dummyhook, 'SERVER') self.irc.run(':00A SERVER test.server * 1 00C :testing raw message syntax') - hookdata = self.irc.takeHooks()[0] - del hookdata['ts'] - self.assertEqual(hookdata, {'name': 'test.server', 'sid': '00C', - 'text': 'testing raw message syntax'}) + hookdata = self.irc.takeHooks()[-1][-1] + self.assertEqual(hookdata['name'], 'test.server') + self.assertEqual(hookdata['sid'], '00C') + self.assertEqual(hookdata['text'], 'testing raw message syntax') self.assertIn('00C', self.irc.servers) def testHandleNick(self): - utils.add_hook(self.irc.dummyhook, 'NICK') self.irc.run(':9PYAAAAAA NICK PyLink-devel 1434744242') - hookdata = self.irc.takeHooks()[0] + hookdata = self.irc.takeHooks()[0][-1] expected = {'newnick': 'PyLink-devel', 'oldnick': 'PyLink', 'ts': 1434744242} self.assertEqual(hookdata, expected) self.assertEqual('PyLink-devel', self.irc.users['9PYAAAAAA'].nick) def testHandleSave(self): - utils.add_hook(self.irc.dummyhook, 'SAVE') self.irc.run(':9PYAAAAAA NICK Derp_ 1433728673') self.irc.run(':70M SAVE 9PYAAAAAA 1433728673') - hookdata = self.irc.takeHooks()[0] - self.assertEqual(hookdata, {'target': '9PYAAAAAA', 'ts': 1433728673}) + hookdata = self.irc.takeHooks()[-1][-1] + self.assertEqual(hookdata, {'target': '9PYAAAAAA', 'ts': 1433728673, 'oldnick': 'Derp_'}) self.assertEqual('9PYAAAAAA', self.irc.users['9PYAAAAAA'].nick) - def testInviteHook(self): - utils.add_hook(self.irc.dummyhook, 'INVITE') + def testHandleInvite(self): self.irc.run(':10XAAAAAA INVITE 9PYAAAAAA #blah 0') - hookdata = self.irc.takeHooks()[0] + hookdata = self.irc.takeHooks()[-1][-1] del hookdata['ts'] self.assertEqual(hookdata, {'target': '9PYAAAAAA', 'channel': '#blah'}) diff --git a/tests/test_relay.py b/tests/test_relay.py index 07240b0..5c999fe 100644 --- a/tests/test_relay.py +++ b/tests/test_relay.py @@ -4,7 +4,6 @@ cwd = os.getcwd() sys.path += [cwd, os.path.join(cwd, 'plugins')] import unittest -import utils import classes import relay @@ -13,28 +12,34 @@ def dummyf(): class TestRelay(unittest.TestCase): def setUp(self): - self.irc = classes.FakeIRC('unittest', classes.FakeProto(), classes.testconf) + self.irc = classes.FakeIRC('unittest', classes.FakeProto()) self.irc.maxnicklen = 20 - self.irc.proto.__name__ = "test" - self.f = relay.normalizeNick + self.f = lambda nick: relay.normalizeNick(self.irc, 'unittest', nick) + # Fake our protocol name to something that supports slashes in nicks. + # relay uses a whitelist for this to prevent accidentally introducing + # bad nicks: + self.irc.proto.__name__ = "inspircd" def testNormalizeNick(self): # Second argument simply states the suffix. - self.assertEqual(self.f(self.irc, 'unittest', 'helloworld'), 'helloworld/unittest') - self.assertEqual(self.f(self.irc, 'unittest', 'ObnoxiouslyLongNick'), 'Obnoxiously/unittest') - self.assertEqual(self.f(self.irc, 'unittest', '10XAAAAAA'), '_10XAAAAAA/unittest') + self.assertEqual(self.f('helloworld'), 'helloworld/unittest') + self.assertEqual(self.f('ObnoxiouslyLongNick'), 'Obnoxiously/unittest') + self.assertEqual(self.f('10XAAAAAA'), '_10XAAAAAA/unittest') def testNormalizeNickConflict(self): - self.assertEqual(self.f(self.irc, 'unittest', 'helloworld'), 'helloworld/unittest') + self.assertEqual(self.f('helloworld'), 'helloworld/unittest') self.irc.users['10XAAAAAA'] = classes.IrcUser('helloworld/unittest', 1234, '10XAAAAAA') # Increase amount of /'s by one - self.assertEqual(self.f(self.irc, 'unittest', 'helloworld'), 'helloworld//unittest') + self.assertEqual(self.f('helloworld'), 'helloworld//unittest') self.irc.users['10XAAAAAB'] = classes.IrcUser('helloworld//unittest', 1234, '10XAAAAAB') # Cut off the nick, not the suffix if the result is too long. - self.assertEqual(self.f(self.irc, 'unittest', 'helloworld'), 'helloworl///unittest') + self.assertEqual(self.f('helloworld'), 'helloworl///unittest') def testNormalizeNickRemovesSlashes(self): self.irc.proto.__name__ = "charybdis" - self.assertEqual(self.f(self.irc, 'unittest', 'helloworld'), 'helloworld|unittest') - self.assertEqual(self.f(self.irc, 'unittest', 'abcde/eJanus'), 'abcde|eJanu|unittest') - self.assertEqual(self.f(self.irc, 'unittest', 'ObnoxiouslyLongNick'), 'Obnoxiously|unittest') + try: + self.assertEqual(self.f('helloworld'), 'helloworld|unittest') + self.assertEqual(self.f('abcde/eJanus'), 'abcde|eJanu|unittest') + self.assertEqual(self.f('ObnoxiouslyLongNick'), 'Obnoxiously|unittest') + finally: + self.irc.proto.__name__ = "inspircd" diff --git a/tests/test_utils.py b/tests/test_utils.py index 36c178f..8ecf82d 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -5,11 +5,16 @@ import unittest import itertools import utils +import classes +import world def dummyf(): pass class TestUtils(unittest.TestCase): + def setUp(self): + self.irc = classes.FakeIRC('fakeirc', classes.FakeProto()) + def testTS6UIDGenerator(self): uidgen = utils.TS6UIDGenerator('9PY') self.assertEqual(uidgen.next_uid(), '9PYAAAAAA') @@ -21,16 +26,16 @@ class TestUtils(unittest.TestCase): utils.add_cmd(dummyf) utils.add_cmd(dummyf, 'TEST') # All command names should be automatically lowercased. - self.assertIn('dummyf', utils.bot_commands) - self.assertIn('test', utils.bot_commands) - self.assertNotIn('TEST', utils.bot_commands) + self.assertIn('dummyf', world.bot_commands) + self.assertIn('test', world.bot_commands) + self.assertNotIn('TEST', world.bot_commands) def test_add_hook(self): utils.add_hook(dummyf, 'join') - self.assertIn('JOIN', utils.command_hooks) + self.assertIn('JOIN', world.command_hooks) # Command names stored in uppercase. - self.assertNotIn('join', utils.command_hooks) - self.assertIn(dummyf, utils.command_hooks['JOIN']) + self.assertNotIn('join', world.command_hooks) + self.assertIn(dummyf, world.command_hooks['JOIN']) def testIsNick(self): self.assertFalse(utils.isNick('abcdefgh', nicklen=3)) @@ -96,5 +101,19 @@ class TestUtils(unittest.TestCase): ('+b', '*!*@*.badisp.net')]) self.assertEqual(res, '-o+l-nm+kb 9PYAAAAAA 50 hello *!*@*.badisp.net') + @unittest.skip('Wait, we need to work out the kinks first! (reversing changes of modes with arguments)') + def testReverseModes(self): + f = lambda x: utils.reverseModes(self.irc, '#test', x) + # Strings. + self.assertEqual(f("+nt-lk"), "-nt+lk") + self.assertEqual(f("nt-k"), "-nt+k") + # Lists. + self.assertEqual(f([('+m', None), ('+t', None), ('+l', '3'), ('-o', 'person')]), + [('-m', None), ('-t', None), ('-l', '3'), ('+o', 'person')]) + # Sets. + self.assertEqual(f({('s', None), ('+o', 'whoever')}), {('-s', None), ('-o', 'whoever')}) + # Combining modes with an initial + and those without + self.assertEqual(f({('s', None), ('+n', None)}), {('-s', None), ('-n', None)}) + if __name__ == '__main__': unittest.main() diff --git a/tests/tests_common.py b/tests/tests_common.py new file mode 100644 index 0000000..b3f19f2 --- /dev/null +++ b/tests/tests_common.py @@ -0,0 +1,96 @@ +import sys +import os +sys.path += [os.getcwd(), os.path.join(os.getcwd(), 'protocols')] +import unittest + +import world +import classes + +world.started.set() + +class PluginTestCase(unittest.TestCase): + def setUp(self): + self.irc = classes.FakeIRC('unittest', world.testing) + self.proto = self.irc.proto + self.irc.connect() + self.sdata = self.irc.serverdata + self.u = self.irc.pseudoclient.uid + self.maxDiff = None + # Dummy servers/users used in tests below. + self.proto.spawnServer(self.irc, 'whatever.', sid='10X') + for x in range(3): + self.proto.spawnClient(self.irc, 'user%s' % x, server='10X') + +class CommonProtoTestCase(PluginTestCase): + def testJoinClient(self): + u = self.u + self.proto.joinClient(self.irc, u, '#Channel') + self.assertIn(u, self.irc.channels['#channel'].users) + # Non-existant user. + self.assertRaises(LookupError, self.proto.joinClient, self.irc, '9PYZZZZZZ', '#test') + + def testKickClient(self): + target = self.proto.spawnClient(self.irc, 'soccerball', 'soccerball', 'abcd').uid + self.proto.joinClient(self.irc, target, '#pylink') + self.assertIn(self.u, self.irc.channels['#pylink'].users) + self.assertIn(target, self.irc.channels['#pylink'].users) + self.proto.kickClient(self.irc, self.u, '#pylink', target, 'Pow!') + self.assertNotIn(target, self.irc.channels['#pylink'].users) + + def testModeClient(self): + testuser = self.proto.spawnClient(self.irc, 'testcakes') + self.irc.takeMsgs() + self.proto.modeClient(self.irc, self.u, testuser.uid, [('+i', None), ('+w', None)]) + self.assertEqual({('i', None), ('w', None)}, testuser.modes) + + self.proto.modeClient(self.irc, self.u, '#pylink', [('+s', None), ('+l', '30')]) + self.assertEqual({('s', None), ('l', '30')}, self.irc.channels['#pylink'].modes) + + cmds = self.irc.takeCommands(self.irc.takeMsgs()) + self.assertEqual(cmds, ['MODE', 'FMODE']) + + def testNickClient(self): + self.proto.nickClient(self.irc, self.u, 'NotPyLink') + self.assertEqual('NotPyLink', self.irc.users[self.u].nick) + + def testPartClient(self): + u = self.u + self.proto.joinClient(self.irc, u, '#channel') + self.proto.partClient(self.irc, u, '#channel') + self.assertNotIn(u, self.irc.channels['#channel'].users) + + def testQuitClient(self): + u = self.proto.spawnClient(self.irc, 'testuser3', 'moo', 'hello.world').uid + self.proto.joinClient(self.irc, u, '#channel') + self.assertRaises(LookupError, self.proto.quitClient, self.irc, '9PYZZZZZZ', 'quit reason') + self.proto.quitClient(self.irc, u, 'quit reason') + self.assertNotIn(u, self.irc.channels['#channel'].users) + self.assertNotIn(u, self.irc.users) + self.assertNotIn(u, self.irc.servers[self.irc.sid].users) + + def testSpawnClient(self): + u = self.proto.spawnClient(self.irc, 'testuser3', 'moo', 'hello.world').uid + # Check the server index and the user index + self.assertIn(u, self.irc.servers[self.irc.sid].users) + self.assertIn(u, self.irc.users) + # Raise ValueError when trying to spawn a client on a server that's not ours + self.assertRaises(ValueError, self.proto.spawnClient, self.irc, 'abcd', 'user', 'dummy.user.net', server='44A') + # Unfilled args should get placeholder fields and not error. + self.proto.spawnClient(self.irc, 'testuser4') + + def testSpawnClientOnServer(self): + self.proto.spawnServer(self.irc, 'subserver.pylink', '34Q') + u = self.proto.spawnClient(self.irc, 'person1', 'person', 'users.overdrive.pw', server='34Q') + # We're spawning clients on the right server, hopefully... + self.assertIn(u.uid, self.irc.servers['34Q'].users) + self.assertNotIn(u.uid, self.irc.servers[self.irc.sid].users) + + def testSpawnServer(self): + # Incorrect SID length + self.assertRaises(Exception, self.proto.spawnServer, self.irc, 'subserver.pylink', '34Q0') + self.proto.spawnServer(self.irc, 'subserver.pylink', '34Q') + # Duplicate server name + self.assertRaises(Exception, self.proto.spawnServer, self.irc, 'Subserver.PyLink', '34Z') + # Duplicate SID + self.assertRaises(Exception, self.proto.spawnServer, self.irc, 'another.Subserver.PyLink', '34Q') + self.assertIn('34Q', self.irc.servers) diff --git a/utils.py b/utils.py index 8eeb092..69ae101 100644 --- a/utils.py +++ b/utils.py @@ -1,19 +1,13 @@ import string import re -from collections import defaultdict -import threading +import inspect from log import log +import world -global bot_commands, command_hooks -# This should be a mapping of command names to functions -bot_commands = {} -command_hooks = defaultdict(list) -networkobjects = {} -schedulers = {} -plugins = [] -whois_handlers = [] -started = threading.Event() +# This is separate from classes.py to prevent import loops. +class NotAuthenticatedError(Exception): + pass class TS6UIDGenerator(): """TS6 UID Generator module, adapted from InspIRCd source @@ -114,12 +108,12 @@ def add_cmd(func, name=None): if name is None: name = func.__name__ name = name.lower() - bot_commands[name] = func + world.bot_commands[name].append(func) def add_hook(func, command): """Add a hook for command .""" command = command.upper() - command_hooks[command].append(func) + world.command_hooks[command].append(func) def toLower(irc, text): """ @@ -168,7 +162,7 @@ def isServerName(s): return _isASCII(s) and '.' in s and not s.startswith('.') def parseModes(irc, target, args): - """Parses a mode string into a list of (mode, argument) tuples. + """Parses a modestring list into a list of (mode, argument) tuples. ['+mitl-o', '3', 'person'] => [('+m', None), ('+i', None), ('+t', None), ('+l', '3'), ('-o', 'person')] """ # http://www.irc.org/tech_docs/005.html @@ -340,6 +334,34 @@ def joinModes(modes): modelist += ' %s' % ' '.join(args) return modelist +def reverseModes(irc, target, modes): + """ + + Reverses/Inverts the mode string or mode list given. + + "+nt-lk" => "-nt+lk" + "nt-k" => "-nt+k" + [('+m', None), ('+t', None), ('+l', '3'), ('-o', 'person')] => + [('-m', None), ('-t', None), ('-l', '3'), ('+o', 'person')] + [('s', None), ('+n', None)] => [('-s', None), ('-n', None)] + """ + origtype = type(modes) + # Operate on joined modestrings only; it's easier. + if origtype != str: + modes = joinModes(modes) + # Swap the +'s and -'s by replacing one with a dummy character, and then changing it back. + assert '\x00' not in modes, 'NUL cannot be in the mode list (it is a reserved character)!' + if not modes.startswith(('+', '-')): + modes = '+' + modes + newmodes = modes.replace('+', '\x00') + newmodes = newmodes.replace('-', '+') + newmodes = newmodes.replace('\x00', '-') + if origtype != str: + # If the original query isn't a string, send back the parseModes() output. + return parseModes(irc, target, newmodes.split(" ")) + else: + return newmodes + def isInternalClient(irc, numeric): """ @@ -357,15 +379,38 @@ def isInternalServer(irc, sid): """ return (sid in irc.servers and irc.servers[sid].internal) -def isOper(irc, uid): +def isOper(irc, uid, allowAuthed=True, allowOper=True): """ Returns whether has operator status on PyLink. This can be achieved - by either identifying to PyLink as admin, or having user mode +o set. + by either identifying to PyLink as admin (if allowAuthed is True), + or having user mode +o set (if allowOper is True). At least one of + allowAuthed or allowOper must be True for this to give any meaningful + results. """ - return (uid in irc.users and (("o", None) in irc.users[uid].modes or irc.users[uid].identified)) + if uid in irc.users: + if allowOper and ("o", None) in irc.users[uid].modes: + return True + elif allowAuthed and irc.users[uid].identified: + return True + return False + +def checkAuthenticated(irc, uid, allowAuthed=True, allowOper=True): + """ + + Checks whether user has operator status on PyLink, raising + NotAuthenticatedError and logging the access denial if not.""" + lastfunc = inspect.stack()[1][3] + if not isOper(irc, uid, allowAuthed=allowAuthed, allowOper=allowOper): + log.warning('(%s) Access denied for %s calling %r', irc.name, + getHostmask(irc, uid), lastfunc) + raise NotAuthenticatedError("You are not authenticated!") + return True def getHostmask(irc, user): + """ + + Gets the hostmask of user , if present.""" userobj = irc.users.get(user) if userobj is None: return '' diff --git a/world.py b/world.py new file mode 100644 index 0000000..7b34d3f --- /dev/null +++ b/world.py @@ -0,0 +1,18 @@ +# world.py: global state variables go here + +from collections import defaultdict +import threading + +# Global variable to indicate whether we're being ran directly, or imported +# for a testcase. +testing = True + +global bot_commands, command_hooks +# This should be a mapping of command names to functions +bot_commands = defaultdict(list) +command_hooks = defaultdict(list) +networkobjects = {} +schedulers = {} +plugins = [] +whois_handlers = [] +started = threading.Event()