diff --git a/earwigbot/__init__.py b/earwigbot/__init__.py index 0da7679..d77092a 100644 --- a/earwigbot/__init__.py +++ b/earwigbot/__init__.py @@ -32,6 +32,5 @@ __version__ = "0.1.dev" __email__ = "ben.kurtovic@verizon.net" from earwigbot import ( - blowfish, config, classes, commands, config, frontend, main, rules, tasks, - tests, watcher, wiki + blowfish, commands, config, irc, main, rules, runner, tasks, tests, wiki ) diff --git a/earwigbot/classes/base_command.py b/earwigbot/classes/base_command.py deleted file mode 100644 index 10ccbc8..0000000 --- a/earwigbot/classes/base_command.py +++ /dev/null @@ -1,75 +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. - -import logging - -__all__ = ["BaseCommand"] - -class BaseCommand(object): - """A base class for commands on IRC. - - This docstring is reported to the user when they use !help . - """ - # This is the command's name, as reported to the user when they use !help: - name = None - - # Hooks are "msg", "msg_private", "msg_public", and "join". "msg" is the - # default behavior; if you wish to override that, change the value in your - # command subclass: - hooks = ["msg"] - - def __init__(self, connection): - """Constructor for new commands. - - This is called once when the command is loaded (from - commands._load_command()). `connection` is a Connection object, - allowing us to do self.connection.say(), self.connection.send(), etc, - from within a method. - """ - self.connection = connection - logger_name = ".".join(("earwigbot", "commands", self.name)) - self.logger = logging.getLogger(logger_name) - self.logger.setLevel(logging.DEBUG) - - def check(self, data): - """Returns whether this command should be called in response to 'data'. - - Given a Data() instance, return True if we should respond to this - activity, or False if we should ignore it or it doesn't apply to us. - - Most commands return True if data.command == self.name, otherwise they - return False. This is the default behavior of check(); you need only - override it if you wish to change that. - """ - if data.is_command and data.command == self.name: - return True - return False - - def process(self, data): - """Main entry point for doing a command. - - Handle an activity (usually a message) on IRC. At this point, thanks - to self.check() which is called automatically by the command handler, - we know this is something we should respond to, so (usually) something - like 'if data.command != "command_name": return' is unnecessary. - """ - pass diff --git a/earwigbot/classes/base_task.py b/earwigbot/classes/base_task.py deleted file mode 100644 index 85229f2..0000000 --- a/earwigbot/classes/base_task.py +++ /dev/null @@ -1,117 +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. - -import logging - -from earwigbot.config import config -from earwigbot import wiki - -__all__ = ["BaseTask"] - -class BaseTask(object): - """A base class for bot tasks that edit Wikipedia.""" - name = None - number = 0 - - def __init__(self): - """Constructor for new tasks. - - This is called once immediately after the task class is loaded by - the task manager (in tasks._load_task()). - """ - pass - - def _setup_logger(self): - """Set up a basic module-level logger.""" - logger_name = ".".join(("earwigbot", "tasks", self.name)) - self.logger = logging.getLogger(logger_name) - self.logger.setLevel(logging.DEBUG) - - def run(self, **kwargs): - """Main entry point to run a given task. - - This is called directly by tasks.start() and is the main way to make a - task do stuff. kwargs will be any keyword arguments passed to start() - which are entirely optional. - - The same task instance is preserved between runs, so you can - theoretically store data in self (e.g. - start('mytask', action='store', data='foo')) and then use it later - (e.g. start('mytask', action='save')). - """ - pass - - def make_summary(self, comment): - """Makes an edit summary by filling in variables in a config value. - - config.wiki["summary"] is used, where $2 is replaced by the main - summary body, given as a method arg, and $1 is replaced by the task - number. - - If the config value is not found, we just return the arg as-is. - """ - try: - summary = config.wiki["summary"] - except KeyError: - return comment - return summary.replace("$1", str(self.number)).replace("$2", comment) - - def shutoff_enabled(self, site=None): - """Returns whether on-wiki shutoff is enabled for this task. - - We check a certain page for certain content. This is determined by - our config file: config.wiki["shutoff"]["page"] is used as the title, - with $1 replaced by our username and $2 replaced by the task number, - and config.wiki["shutoff"]["disabled"] is used as the content. - - If the page has that content or the page does not exist, then shutoff - is "disabled", meaning the bot is supposed to run normally, and we - return False. If the page's content is something other than what we - expect, shutoff is enabled, and we return True. - - If a site is not provided, we'll try to use self.site if it's set. - Otherwise, we'll use our default site. - """ - if not site: - try: - site = self.site - except AttributeError: - site = wiki.get_site() - - try: - cfg = config.wiki["shutoff"] - except KeyError: - return False - title = cfg.get("page", "User:$1/Shutoff/Task $2") - username = site.get_user().name() - title = title.replace("$1", username).replace("$2", str(self.number)) - page = site.get_page(title) - - try: - content = page.get() - except wiki.PageNotFoundError: - return False - if content == cfg.get("disabled", "run"): - return False - - self.logger.warn("Emergency task shutoff has been enabled!") - return True diff --git a/earwigbot/commands/__init__.py b/earwigbot/commands/__init__.py index fda7072..d5543bb 100644 --- a/earwigbot/commands/__init__.py +++ b/earwigbot/commands/__init__.py @@ -24,88 +24,140 @@ EarwigBot's IRC Command Manager This package provides the IRC "commands" used by the bot's front-end component. -In __init__, you can find some functions used to load and run these commands. +This module contains the BaseCommand class (import with +`from earwigbot.commands import BaseCommand`) and an internal _CommandManager +class. This can be accessed through the `command_manager` singleton. """ import logging import os import sys -from earwigbot.classes import BaseCommand from earwigbot.config import config -__all__ = ["load", "get_all", "check"] +__all__ = ["BaseCommand", "command_manager"] -# Base directory when searching for commands: -base_dir = os.path.dirname(os.path.abspath(__file__)) +class BaseCommand(object): + """A base class for commands on IRC. -# Store commands in a dict, where the key is the command's name and the value -# is an instance of the command's class: -_commands = {} - -# Logger for this module: -logger = logging.getLogger("earwigbot.tasks") - -def _load_command(connection, filename): - """Try to load a specific command from a module, identified by file name. - - Given a Connection object and a filename, we'll first try to import it, - and if that works, make an instance of the 'Command' class inside (assuming - it is an instance of BaseCommand), add it to _commands, and report the - addition to the user. Any problems along the way will either be ignored or - reported. - """ - global _commands - - # Strip .py from the end of the filename and join with our package name: - name = ".".join(("commands", filename[:-3])) - try: - __import__(name) - except: - logger.exception("Couldn't load file {0}".format(filename)) - return - - command = sys.modules[name].Command(connection) - if not isinstance(command, BaseCommand): - return - - _commands[command.name] = command - logger.debug("Added command {0}".format(command.name)) - -def load(connection): - """Load all valid commands into the _commands global variable. - - `connection` is a Connection object that is given to each command's - constructor. + This docstring is reported to the user when they use !help . """ - files = os.listdir(base_dir) - files.sort() + # This is the command's name, as reported to the user when they use !help: + name = None + + # Hooks are "msg", "msg_private", "msg_public", and "join". "msg" is the + # default behavior; if you wish to override that, change the value in your + # command subclass: + hooks = ["msg"] + + def __init__(self, connection): + """Constructor for new commands. + + This is called once when the command is loaded (from + commands._load_command()). `connection` is a Connection object, + allowing us to do self.connection.say(), self.connection.send(), etc, + from within a method. + """ + self.connection = connection + logger_name = ".".join(("earwigbot", "commands", self.name)) + self.logger = logging.getLogger(logger_name) + self.logger.setLevel(logging.DEBUG) + + def check(self, data): + """Returns whether this command should be called in response to 'data'. + + Given a Data() instance, return True if we should respond to this + activity, or False if we should ignore it or it doesn't apply to us. + + Most commands return True if data.command == self.name, otherwise they + return False. This is the default behavior of check(); you need only + override it if you wish to change that. + """ + if data.is_command and data.command == self.name: + return True + return False + + def process(self, data): + """Main entry point for doing a command. + + Handle an activity (usually a message) on IRC. At this point, thanks + to self.check() which is called automatically by the command handler, + we know this is something we should respond to, so (usually) something + like 'if data.command != "command_name": return' is unnecessary. + """ + pass + + +class _CommandManager(object): + def __init__(self): + self.logger = logging.getLogger("earwigbot.tasks") + self._base_dir = os.path.dirname(os.path.abspath(__file__)) + self._connection = None + self._commands = {} + + def _load_command(self, filename): + """Load a specific command from a module, identified by filename. + + Given a Connection object and a filename, we'll first try to import + it, and if that works, make an instance of the 'Command' class inside + (assuming it is an instance of BaseCommand), add it to self._commands, + and log the addition. Any problems along the way will either be + ignored or logged. + """ + # Strip .py from the filename's end and join with our package name: + name = ".".join(("commands", filename[:-3])) + try: + __import__(name) + except: + self.logger.exception("Couldn't load file {0}".format(filename)) + return - for filename in files: - if filename.startswith("_") or not filename.endswith(".py"): - continue try: - _load_command(connection, filename) + command = sys.modules[name].Command(self._connection) except AttributeError: - pass # The file is doesn't contain a command, so just move on - - msg = "Found {0} commands: {1}" - logger.info(msg.format(len(_commands), ", ".join(_commands.keys()))) - -def get_all(): - """Return our dict of all loaded commands.""" - return _commands - -def check(hook, data): - """Given an event on IRC, check if there's anything we can respond to.""" - # Parse command arguments into data.command and data.args: - data.parse_args() - - for command in _commands.values(): - if hook in command.hooks: - if command.check(data): - try: - command.process(data) - except: - logger.exception("Error executing command '{0}'".format(data.command)) - break + return # No command in this module + if not isinstance(command, BaseCommand): + return + + self._commands[command.name] = command + self.logger.debug("Added command {0}".format(command.name)) + + def load(self, connection): + """Load all valid commands into self._commands. + + `connection` is a Connection object that is given to each command's + constructor. + """ + self._connection = connection + + files = os.listdir(self._base_dir) + files.sort() + for filename in files: + if filename.startswith("_") or not filename.endswith(".py"): + continue + self._load_command(filename) + + msg = "Found {0} commands: {1}" + commands = ", ".join(self._commands.keys()) + self.logger.info(msg.format(len(self._commands), commands)) + + def get_all(self): + """Return our dict of all loaded commands.""" + return self._commands + + def check(self, hook, data): + """Given an IRC event, check if there's anything we can respond to.""" + # Parse command arguments into data.command and data.args: + data.parse_args() + for command in self._commands.values(): + if hook in command.hooks: + if command.check(data): + try: + command.process(data) + except Exception: + e = "Error executing command '{0}'" + self.logger.exception(e.format(data.command)) + break + + +command_manager = _CommandManager() diff --git a/earwigbot/commands/afc_report.py b/earwigbot/commands/afc_report.py index fe3cd8d..c6a5840 100644 --- a/earwigbot/commands/afc_report.py +++ b/earwigbot/commands/afc_report.py @@ -22,9 +22,9 @@ import re -from earwigbot.classes import BaseCommand -from earwigbot import tasks from earwigbot import wiki +from earwigbot.commands import BaseCommand +from earwigbot.tasks import task_manager class Command(BaseCommand): """Get information about an AFC submission by name.""" @@ -36,7 +36,7 @@ class Command(BaseCommand): self.data = data try: - self.statistics = tasks.get("afc_statistics") + self.statistics = task_manager.get("afc_statistics") except KeyError: e = "Cannot run command: requires afc_statistics task." self.logger.error(e) diff --git a/earwigbot/commands/afc_status.py b/earwigbot/commands/afc_status.py index ab32259..a475caf 100644 --- a/earwigbot/commands/afc_status.py +++ b/earwigbot/commands/afc_status.py @@ -23,7 +23,7 @@ import re from earwigbot import wiki -from earwigbot.classes import BaseCommand +from earwigbot.commands import BaseCommand from earwigbot.config import config class Command(BaseCommand): diff --git a/earwigbot/commands/calc.py b/earwigbot/commands/calc.py index 4b3d25b..f6d3177 100644 --- a/earwigbot/commands/calc.py +++ b/earwigbot/commands/calc.py @@ -23,7 +23,7 @@ import re import urllib -from earwigbot.classes import BaseCommand +from earwigbot.commands import BaseCommand class Command(BaseCommand): """A somewhat advanced calculator: see http://futureboy.us/fsp/frink.fsp diff --git a/earwigbot/commands/chanops.py b/earwigbot/commands/chanops.py index 0a36966..dd59353 100644 --- a/earwigbot/commands/chanops.py +++ b/earwigbot/commands/chanops.py @@ -20,7 +20,7 @@ # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE # SOFTWARE. -from earwigbot.classes import BaseCommand +from earwigbot.commands import BaseCommand from earwigbot.config import config class Command(BaseCommand): diff --git a/earwigbot/commands/crypt.py b/earwigbot/commands/crypt.py index c0fb432..e71e139 100644 --- a/earwigbot/commands/crypt.py +++ b/earwigbot/commands/crypt.py @@ -22,8 +22,8 @@ import hashlib -from earwigbot.classes import BaseCommand from earwigbot import blowfish +from earwigbot.commands import BaseCommand class Command(BaseCommand): """Provides hash functions with !hash (!hash list for supported algorithms) diff --git a/earwigbot/commands/ctcp.py b/earwigbot/commands/ctcp.py index 5c0a352..80b56e2 100644 --- a/earwigbot/commands/ctcp.py +++ b/earwigbot/commands/ctcp.py @@ -23,8 +23,8 @@ import platform import time -import earwigbot -from earwigbot.classes import BaseCommand +from earwigbot import __version__ +from earwigbot.commands import BaseCommand from earwigbot.config import config class Command(BaseCommand): @@ -64,6 +64,6 @@ class Command(BaseCommand): elif command == "VERSION": default = "EarwigBot - $1 - Python/$2 https://github.com/earwig/earwigbot" vers = config.irc.get("version", default) - vers = vers.replace("$1", earwigbot.__version__) + vers = vers.replace("$1", __version__) vers = vers.replace("$2", platform.python_version()) self.connection.notice(target, "\x01VERSION {0}\x01".format(vers)) diff --git a/earwigbot/commands/editcount.py b/earwigbot/commands/editcount.py index 7417ff9..9c58726 100644 --- a/earwigbot/commands/editcount.py +++ b/earwigbot/commands/editcount.py @@ -22,8 +22,8 @@ from urllib import quote_plus -from earwigbot.classes import BaseCommand from earwigbot import wiki +from earwigbot.commands import BaseCommand class Command(BaseCommand): """Return a user's edit count.""" diff --git a/earwigbot/commands/git.py b/earwigbot/commands/git.py index e465e93..dfd9aba 100644 --- a/earwigbot/commands/git.py +++ b/earwigbot/commands/git.py @@ -24,7 +24,7 @@ import shlex import subprocess import re -from earwigbot.classes import BaseCommand +from earwigbot.commands import BaseCommand from earwigbot.config import config class Command(BaseCommand): diff --git a/earwigbot/commands/help.py b/earwigbot/commands/help.py index d0e2ba6..5a6f9dd 100644 --- a/earwigbot/commands/help.py +++ b/earwigbot/commands/help.py @@ -22,15 +22,15 @@ import re -from earwigbot.classes import BaseCommand, Data -from earwigbot import commands +from earwigbot.commands import BaseCommand, command_manager +from earwigbot.irc import Data class Command(BaseCommand): """Displays help information.""" name = "help" def process(self, data): - self.cmnds = commands.get_all() + self.cmnds = command_manager.get_all() if not data.args: self.do_main_help(data) else: diff --git a/earwigbot/commands/link.py b/earwigbot/commands/link.py index 24a4b7a..675096e 100644 --- a/earwigbot/commands/link.py +++ b/earwigbot/commands/link.py @@ -23,7 +23,7 @@ import re from urllib import quote -from earwigbot.classes import BaseCommand +from earwigbot.commands import BaseCommand class Command(BaseCommand): """Convert a Wikipedia page name into a URL.""" diff --git a/earwigbot/commands/praise.py b/earwigbot/commands/praise.py index e82247c..c9e3950 100644 --- a/earwigbot/commands/praise.py +++ b/earwigbot/commands/praise.py @@ -22,7 +22,7 @@ import random -from earwigbot.classes import BaseCommand +from earwigbot.commands import BaseCommand class Command(BaseCommand): """Praise people!""" diff --git a/earwigbot/commands/registration.py b/earwigbot/commands/registration.py index b21a6ac..55b762f 100644 --- a/earwigbot/commands/registration.py +++ b/earwigbot/commands/registration.py @@ -22,8 +22,8 @@ import time -from earwigbot.classes import BaseCommand from earwigbot import wiki +from earwigbot.commands import BaseCommand class Command(BaseCommand): """Return when a user registered.""" diff --git a/earwigbot/commands/remind.py b/earwigbot/commands/remind.py index 73a4a1e..115cb4c 100644 --- a/earwigbot/commands/remind.py +++ b/earwigbot/commands/remind.py @@ -23,7 +23,7 @@ import threading import time -from earwigbot.classes import BaseCommand +from earwigbot.commands import BaseCommand class Command(BaseCommand): """Set a message to be repeated to you in a certain amount of time.""" diff --git a/earwigbot/commands/replag.py b/earwigbot/commands/replag.py index 8347dc7..32b664d 100644 --- a/earwigbot/commands/replag.py +++ b/earwigbot/commands/replag.py @@ -24,7 +24,7 @@ from os.path import expanduser import oursql -from earwigbot.classes import BaseCommand +from earwigbot.commands import BaseCommand class Command(BaseCommand): """Return the replag for a specific database on the Toolserver.""" diff --git a/earwigbot/commands/restart.py b/earwigbot/commands/restart.py new file mode 100644 index 0000000..527fc82 --- /dev/null +++ b/earwigbot/commands/restart.py @@ -0,0 +1,37 @@ +# -*- 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. + +from earwigbot.commands import BaseCommand +from earwigbot.config import config + +class Command(BaseCommand): + """Restart the bot. Only the owner can do this.""" + name = "restart" + + def process(self, data): + if data.host not in config.irc["permissions"]["owners"]: + msg = "you must be a bot owner to use this command." + self.connection.reply(data, msg) + return + + self.connection.logger.info("Restarting bot per owner request") + self.connection.is_running = False diff --git a/earwigbot/commands/rights.py b/earwigbot/commands/rights.py index 31c56db..1a9dd99 100644 --- a/earwigbot/commands/rights.py +++ b/earwigbot/commands/rights.py @@ -20,8 +20,8 @@ # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE # SOFTWARE. -from earwigbot.classes import BaseCommand from earwigbot import wiki +from earwigbot.commands import BaseCommand class Command(BaseCommand): """Retrieve a list of rights for a given username.""" diff --git a/earwigbot/commands/test.py b/earwigbot/commands/test.py index 0c94fa7..edc4567 100644 --- a/earwigbot/commands/test.py +++ b/earwigbot/commands/test.py @@ -22,7 +22,7 @@ import random -from earwigbot.classes import BaseCommand +from earwigbot.commands import BaseCommand class Command(BaseCommand): """Test the bot!""" diff --git a/earwigbot/commands/threads.py b/earwigbot/commands/threads.py index c822691..33f686d 100644 --- a/earwigbot/commands/threads.py +++ b/earwigbot/commands/threads.py @@ -23,9 +23,10 @@ import threading import re -from earwigbot import tasks -from earwigbot.classes import BaseCommand, Data, KwargParseException +from earwigbot.commands import BaseCommand from earwigbot.config import config +from earwigbot.irc import KwargParseException +from earwigbot.tasks import task_manager class Command(BaseCommand): """Manage wiki tasks from IRC, and check on thread status.""" @@ -105,7 +106,7 @@ class Command(BaseCommand): def do_listall(self): """With !tasks listall or !tasks all, list all loaded tasks, and report whether they are currently running or idle.""" - all_tasks = tasks.get_all().keys() + all_tasks = task_manager.get_all().keys() threads = threading.enumerate() tasklist = [] @@ -146,14 +147,14 @@ class Command(BaseCommand): self.connection.reply(data, msg) return - if task_name not in tasks.get_all().keys(): + if task_name not in task_manager.get_all().keys(): # This task does not exist or hasn't been loaded: - msg = "task could not be found; either bot/tasks/{0}.py doesn't exist, or it wasn't loaded correctly." + msg = "task could not be found; either tasks/{0}.py doesn't exist, or it wasn't loaded correctly." self.connection.reply(data, msg.format(task_name)) return data.kwargs["fromIRC"] = True - tasks.start(task_name, **data.kwargs) + task_manager.start(task_name, **data.kwargs) msg = "task \x0302{0}\x0301 started.".format(task_name) self.connection.reply(data, msg) diff --git a/earwigbot/config.py b/earwigbot/config.py index f62791d..9358509 100644 --- a/earwigbot/config.py +++ b/earwigbot/config.py @@ -66,7 +66,7 @@ class _ConfigNode(object): data = self.__dict__.copy() for key, val in data.iteritems(): if isinstance(val, _ConfigNode): - data[key] = val.dump() + data[key] = val._dump() return data def _load(self, data): diff --git a/earwigbot/frontend.py b/earwigbot/frontend.py deleted file mode 100644 index f249c70..0000000 --- a/earwigbot/frontend.py +++ /dev/null @@ -1,137 +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 IRC Frontend Component - -The IRC frontend runs on a normal IRC server and expects users to interact with -it and give it commands. Commands are stored as "command classes", subclasses -of BaseCommand in irc/base_command.py. All command classes are automatically -imported by irc/command_handler.py if they are in irc/commands. -""" - -import logging -import re - -from earwigbot import commands -from earwigbot.classes import Connection, Data, BrokenSocketException -from earwigbot.config import config - -__all__ = ["get_connection", "startup", "main"] - -connection = None -logger = logging.getLogger("earwigbot.frontend") -sender_regex = re.compile(":(.*?)!(.*?)@(.*?)\Z") - -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["ident"], - cf["realname"], logger) - return connection - -def startup(conn): - """Accept a single arg, a Connection() object, and set our global variable - 'connection' to it. Load all command classes in irc/commands with - command_handler, and then establish a connection with the IRC server.""" - global connection - connection = conn - commands.load(connection) - connection.connect() - -def main(): - """Main loop for the frontend component. - - get_connection() and startup() should have already been called before this. - """ - read_buffer = str() - - while 1: - try: - read_buffer = read_buffer + connection.get() - except BrokenSocketException: - logger.warn("Socket has broken on front-end; restarting bot") - return - - lines = read_buffer.split("\n") - read_buffer = lines.pop() - for line in lines: - ret = _process_message(line) - if ret: - return - -def _process_message(line): - """Process a single message from IRC.""" - line = line.strip().split() - data = Data(line) # new Data instance to store info about this line - - if line[1] == "JOIN": - data.nick, data.ident, data.host = sender_regex.findall(line[0])[0] - data.chan = line[2] - # Check for 'join' hooks in our commands: - commands.check("join", data) - - elif line[1] == "PRIVMSG": - data.nick, data.ident, data.host = sender_regex.findall(line[0])[0] - data.msg = ' '.join(line[3:])[1:] - data.chan = line[2] - - 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 - commands.check("msg_private", data) - else: - # Check for public-only command hooks: - commands.check("msg_public", data) - - # Check for command hooks that apply to all messages: - commands.check("msg", data) - - # 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"]: - logger.info("Restarting bot per owner request") - return True - - # If we are pinged, pong back: - elif line[0] == "PING": - msg = " ".join(("PONG", line[1])) - connection.send(msg) - - # On successful connection to the server: - 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"] - except KeyError: - pass - else: - msg = " ".join(("IDENTIFY", username, password)) - connection.say("NickServ", msg) - - # Join all of our startup channels: - for chan in config.irc["frontend"]["channels"]: - connection.join(chan) diff --git a/earwigbot/classes/__init__.py b/earwigbot/irc/__init__.py similarity index 85% rename from earwigbot/classes/__init__.py rename to earwigbot/irc/__init__.py index 88adf84..c889289 100644 --- a/earwigbot/classes/__init__.py +++ b/earwigbot/irc/__init__.py @@ -20,8 +20,8 @@ # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE # SOFTWARE. -from earwigbot.classes.base_command import * -from earwigbot.classes.base_task import * -from earwigbot.classes.connection import * -from earwigbot.classes.data import * -from earwigbot.classes.rc import * +from earwigbot.irc.connection import * +from earwigbot.irc.data import * +from earwigbot.irc.frontend import * +from earwigbot.irc.rc import * +from earwigbot.irc.watcher import * diff --git a/earwigbot/classes/connection.py b/earwigbot/irc/connection.py similarity index 56% rename from earwigbot/classes/connection.py rename to earwigbot/irc/connection.py index 5a45145..3fdb6d3 100644 --- a/earwigbot/classes/connection.py +++ b/earwigbot/irc/connection.py @@ -23,93 +23,118 @@ import socket import threading -__all__ = ["BrokenSocketException", "Connection"] +__all__ = ["BrokenSocketException", "IRCConnection"] class BrokenSocketException(Exception): - """A socket has broken, because it is not sending data. Raised by - Connection.get().""" + """A socket has broken, because it is not sending data. + + Raised by IRCConnection()._get(). + """ pass -class Connection(object): +class IRCConnection(object): """A class to interface with IRC.""" - def __init__(self, host=None, port=None, nick=None, ident=None, - realname=None, logger=None): + def __init__(self, host, port, nick, ident, realname, logger): self.host = host self.port = port self.nick = nick self.ident = ident self.realname = realname self.logger = logger + self.is_running = False # A lock to prevent us from sending two messages at once: - self.lock = threading.Lock() + self._lock = threading.Lock() - def connect(self): + def _connect(self): """Connect to our IRC server.""" - self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + self._sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) try: - self.sock.connect((self.host, self.port)) + 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.send("NICK %s" % self.nick) - self.send("USER %s %s * :%s" % (self.ident, self.host, self.realname)) + self._send("NICK {0}".format(self.nick)) + self._send("USER {0} {1} * :{2}".format(self.ident, self.host, self.realname)) - def close(self): + def _close(self): """Close our connection with the IRC server.""" try: - self.sock.shutdown(socket.SHUT_RDWR) # shut down connection first + self._sock.shutdown(socket.SHUT_RDWR) # Shut down connection first except socket.error: - pass # ignore if the socket is already down - self.sock.close() + pass # Ignore if the socket is already down + self._sock.close() - def get(self, size=4096): + def _get(self, size=4096): """Receive (i.e. get) data from the server.""" - data = self.sock.recv(4096) + data = self._sock.recv(4096) if not data: # Socket isn't giving us any data, so it is dead or broken: raise BrokenSocketException() return data - def send(self, msg): + def _send(self, msg): """Send data to the server.""" # Ensure that we only send one message at a time with a blocking lock: - with self.lock: - self.sock.sendall(msg + "\r\n") + with self._lock: + self._sock.sendall(msg + "\r\n") self.logger.debug(msg) def say(self, target, msg): """Send a private message to a target on the server.""" - message = "".join(("PRIVMSG ", target, " :", msg)) - self.send(message) + msg = "PRIVMSG {0} :{1}".format(target, msg) + self._send(msg) def reply(self, data, msg): """Send a private message as a reply to a user on the server.""" - message = "".join((chr(2), data.nick, chr(0x0f), ": ", msg)) - self.say(data.chan, message) + msg = "\x02{0}\x0f: {1}".format(data.nick, msg) + self.say(data.chan, msg) def action(self, target, msg): """Send a private message to a target on the server as an action.""" - message = "".join((chr(1), "ACTION ", msg, chr(1))) - self.say(target, message) + msg = "\x01ACTION {0}\x01".format(msg) + self.say(target, msg) def notice(self, target, msg): """Send a notice to a target on the server.""" - message = "".join(("NOTICE ", target, " :", msg)) - self.send(message) + msg = "NOTICE {0} :{1}".format(target, msg) + self._send(msg) def join(self, chan): """Join a channel on the server.""" - message = " ".join(("JOIN", chan)) - self.send(message) + msg = "JOIN {0}".format(chan) + self._send(msg) def part(self, chan): """Part from a channel on the server.""" - message = " ".join(("PART", chan)) - self.send(message) + msg = "PART {0}".format(chan) + self._send(msg) def mode(self, chan, level, msg): """Send a mode message to the server.""" - message = " ".join(("MODE", chan, level, msg)) - self.send(message) + msg = "MODE {0} {1} {2}".format(chan, level, msg) + self._send(msg) + + def pong(self, target): + """Pong another entity on the server.""" + msg = "PONG {0}".format(target) + self._send(msg) + + def loop(self): + """Main loop for the IRC connection.""" + self.is_running = True + read_buffer = "" + while 1: + try: + read_buffer += self._get() + except BrokenSocketException: + 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: + break diff --git a/earwigbot/classes/data.py b/earwigbot/irc/data.py similarity index 100% rename from earwigbot/classes/data.py rename to earwigbot/irc/data.py diff --git a/earwigbot/irc/frontend.py b/earwigbot/irc/frontend.py new file mode 100644 index 0000000..2b7e7d1 --- /dev/null +++ b/earwigbot/irc/frontend.py @@ -0,0 +1,99 @@ +# -*- 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 logging +import re + +from earwigbot.commands import command_manager +from earwigbot.irc import IRCConnection, Data, BrokenSocketException +from earwigbot.config import config + +__all__ = ["Frontend"] + +class Frontend(IRCConnection): + """ + EarwigBot's IRC Frontend Component + + The IRC frontend runs on a normal IRC server and expects users to interact + with it and give it commands. Commands are stored as "command classes", + subclasses of BaseCommand in classes/base_command.py. All command classes + are automatically imported by commands/__init__.py if they are in + commands/. + """ + sender_regex = re.compile(":(.*?)!(.*?)@(.*?)\Z") + + def __init__(self): + self.logger = logging.getLogger("earwigbot.frontend") + cf = config.irc["frontend"] + base = super(Frontend, self) + base.__init__(cf["host"], cf["port"], cf["nick"], cf["ident"], + cf["realname"], self.logger) + command_manager.load(self) + self._connect() + + def _process_message(self, line): + """Process a single message from IRC.""" + line = line.strip().split() + data = Data(line) # New Data instance to store info about this line + + if line[1] == "JOIN": + data.nick, data.ident, data.host = self.sender_regex.findall(line[0])[0] + data.chan = line[2] + # Check for 'join' hooks in our commands: + command_manager.check("join", data) + + elif line[1] == "PRIVMSG": + data.nick, data.ident, data.host = self.sender_regex.findall(line[0])[0] + data.msg = " ".join(line[3:])[1:] + data.chan = line[2] + + 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 + command_manager.check("msg_private", data) + else: + # Check for public-only command hooks: + command_manager.check("msg_public", data) + + # Check for command hooks that apply to all messages: + command_manager.check("msg", data) + + # If we are pinged, pong back: + elif line[0] == "PING": + self.pong(line[1]) + + # On successful connection to the server: + 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"] + except KeyError: + pass + else: + msg = "IDENTIFY {0} {1}".format(username, password) + self.say("NickServ", msg) + + # Join all of our startup channels: + for chan in config.irc["frontend"]["channels"]: + self.join(chan) diff --git a/earwigbot/classes/rc.py b/earwigbot/irc/rc.py similarity index 100% rename from earwigbot/classes/rc.py rename to earwigbot/irc/rc.py diff --git a/earwigbot/irc/watcher.py b/earwigbot/irc/watcher.py new file mode 100644 index 0000000..be7328a --- /dev/null +++ b/earwigbot/irc/watcher.py @@ -0,0 +1,88 @@ +# -*- 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 logging + +from earwigbot import rules +from earwigbot.irc import IRCConnection, RC, BrokenSocketException +from earwigbot.config import config + +__all__ = ["Watcher"] + +class Watcher(IRCConnection): + """ + EarwigBot's IRC Watcher Component + + The IRC watcher runs on a wiki recent-changes server and listens for + edits. Users cannot interact with this part of the bot. When an event + occurs, we run it through rules.py's process() function, which can result + in wiki bot tasks being started (located in tasks/) or messages being sent + to channels on the IRC frontend. + """ + + def __init__(self, frontend=None): + self.logger = logging.getLogger("earwigbot.watcher") + cf = config.irc["watcher"] + base = super(Watcher, self) + base.__init__(cf["host"], cf["port"], cf["nick"], cf["ident"], + cf["realname"], self.logger) + self.frontend = frontend + self._connect() + + def _process_message(self, line): + """Process a single message from IRC.""" + line = line.strip().split() + + if line[1] == "PRIVMSG": + chan = line[2] + + # Ignore messages originating from channels not in our list, to + # prevent someone PMing us false data: + if chan not in config.irc["watcher"]["channels"]: + return + + msg = " ".join(line[3:])[1:] + rc = RC(msg) # New RC object to store this event's data + rc.parse() # Parse a message into pagenames, usernames, etc. + self._process_rc(rc) # Report to frontend channels or start tasks + + # If we are pinged, pong back: + elif line[0] == "PING": + self.pong(line[1]) + + # When we've finished starting up, join all watcher channels: + elif line[1] == "376": + for chan in config.irc["watcher"]["channels"]: + self.join(chan) + + def _process_rc(self, rc): + """Process a recent change event from IRC (or, an RC object). + + The actual processing is configurable, so we don't have that hard-coded + here. We simply call rules's process() function and expect a list of + channels back, which we report the event data to. + """ + chans = rules.process(rc) + if chans and self.frontend: + pretty = rc.prettify() + for chan in chans: + self.frontend.say(chan, pretty) diff --git a/earwigbot/main.py b/earwigbot/main.py index a269e34..656633c 100644 --- a/earwigbot/main.py +++ b/earwigbot/main.py @@ -49,27 +49,22 @@ import logging import threading import time -from earwigbot import frontend -from earwigbot import tasks -from earwigbot import watcher from earwigbot.config import config +from earwigbot.irc import Frontend, Watcher +from earwigbot.tasks import task_manager logger = logging.getLogger("earwigbot") -f_conn = None -w_conn = None -def irc_watcher(f_conn=None): +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.""" - global w_conn - while 1: # restart the watcher component if it breaks (and nothing else) - w_conn = watcher.get_connection() - w_conn.connect() + while 1: # Restart the watcher component if it breaks (and nothing else) + watcher = Watcher(frontend) try: - watcher.main(w_conn, f_conn) + watcher.loop() except: logger.exception("Watcher had an error") - time.sleep(5) # sleep a bit before restarting watcher + time.sleep(5) # Sleep a bit before restarting watcher logger.warn("Watcher has stopped; restarting component") def wiki_scheduler(): @@ -77,56 +72,51 @@ def wiki_scheduler(): primary thread if the IRC frontend is not enabled.""" while 1: time_start = time.time() - now = time.gmtime(time_start) - - tasks.schedule(now) - + task_manager.schedule() 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(): """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 logger.info("Starting IRC frontend") - f_conn = frontend.get_connection() - frontend.startup(f_conn) + frontend = Frontend() - if "wiki_schedule" in config.components: + if config.components.get("wiki_schedule"): logger.info("Starting wiki scheduler") - tasks.load() + task_manager.load() t_scheduler = threading.Thread(target=wiki_scheduler) t_scheduler.name = "wiki-scheduler" t_scheduler.daemon = True t_scheduler.start() - if "irc_watcher" in config.components: + if config.components.get("irc_watcher"): logger.info("Starting IRC watcher") - t_watcher = threading.Thread(target=irc_watcher, args=(f_conn,)) + t_watcher = threading.Thread(target=irc_watcher, args=(frontend,)) t_watcher.name = "irc-watcher" t_watcher.daemon = True t_watcher.start() - frontend.main() + frontend.loop() if "irc_watcher" in config.components: w_conn.close() f_conn.close() def main(): - if "irc_frontend" in config.components: + if config.components.get("irc_frontend"): # Make the frontend run on our primary thread if enabled, and enable - # additional components through that function + # additional components through that function: irc_frontend() - elif "wiki_schedule" in config.components: + 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 + # another thread iff it is enabled: logger.info("Starting wiki scheduler") - tasks.load() + task_manager.load() if "irc_watcher" in enabled: logger.info("Starting IRC watcher") t_watcher = threading.Thread(target=irc_watcher) @@ -135,7 +125,7 @@ def main(): t_watcher.start() wiki_scheduler() - elif "irc_watcher" in config.components: + 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") diff --git a/earwigbot/rules.py b/earwigbot/rules.py index 9e5190b..8b58b3b 100644 --- a/earwigbot/rules.py +++ b/earwigbot/rules.py @@ -29,7 +29,7 @@ recieves an event from IRC. import re -from earwigbot import tasks +from earwigbot.tasks import task_manager afc_prefix = "wikipedia( talk)?:(wikiproject )?articles for creation" @@ -56,7 +56,7 @@ def process(rc): chans.update(("##earwigbot", "#wikipedia-en-afc-feed")) if r_page.search(page_name): - #tasks.start("afc_copyvios", page=rc.page) + #task_manager.start("afc_copyvios", page=rc.page) chans.add("#wikipedia-en-afc-feed") elif r_ffu.match(page_name): @@ -76,7 +76,7 @@ def process(rc): elif rc.flags == "restore" and r_restore.match(comment): p = r_restored_page.findall(rc.comment)[0] - #tasks.start("afc_copyvios", page=p) + #task_manager.start("afc_copyvios", page=p) chans.add("#wikipedia-en-afc-feed") elif rc.flags == "protect" and r_protect.match(comment): diff --git a/earwigbot/tasks/__init__.py b/earwigbot/tasks/__init__.py index 75115e9..4af79af 100644 --- a/earwigbot/tasks/__init__.py +++ b/earwigbot/tasks/__init__.py @@ -23,8 +23,10 @@ """ EarwigBot's Wiki Task Manager -This package provides the wiki bot "tasks" EarwigBot runs. Here in __init__, -you can find some functions used to load and run these tasks. +This package provides the wiki bot "tasks" EarwigBot runs. This module contains +the BaseTask class (import with `from earwigbot.tasks import BaseTask`) and an +internal _TaskManager class. This can be accessed through the `task_manager` +singleton. """ import logging @@ -33,106 +35,199 @@ import sys import threading import time -from earwigbot.classes import BaseTask +from earwigbot import wiki from earwigbot.config import config -__all__ = ["load", "schedule", "start", "get", "get_all"] - -# Base directory when searching for tasks: -base_dir = os.path.dirname(os.path.abspath(__file__)) - -# Store loaded tasks as a dict where the key is the task name and the value is -# an instance of the task class: -_tasks = {} - -# Logger for this module: -logger = logging.getLogger("earwigbot.commands") - -def _load_task(filename): - """Try to load a specific task from a module, identified by file name.""" - global _tasks - - # Strip .py from the end of the filename and join with our package name: - name = ".".join(("tasks", filename[:-3])) - try: - __import__(name) - except: - logger.exception("Couldn't load file {0}:".format(filename)) - return - - task = sys.modules[name].Task() - task._setup_logger() - if not isinstance(task, BaseTask): - return - - _tasks[task.name] = task - logger.debug("Added task {0}".format(task.name)) - -def _wrapper(task, **kwargs): - """Wrapper for task classes: run the task and catch any errors.""" - try: - task.run(**kwargs) - except: - error = "Task '{0}' raised an exception and had to stop" - logger.exception(error.format(task.name)) - else: - logger.info("Task '{0}' finished without error".format(task.name)) - -def load(): - """Load all valid tasks from bot/tasks/, into the _tasks variable.""" - files = os.listdir(base_dir) - files.sort() - - for filename in files: - if filename.startswith("_") or not filename.endswith(".py"): - continue +__all__ = ["BaseTask", "task_manager"] + +class BaseTask(object): + """A base class for bot tasks that edit Wikipedia.""" + name = None + number = 0 + + def __init__(self): + """Constructor for new tasks. + + This is called once immediately after the task class is loaded by + the task manager (in tasks._load_task()). + """ + pass + + def _setup_logger(self): + """Set up a basic module-level logger.""" + logger_name = ".".join(("earwigbot", "tasks", self.name)) + self.logger = logging.getLogger(logger_name) + self.logger.setLevel(logging.DEBUG) + + def run(self, **kwargs): + """Main entry point to run a given task. + + This is called directly by tasks.start() and is the main way to make a + task do stuff. kwargs will be any keyword arguments passed to start() + which are entirely optional. + + The same task instance is preserved between runs, so you can + theoretically store data in self (e.g. + start('mytask', action='store', data='foo')) and then use it later + (e.g. start('mytask', action='save')). + """ + pass + + def make_summary(self, comment): + """Makes an edit summary by filling in variables in a config value. + + config.wiki["summary"] is used, where $2 is replaced by the main + summary body, given as a method arg, and $1 is replaced by the task + number. + + If the config value is not found, we just return the arg as-is. + """ try: - _load_task(filename) - except AttributeError: - pass # The file is doesn't contain a task, so just move on + summary = config.wiki["summary"] + except KeyError: + return comment + return summary.replace("$1", str(self.number)).replace("$2", comment) + + def shutoff_enabled(self, site=None): + """Returns whether on-wiki shutoff is enabled for this task. + + We check a certain page for certain content. This is determined by + our config file: config.wiki["shutoff"]["page"] is used as the title, + with $1 replaced by our username and $2 replaced by the task number, + and config.wiki["shutoff"]["disabled"] is used as the content. + + If the page has that content or the page does not exist, then shutoff + is "disabled", meaning the bot is supposed to run normally, and we + return False. If the page's content is something other than what we + expect, shutoff is enabled, and we return True. + + If a site is not provided, we'll try to use self.site if it's set. + Otherwise, we'll use our default site. + """ + if not site: + try: + site = self.site + except AttributeError: + site = wiki.get_site() + + try: + cfg = config.wiki["shutoff"] + except KeyError: + return False + title = cfg.get("page", "User:$1/Shutoff/Task $2") + username = site.get_user().name() + title = title.replace("$1", username).replace("$2", str(self.number)) + page = site.get_page(title) + + try: + content = page.get() + except wiki.PageNotFoundError: + return False + if content == cfg.get("disabled", "run"): + return False + + self.logger.warn("Emergency task shutoff has been enabled!") + return True + + +class _TaskManager(object): + def __init__(self): + self.logger = logging.getLogger("earwigbot.commands") + self._base_dir = os.path.dirname(os.path.abspath(__file__)) + self._tasks = {} + + def _load_task(self, filename): + """Load a specific task from a module, identified by file name.""" + # Strip .py from the filename's end and join with our package name: + name = ".".join(("tasks", filename[:-3])) + try: + __import__(name) + except: + self.logger.exception("Couldn't load file {0}:".format(filename)) + return - logger.info("Found {0} tasks: {1}".format(len(_tasks), ', '.join(_tasks.keys()))) + try: + task = sys.modules[name].Task() + except AttributeError: + return # No task in this module + if not isinstance(task, BaseTask): + return + task._setup_logger() -def schedule(now=time.gmtime()): - """Start all tasks that are supposed to be run at a given time.""" - # 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) + self._tasks[task.name] = task + self.logger.debug("Added task {0}".format(task.name)) - for task in tasks: - if isinstance(task, list): # they've specified kwargs - start(task[0], **task[1]) # so pass those to start_task - else: # otherwise, just pass task_name - start(task) + def _wrapper(self, task, **kwargs): + """Wrapper for task classes: run the task and catch any errors.""" + try: + task.run(**kwargs) + except: + msg = "Task '{0}' raised an exception and had to stop" + self.logger.exception(msg.format(task.name)) + else: + msg = "Task '{0}' finished without error" + self.logger.info(msg.format(task.name)) + + def load(self): + """Load all valid tasks from tasks/ into self._tasks.""" + files = os.listdir(self._base_dir) + files.sort() + + for filename in files: + if filename.startswith("_") or not filename.endswith(".py"): + continue + self._load_task(filename) + + msg = "Found {0} tasks: {1}" + tasks = ', '.join(self._tasks.keys()) + self.logger.info(msg.format(len(self._tasks), tasks)) + + def schedule(self, now=None): + """Start all tasks that are supposed to be run at a given time.""" + if not now: + now = time.gmtime() + # 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) + + for task in tasks: + if isinstance(task, list): # They've specified kwargs, + self.start(task[0], **task[1]) # so pass those to start_task + else: # Otherwise, just pass task_name + self.start(task) + + def start(self, task_name, **kwargs): + """Start a given task in a new thread. Pass args to the task's run() + function.""" + msg = "Starting task '{0}' in a new thread" + self.logger.info(msg.format(task_name)) -def start(task_name, **kwargs): - """Start a given task in a new thread. Pass args to the task's run() - function.""" - logger.info("Starting task '{0}' in a new thread".format(task_name)) + try: + task = self._tasks[task_name] + except KeyError: + e = "Couldn't find task '{0}': bot/tasks/{0}.py does not exist" + self.logger.error(e.format(task_name)) + return - try: - task = _tasks[task_name] - except KeyError: - error = "Couldn't find task '{0}': bot/tasks/{0}.py does not exist" - logger.error(error.format(task_name)) - return + func = lambda: self._wrapper(task, **kwargs) + 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) - task_thread = threading.Thread(target=lambda: _wrapper(task, **kwargs)) - 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 - # Stop bot task threads automagically if the main bot stops: - task_thread.daemon = True + task_thread.start() - task_thread.start() + def get(self, task_name): + """Return the class instance associated with a certain task name. -def get(task_name): - """Return the class instance associated with a certain task name. + Will raise KeyError if the task is not found. + """ + return self._tasks[task_name] - Will raise KeyError if the task is not found. - """ - return _tasks[task_name] + def get_all(self): + """Return our dict of all loaded tasks.""" + return self._tasks -def get_all(): - """Return our dict of all loaded tasks.""" - return _tasks +task_manager = _TaskManager() diff --git a/earwigbot/tasks/afc_catdelink.py b/earwigbot/tasks/afc_catdelink.py index 0708d49..c5d3c0f 100644 --- a/earwigbot/tasks/afc_catdelink.py +++ b/earwigbot/tasks/afc_catdelink.py @@ -20,7 +20,7 @@ # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE # SOFTWARE. -from earwigbot.classes import BaseTask +from earwigbot.tasks import BaseTask class Task(BaseTask): """A task to delink mainspace categories in declined [[WP:AFC]] diff --git a/earwigbot/tasks/afc_copyvios.py b/earwigbot/tasks/afc_copyvios.py index 2d3f5d7..4db5a63 100644 --- a/earwigbot/tasks/afc_copyvios.py +++ b/earwigbot/tasks/afc_copyvios.py @@ -27,8 +27,8 @@ from threading import Lock import oursql from earwigbot import wiki -from earwigbot.classes import BaseTask from earwigbot.config import config +from earwigbot.tasks import BaseTask class Task(BaseTask): """A task to check newly-edited [[WP:AFC]] submissions for copyright diff --git a/earwigbot/tasks/afc_dailycats.py b/earwigbot/tasks/afc_dailycats.py index 44bb093..efddd20 100644 --- a/earwigbot/tasks/afc_dailycats.py +++ b/earwigbot/tasks/afc_dailycats.py @@ -20,7 +20,7 @@ # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE # SOFTWARE. -from earwigbot.classes import BaseTask +from earwigbot.tasks import BaseTask class Task(BaseTask): """ A task to create daily categories for [[WP:AFC]].""" diff --git a/earwigbot/tasks/afc_history.py b/earwigbot/tasks/afc_history.py index 8abe5f5..03117ad 100644 --- a/earwigbot/tasks/afc_history.py +++ b/earwigbot/tasks/afc_history.py @@ -32,8 +32,8 @@ from numpy import arange import oursql from earwigbot import wiki -from earwigbot.classes import BaseTask from earwigbot.config import config +from earwigbot.tasks import BaseTask # Valid submission statuses: STATUS_NONE = 0 diff --git a/earwigbot/tasks/afc_statistics.py b/earwigbot/tasks/afc_statistics.py index 1ae57d5..3de023d 100644 --- a/earwigbot/tasks/afc_statistics.py +++ b/earwigbot/tasks/afc_statistics.py @@ -30,8 +30,8 @@ from time import sleep import oursql from earwigbot import wiki -from earwigbot.classes import BaseTask from earwigbot.config import config +from earwigbot.tasks import BaseTask # Chart status number constants: CHART_NONE = 0 diff --git a/earwigbot/tasks/afc_undated.py b/earwigbot/tasks/afc_undated.py index b947cee..512f09d 100644 --- a/earwigbot/tasks/afc_undated.py +++ b/earwigbot/tasks/afc_undated.py @@ -20,7 +20,7 @@ # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE # SOFTWARE. -from earwigbot.classes import BaseTask +from earwigbot.tasks import BaseTask class Task(BaseTask): """A task to clear [[Category:Undated AfC submissions]].""" diff --git a/earwigbot/tasks/blptag.py b/earwigbot/tasks/blptag.py index c268b45..5bb4052 100644 --- a/earwigbot/tasks/blptag.py +++ b/earwigbot/tasks/blptag.py @@ -20,7 +20,7 @@ # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE # SOFTWARE. -from earwigbot.classes import BaseTask +from earwigbot.tasks import BaseTask class Task(BaseTask): """A task to add |blp=yes to {{WPB}} or {{WPBS}} when it is used along with diff --git a/earwigbot/tasks/feed_dailycats.py b/earwigbot/tasks/feed_dailycats.py index 9798eff..3d6afd7 100644 --- a/earwigbot/tasks/feed_dailycats.py +++ b/earwigbot/tasks/feed_dailycats.py @@ -20,7 +20,7 @@ # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE # SOFTWARE. -from earwigbot.classes import BaseTask +from earwigbot.tasks import BaseTask class Task(BaseTask): """A task to create daily categories for [[WP:FEED]].""" diff --git a/earwigbot/tasks/wrongmime.py b/earwigbot/tasks/wrongmime.py index 6096c92..1b51589 100644 --- a/earwigbot/tasks/wrongmime.py +++ b/earwigbot/tasks/wrongmime.py @@ -20,7 +20,7 @@ # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE # SOFTWARE. -from earwigbot.classes import BaseTask +from earwigbot.tasks import BaseTask class Task(BaseTask): """A task to tag files whose extensions do not agree with their MIME diff --git a/earwigbot/tests/__init__.py b/earwigbot/tests/__init__.py index bd2c481..c3e6ac3 100644 --- a/earwigbot/tests/__init__.py +++ b/earwigbot/tests/__init__.py @@ -34,18 +34,18 @@ instead of a socket for data. import re from unittest import TestCase -from earwigbot.classes import Connection, Data +from earwigbot.irc import IRCConnection, Data class CommandTestCase(TestCase): re_sender = re.compile(":(.*?)!(.*?)@(.*?)\Z") def setUp(self, command): self.connection = FakeConnection() - self.connection.connect() + self.connection._connect() self.command = command(self.connection) def get_single(self): - data = self.connection.get().split("\n") + data = self.connection._get().split("\n") line = data.pop(0) for remaining in data[1:]: self.connection.send(remaining) @@ -92,16 +92,19 @@ class CommandTestCase(TestCase): line = ":Foo!bar@example.com JOIN :#channel".strip().split() return self.maker(line, line[2][1:]) -class FakeConnection(Connection): - def connect(self): +class FakeConnection(IRCConnection): + def __init__(self): + pass + + def _connect(self): self._buffer = "" - def close(self): + def _close(self): pass - def get(self, size=4096): + def _get(self, size=4096): data, self._buffer = self._buffer, "" return data - def send(self, msg): + def _send(self, msg): self._buffer += msg + "\n" diff --git a/earwigbot/watcher.py b/earwigbot/watcher.py deleted file mode 100644 index 670a7aa..0000000 --- a/earwigbot/watcher.py +++ /dev/null @@ -1,114 +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 IRC Watcher Component - -The IRC watcher runs on a wiki recent-changes server and listens for edits. -Users cannot interact with this part of the bot. When an event occurs, we run -it through rules.py's process() function, which can result in wiki bot tasks -being started (located in tasks/) or messages being sent to channels on the IRC -frontend. -""" - -import logging - -from earwigbot import rules -from earwigbot.classes import Connection, RC, BrokenSocketException -from earwigbot.config import config - -frontend_conn = None -logger = logging.getLogger("earwigbot.watcher") - -def get_connection(): - """Return a new Connection() instance with connection information. - - Don't actually connect yet. - """ - cf = config.irc["watcher"] - connection = Connection(cf["host"], cf["port"], cf["nick"], cf["ident"], - cf["realname"], logger) - return connection - -def main(connection, f_conn=None): - """Main loop for the Watcher IRC Bot component. - - get_connection() should have already been called and the connection should - have been started with connection.connect(). Accept the frontend connection - as well as an optional parameter in order to send messages directly to - frontend IRC channels. - """ - global frontend_conn - frontend_conn = f_conn - read_buffer = str() - - while 1: - try: - read_buffer = read_buffer + connection.get() - except BrokenSocketException: - return - - lines = read_buffer.split("\n") - read_buffer = lines.pop() - - for line in lines: - _process_message(connection, line) - -def _process_message(connection, line): - """Process a single message from IRC.""" - line = line.strip().split() - - if line[1] == "PRIVMSG": - chan = line[2] - - # Ignore messages originating from channels not in our list, to prevent - # someone PMing us false data: - if chan not in config.irc["watcher"]["channels"]: - return - - msg = ' '.join(line[3:])[1:] - rc = RC(msg) # new RC object to store this event's data - rc.parse() # parse a message into pagenames, usernames, etc. - process_rc(rc) # report to frontend channels or start tasks - - # If we are pinged, pong back to the server: - elif line[0] == "PING": - msg = " ".join(("PONG", line[1])) - connection.send(msg) - - # When we've finished starting up, join all watcher channels: - elif line[1] == "376": - for chan in config.irc["watcher"]["channels"]: - connection.join(chan) - -def process_rc(rc): - """Process a recent change event from IRC (or, an RC object). - - The actual processing is configurable, so we don't have that hard-coded - here. We simply call rules's process() function and expect a list of - channels back, which we report the event data to. - """ - chans = rules.process(rc) - if chans and frontend_conn: - pretty = rc.prettify() - for chan in chans: - frontend_conn.say(chan, pretty)