| @@ -32,6 +32,5 @@ __version__ = "0.1.dev" | |||||
| __email__ = "ben.kurtovic@verizon.net" | __email__ = "ben.kurtovic@verizon.net" | ||||
| from earwigbot import ( | 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 | |||||
| ) | ) | ||||
| @@ -1,75 +0,0 @@ | |||||
| # -*- coding: utf-8 -*- | |||||
| # | |||||
| # Copyright (C) 2009-2012 by Ben Kurtovic <ben.kurtovic@verizon.net> | |||||
| # | |||||
| # 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 <command>. | |||||
| """ | |||||
| # 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 | |||||
| @@ -1,117 +0,0 @@ | |||||
| # -*- coding: utf-8 -*- | |||||
| # | |||||
| # Copyright (C) 2009-2012 by Ben Kurtovic <ben.kurtovic@verizon.net> | |||||
| # | |||||
| # 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 | |||||
| @@ -24,88 +24,140 @@ | |||||
| EarwigBot's IRC Command Manager | EarwigBot's IRC Command Manager | ||||
| This package provides the IRC "commands" used by the bot's front-end component. | 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 logging | ||||
| import os | import os | ||||
| import sys | import sys | ||||
| from earwigbot.classes import BaseCommand | |||||
| from earwigbot.config import config | 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 <command>. | |||||
| """ | """ | ||||
| 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: | try: | ||||
| _load_command(connection, filename) | |||||
| command = sys.modules[name].Command(self._connection) | |||||
| except AttributeError: | 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() | |||||
| @@ -22,9 +22,9 @@ | |||||
| import re | import re | ||||
| from earwigbot.classes import BaseCommand | |||||
| from earwigbot import tasks | |||||
| from earwigbot import wiki | from earwigbot import wiki | ||||
| from earwigbot.commands import BaseCommand | |||||
| from earwigbot.tasks import task_manager | |||||
| class Command(BaseCommand): | class Command(BaseCommand): | ||||
| """Get information about an AFC submission by name.""" | """Get information about an AFC submission by name.""" | ||||
| @@ -36,7 +36,7 @@ class Command(BaseCommand): | |||||
| self.data = data | self.data = data | ||||
| try: | try: | ||||
| self.statistics = tasks.get("afc_statistics") | |||||
| self.statistics = task_manager.get("afc_statistics") | |||||
| except KeyError: | except KeyError: | ||||
| e = "Cannot run command: requires afc_statistics task." | e = "Cannot run command: requires afc_statistics task." | ||||
| self.logger.error(e) | self.logger.error(e) | ||||
| @@ -23,7 +23,7 @@ | |||||
| import re | import re | ||||
| from earwigbot import wiki | from earwigbot import wiki | ||||
| from earwigbot.classes import BaseCommand | |||||
| from earwigbot.commands import BaseCommand | |||||
| from earwigbot.config import config | from earwigbot.config import config | ||||
| class Command(BaseCommand): | class Command(BaseCommand): | ||||
| @@ -23,7 +23,7 @@ | |||||
| import re | import re | ||||
| import urllib | import urllib | ||||
| from earwigbot.classes import BaseCommand | |||||
| from earwigbot.commands import BaseCommand | |||||
| class Command(BaseCommand): | class Command(BaseCommand): | ||||
| """A somewhat advanced calculator: see http://futureboy.us/fsp/frink.fsp | """A somewhat advanced calculator: see http://futureboy.us/fsp/frink.fsp | ||||
| @@ -20,7 +20,7 @@ | |||||
| # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE | ||||
| # SOFTWARE. | # SOFTWARE. | ||||
| from earwigbot.classes import BaseCommand | |||||
| from earwigbot.commands import BaseCommand | |||||
| from earwigbot.config import config | from earwigbot.config import config | ||||
| class Command(BaseCommand): | class Command(BaseCommand): | ||||
| @@ -22,8 +22,8 @@ | |||||
| import hashlib | import hashlib | ||||
| from earwigbot.classes import BaseCommand | |||||
| from earwigbot import blowfish | from earwigbot import blowfish | ||||
| from earwigbot.commands import BaseCommand | |||||
| class Command(BaseCommand): | class Command(BaseCommand): | ||||
| """Provides hash functions with !hash (!hash list for supported algorithms) | """Provides hash functions with !hash (!hash list for supported algorithms) | ||||
| @@ -23,8 +23,8 @@ | |||||
| import platform | import platform | ||||
| import time | import time | ||||
| import earwigbot | |||||
| from earwigbot.classes import BaseCommand | |||||
| from earwigbot import __version__ | |||||
| from earwigbot.commands import BaseCommand | |||||
| from earwigbot.config import config | from earwigbot.config import config | ||||
| class Command(BaseCommand): | class Command(BaseCommand): | ||||
| @@ -64,6 +64,6 @@ class Command(BaseCommand): | |||||
| elif command == "VERSION": | elif command == "VERSION": | ||||
| default = "EarwigBot - $1 - Python/$2 https://github.com/earwig/earwigbot" | default = "EarwigBot - $1 - Python/$2 https://github.com/earwig/earwigbot" | ||||
| vers = config.irc.get("version", default) | vers = config.irc.get("version", default) | ||||
| vers = vers.replace("$1", earwigbot.__version__) | |||||
| vers = vers.replace("$1", __version__) | |||||
| vers = vers.replace("$2", platform.python_version()) | vers = vers.replace("$2", platform.python_version()) | ||||
| self.connection.notice(target, "\x01VERSION {0}\x01".format(vers)) | self.connection.notice(target, "\x01VERSION {0}\x01".format(vers)) | ||||
| @@ -22,8 +22,8 @@ | |||||
| from urllib import quote_plus | from urllib import quote_plus | ||||
| from earwigbot.classes import BaseCommand | |||||
| from earwigbot import wiki | from earwigbot import wiki | ||||
| from earwigbot.commands import BaseCommand | |||||
| class Command(BaseCommand): | class Command(BaseCommand): | ||||
| """Return a user's edit count.""" | """Return a user's edit count.""" | ||||
| @@ -24,7 +24,7 @@ import shlex | |||||
| import subprocess | import subprocess | ||||
| import re | import re | ||||
| from earwigbot.classes import BaseCommand | |||||
| from earwigbot.commands import BaseCommand | |||||
| from earwigbot.config import config | from earwigbot.config import config | ||||
| class Command(BaseCommand): | class Command(BaseCommand): | ||||
| @@ -22,15 +22,15 @@ | |||||
| import re | 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): | class Command(BaseCommand): | ||||
| """Displays help information.""" | """Displays help information.""" | ||||
| name = "help" | name = "help" | ||||
| def process(self, data): | def process(self, data): | ||||
| self.cmnds = commands.get_all() | |||||
| self.cmnds = command_manager.get_all() | |||||
| if not data.args: | if not data.args: | ||||
| self.do_main_help(data) | self.do_main_help(data) | ||||
| else: | else: | ||||
| @@ -23,7 +23,7 @@ | |||||
| import re | import re | ||||
| from urllib import quote | from urllib import quote | ||||
| from earwigbot.classes import BaseCommand | |||||
| from earwigbot.commands import BaseCommand | |||||
| class Command(BaseCommand): | class Command(BaseCommand): | ||||
| """Convert a Wikipedia page name into a URL.""" | """Convert a Wikipedia page name into a URL.""" | ||||
| @@ -22,7 +22,7 @@ | |||||
| import random | import random | ||||
| from earwigbot.classes import BaseCommand | |||||
| from earwigbot.commands import BaseCommand | |||||
| class Command(BaseCommand): | class Command(BaseCommand): | ||||
| """Praise people!""" | """Praise people!""" | ||||
| @@ -22,8 +22,8 @@ | |||||
| import time | import time | ||||
| from earwigbot.classes import BaseCommand | |||||
| from earwigbot import wiki | from earwigbot import wiki | ||||
| from earwigbot.commands import BaseCommand | |||||
| class Command(BaseCommand): | class Command(BaseCommand): | ||||
| """Return when a user registered.""" | """Return when a user registered.""" | ||||
| @@ -23,7 +23,7 @@ | |||||
| import threading | import threading | ||||
| import time | import time | ||||
| from earwigbot.classes import BaseCommand | |||||
| from earwigbot.commands import BaseCommand | |||||
| class Command(BaseCommand): | class Command(BaseCommand): | ||||
| """Set a message to be repeated to you in a certain amount of time.""" | """Set a message to be repeated to you in a certain amount of time.""" | ||||
| @@ -24,7 +24,7 @@ from os.path import expanduser | |||||
| import oursql | import oursql | ||||
| from earwigbot.classes import BaseCommand | |||||
| from earwigbot.commands import BaseCommand | |||||
| class Command(BaseCommand): | class Command(BaseCommand): | ||||
| """Return the replag for a specific database on the Toolserver.""" | """Return the replag for a specific database on the Toolserver.""" | ||||
| @@ -0,0 +1,37 @@ | |||||
| # -*- coding: utf-8 -*- | |||||
| # | |||||
| # Copyright (C) 2009-2012 by Ben Kurtovic <ben.kurtovic@verizon.net> | |||||
| # | |||||
| # 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 | |||||
| @@ -20,8 +20,8 @@ | |||||
| # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE | ||||
| # SOFTWARE. | # SOFTWARE. | ||||
| from earwigbot.classes import BaseCommand | |||||
| from earwigbot import wiki | from earwigbot import wiki | ||||
| from earwigbot.commands import BaseCommand | |||||
| class Command(BaseCommand): | class Command(BaseCommand): | ||||
| """Retrieve a list of rights for a given username.""" | """Retrieve a list of rights for a given username.""" | ||||
| @@ -22,7 +22,7 @@ | |||||
| import random | import random | ||||
| from earwigbot.classes import BaseCommand | |||||
| from earwigbot.commands import BaseCommand | |||||
| class Command(BaseCommand): | class Command(BaseCommand): | ||||
| """Test the bot!""" | """Test the bot!""" | ||||
| @@ -23,9 +23,10 @@ | |||||
| import threading | import threading | ||||
| import re | 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.config import config | ||||
| from earwigbot.irc import KwargParseException | |||||
| from earwigbot.tasks import task_manager | |||||
| class Command(BaseCommand): | class Command(BaseCommand): | ||||
| """Manage wiki tasks from IRC, and check on thread status.""" | """Manage wiki tasks from IRC, and check on thread status.""" | ||||
| @@ -105,7 +106,7 @@ class Command(BaseCommand): | |||||
| def do_listall(self): | def do_listall(self): | ||||
| """With !tasks listall or !tasks all, list all loaded tasks, and report | """With !tasks listall or !tasks all, list all loaded tasks, and report | ||||
| whether they are currently running or idle.""" | whether they are currently running or idle.""" | ||||
| all_tasks = tasks.get_all().keys() | |||||
| all_tasks = task_manager.get_all().keys() | |||||
| threads = threading.enumerate() | threads = threading.enumerate() | ||||
| tasklist = [] | tasklist = [] | ||||
| @@ -146,14 +147,14 @@ class Command(BaseCommand): | |||||
| self.connection.reply(data, msg) | self.connection.reply(data, msg) | ||||
| return | 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: | # 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)) | self.connection.reply(data, msg.format(task_name)) | ||||
| return | return | ||||
| data.kwargs["fromIRC"] = True | 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) | msg = "task \x0302{0}\x0301 started.".format(task_name) | ||||
| self.connection.reply(data, msg) | self.connection.reply(data, msg) | ||||
| @@ -66,7 +66,7 @@ class _ConfigNode(object): | |||||
| data = self.__dict__.copy() | data = self.__dict__.copy() | ||||
| for key, val in data.iteritems(): | for key, val in data.iteritems(): | ||||
| if isinstance(val, _ConfigNode): | if isinstance(val, _ConfigNode): | ||||
| data[key] = val.dump() | |||||
| data[key] = val._dump() | |||||
| return data | return data | ||||
| def _load(self, data): | def _load(self, data): | ||||
| @@ -1,137 +0,0 @@ | |||||
| # -*- coding: utf-8 -*- | |||||
| # | |||||
| # Copyright (C) 2009-2012 by Ben Kurtovic <ben.kurtovic@verizon.net> | |||||
| # | |||||
| # 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) | |||||
| @@ -20,8 +20,8 @@ | |||||
| # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE | ||||
| # SOFTWARE. | # 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 * | |||||
| @@ -23,93 +23,118 @@ | |||||
| import socket | import socket | ||||
| import threading | import threading | ||||
| __all__ = ["BrokenSocketException", "Connection"] | |||||
| __all__ = ["BrokenSocketException", "IRCConnection"] | |||||
| class BrokenSocketException(Exception): | 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 | pass | ||||
| class Connection(object): | |||||
| class IRCConnection(object): | |||||
| """A class to interface with IRC.""" | """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.host = host | ||||
| self.port = port | self.port = port | ||||
| self.nick = nick | self.nick = nick | ||||
| self.ident = ident | self.ident = ident | ||||
| self.realname = realname | self.realname = realname | ||||
| self.logger = logger | self.logger = logger | ||||
| self.is_running = False | |||||
| # A lock to prevent us from sending two messages at once: | # 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.""" | """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: | try: | ||||
| self.sock.connect((self.host, self.port)) | |||||
| self._sock.connect((self.host, self.port)) | |||||
| except socket.error: | except socket.error: | ||||
| self.logger.critical("Couldn't connect to IRC server", exc_info=1) | self.logger.critical("Couldn't connect to IRC server", exc_info=1) | ||||
| exit(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.""" | """Close our connection with the IRC server.""" | ||||
| try: | try: | ||||
| self.sock.shutdown(socket.SHUT_RDWR) # shut down connection first | |||||
| self._sock.shutdown(socket.SHUT_RDWR) # Shut down connection first | |||||
| except socket.error: | 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.""" | """Receive (i.e. get) data from the server.""" | ||||
| data = self.sock.recv(4096) | |||||
| data = self._sock.recv(4096) | |||||
| if not data: | if not data: | ||||
| # Socket isn't giving us any data, so it is dead or broken: | # Socket isn't giving us any data, so it is dead or broken: | ||||
| raise BrokenSocketException() | raise BrokenSocketException() | ||||
| return data | return data | ||||
| def send(self, msg): | |||||
| def _send(self, msg): | |||||
| """Send data to the server.""" | """Send data to the server.""" | ||||
| # Ensure that we only send one message at a time with a blocking lock: | # 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) | self.logger.debug(msg) | ||||
| def say(self, target, msg): | def say(self, target, msg): | ||||
| """Send a private message to a target on the server.""" | """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): | def reply(self, data, msg): | ||||
| """Send a private message as a reply to a user on the server.""" | """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): | def action(self, target, msg): | ||||
| """Send a private message to a target on the server as an action.""" | """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): | def notice(self, target, msg): | ||||
| """Send a notice to a target on the server.""" | """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): | def join(self, chan): | ||||
| """Join a channel on the server.""" | """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): | def part(self, chan): | ||||
| """Part from a channel on the server.""" | """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): | def mode(self, chan, level, msg): | ||||
| """Send a mode message to the server.""" | """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 | |||||
| @@ -0,0 +1,99 @@ | |||||
| # -*- coding: utf-8 -*- | |||||
| # | |||||
| # Copyright (C) 2009-2012 by Ben Kurtovic <ben.kurtovic@verizon.net> | |||||
| # | |||||
| # 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) | |||||
| @@ -0,0 +1,88 @@ | |||||
| # -*- coding: utf-8 -*- | |||||
| # | |||||
| # Copyright (C) 2009-2012 by Ben Kurtovic <ben.kurtovic@verizon.net> | |||||
| # | |||||
| # 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) | |||||
| @@ -49,27 +49,22 @@ import logging | |||||
| import threading | import threading | ||||
| import time | import time | ||||
| from earwigbot import frontend | |||||
| from earwigbot import tasks | |||||
| from earwigbot import watcher | |||||
| from earwigbot.config import config | from earwigbot.config import config | ||||
| from earwigbot.irc import Frontend, Watcher | |||||
| from earwigbot.tasks import task_manager | |||||
| logger = logging.getLogger("earwigbot") | 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 | """Function to handle the IRC watcher as another thread (if frontend and/or | ||||
| scheduler is enabled), otherwise run as the main thread.""" | 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: | try: | ||||
| watcher.main(w_conn, f_conn) | |||||
| watcher.loop() | |||||
| except: | except: | ||||
| logger.exception("Watcher had an error") | 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") | logger.warn("Watcher has stopped; restarting component") | ||||
| def wiki_scheduler(): | def wiki_scheduler(): | ||||
| @@ -77,56 +72,51 @@ def wiki_scheduler(): | |||||
| primary thread if the IRC frontend is not enabled.""" | primary thread if the IRC frontend is not enabled.""" | ||||
| while 1: | while 1: | ||||
| time_start = time.time() | time_start = time.time() | ||||
| now = time.gmtime(time_start) | |||||
| tasks.schedule(now) | |||||
| task_manager.schedule() | |||||
| time_end = time.time() | time_end = time.time() | ||||
| time_diff = time_start - time_end | 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) | time.sleep(60 - time_diff) | ||||
| def irc_frontend(): | def irc_frontend(): | ||||
| """If the IRC frontend is enabled, make it run on our primary thread, and | """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 | enable the wiki scheduler and IRC watcher on new threads if they are | ||||
| enabled.""" | enabled.""" | ||||
| global f_conn | |||||
| logger.info("Starting IRC frontend") | 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") | logger.info("Starting wiki scheduler") | ||||
| tasks.load() | |||||
| task_manager.load() | |||||
| t_scheduler = threading.Thread(target=wiki_scheduler) | t_scheduler = threading.Thread(target=wiki_scheduler) | ||||
| t_scheduler.name = "wiki-scheduler" | t_scheduler.name = "wiki-scheduler" | ||||
| t_scheduler.daemon = True | t_scheduler.daemon = True | ||||
| t_scheduler.start() | t_scheduler.start() | ||||
| if "irc_watcher" in config.components: | |||||
| if config.components.get("irc_watcher"): | |||||
| logger.info("Starting 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.name = "irc-watcher" | ||||
| t_watcher.daemon = True | t_watcher.daemon = True | ||||
| t_watcher.start() | t_watcher.start() | ||||
| frontend.main() | |||||
| frontend.loop() | |||||
| if "irc_watcher" in config.components: | if "irc_watcher" in config.components: | ||||
| w_conn.close() | w_conn.close() | ||||
| f_conn.close() | f_conn.close() | ||||
| def main(): | 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 | # Make the frontend run on our primary thread if enabled, and enable | ||||
| # additional components through that function | |||||
| # additional components through that function: | |||||
| irc_frontend() | 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 | # 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") | logger.info("Starting wiki scheduler") | ||||
| tasks.load() | |||||
| task_manager.load() | |||||
| if "irc_watcher" in enabled: | if "irc_watcher" in enabled: | ||||
| logger.info("Starting IRC watcher") | logger.info("Starting IRC watcher") | ||||
| t_watcher = threading.Thread(target=irc_watcher) | t_watcher = threading.Thread(target=irc_watcher) | ||||
| @@ -135,7 +125,7 @@ def main(): | |||||
| t_watcher.start() | t_watcher.start() | ||||
| wiki_scheduler() | 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 | # The IRC watcher is our only enabled component, so run its function | ||||
| # only and don't worry about anything else: | # only and don't worry about anything else: | ||||
| logger.info("Starting IRC watcher") | logger.info("Starting IRC watcher") | ||||
| @@ -29,7 +29,7 @@ recieves an event from IRC. | |||||
| import re | import re | ||||
| from earwigbot import tasks | |||||
| from earwigbot.tasks import task_manager | |||||
| afc_prefix = "wikipedia( talk)?:(wikiproject )?articles for creation" | afc_prefix = "wikipedia( talk)?:(wikiproject )?articles for creation" | ||||
| @@ -56,7 +56,7 @@ def process(rc): | |||||
| chans.update(("##earwigbot", "#wikipedia-en-afc-feed")) | chans.update(("##earwigbot", "#wikipedia-en-afc-feed")) | ||||
| if r_page.search(page_name): | 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") | chans.add("#wikipedia-en-afc-feed") | ||||
| elif r_ffu.match(page_name): | elif r_ffu.match(page_name): | ||||
| @@ -76,7 +76,7 @@ def process(rc): | |||||
| elif rc.flags == "restore" and r_restore.match(comment): | elif rc.flags == "restore" and r_restore.match(comment): | ||||
| p = r_restored_page.findall(rc.comment)[0] | 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") | chans.add("#wikipedia-en-afc-feed") | ||||
| elif rc.flags == "protect" and r_protect.match(comment): | elif rc.flags == "protect" and r_protect.match(comment): | ||||
| @@ -23,8 +23,10 @@ | |||||
| """ | """ | ||||
| EarwigBot's Wiki Task Manager | 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 | import logging | ||||
| @@ -33,106 +35,199 @@ import sys | |||||
| import threading | import threading | ||||
| import time | import time | ||||
| from earwigbot.classes import BaseTask | |||||
| from earwigbot import wiki | |||||
| from earwigbot.config import config | 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: | 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() | |||||
| @@ -20,7 +20,7 @@ | |||||
| # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE | ||||
| # SOFTWARE. | # SOFTWARE. | ||||
| from earwigbot.classes import BaseTask | |||||
| from earwigbot.tasks import BaseTask | |||||
| class Task(BaseTask): | class Task(BaseTask): | ||||
| """A task to delink mainspace categories in declined [[WP:AFC]] | """A task to delink mainspace categories in declined [[WP:AFC]] | ||||
| @@ -27,8 +27,8 @@ from threading import Lock | |||||
| import oursql | import oursql | ||||
| from earwigbot import wiki | from earwigbot import wiki | ||||
| from earwigbot.classes import BaseTask | |||||
| from earwigbot.config import config | from earwigbot.config import config | ||||
| from earwigbot.tasks import BaseTask | |||||
| class Task(BaseTask): | class Task(BaseTask): | ||||
| """A task to check newly-edited [[WP:AFC]] submissions for copyright | """A task to check newly-edited [[WP:AFC]] submissions for copyright | ||||
| @@ -20,7 +20,7 @@ | |||||
| # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE | ||||
| # SOFTWARE. | # SOFTWARE. | ||||
| from earwigbot.classes import BaseTask | |||||
| from earwigbot.tasks import BaseTask | |||||
| class Task(BaseTask): | class Task(BaseTask): | ||||
| """ A task to create daily categories for [[WP:AFC]].""" | """ A task to create daily categories for [[WP:AFC]].""" | ||||
| @@ -32,8 +32,8 @@ from numpy import arange | |||||
| import oursql | import oursql | ||||
| from earwigbot import wiki | from earwigbot import wiki | ||||
| from earwigbot.classes import BaseTask | |||||
| from earwigbot.config import config | from earwigbot.config import config | ||||
| from earwigbot.tasks import BaseTask | |||||
| # Valid submission statuses: | # Valid submission statuses: | ||||
| STATUS_NONE = 0 | STATUS_NONE = 0 | ||||
| @@ -30,8 +30,8 @@ from time import sleep | |||||
| import oursql | import oursql | ||||
| from earwigbot import wiki | from earwigbot import wiki | ||||
| from earwigbot.classes import BaseTask | |||||
| from earwigbot.config import config | from earwigbot.config import config | ||||
| from earwigbot.tasks import BaseTask | |||||
| # Chart status number constants: | # Chart status number constants: | ||||
| CHART_NONE = 0 | CHART_NONE = 0 | ||||
| @@ -20,7 +20,7 @@ | |||||
| # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE | ||||
| # SOFTWARE. | # SOFTWARE. | ||||
| from earwigbot.classes import BaseTask | |||||
| from earwigbot.tasks import BaseTask | |||||
| class Task(BaseTask): | class Task(BaseTask): | ||||
| """A task to clear [[Category:Undated AfC submissions]].""" | """A task to clear [[Category:Undated AfC submissions]].""" | ||||
| @@ -20,7 +20,7 @@ | |||||
| # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE | ||||
| # SOFTWARE. | # SOFTWARE. | ||||
| from earwigbot.classes import BaseTask | |||||
| from earwigbot.tasks import BaseTask | |||||
| class Task(BaseTask): | class Task(BaseTask): | ||||
| """A task to add |blp=yes to {{WPB}} or {{WPBS}} when it is used along with | """A task to add |blp=yes to {{WPB}} or {{WPBS}} when it is used along with | ||||
| @@ -20,7 +20,7 @@ | |||||
| # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE | ||||
| # SOFTWARE. | # SOFTWARE. | ||||
| from earwigbot.classes import BaseTask | |||||
| from earwigbot.tasks import BaseTask | |||||
| class Task(BaseTask): | class Task(BaseTask): | ||||
| """A task to create daily categories for [[WP:FEED]].""" | """A task to create daily categories for [[WP:FEED]].""" | ||||
| @@ -20,7 +20,7 @@ | |||||
| # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE | ||||
| # SOFTWARE. | # SOFTWARE. | ||||
| from earwigbot.classes import BaseTask | |||||
| from earwigbot.tasks import BaseTask | |||||
| class Task(BaseTask): | class Task(BaseTask): | ||||
| """A task to tag files whose extensions do not agree with their MIME | """A task to tag files whose extensions do not agree with their MIME | ||||
| @@ -34,18 +34,18 @@ instead of a socket for data. | |||||
| import re | import re | ||||
| from unittest import TestCase | from unittest import TestCase | ||||
| from earwigbot.classes import Connection, Data | |||||
| from earwigbot.irc import IRCConnection, Data | |||||
| class CommandTestCase(TestCase): | class CommandTestCase(TestCase): | ||||
| re_sender = re.compile(":(.*?)!(.*?)@(.*?)\Z") | re_sender = re.compile(":(.*?)!(.*?)@(.*?)\Z") | ||||
| def setUp(self, command): | def setUp(self, command): | ||||
| self.connection = FakeConnection() | self.connection = FakeConnection() | ||||
| self.connection.connect() | |||||
| self.connection._connect() | |||||
| self.command = command(self.connection) | self.command = command(self.connection) | ||||
| def get_single(self): | def get_single(self): | ||||
| data = self.connection.get().split("\n") | |||||
| data = self.connection._get().split("\n") | |||||
| line = data.pop(0) | line = data.pop(0) | ||||
| for remaining in data[1:]: | for remaining in data[1:]: | ||||
| self.connection.send(remaining) | self.connection.send(remaining) | ||||
| @@ -92,16 +92,19 @@ class CommandTestCase(TestCase): | |||||
| line = ":Foo!bar@example.com JOIN :#channel".strip().split() | line = ":Foo!bar@example.com JOIN :#channel".strip().split() | ||||
| return self.maker(line, line[2][1:]) | 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 = "" | self._buffer = "" | ||||
| def close(self): | |||||
| def _close(self): | |||||
| pass | pass | ||||
| def get(self, size=4096): | |||||
| def _get(self, size=4096): | |||||
| data, self._buffer = self._buffer, "" | data, self._buffer = self._buffer, "" | ||||
| return data | return data | ||||
| def send(self, msg): | |||||
| def _send(self, msg): | |||||
| self._buffer += msg + "\n" | self._buffer += msg + "\n" | ||||
| @@ -1,114 +0,0 @@ | |||||
| # -*- coding: utf-8 -*- | |||||
| # | |||||
| # Copyright (C) 2009-2012 by Ben Kurtovic <ben.kurtovic@verizon.net> | |||||
| # | |||||
| # 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) | |||||