diff --git a/.gitignore b/.gitignore index 868c046..1e66a11 100644 --- a/.gitignore +++ b/.gitignore @@ -2,7 +2,7 @@ *.pyc # Ignore bot-specific config file: -config.xml +config.json # Ignore pydev's nonsense: .project diff --git a/core/config.py b/core/config.py index b1331ce..29c3fe1 100644 --- a/core/config.py +++ b/core/config.py @@ -1,74 +1,56 @@ # -*- coding: utf-8 -*- """ -EarwigBot's XML Config File Parser +EarwigBot's JSON Config File Parser This handles all tasks involving reading and writing to our config file, including encrypting and decrypting passwords and making a new config file from scratch at the inital bot run. Usually you'll just want to do "from core import config" and access config data -from within config's four global variables: +from within config's three global variables and one function: -* config.components -* config.wiki -* config.irc -* config.schedule +* config.components - a list of enabled components +* config.wiki - a dict of config information for wiki-editing +* config.irc - a dict of config information for IRC +* config.schedule() - returns a list of tasks scheduled to run now """ -from collections import defaultdict +import json from os import makedirs, path -from xml.dom import minidom -from xml.parsers.expat import ExpatError from lib import blowfish script_dir = path.dirname(path.abspath(__file__)) root_dir = path.split(script_dir)[0] -config_path = path.join(root_dir, "config.xml") +config_path = path.join(root_dir, "config.json") -_config = None # holds the parsed DOM object for our config file +_config = None # holds data loaded from our config file -# initialize our five global variables to store config data -components, wiki, irc, schedule, watcher = (None, None, None, None, None) +# set our three easy-config-access global variables to None +components, wiki, irc = (None, None, None) -class ConfigParseError(Exception): - """Base exception for when we could not parse the config file.""" - -class TypeMismatchError(ConfigParseError): - """A field does not fit to its expected type; e.g., an arbitrary string - where we expected a boolean or integer.""" - -class MissingElementError(ConfigParseError): - """An element in the config file is missing a required sub-element.""" - -class MissingAttributeError(ConfigParseError): - """An element is missing a required attribute to be parsed correctly.""" - -class Container(object): - """A class to hold information in a nice, accessable manner.""" - -def _load_config(): - """Load data from our XML config file (config.xml) into a DOM object.""" +def load_config(): + """Load data from our JSON config file (config.json) into _config.""" global _config - _config = minidom.parse(config_path) + with open(config_path, 'r') as fp: + try: + _config = json.load(fp) + except ValueError as error: + print "Error parsing config file {0}:".format(config_path) + print error + exit(1) def verify_config(): """Check to see if we have a valid config file, and if not, notify the user. If there is no config file at all, offer to make one; otherwise, exit.""" if path.exists(config_path): + load_config() try: - _load_config() - except ExpatError as error: - print "Could not parse config file {0}:\n{1}".format(config_path, - error) - exit() - else: - if not _config.getElementsByTagName("config"): - e = "Config file is missing a tag." - raise MissingElementError(e) - return are_passwords_encrypted() + return _config["encryptPasswords"] # are passwords encrypted? + except KeyError: + return False # assume passwords are not encrypted by default else: print "You haven't configured the bot yet!" choice = raw_input("Would you like to do this now? [y/n] ") @@ -77,235 +59,94 @@ def verify_config(): else: exit() -def make_new_config(): - """Make a new XML config file based on the user's input.""" - makedirs(config_dir) - - encrypt = raw_input("Would you like to encrypt passwords stored in " + - "config.xml? [y/n] ") - if encrypt.lower().startswith("y"): - is_encrypted = True - else: - is_encrypted = False - - return is_encrypted - -def are_passwords_encrypted(): - """Determine if the passwords in our config file are encrypted; return - either True or False, or raise an exception if there was a problem reading - the config file.""" - element = _config.getElementsByTagName("config")[0] - attribute = element.getAttribute("encrypt-passwords") - if not attribute: - return False - return attribute_to_bool(attribute, element, "encrypt-passwords") +def parse_config(key): + """Store data from our config file in three global variables for easy + access, and use the key to unencrypt passwords. Catch password decryption + errors and report them to the user.""" + global components, wiki, irc -def get_first_element(parent, tag_name): - """Return the first child of the parent element with the given tag name, or - return None if no child of that name exists.""" + load_config() # we might be re-loading unnecessarily here, but no harm in + # that! try: - return parent.getElementsByTagName(tag_name)[0] - except IndexError: - return None - -def get_required_element(parent, tag_name): - """Return the first child of the parent element with the given tag name, or - raise MissingElementError() if no child of that name exists.""" - element = get_first_element(parent, tag_name) - if not element: - e = "A <{0}> tag is missing a required <{1}> child tag.".format( - parent.tagName, tag_name) - raise MissingElementError(e) - return element - -def get_required_attribute(element, attr_name): - """Return the value of the attribute 'attr_name' in 'element'. If - undefined, raise MissingAttributeError().""" - attribute = element.getAttribute(attr_name) - if not attribute: - e = "A <{0}> tag is missing the required attribute '{1}'.".format( - element.tagName, attr_name) - raise MissingAttributeError(e) - return attribute - -def attribute_to_bool(value, element, attr_name): - """Return True if 'value' is 'true', '1', or 'on', return False if it is - 'false', '0', or 'off' (regardless of capitalization), or raise - TypeMismatchError() if it does match any of those. 'element' and - 'attr_name' are only used to generate the error message.""" - lcase = value.lower() - if lcase in ["true", "1", "on"]: - return True - elif lcase in ["false", "0", "off"]: - return False - else: - e = ("Expected a bool in attribute '{0}' of tag '{1}', but got '{2}'." - ).format(attr_name, element.tagName, value) - raise TypeMismatchError(e) - -def attribute_to_int(value, element, attr_name): - """Return 'value' after it is converted to an integer. If it could not be - converted, raise TypeMismatchError() using 'element' and 'attr_name' only - to give the user information about what happened.""" + components = _config["components"] + except KeyError: + components = [] + try: + wiki = _config["wiki"] + except KeyError: + wiki = {} try: - return int(value) - except ValueError: - e = ("Expected an integer in attribute '{0}' of tag '{1}', but got " + - "'{2}'.").format(attr_name, element.tagName, value) - raise TypeMismatchError(e) + irc = _config["irc"] + except KeyError: + irc = {} -def parse_config(key): - """A thin wrapper for the actual config parser in _parse_config(): catch - parsing exceptions and report them to the user cleanly.""" try: - _parse_config(key) - except ConfigParseError as error: - print "\nError parsing config file:" - print error - exit(1) + try: + if _config["encryptPasswords"]: + decrypt(key, "wiki['password']") + decrypt(key, "irc['frontend']['nickservPassword']") + decrypt(key, "irc['watcher']['nickservPassword']") + except KeyError: + pass except blowfish.BlowfishError as error: print "\nError decrypting passwords:" print "{0}: {1}.".format(error.__class__.__name__, error) exit(1) -def _parse_config(key): - """Parse config data from a DOM object into the four global variables that - store our config info. The key is used to unencrypt passwords stored in the - XML config file.""" - global components, wiki, irc, schedule - - _load_config() # we might be re-loading unnecessarily here, but no harm in - # that! - data = _config.getElementsByTagName("config")[0] - - components = parse_components(data) - wiki = parse_wiki(data, key) - irc = parse_irc(data, key) - schedule = parse_schedule(data) - -def parse_components(data): - """Parse everything within the XML tag of our config file. - The components object here will exist as config.components, and is a dict - of our enabled components: components[name] = True if it is enabled, False - if it is disabled.""" - components = defaultdict(lambda: False) # all components are disabled by - # default - element = get_required_element(data, "components") - - for component in element.getElementsByTagName("component"): - name = get_required_attribute(component, "name") - components[name] = True - - return components - -def parse_wiki(data, key): - """Parse everything within the tag of our XML config file.""" - pass - -def parse_irc_server(data, key): - """Parse everything within a tag.""" - server = Container() - connection = get_required_element(data, "connection") - - server.host = get_required_attribute(connection, "host") - server.port = get_required_attribute(connection, "port") - server.nick = get_required_attribute(connection, "nick") - server.ident = get_required_attribute(connection, "ident") - server.realname = get_required_attribute(connection, "realname") - - # convert the port from a string to an int - server.port = attribute_to_int(server.port, connection, "port") - - nickserv = get_first_element(data, "nickserv") - if nickserv: - server.nickserv = Container() - server.nickserv.username = get_required_attribute(nickserv, "username") - password = get_required_attribute(nickserv, "password") - if are_passwords_encrypted(): - server.nickserv.password = blowfish.decrypt(key, password) - else: - server.nickserv.password = password - else: - server.nickserv = None - - server.channels = list() - channels = get_first_element(data, "channels") - if channels: - for channel in channels.getElementsByTagName("channel"): - name = get_required_attribute(channel, "name") - server.channels.append(name) - - return server - -def parse_irc(data, key): - """Parse everything within the tag of our XML config file.""" - irc = Container() - - element = get_first_element(data, "irc") - if not element: - return irc - - servers = get_first_element(element, "servers") - if servers: - for server in servers.getElementsByTagName("server"): - server_name = get_required_attribute(server, "name") - if server_name == "frontend": - irc.frontend = parse_irc_server(server, key) - elif server_name == "watcher": - irc.watcher = parse_irc_server(server, key) - else: - print ("Warning: config file specifies a with " + - "unknown name '{0}'. Ignoring.").format(server_name) - - permissions = get_first_element(element, "permissions") - if permissions: - irc.permissions = dict() - for group in permissions.getElementsByTagName("group"): - group_name = get_required_attribute(group, "name") - irc.permissions[group_name] = list() - for user in group.getElementsByTagName("user"): - hostname = get_required_attribute(user, "host") - irc.permissions[group_name].append(hostname) - - return irc +def decrypt(key, item): + """Decrypt 'item' with blowfish.decrypt() using the given key and set it to + the decrypted result. 'item' should be a string, like + decrypt(key, "wiki['password']"), NOT decrypt(key, wiki['password'), + because that won't work.""" + global irc, wiki + try: + result = blowfish.decrypt(key, eval(item)) + except KeyError: + return + exec "{0} = result".format(item) -def parse_schedule(data): - """Store the element in schedule.data and the _schedule() - function as schedule.check().""" - schedule = Container() - schedule.check = _schedule - schedule.data = get_first_element(data, "schedule") - return schedule - -def _schedule(minute, hour, month_day, month, week_day): +def schedule(minute, hour, month_day, month, week_day): """Return a list of tasks that are scheduled to run at the time specified - by the function args. The schedule data comes from our config file's - tag, which is stored as schedule.data. Call this function with - config.schedule.check(args).""" - tasks = [] # tasks to run this turn, each as a tuple of (task_name, + by the function arguments. The schedule data comes from our config file's + 'schedule' field, which is stored as _config["schedule"]. Call this + function with config.schedule(args).""" + tasks = [] # tasks to run this turn, each as a tuple of either (task_name, # kwargs), or just task_name now = {"minute": minute, "hour": hour, "month_day": month_day, "month": month, "week_day": week_day} - for when in schedule.data.getElementsByTagName("when"): + try: + data = _config["schedule"] + except KeyError: + data = [] + for event in data: do = True for key, value in now.items(): - if when.hasAttribute(key): - req = when.getAttribute(key) - if attribute_to_int(req, when, key) != value: - do = False - break + try: + requirement = event[key] + except KeyError: + continue + if requirement != value: + do = False + break if do: - for task in when.getElementsByTagName("task"): - name = get_required_attribute(task, "name") - args = dict() - for key in task.attributes.keys(): - args[key] = task.getAttribute(key) - del args["name"] - if args: - tasks.append((name, args)) - else: - tasks.append(name) + try: + tasks.extend(event["tasks"]) + except KeyError: + pass return tasks + +def make_new_config(): + """Make a new config file based on the user's input.""" + makedirs(config_dir) + + encrypt = raw_input("Would you like to encrypt passwords stored in " + + "config.json? [y/n] ") + if encrypt.lower().startswith("y"): + is_encrypted = True + else: + is_encrypted = False + + return is_encrypted diff --git a/core/main.py b/core/main.py index 649e1f6..0b49842 100644 --- a/core/main.py +++ b/core/main.py @@ -77,7 +77,7 @@ def wiki_scheduler(): if time_diff < 60: # sleep until the next minute time.sleep(60 - time_diff) -def irc_frontend(components): +def irc_frontend(): """If the IRC frontend is enabled, make it run on our primary thread, and enable the wiki scheduler and IRC watcher on new threads if they are enabled.""" @@ -87,7 +87,7 @@ def irc_frontend(components): f_conn = frontend.get_connection() frontend.startup(f_conn) - if config.components["wiki_schedule"]: + if "wiki_schedule" in config.components: print "\nStarting wiki scheduler..." task_manager.load_tasks() t_scheduler = threading.Thread(target=wiki_scheduler) @@ -95,7 +95,7 @@ def irc_frontend(components): t_scheduler.daemon = True t_scheduler.start() - if config.components["irc_watcher"]: + if "irc_watcher" in config.components: print "\nStarting IRC watcher..." t_watcher = threading.Thread(target=irc_watcher, args=(f_conn,)) t_watcher.name = "irc-watcher" @@ -104,7 +104,7 @@ def irc_frontend(components): frontend.main() - if config.components["irc_watcher"]: + if "irc_watcher" in config.components: w_conn.close() f_conn.close() @@ -115,16 +115,16 @@ def run(): key = None config.parse_config(key) # load data from the config file and parse it # using the unlock key - components = config.components + enabled = config.components - if components["irc_frontend"]: # make the frontend run on our primary - irc_frontend(components) # thread if enabled, and enable additional - # components through that function + if "irc_frontend" in enabled: # make the frontend run on our primary + irc_frontend() # thread if enabled, and enable additional + # components through that function - elif components["wiki_schedule"]: # run the scheduler on the main - print "Starting wiki scheduler..." # thread, but also run the IRC - task_manager.load_tasks() # watcher on another thread iff it - if components["irc_watcher"]: # is enabled + elif "wiki_schedule" in enabled: # run the scheduler on the main + print "Starting wiki scheduler..." # thread, but also run the IRC + task_manager.load_tasks() # watcher on another thread iff it + if "irc_watcher" in enabled: # is enabled print "\nStarting IRC watcher..." t_watcher = threading.Thread(target=irc_watcher, args=()) t_watcher.name = "irc-watcher" @@ -132,9 +132,9 @@ def run(): t_watcher.start() wiki_scheduler() - elif components["irc_watcher"]: # the IRC watcher is our only enabled - print "Starting IRC watcher..." # component, so run its function only - irc_watcher() # and don't worry about anything else + elif "irc_watcher" in enabled: # the IRC watcher is our only enabled + print "Starting IRC watcher..." # component, so run its function only + irc_watcher() # and don't worry about anything else else: # nothing is enabled! print "No bot parts are enabled; stopping..." diff --git a/irc/commands/chanops.py b/irc/commands/chanops.py index 1bd7a8d..210a830 100644 --- a/irc/commands/chanops.py +++ b/irc/commands/chanops.py @@ -19,7 +19,7 @@ class ChanOps(BaseCommand): return False def process(self, data): - if data.host not in config.irc.permissions["admins"]: + if data.host not in config.irc["permissions"]["admins"]: self.connection.reply(data, "you must be a bot admin to use this command.") return diff --git a/irc/commands/git.py b/irc/commands/git.py index 4d09d9c..5c35739 100644 --- a/irc/commands/git.py +++ b/irc/commands/git.py @@ -23,7 +23,7 @@ class Git(BaseCommand): def process(self, data): self.data = data - if data.host not in config.irc.permissions["owners"]: + if data.host not in config.irc["permissions"]["owners"]: self.connection.reply(data, "you must be a bot owner to use this command.") return diff --git a/irc/commands/tasks.py b/irc/commands/tasks.py index 5dba593..7776db4 100644 --- a/irc/commands/tasks.py +++ b/irc/commands/tasks.py @@ -23,7 +23,7 @@ class Tasks(BaseCommand): def process(self, data): self.data = data - if data.host not in config.irc.permissions["owners"]: + if data.host not in config.irc["permissions"]["owners"]: self.connection.reply(data, "at this time, you must be a bot owner to use this command.") return @@ -115,9 +115,9 @@ class Tasks(BaseCommand): def get_main_thread_name(self): """Return the "proper" name of the MainThread; e.g. "irc-frontend" or "irc-watcher".""" - if config.components["irc_frontend"]: + if "irc_frontend" in config.components: return "irc-frontend" - elif config.components["wiki_schedule"]: + elif "wiki_schedule" in config.components: return "wiki-scheduler" else: return "irc-watcher" diff --git a/irc/frontend.py b/irc/frontend.py index 56a6f17..2a8537d 100644 --- a/irc/frontend.py +++ b/irc/frontend.py @@ -20,8 +20,9 @@ connection = None def get_connection(): """Return a new Connection() instance with information about our server connection, but don't actually connect yet.""" - cf = config.irc.frontend - connection = Connection(cf.host, cf.port, cf.nick, cf.nick, cf.realname) + cf = config.irc["frontend"] + connection = Connection(cf["host"], cf["port"], cf["nick"], cf["ident"], + cf["realname"]) return connection def startup(conn): @@ -66,7 +67,7 @@ def main(): data.msg = ' '.join(line[3:])[1:] data.chan = line[2] - if data.chan == config.irc.frontend.nick: + if data.chan == config.irc["frontend"]["nick"]: # this is a privmsg to us, so set 'chan' as the nick of the # sender, then check for private-only command hooks data.chan = data.nick @@ -81,7 +82,7 @@ def main(): # hardcode the !restart command (we can't restart from within # an ordinary command) if data.msg in ["!restart", ".restart"]: - if data.host in config.irc.permissions["owners"]: + if data.host in config.irc["permissions"]["owners"]: print "Restarting bot per owner request..." return @@ -89,11 +90,15 @@ def main(): connection.send("PONG %s" % line[1]) if line[1] == "376": # we've successfully connected to the network - ns = config.irc.frontend.nickserv - if ns: # if we're supposed to auth to nickserv, do that - connection.say("NickServ", "IDENTIFY %s %s" % (ns.username, - ns.password)) + try: # if we're supposed to auth to nickserv, do that + ns_username = config.irc["frontend"]["nickservUsername"] + ns_password = config.irc["frontend"]["nickservPassword"] + except KeyError: + pass + else: + connection.say("NickServ", "IDENTIFY {0} {1}".format( + ns_username, ns_password)) # join all of our startup channels - for chan in config.irc.frontend.channels: + for chan in config.irc["frontend"]["channels"]: connection.join(chan) diff --git a/irc/watcher.py b/irc/watcher.py index c39f47c..b031b20 100644 --- a/irc/watcher.py +++ b/irc/watcher.py @@ -19,8 +19,9 @@ frontend_conn = None def get_connection(): """Return a new Connection() instance with information about our server connection, but don't actually connect yet.""" - cf = config.irc.watcher - connection = Connection(cf.host, cf.port, cf.nick, cf.nick, cf.realname) + cf = config.irc["watcher"] + connection = Connection(cf["host"], cf["port"], cf["nick"], cf["ident"], + cf["realname"]) return connection def main(connection, f_conn=None): @@ -49,7 +50,7 @@ def main(connection, f_conn=None): # ignore messages originating from channels not in our list, to # prevent someone PMing us false data - if chan not in config.irc.watcher.channels: + if chan not in config.irc["watcher"]["channels"]: continue msg = ' '.join(line[3:])[1:] @@ -62,7 +63,7 @@ def main(connection, f_conn=None): # when we've finished starting up, join all watcher channels if line[1] == "376": - for chan in config.irc.watcher.channels: + for chan in config.irc["watcher"]["channels"]: connection.join(chan) def process(rc): diff --git a/wiki/task_manager.py b/wiki/task_manager.py index 3c37a63..de40db1 100644 --- a/wiki/task_manager.py +++ b/wiki/task_manager.py @@ -53,8 +53,8 @@ def load_class_from_file(f): def start_tasks(now=time.gmtime()): """Start all tasks that are supposed to be run at a given time.""" - tasks = config.schedule.check(now.tm_min, now.tm_hour, now.tm_mday, - now.tm_mon, now.tm_wday) # get list of tasks to run this turn + tasks = config.schedule(now.tm_min, now.tm_hour, now.tm_mday, now.tm_mon, + now.tm_wday) # get list of tasks to run this turn for task in tasks: if isinstance(task, tuple): # they've specified kwargs