From a568ec67773b9299be30912a8db850c2ddca6f5b Mon Sep 17 00:00:00 2001 From: Ben Kurtovic Date: Thu, 16 Jun 2011 17:45:16 -0400 Subject: [PATCH] lots of work on config.py, earwigbot.py, and main.py; TODO: actually parse config files; convert components to new config format; make_new_config() --- core/config.py | 122 +++++++++++++++++++++++++++++++++++++++----------------- core/main.py | 124 +++++++++++++++++++++++++++++++++------------------------ earwigbot.py | 49 ++++++++++++++++++++--- 3 files changed, 202 insertions(+), 93 deletions(-) diff --git a/core/config.py b/core/config.py index f72a4a4..f7c5785 100644 --- a/core/config.py +++ b/core/config.py @@ -1,46 +1,94 @@ # -*- coding: utf-8 -*- -## EarwigBot's Config File Parser +""" +EarwigBot's XML Config File Parser -from collections import defaultdict -import ConfigParser as configparser -import os +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. +""" -main_cfg_path = os.path.join("config", "main.cfg") -secure_cfg_path = os.path.join("config", "secure.cfg") +from os import makedirs, path +from xml.dom import minidom +from xml.parsers.expat import ExpatError -config = dict() +script_dir = path.dirname(path.abspath(__file__)) +root_dir = path.split(script_dir)[0] +config_path = path.join(root_dir, "config.xml") -def load_config_file(filename): - parser = configparser.SafeConfigParser() - parser.optionxform = str # don't lowercase option names automatically - parser.read(filename) - return parser +_config = None -def make_new_config(): - print "You haven't configured the bot yet!" - choice = raw_input("Would you like to do this now? [y/n] ") - if choice.lower().startswith("y"): - pass +class ConfigParseException(Exception): + """Base exception for when we could not parse the config file.""" + +class TypeMismatchException(ConfigParseException): + """A field does not fit to its expected type; e.g., an aribrary string + where we expected a boolean or integer.""" + +def _load_config(): + """Load data from our XML config file (config.xml) into a DOM object.""" + global _config + _config = minidom.parse(config_path) + +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): + try: + _load_config() + except ExpatError as error: + print "Could not parse config file {0}:\n{1}".format(config_path, + error) + exit() + else: + return are_passwords_encrypted() else: - exit() - -def dump_config_to_dict(parsers): - global config - for parser in parsers: - for section in parser.sections(): - for option in parser.options(section): - try: - config[section][option] = parser.get(section, option) - except KeyError: - config[section] = defaultdict(lambda: None) - config[section][option] = parser.get(section, option) - -def load(): - if not os.path.exists(main_cfg_path): - make_new_config() - - main_cfg = load_config_file(main_cfg_path) - secure_cfg = load_config_file(secure_cfg_path) + print "You haven't configured the bot yet!" + choice = raw_input("Would you like to do this now? [y/n] ") + if choice.lower().startswith("y"): + return make_new_config() + else: + exit() + +def make_new_config(): + """Make a new XML config file based on the user's input.""" + makedirs(config_dir) - dump_config_to_dict([main_cfg, secure_cfg]) + 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, returning + either True or False.""" + data = _config.getElementsByTagName("config")[0] + element = data.getElementsByTagName("encrypt-passwords")[0] + return attribute_to_bool(element, "enabled") + +def attribute_to_bool(element, attribute): + """Return True if the value of element's attribute is 'true', '1', or 'on'; + return False if it is 'false', '0', or 'off' (regardless of + capitalization); raise TypeMismatchException if it does match any of + those.""" + value = element.getAttribute(attribute).lower() + if value in ["true", "1", "on"]: + return True + elif value in ["false", "0", "off"]: + return False + else: + e = ("Expected a bool in attribute '{0}' of element '{1}', but " + + "got '{2}'.").format(attribute, element.tagName, value) + raise TypeMismatchException(e) + +def parse_config(key): + """Parse config data from a DOM object. The key is used to unencrypt + passwords stored in the config file.""" + _load_config() # we might be re-loading unnecessarily here, but no harm in + # that! + data = _config.getElementsByTagName("config")[0] diff --git a/core/main.py b/core/main.py index a5d7f2f..b4e9729 100644 --- a/core/main.py +++ b/core/main.py @@ -1,23 +1,34 @@ +#! /usr/bin/python # -*- coding: utf-8 -*- -## EarwigBot's Core - -## EarwigBot has three components that can run independently of each other: an -## IRC front-end, an IRC watcher, and a wiki scheduler. -## * The IRC front-end runs on a normal IRC server and expects users to -## interact with it/give it commands. -## * The IRC watcher runs on a wiki recent-changes server and listens for -## edits. Users cannot interact with this part of the bot. -## * The wiki scheduler runs wiki-editing bot tasks in separate threads at -## user-defined times through a cron-like interface. - -## There is a "priority" system here: -## 1. If the IRC frontend is enabled, it will run on the main thread, and the -## IRC watcher and wiki scheduler (if enabled) will run on separate threads. -## 2. If the wiki scheduler is enabled, it will run on the main thread, and the -## IRC watcher (if enabled) will run on a separate thread. -## 3. If the IRC watcher is enabled, it will run on the main (and only) thread. -## Else, the bot will stop, as no components are enabled. +""" +EarwigBot's Core + +This (should) not be run directly; the wrapper in "earwigbot.py" is preferred, +but it should work fine alone, as long as you enter the password-unlock key at +the initial hidden prompt. + +The core is essentially responsible for starting the various bot components +(irc, scheduler, etc) and making sure they are all happy. An explanation of the +different components follows: + +EarwigBot has three components that can run independently of each other: an IRC +front-end, an IRC watcher, and a wiki scheduler. +* The IRC front-end runs on a normal IRC server and expects users to interact + with it/give it commands. +* The IRC watcher runs on a wiki recent-changes server and listens for edits. + Users cannot interact with this part of the bot. +* The wiki scheduler runs wiki-editing bot tasks in separate threads at + user-defined times through a cron-like interface. + +There is a "priority" system here: +1. If the IRC frontend is enabled, it will run on the main thread, and the IRC + watcher and wiki scheduler (if enabled) will run on separate threads. +2. If the wiki scheduler is enabled, it will run on the main thread, and the + IRC watcher (if enabled) will run on a separate thread. +3. If the IRC watcher is enabled, it will run on the main (and only) thread. +Else, the bot will stop, as no components are enabled. +""" import threading import time @@ -25,12 +36,14 @@ import traceback import sys import os -parent_dir = os.path.split(sys.path[0])[0] -sys.path.append(parent_dir) # make sure we look in the parent directory for modules +script_dir = os.path.dirname(os.path.abspath(__file__)) +root_dir = os.path.split(script_dir)[0] # the bot's "root" directory relative + # to its different components +sys.path.append(root_dir) # make sure we look in the root dir for modules from core import config -from irc import frontend, watcher -from wiki import task_manager +#from irc import frontend, watcher +#from wiki import task_manager f_conn = None w_conn = None @@ -39,16 +52,15 @@ def irc_watcher(f_conn): """Function to handle the IRC watcher as another thread (if frontend and/or scheduler is enabled), otherwise run as the main thread.""" global w_conn - print "\nStarting IRC watcher..." - while 1: # restart the watcher component if (just) it breaks + while 1: # restart the watcher component if it breaks (and nothing else) w_conn = watcher.get_connection() w_conn.connect() - print # print a blank line here to signify that the bot has finished starting up + print # blank line to signify that the bot has finished starting up try: watcher.main(w_conn, f_conn) except: traceback.print_exc() - time.sleep(5) # sleep a bit before restarting watcher + time.sleep(5) # sleep a bit before restarting watcher print "\nWatcher has stopped; restarting component..." def wiki_scheduler(): @@ -57,24 +69,24 @@ def wiki_scheduler(): while 1: time_start = time.time() now = time.gmtime(time_start) - + task_manager.start_tasks(now) - + time_end = time.time() time_diff = time_start - time_end - if time_diff < 60: # sleep until the next minute + if time_diff < 60: # sleep until the next minute time.sleep(60 - time_diff) -def irc_frontend(): +def irc_frontend(components): """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.""" global f_conn - - print "\nStarting IRC frontend..." + + print "Starting IRC frontend..." f_conn = frontend.get_connection() frontend.startup(f_conn) - + if enable_wiki_schedule: print "\nStarting wiki scheduler..." task_manager.load_tasks() @@ -82,8 +94,9 @@ def irc_frontend(): t_scheduler.name = "wiki-scheduler" t_scheduler.daemon = True t_scheduler.start() - + if enable_irc_watcher: + print "\nStarting IRC watcher..." t_watcher = threading.Thread(target=irc_watcher, args=(f_conn,)) t_watcher.name = "irc-watcher" t_watcher.daemon = True @@ -94,32 +107,41 @@ def irc_frontend(): if enable_irc_watcher: w_conn.close() f_conn.close() - + def run(): - config.load() - components = config.config["main"] - - if components["enable_irc_frontend"]: # make the frontend run on our primary thread if enabled, and enable additional components through that function - irc_frontend() - - elif components["enable_wiki_schedule"]: # the scheduler is enabled - run it on the main thread, but also run the IRC watcher on another thread if it is enabled - print "\nStarting wiki scheduler..." - task_manager.load_tasks() - if enable_irc_watcher: + try: + key = raw_input() # wait for our password unlock key from the bot's + except EOFError: # wrapper + key = None + config.parse_config(key) # load data from the config file and parse it + # using the unlock key + components = None + + if components["irc_frontend"]: # make the frontend run on our primary + irc_frontend(components) # 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 enable_irc_watcher: # is enabled + print "\nStarting IRC watcher..." t_watcher = threading.Thread(target=irc_watcher, args=(f_conn,)) t_watcher.name = "irc-watcher" t_watcher.daemon = True t_watcher.start() wiki_scheduler() - - elif components["enable_irc_watcher"]: # the IRC watcher is our only enabled component, so run its function only and don't worry about anything else - irc_watcher() - + + 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 + else: # nothing is enabled! - exit("\nNo bot parts are enabled; stopping...") + print "No bot parts are enabled; stopping..." if __name__ == "__main__": try: run() except KeyboardInterrupt: - exit("\nKeyboardInterrupt: stopping main bot loop.") + print "\nKeyboardInterrupt: stopping main bot loop." + exit(1) diff --git a/earwigbot.py b/earwigbot.py index ff93bcc..b18f1bc 100644 --- a/earwigbot.py +++ b/earwigbot.py @@ -1,15 +1,54 @@ +#! /usr/bin/python # -*- coding: utf-8 -*- -import time -from subprocess import * +""" +EarwigBot + +A thin wrapper for EarwigBot's main bot code, located in core/main.py. This +wrapper will automatically restart the bot when it shuts down (from !restart, +for example). It requests the bot's password at startup and reuses it every +time the bot restarts internally, so you do not need to re-enter the password +after using !restart. + +For information about the bot as a whole, see the attached README.md file (in +markdown format!) and the LICENSE for licensing information. +""" + +from getpass import getpass +from subprocess import Popen, PIPE +from sys import executable +from time import sleep + +from core.config import verify_config + +__author__ = "Ben Kurtovic" +__copyright__ = "Copyright (c) 2009-2011 by Ben Kurtovic" +__license__ = "MIT License" +__version__ = "0.1dev" +__email__ = "ben.kurtovic@verizon.net" def main(): + print "EarwigBot v{0}\n".format(__version__) + + is_encrypted = verify_config() + if is_encrypted: # passwords in the config file are encrypted + key = getpass("Enter key to unencrypt bot passwords: ") + else: + key = None + while 1: - call(['python', 'core/main.py']) - time.sleep(5) # sleep for five seconds between bot runs + bot = Popen([executable, 'core/main.py'], stdin=PIPE) + bot.communicate(key) # give the key to core.config.load() + return_code = bot.wait() + if return_code == 1: + exit() # let critical exceptions in the subprocess cause us to + # exit as well + else: + sleep(5) # sleep between bot runs following a non-critical + # subprocess exit if __name__ == "__main__": try: main() except KeyboardInterrupt: - exit("\nKeyboardInterrupt: stopping bot wrapper.") + print "\nKeyboardInterrupt: stopping bot wrapper."