From 117eccc35de9b0418f6e25fabe32a27c874e681d Mon Sep 17 00:00:00 2001 From: Ben Kurtovic Date: Thu, 5 Apr 2012 22:04:05 -0400 Subject: [PATCH] Beginning work (#16) --- .gitignore | 6 - bot.py | 70 --------- earwigbot/__init__.py | 4 +- earwigbot/bot.py | 113 +++++++++++++++ earwigbot/commands/restart.py | 2 +- earwigbot/commands/threads.py | 16 +-- earwigbot/config.py | 213 ++++++++++++++-------------- earwigbot/irc/connection.py | 33 ++++- earwigbot/irc/frontend.py | 12 +- earwigbot/irc/watcher.py | 12 +- earwigbot/main.py | 132 ----------------- earwigbot/runner.py | 65 --------- earwigbot/tasks/__init__.py | 4 - earwigbot/util.py | 50 +++++++ setup.py | 40 ++++++ {earwigbot/tests => tests}/__init__.py | 3 +- {earwigbot/tests => tests}/test_blowfish.py | 0 {earwigbot/tests => tests}/test_calc.py | 0 {earwigbot/tests => tests}/test_test.py | 0 19 files changed, 353 insertions(+), 422 deletions(-) delete mode 100755 bot.py create mode 100644 earwigbot/bot.py delete mode 100644 earwigbot/main.py delete mode 100644 earwigbot/runner.py create mode 100755 earwigbot/util.py create mode 100644 setup.py rename {earwigbot/tests => tests}/__init__.py (98%) rename {earwigbot/tests => tests}/test_blowfish.py (100%) rename {earwigbot/tests => tests}/test_calc.py (100%) rename {earwigbot/tests => tests}/test_test.py (100%) diff --git a/.gitignore b/.gitignore index d2b75fb..91e9551 100644 --- a/.gitignore +++ b/.gitignore @@ -1,9 +1,3 @@ -# Ignore bot-specific files: -logs/ -config.yml -sites.db -.cookies - # Ignore python bytecode: *.pyc diff --git a/bot.py b/bot.py deleted file mode 100755 index d8f2d21..0000000 --- a/bot.py +++ /dev/null @@ -1,70 +0,0 @@ -#! /usr/bin/env python -# -*- coding: utf-8 -*- -# -# Copyright (C) 2009-2012 by Ben Kurtovic -# -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to deal -# in the Software without restriction, including without limitation the rights -# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -# copies of the Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions: -# -# The above copyright notice and this permission notice shall be included in -# all copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -# SOFTWARE. - -""" -EarwigBot - -This is a thin wrapper for EarwigBot's main bot code, specified by bot_script. -The 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!), the docs/ directory, and the LICENSE file for licensing -information. EarwigBot is released under the MIT license. -""" -from getpass import getpass -from subprocess import Popen, PIPE -from os import path -from sys import executable -from time import sleep - -import earwigbot - -bot_script = path.join(earwigbot.__path__[0], "runner.py") - -def main(): - print "EarwigBot v{0}\n".format(earwigbot.__version__) - - is_encrypted = earwigbot.config.config.load() - if is_encrypted: # Passwords in the config file are encrypted - key = getpass("Enter key to unencrypt bot passwords: ") - else: - key = None - - while 1: - bot = Popen([executable, bot_script], stdin=PIPE) - print >> bot.stdin, path.dirname(path.abspath(__file__)) - if is_encrypted: - print >> bot.stdin, key - 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__": - main() diff --git a/earwigbot/__init__.py b/earwigbot/__init__.py index 4dab7da..a78194c 100644 --- a/earwigbot/__init__.py +++ b/earwigbot/__init__.py @@ -31,6 +31,4 @@ __license__ = "MIT License" __version__ = "0.1.dev" __email__ = "ben.kurtovic@verizon.net" -from earwigbot import ( - blowfish, commands, config, irc, main, runner, tasks, tests, wiki -) +from earwigbot import blowfish, bot, commands, config, irc, tasks, util, wiki diff --git a/earwigbot/bot.py b/earwigbot/bot.py new file mode 100644 index 0000000..65c5442 --- /dev/null +++ b/earwigbot/bot.py @@ -0,0 +1,113 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2009-2012 by Ben Kurtovic +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +import threading +from time import sleep, time + +from earwigbot.config import BotConfig +from earwigbot.irc import Frontend, Watcher +from earwigbot.tasks import task_manager + +class Bot(object): + """ + The Bot class is the core of EarwigBot, essentially responsible for + starting the various bot components 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. + """ + + def __init__(self, root_dir): + self.config = BotConfig(root_dir) + self.logger = logging.getLogger("earwigbot") + self.frontend = None + self.watcher = None + + self._keep_scheduling = True + self._lock = threading.Lock() + + def _start_thread(self, name, target): + thread = threading.Thread(name=name, target=target) + thread.start() + + def _wiki_scheduler(self): + while self._keep_scheduling: + time_start = time() + task_manager.schedule() + time_end = time() + time_diff = time_start - time_end + if time_diff < 60: # Sleep until the next minute + sleep(60 - time_diff) + + def _start_components(self): + if self.config.components.get("irc_frontend"): + self.logger.info("Starting IRC frontend") + self.frontend = Frontend(self.config) + self._start_thread(name, self.frontend.loop) + + if self.config.components.get("irc_watcher"): + self.logger.info("Starting IRC watcher") + self.watcher = Watcher(self.config, self.frontend) + self._start_thread(name, self.watcher.loop) + + if self.config.components.get("wiki_scheduler"): + self.logger.info("Starting wiki scheduler") + self._start_thread(name, self._wiki_scheduler) + + def _loop(self): + while 1: + with self._lock: + if self.frontend and self.frontend.is_stopped(): + self.frontend._connect() + if self.watcher and self.watcher.is_stopped(): + self.watcher._connect() + sleep(5) + + def run(self): + self.config.load() + self.config.decrypt(config.wiki, "password") + self.config.decrypt(config.wiki, "search", "credentials", "key") + self.config.decrypt(config.wiki, "search", "credentials", "secret") + self.config.decrypt(config.irc, "frontend", "nickservPassword") + self.config.decrypt(config.irc, "watcher", "nickservPassword") + self._start_components() + self._loop() + + def reload(self): + #components = self.config.components + with self._lock: + self.config.load() + #if self.config.components.get("irc_frontend"): + + def stop(self): + if self.frontend: + self.frontend.stop() + if self.watcher: + self.watcher.stop() + self._keep_scheduling = False diff --git a/earwigbot/commands/restart.py b/earwigbot/commands/restart.py index 527fc82..2b39e5e 100644 --- a/earwigbot/commands/restart.py +++ b/earwigbot/commands/restart.py @@ -34,4 +34,4 @@ class Command(BaseCommand): return self.connection.logger.info("Restarting bot per owner request") - self.connection.is_running = False + self.connection.stop() diff --git a/earwigbot/commands/threads.py b/earwigbot/commands/threads.py index 33f686d..7cf70ae 100644 --- a/earwigbot/commands/threads.py +++ b/earwigbot/commands/threads.py @@ -78,10 +78,9 @@ class Command(BaseCommand): for thread in threads: tname = thread.name if tname == "MainThread": - tname = self.get_main_thread_name() - t = "\x0302{0}\x0301 (as main thread, id {1})" - normal_threads.append(t.format(tname, thread.ident)) - elif tname in ["irc-frontend", "irc-watcher", "wiki-scheduler"]: + t = "\x0302MainThread\x0301 (id {1})" + normal_threads.append(t.format(thread.ident)) + elif tname in config.components: t = "\x0302{0}\x0301 (id {1})" normal_threads.append(t.format(tname, thread.ident)) elif tname.startswith("reminder"): @@ -157,12 +156,3 @@ class Command(BaseCommand): task_manager.start(task_name, **data.kwargs) msg = "task \x0302{0}\x0301 started.".format(task_name) self.connection.reply(data, msg) - - def get_main_thread_name(self): - """Return the "proper" name of the MainThread.""" - if "irc_frontend" in config.components: - return "irc-frontend" - elif "wiki_schedule" in config.components: - return "wiki-scheduler" - else: - return "irc-watcher" diff --git a/earwigbot/config.py b/earwigbot/config.py index f1a977c..4cf0721 100644 --- a/earwigbot/config.py +++ b/earwigbot/config.py @@ -20,31 +20,7 @@ # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE # SOFTWARE. -""" -EarwigBot's YAML 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 earwigbot.config import config", which -returns a singleton _BotConfig object, with data accessible from various -attributes and functions: - -* config.components - enabled components -* config.wiki - information about wiki-editing -* config.tasks - information for bot tasks -* config.irc - information about IRC -* config.metadata - miscellaneous information -* config.schedule() - tasks scheduled to run at a given time - -Additionally, _BotConfig has some functions used in config loading: -* config.load() - loads and parses our config file, returning True if - passwords are stored encrypted or False otherwise -* config.decrypt() - given a key, decrypts passwords inside our config - variables; won't work if passwords aren't encrypted -""" - +from getpass import getpass import logging import logging.handlers from os import mkdir, path @@ -53,44 +29,36 @@ import yaml from earwigbot import blowfish -__all__ = ["config"] - -class _ConfigNode(object): - def __iter__(self): - for key in self.__dict__.iterkeys(): - yield key - - def __getitem__(self, item): - return self.__dict__.__getitem__(item) - - def _dump(self): - data = self.__dict__.copy() - for key, val in data.iteritems(): - if isinstance(val, _ConfigNode): - data[key] = val._dump() - return data - - def _load(self, data): - self.__dict__ = data.copy() - - def _decrypt(self, key, intermediates, item): - base = self.__dict__ - try: - for inter in intermediates: - base = base[inter] - except KeyError: - return - if item in base: - base[item] = blowfish.decrypt(key, base[item]) - - def get(self, *args, **kwargs): - return self.__dict__.get(*args, **kwargs) - - -class _BotConfig(object): - def __init__(self): - self._script_dir = path.dirname(path.abspath(__file__)) - self._root_dir = path.split(self._script_dir)[0] +class BotConfig(object): + """ + EarwigBot's YAML Config File Manager + + 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. + + BotConfig has a few properties and functions, including the following: + * config.root_dir - bot's working directory; contains config.yml, logs/ + * config.path - path to the bot's config file + * config.components - enabled components + * config.wiki - information about wiki-editing + * config.tasks - information for bot tasks + * config.irc - information about IRC + * config.metadata - miscellaneous information + * config.schedule() - tasks scheduled to run at a given time + + BotConfig also has some functions used in config loading: + * config.load() - loads and parses our config file, returning True if + passwords are stored encrypted or False otherwise; + can also be used to easily reload config + * config.decrypt() - given a key, decrypts passwords inside our config + variables, and remembers to decrypt the password if + config is reloaded; won't do anything if passwords + aren't encrypted + """ + + def __init__(self, root_dir): + self._root_dir = root_dir self._config_path = path.join(self._root_dir, "config.yml") self._log_dir = path.join(self._root_dir, "logs") self._decryption_key = None @@ -104,17 +72,17 @@ class _BotConfig(object): self._nodes = [self._components, self._wiki, self._tasks, self._irc, self._metadata] + self._decryptable_nodes = [] def _load(self): - """Load data from our JSON config file (config.yml) into _config.""" + """Load data from our JSON config file (config.yml) into self._data.""" filename = self._config_path with open(filename, 'r') as fp: try: self._data = yaml.load(fp) except yaml.YAMLError as error: print "Error parsing config file {0}:".format(filename) - print error - exit(1) + raise def _setup_logging(self): """Configures the logging module so it works the way we want it to.""" @@ -135,7 +103,7 @@ class _BotConfig(object): else: msg = "log_dir ({0}) exists but is not a directory!" print msg.format(log_dir) - exit(1) + return main_handler = hand(logfile("bot.log"), "midnight", 1, 7) error_handler = hand(logfile("error.log"), "W6", 1, 4) @@ -149,27 +117,29 @@ class _BotConfig(object): h.setFormatter(formatter) logger.addHandler(h) - stream_handler = logging.StreamHandler() - stream_handler.setLevel(logging.DEBUG) - stream_handler.setFormatter(color_formatter) - logger.addHandler(stream_handler) + stream_handler = logging.StreamHandler() + stream_handler.setLevel(logging.DEBUG) + stream_handler.setFormatter(color_formatter) + logger.addHandler(stream_handler) - else: - logger.addHandler(logging.NullHandler()) + def _decrypt(self, node, nodes): + """Try to decrypt the contents of a config node. Use self.decrypt().""" + try: + node._decrypt(self._decryption_key, nodes[:-1], nodes[-1]) + except blowfish.BlowfishError as error: + print "Error decrypting passwords:" + raise def _make_new(self): """Make a new config file based on the user's input.""" - encrypt = raw_input("Would you like to encrypt passwords stored in config.yml? [y/n] ") - if encrypt.lower().startswith("y"): - is_encrypted = True - else: - is_encrypted = False - - return is_encrypted - - @property - def script_dir(self): - return self._script_dir + #m = "Would you like to encrypt passwords stored in config.yml? [y/n] " + #encrypt = raw_input(m) + #if encrypt.lower().startswith("y"): + # is_encrypted = True + #else: + # is_encrypted = False + raise NotImplementedError() + # yaml.dumps() @property def root_dir(self): @@ -182,7 +152,7 @@ class _BotConfig(object): @property def log_dir(self): return self._log_dir - + @property def data(self): """The entire config file.""" @@ -221,7 +191,7 @@ class _BotConfig(object): """Return True if passwords are encrypted, otherwise False.""" return self.metadata.get("encryptPasswords", False) - def load(self, config_path=None, log_dir=None): + def load(self): """Load, or reload, our config file. First, check if we have a valid config file, and if not, notify the @@ -232,19 +202,14 @@ class _BotConfig(object): wiki, tasks, irc, metadata) for easy access (as well as the internal _data variable). - If everything goes well, return True if stored passwords are - encrypted in the file, or False if they are not. + If config is being reloaded, encrypted items will be automatically + decrypted if they were decrypted beforehand. """ - if config_path: - self._config_path = config_path - if log_dir: - self._log_dir = log_dir - if not path.exists(self._config_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 self._make_new() + self._make_new() else: exit(1) @@ -257,25 +222,28 @@ class _BotConfig(object): self.metadata._load(data.get("metadata", {})) self._setup_logging() - return self.is_encrypted() + if self.is_encrypted(): + if not self._decryption_key: + key = getpass("Enter key to decrypt bot passwords: ") + self._decryption_key = key + for node, nodes in self._decryptable_nodes: + self._decrypt(node, nodes) def decrypt(self, node, *nodes): """Use self._decryption_key to decrypt an object in our config tree. If this is called when passwords are not encrypted (check with - config.is_encrypted()), nothing will happen. + config.is_encrypted()), nothing will happen. We'll also keep track of + this node if config.load() is called again (i.e. to reload) and + automatically decrypt it. - An example usage would be: + Example usage: config.decrypt(config.irc, "frontend", "nickservPassword") + -> decrypts config.irc["frontend"]["nickservPassword"] """ - if not self.is_encrypted(): - return - try: - node._decrypt(self._decryption_key, nodes[:-1], nodes[-1]) - except blowfish.BlowfishError as error: - print "\nError decrypting passwords:" - print "{0}: {1}.".format(error.__class__.__name__, error) - exit(1) + self._decryptable_nodes.append((node, nodes)) + if self.is_encrypted(): + self._decrypt(node, nodes) def schedule(self, minute, hour, month_day, month, week_day): """Return a list of tasks scheduled to run at the specified time. @@ -311,6 +279,38 @@ class _BotConfig(object): return tasks +class _ConfigNode(object): + def __iter__(self): + for key in self.__dict__.iterkeys(): + yield key + + def __getitem__(self, item): + return self.__dict__.__getitem__(item) + + def _dump(self): + data = self.__dict__.copy() + for key, val in data.iteritems(): + if isinstance(val, _ConfigNode): + data[key] = val._dump() + return data + + def _load(self, data): + self.__dict__ = data.copy() + + def _decrypt(self, key, intermediates, item): + base = self.__dict__ + try: + for inter in intermediates: + base = base[inter] + except KeyError: + return + if item in base: + base[item] = blowfish.decrypt(key, base[item]) + + def get(self, *args, **kwargs): + return self.__dict__.get(*args, **kwargs) + + class _BotFormatter(logging.Formatter): def __init__(self, color=False): self._format = super(_BotFormatter, self).format @@ -336,6 +336,3 @@ class _BotFormatter(logging.Formatter): if record.levelno == logging.CRITICAL: record.lvl = l.join(("\x1b[1m\x1b[31m", "\x1b[0m")) # Bold red return record - - -config = _BotConfig() diff --git a/earwigbot/irc/connection.py b/earwigbot/irc/connection.py index 10b6a4d..cd99421 100644 --- a/earwigbot/irc/connection.py +++ b/earwigbot/irc/connection.py @@ -22,6 +22,7 @@ import socket import threading +from time import sleep __all__ = ["BrokenSocketException", "IRCConnection"] @@ -42,7 +43,7 @@ class IRCConnection(object): self.ident = ident self.realname = realname self.logger = logger - self.is_running = False + self._is_running = False # A lock to prevent us from sending two messages at once: self._lock = threading.Lock() @@ -53,8 +54,9 @@ class IRCConnection(object): try: self._sock.connect((self.host, self.port)) except socket.error: - self.logger.critical("Couldn't connect to IRC server", exc_info=1) - exit(1) + self.logger.exception("Couldn't connect to IRC server") + sleep(8) + self._connect() self._send("NICK {0}".format(self.nick)) self._send("USER {0} {1} * :{2}".format(self.ident, self.host, self.realname)) @@ -68,7 +70,7 @@ class IRCConnection(object): def _get(self, size=4096): """Receive (i.e. get) data from the server.""" - data = self._sock.recv(4096) + data = self._sock.recv(size) if not data: # Socket isn't giving us any data, so it is dead or broken: raise BrokenSocketException() @@ -121,21 +123,38 @@ class IRCConnection(object): msg = "PONG {0}".format(target) self._send(msg) + def quit(self, msg=None): + """Issue a quit message to the server.""" + if msg: + self._send("QUIT {0}".format(msg)) + else: + self._send("QUIT") + def loop(self): """Main loop for the IRC connection.""" - self.is_running = True + self._is_running = True read_buffer = "" while 1: try: read_buffer += self._get() except BrokenSocketException: - self.is_running = False + self._is_running = False break lines = read_buffer.split("\n") read_buffer = lines.pop() for line in lines: self._process_message(line) - if not self.is_running: + if self.is_stopped(): self._close() break + + def stop(self): + """Request the IRC connection to close at earliest convenience.""" + if self._is_running: + self.quit() + self._is_running = False + + def is_stopped(self): + """Return whether the IRC connection has been (or is to be) closed.""" + return not self._is_running diff --git a/earwigbot/irc/frontend.py b/earwigbot/irc/frontend.py index 2b7e7d1..13c6bc3 100644 --- a/earwigbot/irc/frontend.py +++ b/earwigbot/irc/frontend.py @@ -25,7 +25,6 @@ import re from earwigbot.commands import command_manager from earwigbot.irc import IRCConnection, Data, BrokenSocketException -from earwigbot.config import config __all__ = ["Frontend"] @@ -41,7 +40,8 @@ class Frontend(IRCConnection): """ sender_regex = re.compile(":(.*?)!(.*?)@(.*?)\Z") - def __init__(self): + def __init__(self, config): + self.config = config self.logger = logging.getLogger("earwigbot.frontend") cf = config.irc["frontend"] base = super(Frontend, self) @@ -66,7 +66,7 @@ class Frontend(IRCConnection): data.msg = " ".join(line[3:])[1:] data.chan = line[2] - if data.chan == config.irc["frontend"]["nick"]: + if data.chan == self.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 @@ -86,8 +86,8 @@ class Frontend(IRCConnection): elif line[1] == "376": # If we're supposed to auth to NickServ, do that: try: - username = config.irc["frontend"]["nickservUsername"] - password = config.irc["frontend"]["nickservPassword"] + username = self.config.irc["frontend"]["nickservUsername"] + password = self.config.irc["frontend"]["nickservPassword"] except KeyError: pass else: @@ -95,5 +95,5 @@ class Frontend(IRCConnection): self.say("NickServ", msg) # Join all of our startup channels: - for chan in config.irc["frontend"]["channels"]: + for chan in self.config.irc["frontend"]["channels"]: self.join(chan) diff --git a/earwigbot/irc/watcher.py b/earwigbot/irc/watcher.py index ad206d6..f387b13 100644 --- a/earwigbot/irc/watcher.py +++ b/earwigbot/irc/watcher.py @@ -24,7 +24,6 @@ import imp import logging from earwigbot.irc import IRCConnection, RC, BrokenSocketException -from earwigbot.config import config __all__ = ["Watcher"] @@ -39,7 +38,8 @@ class Watcher(IRCConnection): to channels on the IRC frontend. """ - def __init__(self, frontend=None): + def __init__(self, config, frontend=None): + self.config = config self.logger = logging.getLogger("earwigbot.watcher") cf = config.irc["watcher"] base = super(Watcher, self) @@ -58,7 +58,7 @@ class Watcher(IRCConnection): # 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 self.config.irc["watcher"]["channels"]: return msg = " ".join(line[3:])[1:] @@ -72,7 +72,7 @@ class Watcher(IRCConnection): # When we've finished starting up, join all watcher channels: elif line[1] == "376": - for chan in config.irc["watcher"]["channels"]: + for chan in self.config.irc["watcher"]["channels"]: self.join(chan) def _prepare_process_hook(self): @@ -84,12 +84,12 @@ class Watcher(IRCConnection): # Set a default RC process hook that does nothing: self._process_hook = lambda rc: () try: - rules = config.data["rules"] + rules = self.config.data["rules"] except KeyError: return module = imp.new_module("_rc_event_processing_rules") try: - exec compile(rules, config.path, "exec") in module.__dict__ + exec compile(rules, self.config.path, "exec") in module.__dict__ except Exception: e = "Could not compile config file's RC event rules" self.logger.exception(e) diff --git a/earwigbot/main.py b/earwigbot/main.py deleted file mode 100644 index a738d5c..0000000 --- a/earwigbot/main.py +++ /dev/null @@ -1,132 +0,0 @@ -# -*- coding: utf-8 -*- -# -# Copyright (C) 2009-2012 by Ben Kurtovic -# -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to deal -# in the Software without restriction, including without limitation the rights -# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -# copies of the Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions: -# -# The above copyright notice and this permission notice shall be included in -# all copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -# SOFTWARE. - -""" -EarwigBot's Main Module - -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 logging -import threading -import time - -from earwigbot.config import config -from earwigbot.irc import Frontend, Watcher -from earwigbot.tasks import task_manager - -logger = logging.getLogger("earwigbot") - -def irc_watcher(frontend=None): - """Function to handle the IRC watcher as another thread (if frontend and/or - scheduler is enabled), otherwise run as the main thread.""" - while 1: # Restart the watcher component if it breaks (and nothing else) - watcher = Watcher(frontend) - try: - watcher.loop() - except: - logger.exception("Watcher had an error") - time.sleep(5) # Sleep a bit before restarting watcher - logger.warn("Watcher has stopped; restarting component") - -def wiki_scheduler(): - """Function to handle the wiki scheduler as another thread, or as the - primary thread if the IRC frontend is not enabled.""" - while 1: - time_start = time.time() - task_manager.schedule() - time_end = time.time() - time_diff = time_start - time_end - if time_diff < 60: # Sleep until the next minute - time.sleep(60 - time_diff) - -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.""" - logger.info("Starting IRC frontend") - frontend = Frontend() - - if config.components.get("wiki_schedule"): - logger.info("Starting wiki scheduler") - task_manager.load() - t_scheduler = threading.Thread(target=wiki_scheduler) - t_scheduler.name = "wiki-scheduler" - t_scheduler.daemon = True - t_scheduler.start() - - if config.components.get("irc_watcher"): - logger.info("Starting IRC watcher") - t_watcher = threading.Thread(target=irc_watcher, args=(frontend,)) - t_watcher.name = "irc-watcher" - t_watcher.daemon = True - t_watcher.start() - - frontend.loop() - -def main(): - if config.components.get("irc_frontend"): - # Make the frontend run on our primary thread if enabled, and enable - # additional components through that function: - irc_frontend() - - elif config.components.get("wiki_schedule"): - # Run the scheduler on the main thread, but also run the IRC watcher on - # another thread iff it is enabled: - logger.info("Starting wiki scheduler") - task_manager.load() - if "irc_watcher" in enabled: - logger.info("Starting IRC watcher") - t_watcher = threading.Thread(target=irc_watcher) - t_watcher.name = "irc-watcher" - t_watcher.daemon = True - t_watcher.start() - wiki_scheduler() - - elif config.components.get("irc_watcher"): - # The IRC watcher is our only enabled component, so run its function - # only and don't worry about anything else: - logger.info("Starting IRC watcher") - irc_watcher() - - else: # Nothing is enabled! - logger.critical("No bot parts are enabled; stopping") - exit(1) diff --git a/earwigbot/runner.py b/earwigbot/runner.py deleted file mode 100644 index 2e03dfc..0000000 --- a/earwigbot/runner.py +++ /dev/null @@ -1,65 +0,0 @@ -#! /usr/bin/env python -# -*- coding: utf-8 -*- -# -# Copyright (C) 2009-2012 by Ben Kurtovic -# -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to deal -# in the Software without restriction, including without limitation the rights -# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -# copies of the Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions: -# -# The above copyright notice and this permission notice shall be included in -# all copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -# SOFTWARE. - -""" -EarwigBot Runner - -This is a very simple script that can be run from anywhere. It will add the -'earwigbot' package to sys.path if it's not already in there (i.e., it hasn't -been "installed"), accept a root_dir (the directory in which bot.py is located) -and a decryption key from raw_input (if passwords are encrypted), then call -config.load() and decrypt any passwords, and finally call the main() function -of earwigbot.main. -""" - -from os import path -import sys - -def run(): - pkg_dir = path.split(path.dirname(path.abspath(__file__)))[0] - if pkg_dir not in sys.path: - sys.path.insert(0, pkg_dir) - - from earwigbot.config import config - from earwigbot import main - - root_dir = raw_input() - config_path = path.join(root_dir, "config.yml") - log_dir = path.join(root_dir, "logs") - is_encrypted = config.load(config_path, log_dir) - if is_encrypted: - config._decryption_key = raw_input() - config.decrypt(config.wiki, "password") - config.decrypt(config.wiki, "search", "credentials", "key") - config.decrypt(config.wiki, "search", "credentials", "secret") - config.decrypt(config.irc, "frontend", "nickservPassword") - config.decrypt(config.irc, "watcher", "nickservPassword") - - try: - main.main() - except KeyboardInterrupt: - main.logger.critical("KeyboardInterrupt: stopping main bot loop") - exit(1) - -if __name__ == "__main__": - run() diff --git a/earwigbot/tasks/__init__.py b/earwigbot/tasks/__init__.py index 4af79af..7e57b30 100644 --- a/earwigbot/tasks/__init__.py +++ b/earwigbot/tasks/__init__.py @@ -213,10 +213,6 @@ class _TaskManager(object): task_thread = threading.Thread(target=func) start_time = time.strftime("%b %d %H:%M:%S") task_thread.name = "{0} ({1})".format(task_name, start_time) - - # Stop bot task threads automagically if the main bot stops: - task_thread.daemon = True - task_thread.start() def get(self, task_name): diff --git a/earwigbot/util.py b/earwigbot/util.py new file mode 100755 index 0000000..6f87740 --- /dev/null +++ b/earwigbot/util.py @@ -0,0 +1,50 @@ +#! /usr/bin/env python +# -*- coding: utf-8 -*- +# +# Copyright (C) 2009-2012 by Ben Kurtovic +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +import argparse +from os import path + +from earwigbot import __version__ +from earwigbot.bot import Bot + +class BotUtility(object): + """ + DOCSTRING NEEDED + """ + + def version(self): + return __version__ + + def run(self): + print "EarwigBot v{0}\n".format(self.version()) + + def main(self): + root_dir = path.abspath(path.curdir()) + bot = Bot(root_dir) + bot.run() + + +main = BotUtility().main + +if __name__ == "__main__": + main() diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..41dc7d9 --- /dev/null +++ b/setup.py @@ -0,0 +1,40 @@ +#! /usr/bin/env python +# -*- coding: utf-8 -*- +# +# Copyright (C) 2009-2012 by Ben Kurtovic +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +""" +DOCSTRING NEEDED +""" + +from setuptools import setup + +setup( + name = "earwigbot", + entry_points = {"console_scripts": ["earwigbot = earwigbot.util:main"]}, + install_requires = ["PyYAML>=3.10", "oursql>=0.9.3", "oauth2>=1.5.211", + "numpy>=1.6.1", "matplotlib>=1.1.0"], + version = "0.1.dev", + author = "Ben Kurtovic", + author_email = "ben.kurtovic@verizon.net", + license = "MIT License", + url = "https://github.com/earwig/earwigbot", +) diff --git a/earwigbot/tests/__init__.py b/tests/__init__.py similarity index 98% rename from earwigbot/tests/__init__.py rename to tests/__init__.py index c3e6ac3..dfbc32c 100644 --- a/earwigbot/tests/__init__.py +++ b/tests/__init__.py @@ -23,7 +23,7 @@ """ EarwigBot's Unit Tests -This module __init__ file provides some support code for unit tests. +This package __init__ file provides some support code for unit tests. CommandTestCase is a subclass of unittest.TestCase that provides setUp() for creating a fake connection and some other helpful methods. It uses @@ -92,6 +92,7 @@ class CommandTestCase(TestCase): line = ":Foo!bar@example.com JOIN :#channel".strip().split() return self.maker(line, line[2][1:]) + class FakeConnection(IRCConnection): def __init__(self): pass diff --git a/earwigbot/tests/test_blowfish.py b/tests/test_blowfish.py similarity index 100% rename from earwigbot/tests/test_blowfish.py rename to tests/test_blowfish.py diff --git a/earwigbot/tests/test_calc.py b/tests/test_calc.py similarity index 100% rename from earwigbot/tests/test_calc.py rename to tests/test_calc.py diff --git a/earwigbot/tests/test_test.py b/tests/test_test.py similarity index 100% rename from earwigbot/tests/test_test.py rename to tests/test_test.py