| @@ -1,19 +1,3 @@ | |||||
| # Ignore python bytecode: | |||||
| *.pyc | *.pyc | ||||
| # Ignore bot-specific config file: | |||||
| config.yml | |||||
| # Ignore logs directory: | |||||
| logs/ | |||||
| # Ignore cookies file: | |||||
| .cookies | |||||
| # Ignore OS X's crud: | |||||
| *.egg-info | |||||
| .DS_Store | .DS_Store | ||||
| # Ignore pydev's nonsense: | |||||
| .project | |||||
| .pydevproject | |||||
| .settings/ | |||||
| @@ -1,70 +0,0 @@ | |||||
| #! /usr/bin/env python | |||||
| # -*- 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 | |||||
| This is a thin wrapper for EarwigBot's main bot code, specified by bot_script. | |||||
| The wrapper will automatically restart the bot when it shuts down (from | |||||
| !restart, for example). It requests the bot's password at startup and reuses it | |||||
| every time the bot restarts internally, so you do not need to re-enter the | |||||
| password after using !restart. | |||||
| For information about the bot as a whole, see the attached README.md file (in | |||||
| markdown format!), the docs/ directory, and the LICENSE file for licensing | |||||
| information. EarwigBot is released under the MIT license. | |||||
| """ | |||||
| from getpass import getpass | |||||
| from subprocess import Popen, PIPE | |||||
| from os import path | |||||
| from sys import executable | |||||
| from time import sleep | |||||
| import earwigbot | |||||
| bot_script = path.join(earwigbot.__path__[0], "runner.py") | |||||
| def main(): | |||||
| print "EarwigBot v{0}\n".format(earwigbot.__version__) | |||||
| is_encrypted = earwigbot.config.config.load() | |||||
| if is_encrypted: # Passwords in the config file are encrypted | |||||
| key = getpass("Enter key to unencrypt bot passwords: ") | |||||
| else: | |||||
| key = None | |||||
| while 1: | |||||
| bot = Popen([executable, bot_script], stdin=PIPE) | |||||
| print >> bot.stdin, path.dirname(path.abspath(__file__)) | |||||
| if is_encrypted: | |||||
| print >> bot.stdin, key | |||||
| return_code = bot.wait() | |||||
| if return_code == 1: | |||||
| exit() # Let critical exceptions in the subprocess cause us to | |||||
| # exit as well | |||||
| else: | |||||
| sleep(5) # Sleep between bot runs following a non-critical | |||||
| # subprocess exit | |||||
| if __name__ == "__main__": | |||||
| main() | |||||
| @@ -21,16 +21,32 @@ | |||||
| # SOFTWARE. | # SOFTWARE. | ||||
| """ | """ | ||||
| EarwigBot - http://earwig.github.com/earwig/earwigbot | |||||
| EarwigBot is a Python robot that edits Wikipedia and interacts with people over | |||||
| IRC. - http://earwig.github.com/earwig/earwigbot | |||||
| See README.md for a basic overview, or the docs/ directory for details. | See README.md for a basic overview, or the docs/ directory for details. | ||||
| """ | """ | ||||
| __author__ = "Ben Kurtovic" | __author__ = "Ben Kurtovic" | ||||
| __copyright__ = "Copyright (C) 2009, 2010, 2011 by Ben Kurtovic" | |||||
| __copyright__ = "Copyright (C) 2009, 2010, 2011, 2012 by Ben Kurtovic" | |||||
| __license__ = "MIT License" | __license__ = "MIT License" | ||||
| __version__ = "0.1.dev" | __version__ = "0.1.dev" | ||||
| __email__ = "ben.kurtovic@verizon.net" | __email__ = "ben.kurtovic@verizon.net" | ||||
| __release__ = False | |||||
| if not __release__: | |||||
| def _add_git_commit_id_to_version(version): | |||||
| from git import Repo | |||||
| from os.path import split, dirname | |||||
| path = split(dirname(__file__))[0] | |||||
| commit_id = Repo(path).head.object.hexsha | |||||
| return version + ".git+" + commit_id[:8] | |||||
| try: | |||||
| __version__ = _add_git_commit_id_to_version(__version__) | |||||
| except Exception: | |||||
| pass | |||||
| finally: | |||||
| del _add_git_commit_id_to_version | |||||
| from earwigbot import ( | |||||
| blowfish, commands, config, irc, main, runner, tasks, tests, wiki | |||||
| ) | |||||
| from earwigbot import (blowfish, bot, commands, config, irc, managers, tasks, | |||||
| util, wiki) | |||||
| @@ -0,0 +1,188 @@ | |||||
| # -*- 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 threading import Lock, Thread, enumerate as enumerate_threads | |||||
| from time import sleep, time | |||||
| from earwigbot.config import BotConfig | |||||
| from earwigbot.irc import Frontend, Watcher | |||||
| from earwigbot.managers import CommandManager, TaskManager | |||||
| from earwigbot.wiki import SitesDB | |||||
| __all__ = ["Bot"] | |||||
| class Bot(object): | |||||
| """ | |||||
| The Bot class is the core of EarwigBot, essentially responsible for | |||||
| starting the various bot components and making sure they are all happy. | |||||
| EarwigBot has three components that can run independently of each other: an | |||||
| IRC front-end, an IRC watcher, and a wiki scheduler. | |||||
| * The IRC front-end runs on a normal IRC server and expects users to | |||||
| interact with it/give it commands. | |||||
| * The IRC watcher runs on a wiki recent-changes server and listens for | |||||
| edits. Users cannot interact with this part of the bot. | |||||
| * The wiki scheduler runs wiki-editing bot tasks in separate threads at | |||||
| user-defined times through a cron-like interface. | |||||
| The Bot() object is accessable from within commands and tasks as self.bot. | |||||
| This is the primary way to access data from other components of the bot. | |||||
| For example, our BotConfig object is accessable from bot.config, tasks | |||||
| can be started with bot.tasks.start(), and sites can be loaded from the | |||||
| wiki toolset with bot.wiki.get_site(). | |||||
| """ | |||||
| def __init__(self, root_dir, level=logging.INFO): | |||||
| self.config = BotConfig(root_dir, level) | |||||
| self.logger = logging.getLogger("earwigbot") | |||||
| self.commands = CommandManager(self) | |||||
| self.tasks = TaskManager(self) | |||||
| self.wiki = SitesDB(self.config) | |||||
| self.frontend = None | |||||
| self.watcher = None | |||||
| self.component_lock = Lock() | |||||
| self._keep_looping = True | |||||
| self.config.load() | |||||
| self.commands.load() | |||||
| self.tasks.load() | |||||
| def _start_irc_components(self): | |||||
| """Start the IRC frontend/watcher in separate threads if enabled.""" | |||||
| if self.config.components.get("irc_frontend"): | |||||
| self.logger.info("Starting IRC frontend") | |||||
| self.frontend = Frontend(self) | |||||
| Thread(name="irc_frontend", target=self.frontend.loop).start() | |||||
| if self.config.components.get("irc_watcher"): | |||||
| self.logger.info("Starting IRC watcher") | |||||
| self.watcher = Watcher(self) | |||||
| Thread(name="irc_watcher", target=self.watcher.loop).start() | |||||
| def _start_wiki_scheduler(self): | |||||
| """Start the wiki scheduler in a separate thread if enabled.""" | |||||
| def wiki_scheduler(): | |||||
| while self._keep_looping: | |||||
| time_start = time() | |||||
| self.tasks.schedule() | |||||
| time_end = time() | |||||
| time_diff = time_start - time_end | |||||
| if time_diff < 60: # Sleep until the next minute | |||||
| sleep(60 - time_diff) | |||||
| if self.config.components.get("wiki_scheduler"): | |||||
| self.logger.info("Starting wiki scheduler") | |||||
| thread = Thread(name="wiki_scheduler", target=wiki_scheduler) | |||||
| thread.daemon = True # Stop if other threads stop | |||||
| thread.start() | |||||
| def _stop_irc_components(self, msg): | |||||
| """Request the IRC frontend and watcher to stop if enabled.""" | |||||
| if self.frontend: | |||||
| self.frontend.stop(msg) | |||||
| if self.watcher: | |||||
| self.watcher.stop(msg) | |||||
| def _stop_task_threads(self): | |||||
| """Notify the user of which task threads are going to be killed. | |||||
| Unfortunately, there is no method right now of stopping task threads | |||||
| safely. This is because there is no way to tell them to stop like the | |||||
| IRC components can be told; furthermore, they are run as daemons, and | |||||
| daemon threads automatically stop without calling any __exit__ or | |||||
| try/finally code when all non-daemon threads stop. They were originally | |||||
| implemented as regular non-daemon threads, but this meant there was no | |||||
| way to completely stop the bot if tasks were running, because all other | |||||
| threads would exit and threading would absorb KeyboardInterrupts. | |||||
| The advantage of this is that stopping the bot is truly guarenteed to | |||||
| *stop* the bot, while the disadvantage is that the tasks are given no | |||||
| advance warning of their forced shutdown. | |||||
| """ | |||||
| tasks = [] | |||||
| non_tasks = self.config.components.keys() + ["MainThread", "reminder"] | |||||
| for thread in enumerate_threads(): | |||||
| if thread.name not in non_tasks and thread.is_alive(): | |||||
| tasks.append(thread.name) | |||||
| if tasks: | |||||
| log = "The following tasks will be killed: {0}" | |||||
| self.logger.warn(log.format(" ".join(tasks))) | |||||
| def run(self): | |||||
| """Main entry point into running the bot. | |||||
| Starts all config-enabled components and then enters an idle loop, | |||||
| ensuring that all components remain online and restarting components | |||||
| that get disconnected from their servers. | |||||
| """ | |||||
| self.logger.info("Starting bot") | |||||
| self._start_irc_components() | |||||
| self._start_wiki_scheduler() | |||||
| while self._keep_looping: | |||||
| with self.component_lock: | |||||
| if self.frontend and self.frontend.is_stopped(): | |||||
| self.logger.warn("IRC frontend has stopped; restarting") | |||||
| self.frontend = Frontend(self) | |||||
| Thread(name=name, target=self.frontend.loop).start() | |||||
| if self.watcher and self.watcher.is_stopped(): | |||||
| self.logger.warn("IRC watcher has stopped; restarting") | |||||
| self.watcher = Watcher(self) | |||||
| Thread(name=name, target=self.watcher.loop).start() | |||||
| sleep(2) | |||||
| def restart(self, msg=None): | |||||
| """Reload config, commands, tasks, and safely restart IRC components. | |||||
| This is thread-safe, and it will gracefully stop IRC components before | |||||
| reloading anything. Note that you can safely reload commands or tasks | |||||
| without restarting the bot with bot.commands.load() or | |||||
| bot.tasks.load(). These should not interfere with running components | |||||
| or tasks. | |||||
| If given, 'msg' will be used as our quit message. | |||||
| """ | |||||
| if msg: | |||||
| self.logger.info('Restarting bot ("{0}")'.format(msg)) | |||||
| else: | |||||
| self.logger.info("Restarting bot") | |||||
| with self.component_lock: | |||||
| self._stop_irc_components(msg) | |||||
| self.config.load() | |||||
| self.commands.load() | |||||
| self.tasks.load() | |||||
| self._start_irc_components() | |||||
| def stop(self, msg=None): | |||||
| """Gracefully stop all bot components. | |||||
| If given, 'msg' will be used as our quit message. | |||||
| """ | |||||
| if msg: | |||||
| self.logger.info('Stopping bot ("{0}")'.format(msg)) | |||||
| else: | |||||
| self.logger.info("Stopping bot") | |||||
| with self.component_lock: | |||||
| self._stop_irc_components(msg) | |||||
| self._keep_looping = False | |||||
| self._stop_task_threads() | |||||
| @@ -21,21 +21,16 @@ | |||||
| # SOFTWARE. | # SOFTWARE. | ||||
| """ | """ | ||||
| EarwigBot's IRC Command Manager | |||||
| EarwigBot's IRC Commands | |||||
| 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. | ||||
| This module contains the BaseCommand class (import with | 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. | |||||
| `from earwigbot.commands import BaseCommand`), whereas the package contains | |||||
| various built-in commands. Additional commands can be installed as plugins in | |||||
| the bot's working directory. | |||||
| """ | """ | ||||
| import logging | |||||
| import os | |||||
| import sys | |||||
| from earwigbot.config import config | |||||
| __all__ = ["BaseCommand", "command_manager"] | |||||
| __all__ = ["BaseCommand"] | |||||
| class BaseCommand(object): | class BaseCommand(object): | ||||
| """A base class for commands on IRC. | """A base class for commands on IRC. | ||||
| @@ -50,114 +45,65 @@ class BaseCommand(object): | |||||
| # command subclass: | # command subclass: | ||||
| hooks = ["msg"] | hooks = ["msg"] | ||||
| def __init__(self, connection): | |||||
| def __init__(self, bot): | |||||
| """Constructor for new commands. | """Constructor for new commands. | ||||
| This is called once when the command is loaded (from | 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. | |||||
| commands._load_command()). `bot` is out base Bot object. Generally you | |||||
| shouldn't need to override this; if you do, call | |||||
| super(Command, self).__init__() first. | |||||
| """ | """ | ||||
| self.connection = connection | |||||
| logger_name = ".".join(("earwigbot", "commands", self.name)) | |||||
| self.logger = logging.getLogger(logger_name) | |||||
| self.logger.setLevel(logging.DEBUG) | |||||
| self.bot = bot | |||||
| self.config = bot.config | |||||
| self.logger = bot.commands.logger.getChild(self.name) | |||||
| # Convenience functions: | |||||
| self.say = lambda target, msg: self.bot.frontend.say(target, msg) | |||||
| self.reply = lambda data, msg: self.bot.frontend.reply(data, msg) | |||||
| self.action = lambda target, msg: self.bot.frontend.action(target, msg) | |||||
| self.notice = lambda target, msg: self.bot.frontend.notice(target, msg) | |||||
| self.join = lambda chan: self.bot.frontend.join(chan) | |||||
| self.part = lambda chan, msg=None: self.bot.frontend.part(chan, msg) | |||||
| self.mode = lambda t, level, msg: self.bot.frontend.mode(t, level, msg) | |||||
| self.pong = lambda target: self.bot.frontend.pong(target) | |||||
| def _wrap_check(self, data): | |||||
| """Check whether this command should be called, catching errors.""" | |||||
| try: | |||||
| return self.check(data) | |||||
| except Exception: | |||||
| e = "Error checking command '{0}' with data: {1}:" | |||||
| self.logger.exception(e.format(self.name, data)) | |||||
| def _wrap_process(self, data): | |||||
| """process() the message, catching and reporting any errors.""" | |||||
| try: | |||||
| self.process(data) | |||||
| except Exception: | |||||
| e = "Error executing command '{0}':" | |||||
| self.logger.exception(e.format(data.command)) | |||||
| def check(self, data): | def check(self, data): | ||||
| """Returns whether this command should be called in response to 'data'. | |||||
| """Return whether this command should be called in response to 'data'. | |||||
| Given a Data() instance, return True if we should respond to this | 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. | activity, or False if we should ignore it or it doesn't apply to us. | ||||
| Be aware that since this is called for each message sent on IRC, it | |||||
| should not be cheap to execute and unlikely to throw exceptions. | |||||
| Most commands return True if data.command == self.name, otherwise they | Most commands return True if data.command == self.name, otherwise they | ||||
| return False. This is the default behavior of check(); you need only | return False. This is the default behavior of check(); you need only | ||||
| override it if you wish to change that. | override it if you wish to change that. | ||||
| """ | """ | ||||
| if data.is_command and data.command == self.name: | |||||
| return True | |||||
| return False | |||||
| return data.is_command and data.command == self.name | |||||
| def process(self, data): | def process(self, data): | ||||
| """Main entry point for doing a command. | """Main entry point for doing a command. | ||||
| Handle an activity (usually a message) on IRC. At this point, thanks | Handle an activity (usually a message) on IRC. At this point, thanks | ||||
| to self.check() which is called automatically by the command handler, | 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. | |||||
| we know this is something we should respond to, so something like | |||||
| `if data.command != "command_name": return` is usually unnecessary. | |||||
| Note that | |||||
| """ | """ | ||||
| pass | 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 | |||||
| try: | |||||
| command = sys.modules[name].Command(self._connection) | |||||
| except AttributeError: | |||||
| 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() | |||||
| @@ -24,27 +24,28 @@ import re | |||||
| from earwigbot import wiki | from earwigbot import wiki | ||||
| from earwigbot.commands import BaseCommand | 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.""" | ||||
| name = "report" | name = "report" | ||||
| def process(self, data): | def process(self, data): | ||||
| self.site = wiki.get_site() | |||||
| self.site = self.bot.wiki.get_site() | |||||
| self.site._maxlag = None | self.site._maxlag = None | ||||
| self.data = data | self.data = data | ||||
| try: | try: | ||||
| self.statistics = task_manager.get("afc_statistics") | |||||
| self.statistics = self.bot.tasks.get("afc_statistics") | |||||
| except KeyError: | except KeyError: | ||||
| e = "Cannot run command: requires afc_statistics task." | |||||
| e = "Cannot run command: requires afc_statistics task (from earwigbot_plugins)" | |||||
| self.logger.error(e) | self.logger.error(e) | ||||
| msg = "command requires afc_statistics task (from earwigbot_plugins)" | |||||
| self.reply(data, msg) | |||||
| return | return | ||||
| if not data.args: | if not data.args: | ||||
| msg = "what submission do you want me to give information about?" | msg = "what submission do you want me to give information about?" | ||||
| self.connection.reply(data, msg) | |||||
| self.reply(data, msg) | |||||
| return | return | ||||
| title = " ".join(data.args) | title = " ".join(data.args) | ||||
| @@ -68,8 +69,7 @@ class Command(BaseCommand): | |||||
| if page: | if page: | ||||
| return self.report(page) | return self.report(page) | ||||
| msg = "submission \x0302{0}\x0301 not found.".format(title) | |||||
| self.connection.reply(data, msg) | |||||
| self.reply(data, "submission \x0302{0}\x0301 not found.".format(title)) | |||||
| def get_page(self, title): | def get_page(self, title): | ||||
| page = self.site.get_page(title, follow_redirects=False) | page = self.site.get_page(title, follow_redirects=False) | ||||
| @@ -90,9 +90,9 @@ class Command(BaseCommand): | |||||
| if status == "accepted": | if status == "accepted": | ||||
| msg3 = "Reviewed by \x0302{0}\x0301 ({1})" | msg3 = "Reviewed by \x0302{0}\x0301 ({1})" | ||||
| self.connection.reply(self.data, msg1.format(short, url)) | |||||
| self.connection.say(self.data.chan, msg2.format(status)) | |||||
| self.connection.say(self.data.chan, msg3.format(user_name, user_url)) | |||||
| self.reply(self.data, msg1.format(short, url)) | |||||
| self.say(self.data.chan, msg2.format(status)) | |||||
| self.say(self.data.chan, msg3.format(user_name, user_url)) | |||||
| def get_status(self, page): | def get_status(self, page): | ||||
| if page.is_redirect(): | if page.is_redirect(): | ||||
| @@ -22,9 +22,7 @@ | |||||
| import re | import re | ||||
| from earwigbot import wiki | |||||
| from earwigbot.commands import BaseCommand | from earwigbot.commands import BaseCommand | ||||
| from earwigbot.config import config | |||||
| class Command(BaseCommand): | class Command(BaseCommand): | ||||
| """Get the number of pending AfC submissions, open redirect requests, and | """Get the number of pending AfC submissions, open redirect requests, and | ||||
| @@ -39,19 +37,19 @@ class Command(BaseCommand): | |||||
| try: | try: | ||||
| if data.line[1] == "JOIN" and data.chan == "#wikipedia-en-afc": | if data.line[1] == "JOIN" and data.chan == "#wikipedia-en-afc": | ||||
| if data.nick != config.irc["frontend"]["nick"]: | |||||
| if data.nick != self.config.irc["frontend"]["nick"]: | |||||
| return True | return True | ||||
| except IndexError: | except IndexError: | ||||
| pass | pass | ||||
| return False | return False | ||||
| def process(self, data): | def process(self, data): | ||||
| self.site = wiki.get_site() | |||||
| self.site = self.bot.wiki.get_site() | |||||
| self.site._maxlag = None | self.site._maxlag = None | ||||
| if data.line[1] == "JOIN": | if data.line[1] == "JOIN": | ||||
| status = " ".join(("\x02Current status:\x0F", self.get_status())) | status = " ".join(("\x02Current status:\x0F", self.get_status())) | ||||
| self.connection.notice(data.nick, status) | |||||
| self.notice(data.nick, status) | |||||
| return | return | ||||
| if data.args: | if data.args: | ||||
| @@ -59,17 +57,17 @@ class Command(BaseCommand): | |||||
| if action.startswith("sub") or action == "s": | if action.startswith("sub") or action == "s": | ||||
| subs = self.count_submissions() | subs = self.count_submissions() | ||||
| msg = "there are \x0305{0}\x0301 pending AfC submissions (\x0302WP:AFC\x0301)." | msg = "there are \x0305{0}\x0301 pending AfC submissions (\x0302WP:AFC\x0301)." | ||||
| self.connection.reply(data, msg.format(subs)) | |||||
| self.reply(data, msg.format(subs)) | |||||
| elif action.startswith("redir") or action == "r": | elif action.startswith("redir") or action == "r": | ||||
| redirs = self.count_redirects() | redirs = self.count_redirects() | ||||
| msg = "there are \x0305{0}\x0301 open redirect requests (\x0302WP:AFC/R\x0301)." | msg = "there are \x0305{0}\x0301 open redirect requests (\x0302WP:AFC/R\x0301)." | ||||
| self.connection.reply(data, msg.format(redirs)) | |||||
| self.reply(data, msg.format(redirs)) | |||||
| elif action.startswith("file") or action == "f": | elif action.startswith("file") or action == "f": | ||||
| files = self.count_redirects() | files = self.count_redirects() | ||||
| msg = "there are \x0305{0}\x0301 open file upload requests (\x0302WP:FFU\x0301)." | msg = "there are \x0305{0}\x0301 open file upload requests (\x0302WP:FFU\x0301)." | ||||
| self.connection.reply(data, msg.format(files)) | |||||
| self.reply(data, msg.format(files)) | |||||
| elif action.startswith("agg") or action == "a": | elif action.startswith("agg") or action == "a": | ||||
| try: | try: | ||||
| @@ -80,21 +78,21 @@ class Command(BaseCommand): | |||||
| agg_num = self.get_aggregate_number(agg_data) | agg_num = self.get_aggregate_number(agg_data) | ||||
| except ValueError: | except ValueError: | ||||
| msg = "\x0303{0}\x0301 isn't a number!" | msg = "\x0303{0}\x0301 isn't a number!" | ||||
| self.connection.reply(data, msg.format(data.args[1])) | |||||
| self.reply(data, msg.format(data.args[1])) | |||||
| return | return | ||||
| aggregate = self.get_aggregate(agg_num) | aggregate = self.get_aggregate(agg_num) | ||||
| msg = "aggregate is \x0305{0}\x0301 (AfC {1})." | msg = "aggregate is \x0305{0}\x0301 (AfC {1})." | ||||
| self.connection.reply(data, msg.format(agg_num, aggregate)) | |||||
| self.reply(data, msg.format(agg_num, aggregate)) | |||||
| elif action.startswith("nocolor") or action == "n": | elif action.startswith("nocolor") or action == "n": | ||||
| self.connection.reply(data, self.get_status(color=False)) | |||||
| self.reply(data, self.get_status(color=False)) | |||||
| else: | else: | ||||
| msg = "unknown argument: \x0303{0}\x0301. Valid args are 'subs', 'redirs', 'files', 'agg', 'nocolor'." | msg = "unknown argument: \x0303{0}\x0301. Valid args are 'subs', 'redirs', 'files', 'agg', 'nocolor'." | ||||
| self.connection.reply(data, msg.format(data.args[0])) | |||||
| self.reply(data, msg.format(data.args[0])) | |||||
| else: | else: | ||||
| self.connection.reply(data, self.get_status()) | |||||
| self.reply(data, self.get_status()) | |||||
| def get_status(self, color=True): | def get_status(self, color=True): | ||||
| subs = self.count_submissions() | subs = self.count_submissions() | ||||
| @@ -32,7 +32,7 @@ class Command(BaseCommand): | |||||
| def process(self, data): | def process(self, data): | ||||
| if not data.args: | if not data.args: | ||||
| self.connection.reply(data, "what do you want me to calculate?") | |||||
| self.reply(data, "what do you want me to calculate?") | |||||
| return | return | ||||
| query = ' '.join(data.args) | query = ' '.join(data.args) | ||||
| @@ -47,7 +47,7 @@ class Command(BaseCommand): | |||||
| match = r_result.search(result) | match = r_result.search(result) | ||||
| if not match: | if not match: | ||||
| self.connection.reply(data, "Calculation error.") | |||||
| self.reply(data, "Calculation error.") | |||||
| return | return | ||||
| result = match.group(1) | result = match.group(1) | ||||
| @@ -62,7 +62,7 @@ class Command(BaseCommand): | |||||
| result += " " + query.split(" in ", 1)[1] | result += " " + query.split(" in ", 1)[1] | ||||
| res = "%s = %s" % (query, result) | res = "%s = %s" % (query, result) | ||||
| self.connection.reply(data, res) | |||||
| self.reply(data, res) | |||||
| def cleanup(self, query): | def cleanup(self, query): | ||||
| fixes = [ | fixes = [ | ||||
| @@ -21,35 +21,73 @@ | |||||
| # SOFTWARE. | # SOFTWARE. | ||||
| from earwigbot.commands import BaseCommand | from earwigbot.commands import BaseCommand | ||||
| from earwigbot.config import config | |||||
| class Command(BaseCommand): | class Command(BaseCommand): | ||||
| """Voice, devoice, op, or deop users in the channel.""" | |||||
| """Voice, devoice, op, or deop users in the channel, or join or part from | |||||
| other channels.""" | |||||
| name = "chanops" | name = "chanops" | ||||
| def check(self, data): | def check(self, data): | ||||
| commands = ["chanops", "voice", "devoice", "op", "deop"] | |||||
| if data.is_command and data.command in commands: | |||||
| cmnds = ["chanops", "voice", "devoice", "op", "deop", "join", "part"] | |||||
| if data.is_command and data.command in cmnds: | |||||
| return True | return True | ||||
| return False | return False | ||||
| def process(self, data): | def process(self, data): | ||||
| if data.command == "chanops": | if data.command == "chanops": | ||||
| msg = "available commands are !voice, !devoice, !op, and !deop." | |||||
| self.connection.reply(data, msg) | |||||
| msg = "available commands are !voice, !devoice, !op, !deop, !join, and !part." | |||||
| self.reply(data, msg) | |||||
| return | return | ||||
| if data.host not in config.irc["permissions"]["admins"]: | |||||
| msg = "you must be a bot admin to use this command." | |||||
| self.connection.reply(data, msg) | |||||
| if data.host not in self.config.irc["permissions"]["admins"]: | |||||
| self.reply(data, "you must be a bot admin to use this command.") | |||||
| return | return | ||||
| # If it is just !op/!devoice/whatever without arguments, assume they | |||||
| # want to do this to themselves: | |||||
| if not data.args: | |||||
| target = data.nick | |||||
| if data.command == "join": | |||||
| self.do_join(data) | |||||
| elif data.command == "part": | |||||
| self.do_part(data) | |||||
| else: | |||||
| # If it is just !op/!devoice/whatever without arguments, assume | |||||
| # they want to do this to themselves: | |||||
| if not data.args: | |||||
| target = data.nick | |||||
| else: | |||||
| target = data.args[0] | |||||
| command = data.command.upper() | |||||
| self.say("ChanServ", " ".join((command, data.chan, target))) | |||||
| log = "{0} requested {1} on {2} in {3}" | |||||
| self.logger.info(log.format(data.nick, command, target, data.chan)) | |||||
| def do_join(self, data): | |||||
| if data.args: | |||||
| channel = data.args[0] | |||||
| if not channel.startswith("#"): | |||||
| channel = "#" + channel | |||||
| else: | else: | ||||
| target = data.args[0] | |||||
| msg = "you must specify a channel to join or part from." | |||||
| self.reply(data, msg) | |||||
| return | |||||
| self.join(channel) | |||||
| log = "{0} requested JOIN to {1}".format(data.nick, channel) | |||||
| self.logger.info(log) | |||||
| def do_part(self, data): | |||||
| channel = data.chan | |||||
| reason = None | |||||
| if data.args: | |||||
| if data.args[0].startswith("#"): | |||||
| # !part #channel reason for parting | |||||
| channel = data.args[0] | |||||
| if data.args[1:]: | |||||
| reason = " ".join(data.args[1:]) | |||||
| else: # !part reason for parting; assume current channel | |||||
| reason = " ".join(data.args) | |||||
| msg = " ".join((data.command, data.chan, target)) | |||||
| self.connection.say("ChanServ", msg) | |||||
| msg = "Requested by {0}".format(data.nick) | |||||
| log = "{0} requested PART from {1}".format(data.nick, channel) | |||||
| if reason: | |||||
| msg += ": {0}".format(reason) | |||||
| log += ' ("{0}")'.format(reason) | |||||
| self.part(channel, msg) | |||||
| self.logger.info(log) | |||||
| @@ -39,12 +39,12 @@ class Command(BaseCommand): | |||||
| def process(self, data): | def process(self, data): | ||||
| if data.command == "crypt": | if data.command == "crypt": | ||||
| msg = "available commands are !hash, !encrypt, and !decrypt." | msg = "available commands are !hash, !encrypt, and !decrypt." | ||||
| self.connection.reply(data, msg) | |||||
| self.reply(data, msg) | |||||
| return | return | ||||
| if not data.args: | if not data.args: | ||||
| msg = "what do you want me to {0}?".format(data.command) | msg = "what do you want me to {0}?".format(data.command) | ||||
| self.connection.reply(data, msg) | |||||
| self.reply(data, msg) | |||||
| return | return | ||||
| if data.command == "hash": | if data.command == "hash": | ||||
| @@ -52,14 +52,14 @@ class Command(BaseCommand): | |||||
| if algo == "list": | if algo == "list": | ||||
| algos = ', '.join(hashlib.algorithms) | algos = ', '.join(hashlib.algorithms) | ||||
| msg = algos.join(("supported algorithms: ", ".")) | msg = algos.join(("supported algorithms: ", ".")) | ||||
| self.connection.reply(data, msg) | |||||
| self.reply(data, msg) | |||||
| elif algo in hashlib.algorithms: | elif algo in hashlib.algorithms: | ||||
| string = ' '.join(data.args[1:]) | string = ' '.join(data.args[1:]) | ||||
| result = getattr(hashlib, algo)(string).hexdigest() | result = getattr(hashlib, algo)(string).hexdigest() | ||||
| self.connection.reply(data, result) | |||||
| self.reply(data, result) | |||||
| else: | else: | ||||
| msg = "unknown algorithm: '{0}'.".format(algo) | msg = "unknown algorithm: '{0}'.".format(algo) | ||||
| self.connection.reply(data, msg) | |||||
| self.reply(data, msg) | |||||
| else: | else: | ||||
| key = data.args[0] | key = data.args[0] | ||||
| @@ -67,14 +67,14 @@ class Command(BaseCommand): | |||||
| if not text: | if not text: | ||||
| msg = "a key was provided, but text to {0} was not." | msg = "a key was provided, but text to {0} was not." | ||||
| self.connection.reply(data, msg.format(data.command)) | |||||
| self.reply(data, msg.format(data.command)) | |||||
| return | return | ||||
| try: | try: | ||||
| if data.command == "encrypt": | if data.command == "encrypt": | ||||
| self.connection.reply(data, blowfish.encrypt(key, text)) | |||||
| self.reply(data, blowfish.encrypt(key, text)) | |||||
| else: | else: | ||||
| self.connection.reply(data, blowfish.decrypt(key, text)) | |||||
| self.reply(data, blowfish.decrypt(key, text)) | |||||
| except blowfish.BlowfishError as error: | except blowfish.BlowfishError as error: | ||||
| msg = "{0}: {1}.".format(error.__class__.__name__, error) | msg = "{0}: {1}.".format(error.__class__.__name__, error) | ||||
| self.connection.reply(data, msg) | |||||
| self.reply(data, msg) | |||||
| @@ -25,11 +25,10 @@ import time | |||||
| from earwigbot import __version__ | from earwigbot import __version__ | ||||
| from earwigbot.commands import BaseCommand | from earwigbot.commands import BaseCommand | ||||
| from earwigbot.config import config | |||||
| class Command(BaseCommand): | class Command(BaseCommand): | ||||
| """Not an actual command, this module is used to respond to the CTCP | |||||
| commands PING, TIME, and VERSION.""" | |||||
| """Not an actual command; this module implements responses to the CTCP | |||||
| requests PING, TIME, and VERSION.""" | |||||
| name = "ctcp" | name = "ctcp" | ||||
| hooks = ["msg_private"] | hooks = ["msg_private"] | ||||
| @@ -53,17 +52,17 @@ class Command(BaseCommand): | |||||
| if command == "PING": | if command == "PING": | ||||
| msg = " ".join(data.line[4:]) | msg = " ".join(data.line[4:]) | ||||
| if msg: | if msg: | ||||
| self.connection.notice(target, "\x01PING {0}\x01".format(msg)) | |||||
| self.notice(target, "\x01PING {0}\x01".format(msg)) | |||||
| else: | else: | ||||
| self.connection.notice(target, "\x01PING\x01") | |||||
| self.notice(target, "\x01PING\x01") | |||||
| elif command == "TIME": | elif command == "TIME": | ||||
| ts = time.strftime("%a, %d %b %Y %H:%M:%S %Z", time.localtime()) | ts = time.strftime("%a, %d %b %Y %H:%M:%S %Z", time.localtime()) | ||||
| self.connection.notice(target, "\x01TIME {0}\x01".format(ts)) | |||||
| self.notice(target, "\x01TIME {0}\x01".format(ts)) | |||||
| 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 = self.config.irc.get("version", default) | |||||
| vers = vers.replace("$1", __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.notice(target, "\x01VERSION {0}\x01".format(vers)) | |||||
| @@ -41,7 +41,7 @@ class Command(BaseCommand): | |||||
| else: | else: | ||||
| name = ' '.join(data.args) | name = ' '.join(data.args) | ||||
| site = wiki.get_site() | |||||
| site = self.bot.wiki.get_site() | |||||
| site._maxlag = None | site._maxlag = None | ||||
| user = site.get_user(name) | user = site.get_user(name) | ||||
| @@ -49,10 +49,10 @@ class Command(BaseCommand): | |||||
| count = user.editcount() | count = user.editcount() | ||||
| except wiki.UserNotFoundError: | except wiki.UserNotFoundError: | ||||
| msg = "the user \x0302{0}\x0301 does not exist." | msg = "the user \x0302{0}\x0301 does not exist." | ||||
| self.connection.reply(data, msg.format(name)) | |||||
| self.reply(data, msg.format(name)) | |||||
| return | return | ||||
| safe = quote_plus(user.name()) | safe = quote_plus(user.name()) | ||||
| url = "http://toolserver.org/~tparis/pcount/index.php?name={0}&lang=en&wiki=wikipedia" | url = "http://toolserver.org/~tparis/pcount/index.php?name={0}&lang=en&wiki=wikipedia" | ||||
| msg = "\x0302{0}\x0301 has {1} edits ({2})." | msg = "\x0302{0}\x0301 has {1} edits ({2})." | ||||
| self.connection.reply(data, msg.format(name, count, url.format(safe))) | |||||
| self.reply(data, msg.format(name, count, url.format(safe))) | |||||
| @@ -25,7 +25,6 @@ import subprocess | |||||
| import re | import re | ||||
| from earwigbot.commands import BaseCommand | from earwigbot.commands import BaseCommand | ||||
| from earwigbot.config import config | |||||
| class Command(BaseCommand): | class Command(BaseCommand): | ||||
| """Commands to interface with the bot's git repository; use '!git' for a | """Commands to interface with the bot's git repository; use '!git' for a | ||||
| @@ -34,9 +33,9 @@ class Command(BaseCommand): | |||||
| def process(self, data): | def process(self, data): | ||||
| self.data = data | self.data = data | ||||
| if data.host not in config.irc["permissions"]["owners"]: | |||||
| if data.host not in self.config.irc["permissions"]["owners"]: | |||||
| msg = "you must be a bot owner to use this command." | msg = "you must be a bot owner to use this command." | ||||
| self.connection.reply(data, msg) | |||||
| self.reply(data, msg) | |||||
| return | return | ||||
| if not data.args: | if not data.args: | ||||
| @@ -66,7 +65,7 @@ class Command(BaseCommand): | |||||
| else: # They asked us to do something we don't know | else: # They asked us to do something we don't know | ||||
| msg = "unknown argument: \x0303{0}\x0301.".format(data.args[0]) | msg = "unknown argument: \x0303{0}\x0301.".format(data.args[0]) | ||||
| self.connection.reply(data, msg) | |||||
| self.reply(data, msg) | |||||
| def exec_shell(self, command): | def exec_shell(self, command): | ||||
| """Execute a shell command and get the output.""" | """Execute a shell command and get the output.""" | ||||
| @@ -90,13 +89,13 @@ class Command(BaseCommand): | |||||
| for key in sorted(help.keys()): | for key in sorted(help.keys()): | ||||
| msg += "\x0303{0}\x0301 ({1}), ".format(key, help[key]) | msg += "\x0303{0}\x0301 ({1}), ".format(key, help[key]) | ||||
| msg = msg[:-2] # Trim last comma and space | msg = msg[:-2] # Trim last comma and space | ||||
| self.connection.reply(self.data, "sub-commands are: {0}.".format(msg)) | |||||
| self.reply(self.data, "sub-commands are: {0}.".format(msg)) | |||||
| def do_branch(self): | def do_branch(self): | ||||
| """Get our current branch.""" | """Get our current branch.""" | ||||
| branch = self.exec_shell("git name-rev --name-only HEAD") | branch = self.exec_shell("git name-rev --name-only HEAD") | ||||
| msg = "currently on branch \x0302{0}\x0301.".format(branch) | msg = "currently on branch \x0302{0}\x0301.".format(branch) | ||||
| self.connection.reply(self.data, msg) | |||||
| self.reply(self.data, msg) | |||||
| def do_branches(self): | def do_branches(self): | ||||
| """Get a list of branches.""" | """Get a list of branches.""" | ||||
| @@ -107,14 +106,14 @@ class Command(BaseCommand): | |||||
| branches = branches.replace('\n ', ', ') | branches = branches.replace('\n ', ', ') | ||||
| branches = branches.strip() | branches = branches.strip() | ||||
| msg = "branches: \x0302{0}\x0301.".format(branches) | msg = "branches: \x0302{0}\x0301.".format(branches) | ||||
| self.connection.reply(self.data, msg) | |||||
| self.reply(self.data, msg) | |||||
| def do_checkout(self): | def do_checkout(self): | ||||
| """Switch branches.""" | """Switch branches.""" | ||||
| try: | try: | ||||
| branch = self.data.args[1] | branch = self.data.args[1] | ||||
| except IndexError: # no branch name provided | except IndexError: # no branch name provided | ||||
| self.connection.reply(self.data, "switch to which branch?") | |||||
| self.reply(self.data, "switch to which branch?") | |||||
| return | return | ||||
| current_branch = self.exec_shell("git name-rev --name-only HEAD") | current_branch = self.exec_shell("git name-rev --name-only HEAD") | ||||
| @@ -123,51 +122,51 @@ class Command(BaseCommand): | |||||
| result = self.exec_shell("git checkout %s" % branch) | result = self.exec_shell("git checkout %s" % branch) | ||||
| if "Already on" in result: | if "Already on" in result: | ||||
| msg = "already on \x0302{0}\x0301!".format(branch) | msg = "already on \x0302{0}\x0301!".format(branch) | ||||
| self.connection.reply(self.data, msg) | |||||
| self.reply(self.data, msg) | |||||
| else: | else: | ||||
| ms = "switched from branch \x0302{1}\x0301 to \x0302{1}\x0301." | ms = "switched from branch \x0302{1}\x0301 to \x0302{1}\x0301." | ||||
| msg = ms.format(current_branch, branch) | msg = ms.format(current_branch, branch) | ||||
| self.connection.reply(self.data, msg) | |||||
| self.reply(self.data, msg) | |||||
| except subprocess.CalledProcessError: | except subprocess.CalledProcessError: | ||||
| # Git couldn't switch branches; assume the branch doesn't exist: | # Git couldn't switch branches; assume the branch doesn't exist: | ||||
| msg = "branch \x0302{0}\x0301 doesn't exist!".format(branch) | msg = "branch \x0302{0}\x0301 doesn't exist!".format(branch) | ||||
| self.connection.reply(self.data, msg) | |||||
| self.reply(self.data, msg) | |||||
| def do_delete(self): | def do_delete(self): | ||||
| """Delete a branch, while making sure that we are not already on it.""" | """Delete a branch, while making sure that we are not already on it.""" | ||||
| try: | try: | ||||
| delete_branch = self.data.args[1] | delete_branch = self.data.args[1] | ||||
| except IndexError: # no branch name provided | except IndexError: # no branch name provided | ||||
| self.connection.reply(self.data, "delete which branch?") | |||||
| self.reply(self.data, "delete which branch?") | |||||
| return | return | ||||
| current_branch = self.exec_shell("git name-rev --name-only HEAD") | current_branch = self.exec_shell("git name-rev --name-only HEAD") | ||||
| if current_branch == delete_branch: | if current_branch == delete_branch: | ||||
| msg = "you're currently on this branch; please checkout to a different branch before deleting." | msg = "you're currently on this branch; please checkout to a different branch before deleting." | ||||
| self.connection.reply(self.data, msg) | |||||
| self.reply(self.data, msg) | |||||
| return | return | ||||
| try: | try: | ||||
| self.exec_shell("git branch -d %s" % delete_branch) | self.exec_shell("git branch -d %s" % delete_branch) | ||||
| msg = "branch \x0302{0}\x0301 has been deleted locally." | msg = "branch \x0302{0}\x0301 has been deleted locally." | ||||
| self.connection.reply(self.data, msg.format(delete_branch)) | |||||
| self.reply(self.data, msg.format(delete_branch)) | |||||
| except subprocess.CalledProcessError: | except subprocess.CalledProcessError: | ||||
| # Git couldn't switch branches; assume the branch doesn't exist: | # Git couldn't switch branches; assume the branch doesn't exist: | ||||
| msg = "branch \x0302{0}\x0301 doesn't exist!".format(delete_branch) | msg = "branch \x0302{0}\x0301 doesn't exist!".format(delete_branch) | ||||
| self.connection.reply(self.data, msg) | |||||
| self.reply(self.data, msg) | |||||
| def do_pull(self): | def do_pull(self): | ||||
| """Pull from our remote repository.""" | """Pull from our remote repository.""" | ||||
| branch = self.exec_shell("git name-rev --name-only HEAD") | branch = self.exec_shell("git name-rev --name-only HEAD") | ||||
| msg = "pulling from remote (currently on \x0302{0}\x0301)..." | msg = "pulling from remote (currently on \x0302{0}\x0301)..." | ||||
| self.connection.reply(self.data, msg.format(branch)) | |||||
| self.reply(self.data, msg.format(branch)) | |||||
| result = self.exec_shell("git pull") | result = self.exec_shell("git pull") | ||||
| if "Already up-to-date." in result: | if "Already up-to-date." in result: | ||||
| self.connection.reply(self.data, "done; no new changes.") | |||||
| self.reply(self.data, "done; no new changes.") | |||||
| else: | else: | ||||
| regex = "\s*((.*?)\sfile(.*?)tions?\(-\))" | regex = "\s*((.*?)\sfile(.*?)tions?\(-\))" | ||||
| changes = re.findall(regex, result)[0][0] | changes = re.findall(regex, result)[0][0] | ||||
| @@ -177,11 +176,11 @@ class Command(BaseCommand): | |||||
| cmnd_url = "git config --get remote.{0}.url".format(remote) | cmnd_url = "git config --get remote.{0}.url".format(remote) | ||||
| url = self.exec_shell(cmnd_url) | url = self.exec_shell(cmnd_url) | ||||
| msg = "done; {0} [from {1}].".format(changes, url) | msg = "done; {0} [from {1}].".format(changes, url) | ||||
| self.connection.reply(self.data, msg) | |||||
| self.reply(self.data, msg) | |||||
| except subprocess.CalledProcessError: | except subprocess.CalledProcessError: | ||||
| # Something in .git/config is not specified correctly, so we | # Something in .git/config is not specified correctly, so we | ||||
| # cannot get the remote's URL. However, pull was a success: | # cannot get the remote's URL. However, pull was a success: | ||||
| self.connection.reply(self.data, "done; %s." % changes) | |||||
| self.reply(self.data, "done; %s." % changes) | |||||
| def do_status(self): | def do_status(self): | ||||
| """Check whether we have anything to pull.""" | """Check whether we have anything to pull.""" | ||||
| @@ -189,7 +188,7 @@ class Command(BaseCommand): | |||||
| result = self.exec_shell("git fetch --dry-run") | result = self.exec_shell("git fetch --dry-run") | ||||
| if not result: # Nothing was fetched, so remote and local are equal | if not result: # Nothing was fetched, so remote and local are equal | ||||
| msg = "last commit was {0}. Local copy is \x02up-to-date\x0F with remote." | msg = "last commit was {0}. Local copy is \x02up-to-date\x0F with remote." | ||||
| self.connection.reply(self.data, msg.format(last)) | |||||
| self.reply(self.data, msg.format(last)) | |||||
| else: | else: | ||||
| msg = "last local commit was {0}. Remote is \x02ahead\x0F of local copy." | msg = "last local commit was {0}. Remote is \x02ahead\x0F of local copy." | ||||
| self.connection.reply(self.data, msg.format(last)) | |||||
| self.reply(self.data, msg.format(last)) | |||||
| @@ -22,7 +22,7 @@ | |||||
| import re | import re | ||||
| from earwigbot.commands import BaseCommand, command_manager | |||||
| from earwigbot.commands import BaseCommand | |||||
| from earwigbot.irc import Data | from earwigbot.irc import Data | ||||
| class Command(BaseCommand): | class Command(BaseCommand): | ||||
| @@ -30,7 +30,6 @@ class Command(BaseCommand): | |||||
| name = "help" | name = "help" | ||||
| def process(self, data): | def process(self, data): | ||||
| 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: | ||||
| @@ -39,9 +38,9 @@ class Command(BaseCommand): | |||||
| def do_main_help(self, data): | def do_main_help(self, data): | ||||
| """Give the user a general help message with a list of all commands.""" | """Give the user a general help message with a list of all commands.""" | ||||
| msg = "Hi, I'm a bot! I have {0} commands loaded: {1}. You can get help for any command with '!help <command>'." | msg = "Hi, I'm a bot! I have {0} commands loaded: {1}. You can get help for any command with '!help <command>'." | ||||
| cmnds = sorted(self.cmnds.keys()) | |||||
| cmnds = sorted(self.bot.commands) | |||||
| msg = msg.format(len(cmnds), ', '.join(cmnds)) | msg = msg.format(len(cmnds), ', '.join(cmnds)) | ||||
| self.connection.reply(data, msg) | |||||
| self.reply(data, msg) | |||||
| def do_command_help(self, data): | def do_command_help(self, data): | ||||
| """Give the user help for a specific command.""" | """Give the user help for a specific command.""" | ||||
| @@ -53,16 +52,17 @@ class Command(BaseCommand): | |||||
| dummy.command = command.lower() | dummy.command = command.lower() | ||||
| dummy.is_command = True | dummy.is_command = True | ||||
| for cmnd in self.cmnds.values(): | |||||
| for cmnd_name in self.bot.commands: | |||||
| cmnd = self.bot.commands.get(cmnd_name) | |||||
| if not cmnd.check(dummy): | if not cmnd.check(dummy): | ||||
| continue | continue | ||||
| if cmnd.__doc__: | if cmnd.__doc__: | ||||
| doc = cmnd.__doc__.replace("\n", "") | doc = cmnd.__doc__.replace("\n", "") | ||||
| doc = re.sub("\s\s+", " ", doc) | doc = re.sub("\s\s+", " ", doc) | ||||
| msg = "info for command \x0303{0}\x0301: \"{1}\"" | |||||
| self.connection.reply(data, msg.format(command, doc)) | |||||
| msg = "help for command \x0303{0}\x0301: \"{1}\"" | |||||
| self.reply(data, msg.format(command, doc)) | |||||
| return | return | ||||
| break | break | ||||
| msg = "sorry, no help for \x0303{0}\x0301.".format(command) | msg = "sorry, no help for \x0303{0}\x0301.".format(command) | ||||
| self.connection.reply(data, msg) | |||||
| self.reply(data, msg) | |||||
| @@ -43,15 +43,15 @@ class Command(BaseCommand): | |||||
| if re.search("(\[\[(.*?)\]\])|(\{\{(.*?)\}\})", msg): | if re.search("(\[\[(.*?)\]\])|(\{\{(.*?)\}\})", msg): | ||||
| links = self.parse_line(msg) | links = self.parse_line(msg) | ||||
| links = " , ".join(links) | links = " , ".join(links) | ||||
| self.connection.reply(data, links) | |||||
| self.reply(data, links) | |||||
| elif data.command == "link": | elif data.command == "link": | ||||
| if not data.args: | if not data.args: | ||||
| self.connection.reply(data, "what do you want me to link to?") | |||||
| self.reply(data, "what do you want me to link to?") | |||||
| return | return | ||||
| pagename = ' '.join(data.args) | pagename = ' '.join(data.args) | ||||
| link = self.parse_link(pagename) | link = self.parse_link(pagename) | ||||
| self.connection.reply(data, link) | |||||
| self.reply(data, link) | |||||
| def parse_line(self, line): | def parse_line(self, line): | ||||
| results = [] | results = [] | ||||
| @@ -45,7 +45,7 @@ class Command(BaseCommand): | |||||
| msg = "You use this command to praise certain people. Who they are is a secret." | msg = "You use this command to praise certain people. Who they are is a secret." | ||||
| else: | else: | ||||
| msg = "You're doing it wrong." | msg = "You're doing it wrong." | ||||
| self.connection.reply(data, msg) | |||||
| self.reply(data, msg) | |||||
| return | return | ||||
| self.connection.say(data.chan, msg) | |||||
| self.say(data.chan, msg) | |||||
| @@ -0,0 +1,67 @@ | |||||
| # -*- 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 | |||||
| class Command(BaseCommand): | |||||
| """Quit, restart, or reload components from the bot. Only the owners can | |||||
| run this command.""" | |||||
| name = "quit" | |||||
| def check(self, data): | |||||
| commands = ["quit", "restart", "reload"] | |||||
| return data.is_command and data.command in commands | |||||
| def process(self, data): | |||||
| if data.host not in self.config.irc["permissions"]["owners"]: | |||||
| self.reply(data, "you must be a bot owner to use this command.") | |||||
| return | |||||
| if data.command == "quit": | |||||
| self.do_quit(data) | |||||
| elif data.command == "restart": | |||||
| self.do_restart(data) | |||||
| else: | |||||
| self.do_reload(data) | |||||
| def do_quit(self, data): | |||||
| nick = self.config.irc.frontend["nick"] | |||||
| if not data.args or data.args[0].lower() != nick.lower(): | |||||
| self.reply(data, "to confirm this action, the first argument must be my nickname.") | |||||
| return | |||||
| if data.args[1:]: | |||||
| msg = " ".join(data.args[1:]) | |||||
| self.bot.stop("Stopped by {0}: {1}".format(data.nick, msg)) | |||||
| else: | |||||
| self.bot.stop("Stopped by {0}".format(data.nick)) | |||||
| def do_restart(self, data): | |||||
| if data.args: | |||||
| msg = " ".join(data.args) | |||||
| self.bot.restart("Restarted by {0}: {1}".format(data.nick, msg)) | |||||
| else: | |||||
| self.bot.restart("Restarted by {0}".format(data.nick)) | |||||
| def do_reload(self, data): | |||||
| self.logger.info("{0} requested command/task reload".format(data.nick)) | |||||
| self.bot.commands.load() | |||||
| self.bot.tasks.load() | |||||
| self.reply(data, "IRC commands and bot tasks reloaded.") | |||||
| @@ -30,7 +30,7 @@ class Command(BaseCommand): | |||||
| name = "registration" | name = "registration" | ||||
| def check(self, data): | def check(self, data): | ||||
| commands = ["registration", "age"] | |||||
| commands = ["registration", "reg", "age"] | |||||
| if data.is_command and data.command in commands: | if data.is_command and data.command in commands: | ||||
| return True | return True | ||||
| return False | return False | ||||
| @@ -41,7 +41,7 @@ class Command(BaseCommand): | |||||
| else: | else: | ||||
| name = ' '.join(data.args) | name = ' '.join(data.args) | ||||
| site = wiki.get_site() | |||||
| site = self.bot.wiki.get_site() | |||||
| site._maxlag = None | site._maxlag = None | ||||
| user = site.get_user(name) | user = site.get_user(name) | ||||
| @@ -49,7 +49,7 @@ class Command(BaseCommand): | |||||
| reg = user.registration() | reg = user.registration() | ||||
| except wiki.UserNotFoundError: | except wiki.UserNotFoundError: | ||||
| msg = "the user \x0302{0}\x0301 does not exist." | msg = "the user \x0302{0}\x0301 does not exist." | ||||
| self.connection.reply(data, msg.format(name)) | |||||
| self.reply(data, msg.format(name)) | |||||
| return | return | ||||
| date = time.strftime("%b %d, %Y at %H:%M:%S UTC", reg) | date = time.strftime("%b %d, %Y at %H:%M:%S UTC", reg) | ||||
| @@ -64,7 +64,7 @@ class Command(BaseCommand): | |||||
| gender = "They're" | gender = "They're" | ||||
| msg = "\x0302{0}\x0301 registered on {1}. {2} {3} old." | msg = "\x0302{0}\x0301 registered on {1}. {2} {3} old." | ||||
| self.connection.reply(data, msg.format(name, date, gender, age)) | |||||
| self.reply(data, msg.format(name, date, gender, age)) | |||||
| def get_diff(self, t1, t2): | def get_diff(self, t1, t2): | ||||
| parts = {"years": 31536000, "days": 86400, "hours": 3600, | parts = {"years": 31536000, "days": 86400, "hours": 3600, | ||||
| @@ -37,19 +37,19 @@ class Command(BaseCommand): | |||||
| def process(self, data): | def process(self, data): | ||||
| if not data.args: | if not data.args: | ||||
| msg = "please specify a time (in seconds) and a message in the following format: !remind <time> <msg>." | msg = "please specify a time (in seconds) and a message in the following format: !remind <time> <msg>." | ||||
| self.connection.reply(data, msg) | |||||
| self.reply(data, msg) | |||||
| return | return | ||||
| try: | try: | ||||
| wait = int(data.args[0]) | wait = int(data.args[0]) | ||||
| except ValueError: | except ValueError: | ||||
| msg = "the time must be given as an integer, in seconds." | msg = "the time must be given as an integer, in seconds." | ||||
| self.connection.reply(data, msg) | |||||
| self.reply(data, msg) | |||||
| return | return | ||||
| message = ' '.join(data.args[1:]) | message = ' '.join(data.args[1:]) | ||||
| if not message: | if not message: | ||||
| msg = "what message do you want me to give you when time is up?" | msg = "what message do you want me to give you when time is up?" | ||||
| self.connection.reply(data, msg) | |||||
| self.reply(data, msg) | |||||
| return | return | ||||
| end = time.localtime(time.time() + wait) | end = time.localtime(time.time() + wait) | ||||
| @@ -58,7 +58,7 @@ class Command(BaseCommand): | |||||
| msg = 'Set reminder for "{0}" in {1} seconds (ends {2}).' | msg = 'Set reminder for "{0}" in {1} seconds (ends {2}).' | ||||
| msg = msg.format(message, wait, end_time_with_timezone) | msg = msg.format(message, wait, end_time_with_timezone) | ||||
| self.connection.reply(data, msg) | |||||
| self.reply(data, msg) | |||||
| t_reminder = threading.Thread(target=self.reminder, | t_reminder = threading.Thread(target=self.reminder, | ||||
| args=(data, message, wait)) | args=(data, message, wait)) | ||||
| @@ -68,4 +68,4 @@ class Command(BaseCommand): | |||||
| def reminder(self, data, message, wait): | def reminder(self, data, message, wait): | ||||
| time.sleep(wait) | time.sleep(wait) | ||||
| self.connection.reply(data, message) | |||||
| self.reply(data, message) | |||||
| @@ -47,4 +47,4 @@ class Command(BaseCommand): | |||||
| conn.close() | conn.close() | ||||
| msg = "Replag on \x0302{0}\x0301 is \x02{1}\x0F seconds." | msg = "Replag on \x0302{0}\x0301 is \x02{1}\x0F seconds." | ||||
| self.connection.reply(data, msg.format(args["db"], replag)) | |||||
| self.reply(data, msg.format(args["db"], replag)) | |||||
| @@ -39,7 +39,7 @@ class Command(BaseCommand): | |||||
| else: | else: | ||||
| name = ' '.join(data.args) | name = ' '.join(data.args) | ||||
| site = wiki.get_site() | |||||
| site = self.bot.wiki.get_site() | |||||
| site._maxlag = None | site._maxlag = None | ||||
| user = site.get_user(name) | user = site.get_user(name) | ||||
| @@ -47,7 +47,7 @@ class Command(BaseCommand): | |||||
| rights = user.groups() | rights = user.groups() | ||||
| except wiki.UserNotFoundError: | except wiki.UserNotFoundError: | ||||
| msg = "the user \x0302{0}\x0301 does not exist." | msg = "the user \x0302{0}\x0301 does not exist." | ||||
| self.connection.reply(data, msg.format(name)) | |||||
| self.reply(data, msg.format(name)) | |||||
| return | return | ||||
| try: | try: | ||||
| @@ -55,4 +55,4 @@ class Command(BaseCommand): | |||||
| except ValueError: | except ValueError: | ||||
| pass | pass | ||||
| msg = "the rights for \x0302{0}\x0301 are {1}." | msg = "the rights for \x0302{0}\x0301 are {1}." | ||||
| self.connection.reply(data, msg.format(name, ', '.join(rights))) | |||||
| self.reply(data, msg.format(name, ', '.join(rights))) | |||||
| @@ -29,8 +29,9 @@ class Command(BaseCommand): | |||||
| name = "test" | name = "test" | ||||
| def process(self, data): | def process(self, data): | ||||
| user = "\x02{0}\x0F".format(data.nick) | |||||
| hey = random.randint(0, 1) | hey = random.randint(0, 1) | ||||
| if hey: | if hey: | ||||
| self.connection.say(data.chan, "Hey \x02%s\x0F!" % data.nick) | |||||
| self.say(data.chan, "Hey {0}!".format(user)) | |||||
| else: | else: | ||||
| self.connection.say(data.chan, "'sup \x02%s\x0F?" % data.nick) | |||||
| self.say(data.chan, "'sup {0}?".format(user)) | |||||
| @@ -24,9 +24,7 @@ import threading | |||||
| import re | import re | ||||
| from earwigbot.commands import BaseCommand | from earwigbot.commands import BaseCommand | ||||
| from earwigbot.config import config | |||||
| from earwigbot.irc import KwargParseException | 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.""" | ||||
| @@ -40,9 +38,9 @@ class Command(BaseCommand): | |||||
| def process(self, data): | def process(self, data): | ||||
| self.data = data | self.data = data | ||||
| if data.host not in config.irc["permissions"]["owners"]: | |||||
| if data.host not in self.config.irc["permissions"]["owners"]: | |||||
| msg = "you must be a bot owner to use this command." | msg = "you must be a bot owner to use this command." | ||||
| self.connection.reply(data, msg) | |||||
| self.reply(data, msg) | |||||
| return | return | ||||
| if not data.args: | if not data.args: | ||||
| @@ -50,7 +48,7 @@ class Command(BaseCommand): | |||||
| self.do_list() | self.do_list() | ||||
| else: | else: | ||||
| msg = "no arguments provided. Maybe you wanted '!{0} list', '!{0} start', or '!{0} listall'?" | msg = "no arguments provided. Maybe you wanted '!{0} list', '!{0} start', or '!{0} listall'?" | ||||
| self.connection.reply(data, msg.format(data.command)) | |||||
| self.reply(data, msg.format(data.command)) | |||||
| return | return | ||||
| if data.args[0] == "list": | if data.args[0] == "list": | ||||
| @@ -64,7 +62,7 @@ class Command(BaseCommand): | |||||
| else: # They asked us to do something we don't know | else: # They asked us to do something we don't know | ||||
| msg = "unknown argument: \x0303{0}\x0301.".format(data.args[0]) | msg = "unknown argument: \x0303{0}\x0301.".format(data.args[0]) | ||||
| self.connection.reply(data, msg) | |||||
| self.reply(data, msg) | |||||
| def do_list(self): | def do_list(self): | ||||
| """With !tasks list (or abbreviation !tasklist), list all running | """With !tasks list (or abbreviation !tasklist), list all running | ||||
| @@ -78,10 +76,9 @@ class Command(BaseCommand): | |||||
| for thread in threads: | for thread in threads: | ||||
| tname = thread.name | tname = thread.name | ||||
| if tname == "MainThread": | if tname == "MainThread": | ||||
| tname = self.get_main_thread_name() | |||||
| t = "\x0302{0}\x0301 (as main thread, id {1})" | |||||
| normal_threads.append(t.format(tname, thread.ident)) | |||||
| elif tname in ["irc-frontend", "irc-watcher", "wiki-scheduler"]: | |||||
| t = "\x0302MainThread\x0301 (id {0})" | |||||
| normal_threads.append(t.format(thread.ident)) | |||||
| elif tname in self.config.components: | |||||
| t = "\x0302{0}\x0301 (id {1})" | t = "\x0302{0}\x0301 (id {1})" | ||||
| normal_threads.append(t.format(tname, thread.ident)) | normal_threads.append(t.format(tname, thread.ident)) | ||||
| elif tname.startswith("reminder"): | elif tname.startswith("reminder"): | ||||
| @@ -101,18 +98,14 @@ class Command(BaseCommand): | |||||
| msg = "\x02{0}\x0F threads active: {1}, and \x020\x0F task threads." | msg = "\x02{0}\x0F threads active: {1}, and \x020\x0F task threads." | ||||
| msg = msg.format(len(threads), ', '.join(normal_threads)) | msg = msg.format(len(threads), ', '.join(normal_threads)) | ||||
| self.connection.reply(self.data, msg) | |||||
| self.reply(self.data, msg) | |||||
| 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 = task_manager.get_all().keys() | |||||
| threads = threading.enumerate() | threads = threading.enumerate() | ||||
| tasklist = [] | tasklist = [] | ||||
| all_tasks.sort() | |||||
| for task in all_tasks: | |||||
| for task in sorted(self.bot.tasks): | |||||
| threadlist = [t for t in threads if t.name.startswith(task)] | threadlist = [t for t in threads if t.name.startswith(task)] | ||||
| ids = [str(t.ident) for t in threadlist] | ids = [str(t.ident) for t in threadlist] | ||||
| if not ids: | if not ids: | ||||
| @@ -124,10 +117,10 @@ class Command(BaseCommand): | |||||
| t = "\x0302{0}\x0301 (\x02active\x0F as ids {1})" | t = "\x0302{0}\x0301 (\x02active\x0F as ids {1})" | ||||
| tasklist.append(t.format(task, ', '.join(ids))) | tasklist.append(t.format(task, ', '.join(ids))) | ||||
| tasklist = ", ".join(tasklist) | |||||
| tasks = ", ".join(tasklist) | |||||
| msg = "{0} tasks loaded: {1}.".format(len(all_tasks), tasklist) | |||||
| self.connection.reply(self.data, msg) | |||||
| msg = "{0} tasks loaded: {1}.".format(len(tasklist), tasks) | |||||
| self.reply(self.data, msg) | |||||
| def do_start(self): | def do_start(self): | ||||
| """With !tasks start, start any loaded task by name with or without | """With !tasks start, start any loaded task by name with or without | ||||
| @@ -137,32 +130,23 @@ class Command(BaseCommand): | |||||
| try: | try: | ||||
| task_name = data.args[1] | task_name = data.args[1] | ||||
| except IndexError: # No task name given | except IndexError: # No task name given | ||||
| self.connection.reply(data, "what task do you want me to start?") | |||||
| self.reply(data, "what task do you want me to start?") | |||||
| return | return | ||||
| try: | try: | ||||
| data.parse_kwargs() | data.parse_kwargs() | ||||
| except KwargParseException, arg: | except KwargParseException, arg: | ||||
| msg = "error parsing argument: \x0303{0}\x0301.".format(arg) | msg = "error parsing argument: \x0303{0}\x0301.".format(arg) | ||||
| self.connection.reply(data, msg) | |||||
| self.reply(data, msg) | |||||
| return | return | ||||
| if task_name not in task_manager.get_all().keys(): | |||||
| if task_name not in self.bot.tasks: | |||||
| # 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 tasks/{0}.py doesn't exist, or it wasn't loaded correctly." | |||||
| self.connection.reply(data, msg.format(task_name)) | |||||
| msg = "task could not be found; either it doesn't exist, or it wasn't loaded correctly." | |||||
| self.reply(data, msg.format(task_name)) | |||||
| return | return | ||||
| data.kwargs["fromIRC"] = True | data.kwargs["fromIRC"] = True | ||||
| task_manager.start(task_name, **data.kwargs) | |||||
| self.bot.tasks.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) | |||||
| def get_main_thread_name(self): | |||||
| """Return the "proper" name of the MainThread.""" | |||||
| if "irc_frontend" in config.components: | |||||
| return "irc-frontend" | |||||
| elif "wiki_schedule" in config.components: | |||||
| return "wiki-scheduler" | |||||
| else: | |||||
| return "irc-watcher" | |||||
| self.reply(data, msg) | |||||
| @@ -20,31 +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. | ||||
| """ | |||||
| EarwigBot's YAML Config File Parser | |||||
| This handles all tasks involving reading and writing to our config file, | |||||
| including encrypting and decrypting passwords and making a new config file from | |||||
| scratch at the inital bot run. | |||||
| Usually you'll just want to do "from earwigbot.config import config", which | |||||
| returns a singleton _BotConfig object, with data accessible from various | |||||
| attributes and functions: | |||||
| * config.components - enabled components | |||||
| * config.wiki - information about wiki-editing | |||||
| * config.tasks - information for bot tasks | |||||
| * config.irc - information about IRC | |||||
| * config.metadata - miscellaneous information | |||||
| * config.schedule() - tasks scheduled to run at a given time | |||||
| Additionally, _BotConfig has some functions used in config loading: | |||||
| * config.load() - loads and parses our config file, returning True if | |||||
| passwords are stored encrypted or False otherwise | |||||
| * config.decrypt() - given a key, decrypts passwords inside our config | |||||
| variables; won't work if passwords aren't encrypted | |||||
| """ | |||||
| from getpass import getpass | |||||
| import logging | import logging | ||||
| import logging.handlers | import logging.handlers | ||||
| from os import mkdir, path | from os import mkdir, path | ||||
| @@ -53,44 +29,39 @@ import yaml | |||||
| from earwigbot import blowfish | from earwigbot import blowfish | ||||
| __all__ = ["config"] | |||||
| class _ConfigNode(object): | |||||
| def __iter__(self): | |||||
| for key in self.__dict__.iterkeys(): | |||||
| yield key | |||||
| def __getitem__(self, item): | |||||
| return self.__dict__.__getitem__(item) | |||||
| def _dump(self): | |||||
| data = self.__dict__.copy() | |||||
| for key, val in data.iteritems(): | |||||
| if isinstance(val, _ConfigNode): | |||||
| data[key] = val._dump() | |||||
| return data | |||||
| def _load(self, data): | |||||
| self.__dict__ = data.copy() | |||||
| def _decrypt(self, key, intermediates, item): | |||||
| base = self.__dict__ | |||||
| try: | |||||
| for inter in intermediates: | |||||
| base = base[inter] | |||||
| except KeyError: | |||||
| return | |||||
| if item in base: | |||||
| base[item] = blowfish.decrypt(key, base[item]) | |||||
| def get(self, *args, **kwargs): | |||||
| return self.__dict__.get(*args, **kwargs) | |||||
| class _BotConfig(object): | |||||
| def __init__(self): | |||||
| self._script_dir = path.dirname(path.abspath(__file__)) | |||||
| self._root_dir = path.split(self._script_dir)[0] | |||||
| __all__ = ["BotConfig"] | |||||
| class BotConfig(object): | |||||
| """ | |||||
| EarwigBot's YAML Config File Manager | |||||
| This handles all tasks involving reading and writing to our config file, | |||||
| including encrypting and decrypting passwords and making a new config file | |||||
| from scratch at the inital bot run. | |||||
| BotConfig has a few properties and functions, including the following: | |||||
| * config.root_dir - bot's working directory; contains config.yml, logs/ | |||||
| * config.path - path to the bot's config file | |||||
| * config.components - enabled components | |||||
| * config.wiki - information about wiki-editing | |||||
| * config.tasks - information for bot tasks | |||||
| * config.irc - information about IRC | |||||
| * config.metadata - miscellaneous information | |||||
| * config.schedule() - tasks scheduled to run at a given time | |||||
| BotConfig also has some functions used in config loading: | |||||
| * config.load() - loads and parses our config file, returning True if | |||||
| passwords are stored encrypted or False otherwise; | |||||
| can also be used to easily reload config | |||||
| * config.decrypt() - given a key, decrypts passwords inside our config | |||||
| variables, and remembers to decrypt the password if | |||||
| config is reloaded; won't do anything if passwords | |||||
| aren't encrypted | |||||
| """ | |||||
| def __init__(self, root_dir, level): | |||||
| self._root_dir = root_dir | |||||
| self._logging_level = level | |||||
| self._config_path = path.join(self._root_dir, "config.yml") | self._config_path = path.join(self._root_dir, "config.yml") | ||||
| self._log_dir = path.join(self._root_dir, "logs") | self._log_dir = path.join(self._root_dir, "logs") | ||||
| self._decryption_key = None | self._decryption_key = None | ||||
| @@ -105,21 +76,29 @@ class _BotConfig(object): | |||||
| self._nodes = [self._components, self._wiki, self._tasks, self._irc, | self._nodes = [self._components, self._wiki, self._tasks, self._irc, | ||||
| self._metadata] | self._metadata] | ||||
| self._decryptable_nodes = [ # Default nodes to decrypt | |||||
| (self._wiki, ("password")), | |||||
| (self._wiki, ("search", "credentials", "key")), | |||||
| (self._wiki, ("search", "credentials", "secret")), | |||||
| (self._irc, ("frontend", "nickservPassword")), | |||||
| (self._irc, ("watcher", "nickservPassword")), | |||||
| ] | |||||
| def _load(self): | def _load(self): | ||||
| """Load data from our JSON config file (config.yml) into _config.""" | |||||
| """Load data from our JSON config file (config.yml) into self._data.""" | |||||
| filename = self._config_path | filename = self._config_path | ||||
| with open(filename, 'r') as fp: | with open(filename, 'r') as fp: | ||||
| try: | try: | ||||
| self._data = yaml.load(fp) | self._data = yaml.load(fp) | ||||
| except yaml.YAMLError as error: | except yaml.YAMLError as error: | ||||
| print "Error parsing config file {0}:".format(filename) | print "Error parsing config file {0}:".format(filename) | ||||
| print error | |||||
| exit(1) | |||||
| raise | |||||
| def _setup_logging(self): | def _setup_logging(self): | ||||
| """Configures the logging module so it works the way we want it to.""" | """Configures the logging module so it works the way we want it to.""" | ||||
| log_dir = self._log_dir | log_dir = self._log_dir | ||||
| logger = logging.getLogger("earwigbot") | logger = logging.getLogger("earwigbot") | ||||
| logger.handlers = [] # Remove any handlers already attached to us | |||||
| logger.setLevel(logging.DEBUG) | logger.setLevel(logging.DEBUG) | ||||
| if self.metadata.get("enableLogging"): | if self.metadata.get("enableLogging"): | ||||
| @@ -135,7 +114,7 @@ class _BotConfig(object): | |||||
| else: | else: | ||||
| msg = "log_dir ({0}) exists but is not a directory!" | msg = "log_dir ({0}) exists but is not a directory!" | ||||
| print msg.format(log_dir) | print msg.format(log_dir) | ||||
| exit(1) | |||||
| return | |||||
| main_handler = hand(logfile("bot.log"), "midnight", 1, 7) | main_handler = hand(logfile("bot.log"), "midnight", 1, 7) | ||||
| error_handler = hand(logfile("error.log"), "W6", 1, 4) | error_handler = hand(logfile("error.log"), "W6", 1, 4) | ||||
| @@ -149,40 +128,51 @@ class _BotConfig(object): | |||||
| h.setFormatter(formatter) | h.setFormatter(formatter) | ||||
| logger.addHandler(h) | logger.addHandler(h) | ||||
| stream_handler = logging.StreamHandler() | |||||
| stream_handler.setLevel(logging.DEBUG) | |||||
| stream_handler.setFormatter(color_formatter) | |||||
| logger.addHandler(stream_handler) | |||||
| self._stream_handler = stream = logging.StreamHandler() | |||||
| stream.setLevel(self._logging_level) | |||||
| stream.setFormatter(color_formatter) | |||||
| logger.addHandler(stream) | |||||
| else: | |||||
| logger.addHandler(logging.NullHandler()) | |||||
| def _decrypt(self, node, nodes): | |||||
| """Try to decrypt the contents of a config node. Use self.decrypt().""" | |||||
| try: | |||||
| node._decrypt(self._decryption_key, nodes[:-1], nodes[-1]) | |||||
| except blowfish.BlowfishError as error: | |||||
| print "Error decrypting passwords:" | |||||
| raise | |||||
| def _make_new(self): | def _make_new(self): | ||||
| """Make a new config file based on the user's input.""" | """Make a new config file based on the user's input.""" | ||||
| encrypt = raw_input("Would you like to encrypt passwords stored in config.yml? [y/n] ") | |||||
| if encrypt.lower().startswith("y"): | |||||
| is_encrypted = True | |||||
| else: | |||||
| is_encrypted = False | |||||
| return is_encrypted | |||||
| @property | |||||
| def script_dir(self): | |||||
| return self._script_dir | |||||
| #m = "Would you like to encrypt passwords stored in config.yml? [y/n] " | |||||
| #encrypt = raw_input(m) | |||||
| #if encrypt.lower().startswith("y"): | |||||
| # is_encrypted = True | |||||
| #else: | |||||
| # is_encrypted = False | |||||
| raise NotImplementedError() | |||||
| # yaml.dumps() | |||||
| @property | @property | ||||
| def root_dir(self): | def root_dir(self): | ||||
| return self._root_dir | return self._root_dir | ||||
| @property | @property | ||||
| def config_path(self): | |||||
| def logging_level(self): | |||||
| return self._logging_level | |||||
| @logging_level.setter | |||||
| def logging_level(self, level): | |||||
| self._logging_level = level | |||||
| self._stream_handler.setLevel(level) | |||||
| @property | |||||
| def path(self): | |||||
| return self._config_path | return self._config_path | ||||
| @property | @property | ||||
| def log_dir(self): | def log_dir(self): | ||||
| return self._log_dir | return self._log_dir | ||||
| @property | @property | ||||
| def data(self): | def data(self): | ||||
| """The entire config file.""" | """The entire config file.""" | ||||
| @@ -221,7 +211,7 @@ class _BotConfig(object): | |||||
| """Return True if passwords are encrypted, otherwise False.""" | """Return True if passwords are encrypted, otherwise False.""" | ||||
| return self.metadata.get("encryptPasswords", False) | return self.metadata.get("encryptPasswords", False) | ||||
| def load(self, config_path=None, log_dir=None): | |||||
| def load(self): | |||||
| """Load, or reload, our config file. | """Load, or reload, our config file. | ||||
| First, check if we have a valid config file, and if not, notify the | First, check if we have a valid config file, and if not, notify the | ||||
| @@ -232,21 +222,16 @@ class _BotConfig(object): | |||||
| wiki, tasks, irc, metadata) for easy access (as well as the internal | wiki, tasks, irc, metadata) for easy access (as well as the internal | ||||
| _data variable). | _data variable). | ||||
| If everything goes well, return True if stored passwords are | |||||
| encrypted in the file, or False if they are not. | |||||
| If config is being reloaded, encrypted items will be automatically | |||||
| decrypted if they were decrypted beforehand. | |||||
| """ | """ | ||||
| if config_path: | |||||
| self._config_path = config_path | |||||
| if log_dir: | |||||
| self._log_dir = log_dir | |||||
| if not path.exists(self._config_path): | if not path.exists(self._config_path): | ||||
| print "You haven't configured the bot yet!" | |||||
| choice = raw_input("Would you like to do this now? [y/n] ") | |||||
| print "Config file not found:", self._config_path | |||||
| choice = raw_input("Would you like to create a config file now? [y/n] ") | |||||
| if choice.lower().startswith("y"): | if choice.lower().startswith("y"): | ||||
| return self._make_new() | |||||
| self._make_new() | |||||
| else: | else: | ||||
| exit(1) | |||||
| exit(1) # TODO: raise an exception instead | |||||
| self._load() | self._load() | ||||
| data = self._data | data = self._data | ||||
| @@ -257,25 +242,28 @@ class _BotConfig(object): | |||||
| self.metadata._load(data.get("metadata", {})) | self.metadata._load(data.get("metadata", {})) | ||||
| self._setup_logging() | self._setup_logging() | ||||
| return self.is_encrypted() | |||||
| if self.is_encrypted(): | |||||
| if not self._decryption_key: | |||||
| key = getpass("Enter key to decrypt bot passwords: ") | |||||
| self._decryption_key = key | |||||
| for node, nodes in self._decryptable_nodes: | |||||
| self._decrypt(node, nodes) | |||||
| def decrypt(self, node, *nodes): | def decrypt(self, node, *nodes): | ||||
| """Use self._decryption_key to decrypt an object in our config tree. | """Use self._decryption_key to decrypt an object in our config tree. | ||||
| If this is called when passwords are not encrypted (check with | If this is called when passwords are not encrypted (check with | ||||
| config.is_encrypted()), nothing will happen. | |||||
| config.is_encrypted()), nothing will happen. We'll also keep track of | |||||
| this node if config.load() is called again (i.e. to reload) and | |||||
| automatically decrypt it. | |||||
| An example usage would be: | |||||
| Example usage: | |||||
| config.decrypt(config.irc, "frontend", "nickservPassword") | config.decrypt(config.irc, "frontend", "nickservPassword") | ||||
| -> decrypts config.irc["frontend"]["nickservPassword"] | |||||
| """ | """ | ||||
| if not self.is_encrypted(): | |||||
| return | |||||
| try: | |||||
| node._decrypt(self._decryption_key, nodes[:-1], nodes[-1]) | |||||
| except blowfish.BlowfishError as error: | |||||
| print "\nError decrypting passwords:" | |||||
| print "{0}: {1}.".format(error.__class__.__name__, error) | |||||
| exit(1) | |||||
| self._decryptable_nodes.append((node, nodes)) | |||||
| if self.is_encrypted(): | |||||
| self._decrypt(node, nodes) | |||||
| def schedule(self, minute, hour, month_day, month, week_day): | def schedule(self, minute, hour, month_day, month, week_day): | ||||
| """Return a list of tasks scheduled to run at the specified time. | """Return a list of tasks scheduled to run at the specified time. | ||||
| @@ -311,6 +299,56 @@ class _BotConfig(object): | |||||
| return tasks | return tasks | ||||
| class _ConfigNode(object): | |||||
| def __iter__(self): | |||||
| for key in self.__dict__: | |||||
| yield key | |||||
| def __getitem__(self, item): | |||||
| return self.__dict__.__getitem__(item) | |||||
| def _dump(self): | |||||
| data = self.__dict__.copy() | |||||
| for key, val in data.iteritems(): | |||||
| if isinstance(val, _ConfigNode): | |||||
| data[key] = val._dump() | |||||
| return data | |||||
| def _load(self, data): | |||||
| self.__dict__ = data.copy() | |||||
| def _decrypt(self, key, intermediates, item): | |||||
| base = self.__dict__ | |||||
| try: | |||||
| for inter in intermediates: | |||||
| base = base[inter] | |||||
| except KeyError: | |||||
| return | |||||
| if item in base: | |||||
| base[item] = blowfish.decrypt(key, base[item]) | |||||
| def get(self, *args, **kwargs): | |||||
| return self.__dict__.get(*args, **kwargs) | |||||
| def keys(self): | |||||
| return self.__dict__.keys() | |||||
| def values(self): | |||||
| return self.__dict__.values() | |||||
| def items(self): | |||||
| return self.__dict__.items() | |||||
| def iterkeys(self): | |||||
| return self.__dict__.iterkeys() | |||||
| def itervalues(self): | |||||
| return self.__dict__.itervalues() | |||||
| def iteritems(self): | |||||
| return self.__dict__.iteritems() | |||||
| class _BotFormatter(logging.Formatter): | class _BotFormatter(logging.Formatter): | ||||
| def __init__(self, color=False): | def __init__(self, color=False): | ||||
| self._format = super(_BotFormatter, self).format | self._format = super(_BotFormatter, self).format | ||||
| @@ -336,6 +374,3 @@ class _BotFormatter(logging.Formatter): | |||||
| if record.levelno == logging.CRITICAL: | if record.levelno == logging.CRITICAL: | ||||
| record.lvl = l.join(("\x1b[1m\x1b[31m", "\x1b[0m")) # Bold red | record.lvl = l.join(("\x1b[1m\x1b[31m", "\x1b[0m")) # Bold red | ||||
| return record | return record | ||||
| config = _BotConfig() | |||||
| @@ -21,7 +21,8 @@ | |||||
| # SOFTWARE. | # SOFTWARE. | ||||
| import socket | import socket | ||||
| import threading | |||||
| from threading import Lock | |||||
| from time import sleep | |||||
| __all__ = ["BrokenSocketException", "IRCConnection"] | __all__ = ["BrokenSocketException", "IRCConnection"] | ||||
| @@ -35,17 +36,16 @@ class BrokenSocketException(Exception): | |||||
| class IRCConnection(object): | class IRCConnection(object): | ||||
| """A class to interface with IRC.""" | """A class to interface with IRC.""" | ||||
| def __init__(self, host, port, nick, ident, realname, logger): | |||||
| def __init__(self, host, port, nick, ident, realname): | |||||
| 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.is_running = False | |||||
| 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._send_lock = Lock() | |||||
| def _connect(self): | def _connect(self): | ||||
| """Connect to our IRC server.""" | """Connect to our IRC server.""" | ||||
| @@ -53,8 +53,9 @@ class IRCConnection(object): | |||||
| 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) | |||||
| exit(1) | |||||
| self.logger.exception("Couldn't connect to IRC server; retrying") | |||||
| sleep(8) | |||||
| self._connect() | |||||
| self._send("NICK {0}".format(self.nick)) | self._send("NICK {0}".format(self.nick)) | ||||
| self._send("USER {0} {1} * :{2}".format(self.ident, self.host, self.realname)) | self._send("USER {0} {1} * :{2}".format(self.ident, self.host, self.realname)) | ||||
| @@ -68,7 +69,7 @@ class IRCConnection(object): | |||||
| 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(size) | |||||
| 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() | ||||
| @@ -76,11 +77,17 @@ class IRCConnection(object): | |||||
| 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: | |||||
| with self._lock: | |||||
| with self._send_lock: | |||||
| self._sock.sendall(msg + "\r\n") | self._sock.sendall(msg + "\r\n") | ||||
| self.logger.debug(msg) | self.logger.debug(msg) | ||||
| def _quit(self, msg=None): | |||||
| """Issue a quit message to the server.""" | |||||
| if msg: | |||||
| self._send("QUIT :{0}".format(msg)) | |||||
| else: | |||||
| self._send("QUIT") | |||||
| def 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.""" | ||||
| msg = "PRIVMSG {0} :{1}".format(target, msg) | msg = "PRIVMSG {0} :{1}".format(target, msg) | ||||
| @@ -106,14 +113,16 @@ class IRCConnection(object): | |||||
| msg = "JOIN {0}".format(chan) | msg = "JOIN {0}".format(chan) | ||||
| self._send(msg) | self._send(msg) | ||||
| def part(self, chan): | |||||
| """Part from a channel on the server.""" | |||||
| msg = "PART {0}".format(chan) | |||||
| self._send(msg) | |||||
| def part(self, chan, msg=None): | |||||
| """Part from a channel on the server, optionally using an message.""" | |||||
| if msg: | |||||
| self._send("PART {0} :{1}".format(chan, msg)) | |||||
| else: | |||||
| self._send("PART {0}".format(chan)) | |||||
| def mode(self, chan, level, msg): | |||||
| def mode(self, target, level, msg): | |||||
| """Send a mode message to the server.""" | """Send a mode message to the server.""" | ||||
| msg = "MODE {0} {1} {2}".format(chan, level, msg) | |||||
| msg = "MODE {0} {1} {2}".format(target, level, msg) | |||||
| self._send(msg) | self._send(msg) | ||||
| def pong(self, target): | def pong(self, target): | ||||
| @@ -123,19 +132,29 @@ class IRCConnection(object): | |||||
| def loop(self): | def loop(self): | ||||
| """Main loop for the IRC connection.""" | """Main loop for the IRC connection.""" | ||||
| self.is_running = True | |||||
| self._is_running = True | |||||
| read_buffer = "" | read_buffer = "" | ||||
| while 1: | while 1: | ||||
| try: | try: | ||||
| read_buffer += self._get() | read_buffer += self._get() | ||||
| except BrokenSocketException: | except BrokenSocketException: | ||||
| self.is_running = False | |||||
| self._is_running = False | |||||
| break | break | ||||
| lines = read_buffer.split("\n") | lines = read_buffer.split("\n") | ||||
| read_buffer = lines.pop() | read_buffer = lines.pop() | ||||
| for line in lines: | for line in lines: | ||||
| self._process_message(line) | self._process_message(line) | ||||
| if not self.is_running: | |||||
| if self.is_stopped(): | |||||
| self._close() | self._close() | ||||
| break | break | ||||
| def stop(self, msg=None): | |||||
| """Request the IRC connection to close at earliest convenience.""" | |||||
| if self._is_running: | |||||
| self._quit(msg) | |||||
| self._is_running = False | |||||
| def is_stopped(self): | |||||
| """Return whether the IRC connection has been (or is to be) closed.""" | |||||
| return not self._is_running | |||||
| @@ -20,12 +20,9 @@ | |||||
| # 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. | ||||
| import logging | |||||
| import re | import re | ||||
| from earwigbot.commands import command_manager | |||||
| from earwigbot.irc import IRCConnection, Data, BrokenSocketException | |||||
| from earwigbot.config import config | |||||
| from earwigbot.irc import IRCConnection, Data | |||||
| __all__ = ["Frontend"] | __all__ = ["Frontend"] | ||||
| @@ -41,13 +38,14 @@ class Frontend(IRCConnection): | |||||
| """ | """ | ||||
| sender_regex = re.compile(":(.*?)!(.*?)@(.*?)\Z") | sender_regex = re.compile(":(.*?)!(.*?)@(.*?)\Z") | ||||
| def __init__(self): | |||||
| self.logger = logging.getLogger("earwigbot.frontend") | |||||
| cf = config.irc["frontend"] | |||||
| def __init__(self, bot): | |||||
| self.bot = bot | |||||
| self.logger = bot.logger.getChild("frontend") | |||||
| cf = bot.config.irc["frontend"] | |||||
| base = super(Frontend, self) | base = super(Frontend, self) | ||||
| base.__init__(cf["host"], cf["port"], cf["nick"], cf["ident"], | base.__init__(cf["host"], cf["port"], cf["nick"], cf["ident"], | ||||
| cf["realname"], self.logger) | |||||
| command_manager.load(self) | |||||
| cf["realname"]) | |||||
| self._connect() | self._connect() | ||||
| def _process_message(self, line): | def _process_message(self, line): | ||||
| @@ -58,36 +56,35 @@ class Frontend(IRCConnection): | |||||
| if line[1] == "JOIN": | if line[1] == "JOIN": | ||||
| data.nick, data.ident, data.host = self.sender_regex.findall(line[0])[0] | data.nick, data.ident, data.host = self.sender_regex.findall(line[0])[0] | ||||
| data.chan = line[2] | data.chan = line[2] | ||||
| # Check for 'join' hooks in our commands: | |||||
| command_manager.check("join", data) | |||||
| data.parse_args() | |||||
| self.bot.commands.check("join", data) | |||||
| elif line[1] == "PRIVMSG": | elif line[1] == "PRIVMSG": | ||||
| data.nick, data.ident, data.host = self.sender_regex.findall(line[0])[0] | data.nick, data.ident, data.host = self.sender_regex.findall(line[0])[0] | ||||
| data.msg = " ".join(line[3:])[1:] | data.msg = " ".join(line[3:])[1:] | ||||
| data.chan = line[2] | data.chan = line[2] | ||||
| data.parse_args() | |||||
| if data.chan == config.irc["frontend"]["nick"]: | |||||
| if data.chan == self.bot.config.irc["frontend"]["nick"]: | |||||
| # This is a privmsg to us, so set 'chan' as the nick of the | # This is a privmsg to us, so set 'chan' as the nick of the | ||||
| # sender, then check for private-only command hooks: | # sender, then check for private-only command hooks: | ||||
| data.chan = data.nick | data.chan = data.nick | ||||
| command_manager.check("msg_private", data) | |||||
| self.bot.commands.check("msg_private", data) | |||||
| else: | else: | ||||
| # Check for public-only command hooks: | # Check for public-only command hooks: | ||||
| command_manager.check("msg_public", data) | |||||
| self.bot.commands.check("msg_public", data) | |||||
| # Check for command hooks that apply to all messages: | # Check for command hooks that apply to all messages: | ||||
| command_manager.check("msg", data) | |||||
| self.bot.commands.check("msg", data) | |||||
| # If we are pinged, pong back: | |||||
| elif line[0] == "PING": | |||||
| elif line[0] == "PING": # If we are pinged, pong back | |||||
| self.pong(line[1]) | self.pong(line[1]) | ||||
| # On successful connection to the server: | |||||
| elif line[1] == "376": | |||||
| elif line[1] == "376": # On successful connection to the server | |||||
| # If we're supposed to auth to NickServ, do that: | # If we're supposed to auth to NickServ, do that: | ||||
| try: | try: | ||||
| username = config.irc["frontend"]["nickservUsername"] | |||||
| password = config.irc["frontend"]["nickservPassword"] | |||||
| username = self.bot.config.irc["frontend"]["nickservUsername"] | |||||
| password = self.bot.config.irc["frontend"]["nickservPassword"] | |||||
| except KeyError: | except KeyError: | ||||
| pass | pass | ||||
| else: | else: | ||||
| @@ -95,5 +92,5 @@ class Frontend(IRCConnection): | |||||
| self.say("NickServ", msg) | self.say("NickServ", msg) | ||||
| # Join all of our startup channels: | # Join all of our startup channels: | ||||
| for chan in config.irc["frontend"]["channels"]: | |||||
| for chan in self.bot.config.irc["frontend"]["channels"]: | |||||
| self.join(chan) | self.join(chan) | ||||
| @@ -21,10 +21,8 @@ | |||||
| # SOFTWARE. | # SOFTWARE. | ||||
| import imp | import imp | ||||
| import logging | |||||
| from earwigbot.irc import IRCConnection, RC, BrokenSocketException | |||||
| from earwigbot.config import config | |||||
| from earwigbot.irc import IRCConnection, RC | |||||
| __all__ = ["Watcher"] | __all__ = ["Watcher"] | ||||
| @@ -35,17 +33,18 @@ class Watcher(IRCConnection): | |||||
| The IRC watcher runs on a wiki recent-changes server and listens for | 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 | edits. Users cannot interact with this part of the bot. When an event | ||||
| occurs, we run it through some rules stored in our config, which can result | occurs, we run it through some rules stored in our config, which can result | ||||
| in wiki bot tasks being started (located in tasks/) or messages being sent | |||||
| to channels on the IRC frontend. | |||||
| in wiki bot tasks being started 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"] | |||||
| def __init__(self, bot): | |||||
| self.bot = bot | |||||
| self.logger = bot.logger.getChild("watcher") | |||||
| cf = bot.config.irc["watcher"] | |||||
| base = super(Watcher, self) | base = super(Watcher, self) | ||||
| base.__init__(cf["host"], cf["port"], cf["nick"], cf["ident"], | base.__init__(cf["host"], cf["port"], cf["nick"], cf["ident"], | ||||
| cf["realname"], self.logger) | |||||
| self.frontend = frontend | |||||
| cf["realname"]) | |||||
| self._prepare_process_hook() | self._prepare_process_hook() | ||||
| self._connect() | self._connect() | ||||
| @@ -58,7 +57,7 @@ class Watcher(IRCConnection): | |||||
| # Ignore messages originating from channels not in our list, to | # Ignore messages originating from channels not in our list, to | ||||
| # prevent someone PMing us false data: | # prevent someone PMing us false data: | ||||
| if chan not in config.irc["watcher"]["channels"]: | |||||
| if chan not in self.bot.config.irc["watcher"]["channels"]: | |||||
| return | return | ||||
| msg = " ".join(line[3:])[1:] | msg = " ".join(line[3:])[1:] | ||||
| @@ -72,33 +71,35 @@ class Watcher(IRCConnection): | |||||
| # When we've finished starting up, join all watcher channels: | # When we've finished starting up, join all watcher channels: | ||||
| elif line[1] == "376": | elif line[1] == "376": | ||||
| for chan in config.irc["watcher"]["channels"]: | |||||
| for chan in self.bot.config.irc["watcher"]["channels"]: | |||||
| self.join(chan) | self.join(chan) | ||||
| def _prepare_process_hook(self): | def _prepare_process_hook(self): | ||||
| """Create our RC event process hook from information in config. | """Create our RC event process hook from information in config. | ||||
| This will get put in the function self._process_hook, which takes an RC | |||||
| object and returns a list of frontend channels to report this event to. | |||||
| This will get put in the function self._process_hook, which takes the | |||||
| Bot object and an RC object and returns a list of frontend channels to | |||||
| report this event to. | |||||
| """ | """ | ||||
| # Set a default RC process hook that does nothing: | # Set a default RC process hook that does nothing: | ||||
| self._process_hook = lambda rc: () | self._process_hook = lambda rc: () | ||||
| try: | try: | ||||
| rules = config.data["rules"] | |||||
| rules = self.bot.config.data["rules"] | |||||
| except KeyError: | except KeyError: | ||||
| return | return | ||||
| module = imp.new_module("_rc_event_processing_rules") | module = imp.new_module("_rc_event_processing_rules") | ||||
| path = self.bot.config.path | |||||
| try: | try: | ||||
| exec compile(rules, config.config_path, "exec") in module.__dict__ | |||||
| exec compile(rules, path, "exec") in module.__dict__ | |||||
| except Exception: | except Exception: | ||||
| e = "Could not compile config file's RC event rules" | |||||
| e = "Could not compile config file's RC event rules:" | |||||
| self.logger.exception(e) | self.logger.exception(e) | ||||
| return | return | ||||
| self._process_hook_module = module | self._process_hook_module = module | ||||
| try: | try: | ||||
| self._process_hook = module.process | self._process_hook = module.process | ||||
| except AttributeError: | except AttributeError: | ||||
| e = "RC event rules compiled correctly, but no process(rc) function was found" | |||||
| e = "RC event rules compiled correctly, but no process(bot, rc) function was found" | |||||
| self.logger.error(e) | self.logger.error(e) | ||||
| return | return | ||||
| @@ -110,8 +111,10 @@ class Watcher(IRCConnection): | |||||
| self._prepare_process_hook() from information in the "rules" section of | self._prepare_process_hook() from information in the "rules" section of | ||||
| our config. | our config. | ||||
| """ | """ | ||||
| chans = self._process_hook(rc) | |||||
| if chans and self.frontend: | |||||
| pretty = rc.prettify() | |||||
| for chan in chans: | |||||
| self.frontend.say(chan, pretty) | |||||
| chans = self._process_hook(self.bot, rc) | |||||
| with self.bot.component_lock: | |||||
| frontend = self.bot.frontend | |||||
| if chans and frontend and not frontend.is_stopped(): | |||||
| pretty = rc.prettify() | |||||
| for chan in chans: | |||||
| frontend.say(chan, pretty) | |||||
| @@ -1,132 +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 Main Module | |||||
| The core is essentially responsible for starting the various bot components | |||||
| (irc, scheduler, etc) and making sure they are all happy. An explanation of the | |||||
| different components follows: | |||||
| EarwigBot has three components that can run independently of each other: an IRC | |||||
| front-end, an IRC watcher, and a wiki scheduler. | |||||
| * The IRC front-end runs on a normal IRC server and expects users to interact | |||||
| with it/give it commands. | |||||
| * The IRC watcher runs on a wiki recent-changes server and listens for edits. | |||||
| Users cannot interact with this part of the bot. | |||||
| * The wiki scheduler runs wiki-editing bot tasks in separate threads at | |||||
| user-defined times through a cron-like interface. | |||||
| There is a "priority" system here: | |||||
| 1. If the IRC frontend is enabled, it will run on the main thread, and the IRC | |||||
| watcher and wiki scheduler (if enabled) will run on separate threads. | |||||
| 2. If the wiki scheduler is enabled, it will run on the main thread, and the | |||||
| IRC watcher (if enabled) will run on a separate thread. | |||||
| 3. If the IRC watcher is enabled, it will run on the main (and only) thread. | |||||
| Else, the bot will stop, as no components are enabled. | |||||
| """ | |||||
| import logging | |||||
| import threading | |||||
| import time | |||||
| from earwigbot.config import config | |||||
| from earwigbot.irc import Frontend, Watcher | |||||
| from earwigbot.tasks import task_manager | |||||
| logger = logging.getLogger("earwigbot") | |||||
| def irc_watcher(frontend=None): | |||||
| """Function to handle the IRC watcher as another thread (if frontend and/or | |||||
| scheduler is enabled), otherwise run as the main thread.""" | |||||
| while 1: # Restart the watcher component if it breaks (and nothing else) | |||||
| watcher = Watcher(frontend) | |||||
| try: | |||||
| watcher.loop() | |||||
| except: | |||||
| logger.exception("Watcher had an error") | |||||
| time.sleep(5) # Sleep a bit before restarting watcher | |||||
| logger.warn("Watcher has stopped; restarting component") | |||||
| def wiki_scheduler(): | |||||
| """Function to handle the wiki scheduler as another thread, or as the | |||||
| primary thread if the IRC frontend is not enabled.""" | |||||
| while 1: | |||||
| time_start = time.time() | |||||
| task_manager.schedule() | |||||
| time_end = time.time() | |||||
| time_diff = time_start - time_end | |||||
| if time_diff < 60: # Sleep until the next minute | |||||
| time.sleep(60 - time_diff) | |||||
| def irc_frontend(): | |||||
| """If the IRC frontend is enabled, make it run on our primary thread, and | |||||
| enable the wiki scheduler and IRC watcher on new threads if they are | |||||
| enabled.""" | |||||
| logger.info("Starting IRC frontend") | |||||
| frontend = Frontend() | |||||
| if config.components.get("wiki_schedule"): | |||||
| logger.info("Starting wiki scheduler") | |||||
| task_manager.load() | |||||
| t_scheduler = threading.Thread(target=wiki_scheduler) | |||||
| t_scheduler.name = "wiki-scheduler" | |||||
| t_scheduler.daemon = True | |||||
| t_scheduler.start() | |||||
| if config.components.get("irc_watcher"): | |||||
| logger.info("Starting IRC watcher") | |||||
| t_watcher = threading.Thread(target=irc_watcher, args=(frontend,)) | |||||
| t_watcher.name = "irc-watcher" | |||||
| t_watcher.daemon = True | |||||
| t_watcher.start() | |||||
| frontend.loop() | |||||
| def main(): | |||||
| if config.components.get("irc_frontend"): | |||||
| # Make the frontend run on our primary thread if enabled, and enable | |||||
| # additional components through that function: | |||||
| irc_frontend() | |||||
| elif config.components.get("wiki_schedule"): | |||||
| # Run the scheduler on the main thread, but also run the IRC watcher on | |||||
| # another thread iff it is enabled: | |||||
| logger.info("Starting wiki scheduler") | |||||
| task_manager.load() | |||||
| if "irc_watcher" in enabled: | |||||
| logger.info("Starting IRC watcher") | |||||
| t_watcher = threading.Thread(target=irc_watcher) | |||||
| t_watcher.name = "irc-watcher" | |||||
| t_watcher.daemon = True | |||||
| t_watcher.start() | |||||
| wiki_scheduler() | |||||
| elif config.components.get("irc_watcher"): | |||||
| # The IRC watcher is our only enabled component, so run its function | |||||
| # only and don't worry about anything else: | |||||
| logger.info("Starting IRC watcher") | |||||
| irc_watcher() | |||||
| else: # Nothing is enabled! | |||||
| logger.critical("No bot parts are enabled; stopping") | |||||
| exit(1) | |||||
| @@ -0,0 +1,216 @@ | |||||
| #! /usr/bin/env python | |||||
| # -*- 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 imp | |||||
| from os import listdir, path | |||||
| from re import sub | |||||
| from threading import Lock, Thread | |||||
| from time import gmtime, strftime | |||||
| from earwigbot.commands import BaseCommand | |||||
| from earwigbot.tasks import BaseTask | |||||
| __all__ = ["CommandManager", "TaskManager"] | |||||
| class _ResourceManager(object): | |||||
| """ | |||||
| EarwigBot's Base Resource Manager | |||||
| Resources are essentially objects dynamically loaded by the bot, both | |||||
| packaged with it (built-in resources) and created by users (plugins, aka | |||||
| custom resources). Currently, the only two types of resources are IRC | |||||
| commands and bot tasks. These are both loaded from two locations: the | |||||
| earwigbot.commands and earwigbot.tasks packages, and the commands/ and | |||||
| tasks/ directories within the bot's working directory. | |||||
| This class handles the low-level tasks of (re)loading resources via load(), | |||||
| retrieving specific resources via get(), and iterating over all resources | |||||
| via __iter__(). If iterating over resources, it is recommended to acquire | |||||
| self.lock beforehand and release it afterwards (alternatively, wrap your | |||||
| code in a `with` statement) so an attempt at reloading resources in another | |||||
| thread won't disrupt your iteration. | |||||
| """ | |||||
| def __init__(self, bot, name, attribute, base): | |||||
| self.bot = bot | |||||
| self.logger = bot.logger.getChild(name) | |||||
| self._resources = {} | |||||
| self._resource_name = name # e.g. "commands" or "tasks" | |||||
| self._resource_attribute = attribute # e.g. "Command" or "Task" | |||||
| self._resource_base = base # e.g. BaseCommand or BaseTask | |||||
| self._resource_access_lock = Lock() | |||||
| @property | |||||
| def lock(self): | |||||
| return self._resource_access_lock | |||||
| def __iter__(self): | |||||
| for name in self._resources: | |||||
| yield name | |||||
| def _load_resource(self, name, path): | |||||
| """Load a specific resource from a module, identified by name and path. | |||||
| We'll first try to import it using imp magic, 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. | |||||
| """ | |||||
| f, path, desc = imp.find_module(name, [path]) | |||||
| try: | |||||
| module = imp.load_module(name, f, path, desc) | |||||
| except Exception: | |||||
| e = "Couldn't load module {0} (from {1})" | |||||
| self.logger.exception(e.format(name, path)) | |||||
| return | |||||
| finally: | |||||
| f.close() | |||||
| attr = self._resource_attribute | |||||
| if not hasattr(module, attr): | |||||
| return # No resources in this module | |||||
| resource_class = getattr(module, attr) | |||||
| try: | |||||
| resource = resource_class(self.bot) # Create instance of resource | |||||
| except Exception: | |||||
| e = "Error instantiating {0} class in {1} (from {2})" | |||||
| self.logger.exception(e.format(attr, name, path)) | |||||
| return | |||||
| if not isinstance(resource, self._resource_base): | |||||
| return | |||||
| self._resources[resource.name] = resource | |||||
| self.logger.debug("Loaded {0} {1}".format(attr.lower(), resource.name)) | |||||
| def _load_directory(self, dir): | |||||
| """Load all valid resources in a given directory.""" | |||||
| processed = [] | |||||
| for name in listdir(dir): | |||||
| if not name.endswith(".py") and not name.endswith(".pyc"): | |||||
| continue | |||||
| if name.startswith("_") or name.startswith("."): | |||||
| continue | |||||
| modname = sub("\.pyc?$", "", name) # Remove extension | |||||
| if modname not in processed: | |||||
| self._load_resource(modname, dir) | |||||
| processed.append(modname) | |||||
| def load(self): | |||||
| """Load (or reload) all valid resources into self._resources.""" | |||||
| name = self._resource_name # e.g. "commands" or "tasks" | |||||
| with self.lock: | |||||
| self._resources.clear() | |||||
| builtin_dir = path.join(path.dirname(__file__), name) | |||||
| plugins_dir = path.join(self.bot.config.root_dir, name) | |||||
| self._load_directory(builtin_dir) # Built-in resources | |||||
| self._load_directory(plugins_dir) # Custom resources, aka plugins | |||||
| msg = "Loaded {0} {1}: {2}" | |||||
| resources = ", ".join(self._resources.keys()) | |||||
| self.logger.info(msg.format(len(self._resources), name, resources)) | |||||
| def get(self, key): | |||||
| """Return the class instance associated with a certain resource. | |||||
| Will raise KeyError if the resource (command or task) is not found. | |||||
| """ | |||||
| return self._resources[key] | |||||
| class CommandManager(_ResourceManager): | |||||
| """ | |||||
| EarwigBot's IRC Command Manager | |||||
| Manages (i.e., loads, reloads, and calls) IRC commands. | |||||
| """ | |||||
| def __init__(self, bot): | |||||
| base = super(CommandManager, self) | |||||
| base.__init__(bot, "commands", "Command", BaseCommand) | |||||
| def check(self, hook, data): | |||||
| """Given an IRC event, check if there's anything we can respond to.""" | |||||
| self.lock.acquire() | |||||
| for command in self._resources.itervalues(): | |||||
| if hook in command.hooks and command._wrap_check(data): | |||||
| self.lock.release() | |||||
| command._wrap_process(data) | |||||
| return | |||||
| self.lock.release() | |||||
| class TaskManager(_ResourceManager): | |||||
| """ | |||||
| EarwigBot's Bot Task Manager | |||||
| Manages (i.e., loads, reloads, schedules, and runs) wiki bot tasks. | |||||
| """ | |||||
| def __init__(self, bot): | |||||
| super(TaskManager, self).__init__(bot, "tasks", "Task", BaseTask) | |||||
| def _wrapper(self, task, **kwargs): | |||||
| """Wrapper for task classes: run the task and catch any errors.""" | |||||
| try: | |||||
| task.run(**kwargs) | |||||
| except Exception: | |||||
| 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 start(self, task_name, **kwargs): | |||||
| """Start a given task in a new daemon thread, and return the thread. | |||||
| kwargs are passed to task.run(). If the task is not found, None will be | |||||
| returned. | |||||
| """ | |||||
| msg = "Starting task '{0}' in a new thread" | |||||
| self.logger.info(msg.format(task_name)) | |||||
| try: | |||||
| task = self.get(task_name) | |||||
| except KeyError: | |||||
| e = "Couldn't find task '{0}'" | |||||
| self.logger.error(e.format(task_name)) | |||||
| return | |||||
| task_thread = Thread(target=self._wrapper, args=(task,), kwargs=kwargs) | |||||
| start_time = strftime("%b %d %H:%M:%S") | |||||
| task_thread.name = "{0} ({1})".format(task_name, start_time) | |||||
| task_thread.daemon = True | |||||
| task_thread.start() | |||||
| return task_thread | |||||
| def schedule(self, now=None): | |||||
| """Start all tasks that are supposed to be run at a given time.""" | |||||
| if not now: | |||||
| now = gmtime() | |||||
| # Get list of tasks to run this turn: | |||||
| tasks = self.bot.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 | |||||
| else: # Otherwise, just pass task_name | |||||
| self.start(task) | |||||
| @@ -1,65 +0,0 @@ | |||||
| #! /usr/bin/env python | |||||
| # -*- 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 Runner | |||||
| This is a very simple script that can be run from anywhere. It will add the | |||||
| 'earwigbot' package to sys.path if it's not already in there (i.e., it hasn't | |||||
| been "installed"), accept a root_dir (the directory in which bot.py is located) | |||||
| and a decryption key from raw_input (if passwords are encrypted), then call | |||||
| config.load() and decrypt any passwords, and finally call the main() function | |||||
| of earwigbot.main. | |||||
| """ | |||||
| from os import path | |||||
| import sys | |||||
| def run(): | |||||
| pkg_dir = path.split(path.dirname(path.abspath(__file__)))[0] | |||||
| if pkg_dir not in sys.path: | |||||
| sys.path.insert(0, pkg_dir) | |||||
| from earwigbot.config import config | |||||
| from earwigbot import main | |||||
| root_dir = raw_input() | |||||
| config_path = path.join(root_dir, "config.yml") | |||||
| log_dir = path.join(root_dir, "logs") | |||||
| is_encrypted = config.load(config_path, log_dir) | |||||
| if is_encrypted: | |||||
| config._decryption_key = raw_input() | |||||
| config.decrypt(config.wiki, "password") | |||||
| config.decrypt(config.wiki, "search", "credentials", "key") | |||||
| config.decrypt(config.wiki, "search", "credentials", "secret") | |||||
| config.decrypt(config.irc, "frontend", "nickservPassword") | |||||
| config.decrypt(config.irc, "watcher", "nickservPassword") | |||||
| try: | |||||
| main.main() | |||||
| except KeyboardInterrupt: | |||||
| main.logger.critical("KeyboardInterrupt: stopping main bot loop") | |||||
| exit(1) | |||||
| if __name__ == "__main__": | |||||
| run() | |||||
| @@ -21,43 +21,44 @@ | |||||
| # SOFTWARE. | # SOFTWARE. | ||||
| """ | """ | ||||
| EarwigBot's Wiki Task Manager | |||||
| EarwigBot's Bot Tasks | |||||
| This package provides the wiki bot "tasks" EarwigBot runs. This module contains | 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. | |||||
| """ | |||||
| the BaseTask class (import with `from earwigbot.tasks import BaseTask`), | |||||
| whereas the package contains various built-in tasks. Additional tasks can be | |||||
| installed as plugins in the bot's working directory. | |||||
| import logging | |||||
| import os | |||||
| import sys | |||||
| import threading | |||||
| import time | |||||
| To run a task, use bot.tasks.start(name, **kwargs). **kwargs get passed to the | |||||
| Task's run() function. | |||||
| """ | |||||
| from earwigbot import wiki | from earwigbot import wiki | ||||
| from earwigbot.config import config | |||||
| __all__ = ["BaseTask", "task_manager"] | |||||
| __all__ = ["BaseTask"] | |||||
| class BaseTask(object): | class BaseTask(object): | ||||
| """A base class for bot tasks that edit Wikipedia.""" | """A base class for bot tasks that edit Wikipedia.""" | ||||
| name = None | name = None | ||||
| number = 0 | number = 0 | ||||
| def __init__(self): | |||||
| def __init__(self, bot): | |||||
| """Constructor for new tasks. | """Constructor for new tasks. | ||||
| This is called once immediately after the task class is loaded by | This is called once immediately after the task class is loaded by | ||||
| the task manager (in tasks._load_task()). | |||||
| the task manager (in tasks._load_task()). Don't override this directly | |||||
| (or if you do, remember super(Task, self).__init()) - use setup(). | |||||
| """ | """ | ||||
| pass | |||||
| self.bot = bot | |||||
| self.config = bot.config | |||||
| self.logger = bot.tasks.logger.getChild(self.name) | |||||
| self.setup() | |||||
| 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 setup(self): | |||||
| """Hook called immediately after the task is loaded. | |||||
| Does nothing by default; feel free to override. | |||||
| """ | |||||
| pass | |||||
| def run(self, **kwargs): | def run(self, **kwargs): | ||||
| """Main entry point to run a given task. | """Main entry point to run a given task. | ||||
| @@ -83,7 +84,7 @@ class BaseTask(object): | |||||
| If the config value is not found, we just return the arg as-is. | If the config value is not found, we just return the arg as-is. | ||||
| """ | """ | ||||
| try: | try: | ||||
| summary = config.wiki["summary"] | |||||
| summary = self.bot.config.wiki["summary"] | |||||
| except KeyError: | except KeyError: | ||||
| return comment | return comment | ||||
| return summary.replace("$1", str(self.number)).replace("$2", comment) | return summary.replace("$1", str(self.number)).replace("$2", comment) | ||||
| @@ -108,10 +109,10 @@ class BaseTask(object): | |||||
| try: | try: | ||||
| site = self.site | site = self.site | ||||
| except AttributeError: | except AttributeError: | ||||
| site = wiki.get_site() | |||||
| site = self.bot.wiki.get_site() | |||||
| try: | try: | ||||
| cfg = config.wiki["shutoff"] | |||||
| cfg = self.config.wiki["shutoff"] | |||||
| except KeyError: | except KeyError: | ||||
| return False | return False | ||||
| title = cfg.get("page", "User:$1/Shutoff/Task $2") | title = cfg.get("page", "User:$1/Shutoff/Task $2") | ||||
| @@ -128,106 +129,3 @@ class BaseTask(object): | |||||
| self.logger.warn("Emergency task shutoff has been enabled!") | self.logger.warn("Emergency task shutoff has been enabled!") | ||||
| return True | 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 | |||||
| try: | |||||
| task = sys.modules[name].Task() | |||||
| except AttributeError: | |||||
| return # No task in this module | |||||
| if not isinstance(task, BaseTask): | |||||
| return | |||||
| task._setup_logger() | |||||
| self._tasks[task.name] = task | |||||
| self.logger.debug("Added task {0}".format(task.name)) | |||||
| 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)) | |||||
| 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 | |||||
| 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) | |||||
| # Stop bot task threads automagically if the main bot stops: | |||||
| task_thread.daemon = True | |||||
| task_thread.start() | |||||
| def get(self, 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] | |||||
| def get_all(self): | |||||
| """Return our dict of all loaded tasks.""" | |||||
| return self._tasks | |||||
| task_manager = _TaskManager() | |||||
| @@ -22,12 +22,14 @@ | |||||
| from earwigbot.tasks import BaseTask | from earwigbot.tasks import BaseTask | ||||
| __all__ = ["Task"] | |||||
| 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]] | ||||
| submissions.""" | submissions.""" | ||||
| name = "afc_catdelink" | name = "afc_catdelink" | ||||
| def __init__(self): | |||||
| def setup(self): | |||||
| pass | pass | ||||
| def run(self, **kwargs): | def run(self, **kwargs): | ||||
| @@ -26,18 +26,18 @@ from threading import Lock | |||||
| import oursql | import oursql | ||||
| from earwigbot import wiki | |||||
| from earwigbot.config import config | |||||
| from earwigbot.tasks import BaseTask | from earwigbot.tasks import BaseTask | ||||
| __all__ = ["Task"] | |||||
| 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 | ||||
| violations.""" | violations.""" | ||||
| name = "afc_copyvios" | name = "afc_copyvios" | ||||
| number = 1 | number = 1 | ||||
| def __init__(self): | |||||
| cfg = config.tasks.get(self.name, {}) | |||||
| def setup(self): | |||||
| cfg = self.config.tasks.get(self.name, {}) | |||||
| self.template = cfg.get("template", "AfC suspected copyvio") | self.template = cfg.get("template", "AfC suspected copyvio") | ||||
| self.ignore_list = cfg.get("ignoreList", []) | self.ignore_list = cfg.get("ignoreList", []) | ||||
| self.min_confidence = cfg.get("minConfidence", 0.5) | self.min_confidence = cfg.get("minConfidence", 0.5) | ||||
| @@ -63,7 +63,7 @@ class Task(BaseTask): | |||||
| if self.shutoff_enabled(): | if self.shutoff_enabled(): | ||||
| return | return | ||||
| title = kwargs["page"] | title = kwargs["page"] | ||||
| page = wiki.get_site().get_page(title) | |||||
| page = self.bot.wiki.get_site().get_page(title) | |||||
| with self.db_access_lock: | with self.db_access_lock: | ||||
| self.conn = oursql.connect(**self.conn_data) | self.conn = oursql.connect(**self.conn_data) | ||||
| self.process(page) | self.process(page) | ||||
| @@ -22,12 +22,14 @@ | |||||
| from earwigbot.tasks import BaseTask | from earwigbot.tasks import BaseTask | ||||
| __all__ = ["Task"] | |||||
| class Task(BaseTask): | class Task(BaseTask): | ||||
| """ A task to create daily categories for [[WP:AFC]].""" | """ A task to create daily categories for [[WP:AFC]].""" | ||||
| name = "afc_dailycats" | name = "afc_dailycats" | ||||
| number = 3 | number = 3 | ||||
| def __init__(self): | |||||
| def setup(self): | |||||
| pass | pass | ||||
| def run(self, **kwargs): | def run(self, **kwargs): | ||||
| @@ -32,14 +32,9 @@ from numpy import arange | |||||
| import oursql | import oursql | ||||
| from earwigbot import wiki | from earwigbot import wiki | ||||
| from earwigbot.config import config | |||||
| from earwigbot.tasks import BaseTask | from earwigbot.tasks import BaseTask | ||||
| # Valid submission statuses: | |||||
| STATUS_NONE = 0 | |||||
| STATUS_PEND = 1 | |||||
| STATUS_DECLINE = 2 | |||||
| STATUS_ACCEPT = 3 | |||||
| __all__ = ["Task"] | |||||
| class Task(BaseTask): | class Task(BaseTask): | ||||
| """A task to generate charts about AfC submissions over time. | """A task to generate charts about AfC submissions over time. | ||||
| @@ -57,8 +52,14 @@ class Task(BaseTask): | |||||
| """ | """ | ||||
| name = "afc_history" | name = "afc_history" | ||||
| def __init__(self): | |||||
| cfg = config.tasks.get(self.name, {}) | |||||
| # Valid submission statuses: | |||||
| STATUS_NONE = 0 | |||||
| STATUS_PEND = 1 | |||||
| STATUS_DECLINE = 2 | |||||
| STATUS_ACCEPT = 3 | |||||
| def setup(self): | |||||
| cfg = self.config.tasks.get(self.name, {}) | |||||
| self.num_days = cfg.get("days", 90) | self.num_days = cfg.get("days", 90) | ||||
| self.categories = cfg.get("categories", {}) | self.categories = cfg.get("categories", {}) | ||||
| @@ -73,7 +74,7 @@ class Task(BaseTask): | |||||
| self.db_access_lock = Lock() | self.db_access_lock = Lock() | ||||
| def run(self, **kwargs): | def run(self, **kwargs): | ||||
| self.site = wiki.get_site() | |||||
| self.site = self.bot.wiki.get_site() | |||||
| with self.db_access_lock: | with self.db_access_lock: | ||||
| self.conn = oursql.connect(**self.conn_data) | self.conn = oursql.connect(**self.conn_data) | ||||
| @@ -137,7 +138,7 @@ class Task(BaseTask): | |||||
| stored = cursor.fetchall() | stored = cursor.fetchall() | ||||
| status = self.get_status(title, pageid) | status = self.get_status(title, pageid) | ||||
| if status == STATUS_NONE: | |||||
| if status == self.STATUS_NONE: | |||||
| if stored: | if stored: | ||||
| cursor.execute(q_delete, (pageid,)) | cursor.execute(q_delete, (pageid,)) | ||||
| continue | continue | ||||
| @@ -155,14 +156,14 @@ class Task(BaseTask): | |||||
| ns = page.namespace() | ns = page.namespace() | ||||
| if ns == wiki.NS_FILE_TALK: # Ignore accepted FFU requests | if ns == wiki.NS_FILE_TALK: # Ignore accepted FFU requests | ||||
| return STATUS_NONE | |||||
| return self.STATUS_NONE | |||||
| if ns == wiki.NS_TALK: | if ns == wiki.NS_TALK: | ||||
| new_page = page.toggle_talk() | new_page = page.toggle_talk() | ||||
| sleep(2) | sleep(2) | ||||
| if new_page.is_redirect(): | if new_page.is_redirect(): | ||||
| return STATUS_NONE # Ignore accepted AFC/R requests | |||||
| return STATUS_ACCEPT | |||||
| return self.STATUS_NONE # Ignore accepted AFC/R requests | |||||
| return self.STATUS_ACCEPT | |||||
| cats = self.categories | cats = self.categories | ||||
| sq = self.site.sql_query | sq = self.site.sql_query | ||||
| @@ -170,16 +171,16 @@ class Task(BaseTask): | |||||
| match = lambda cat: list(sq(query, (cat.replace(" ", "_"), pageid))) | match = lambda cat: list(sq(query, (cat.replace(" ", "_"), pageid))) | ||||
| if match(cats["pending"]): | if match(cats["pending"]): | ||||
| return STATUS_PEND | |||||
| return self.STATUS_PEND | |||||
| elif match(cats["unsubmitted"]): | elif match(cats["unsubmitted"]): | ||||
| return STATUS_NONE | |||||
| return self.STATUS_NONE | |||||
| elif match(cats["declined"]): | elif match(cats["declined"]): | ||||
| return STATUS_DECLINE | |||||
| return STATUS_NONE | |||||
| return self.STATUS_DECLINE | |||||
| return self.STATUS_NONE | |||||
| def get_date_counts(self, date): | def get_date_counts(self, date): | ||||
| query = "SELECT COUNT(*) FROM page WHERE page_date = ? AND page_status = ?" | query = "SELECT COUNT(*) FROM page WHERE page_date = ? AND page_status = ?" | ||||
| statuses = [STATUS_PEND, STATUS_DECLINE, STATUS_ACCEPT] | |||||
| statuses = [self.STATUS_PEND, self.STATUS_DECLINE, self.STATUS_ACCEPT] | |||||
| counts = {} | counts = {} | ||||
| with self.conn.cursor() as cursor: | with self.conn.cursor() as cursor: | ||||
| for status in statuses: | for status in statuses: | ||||
| @@ -193,9 +194,9 @@ class Task(BaseTask): | |||||
| plt.xlabel(self.graph.get("xaxis", "Date")) | plt.xlabel(self.graph.get("xaxis", "Date")) | ||||
| plt.ylabel(self.graph.get("yaxis", "Submissions")) | plt.ylabel(self.graph.get("yaxis", "Submissions")) | ||||
| pends = [d[STATUS_PEND] for d in data.itervalues()] | |||||
| declines = [d[STATUS_DECLINE] for d in data.itervalues()] | |||||
| accepts = [d[STATUS_ACCEPT] for d in data.itervalues()] | |||||
| pends = [d[self.STATUS_PEND] for d in data.itervalues()] | |||||
| declines = [d[self.STATUS_DECLINE] for d in data.itervalues()] | |||||
| accepts = [d[self.STATUS_ACCEPT] for d in data.itervalues()] | |||||
| pends_declines = [p + d for p, d in zip(pends, declines)] | pends_declines = [p + d for p, d in zip(pends, declines)] | ||||
| ind = arange(len(data)) | ind = arange(len(data)) | ||||
| xsize = self.graph.get("xsize", 1200) | xsize = self.graph.get("xsize", 1200) | ||||
| @@ -30,17 +30,9 @@ from time import sleep | |||||
| import oursql | import oursql | ||||
| from earwigbot import wiki | from earwigbot import wiki | ||||
| from earwigbot.config import config | |||||
| from earwigbot.tasks import BaseTask | from earwigbot.tasks import BaseTask | ||||
| # Chart status number constants: | |||||
| CHART_NONE = 0 | |||||
| CHART_PEND = 1 | |||||
| CHART_DRAFT = 2 | |||||
| CHART_REVIEW = 3 | |||||
| CHART_ACCEPT = 4 | |||||
| CHART_DECLINE = 5 | |||||
| CHART_MISPLACE = 6 | |||||
| __all__ = ["Task"] | |||||
| class Task(BaseTask): | class Task(BaseTask): | ||||
| """A task to generate statistics for WikiProject Articles for Creation. | """A task to generate statistics for WikiProject Articles for Creation. | ||||
| @@ -53,8 +45,17 @@ class Task(BaseTask): | |||||
| name = "afc_statistics" | name = "afc_statistics" | ||||
| number = 2 | number = 2 | ||||
| def __init__(self): | |||||
| self.cfg = cfg = config.tasks.get(self.name, {}) | |||||
| # Chart status number constants: | |||||
| CHART_NONE = 0 | |||||
| CHART_PEND = 1 | |||||
| CHART_DRAFT = 2 | |||||
| CHART_REVIEW = 3 | |||||
| CHART_ACCEPT = 4 | |||||
| CHART_DECLINE = 5 | |||||
| CHART_MISPLACE = 6 | |||||
| def setup(self): | |||||
| self.cfg = cfg = self.config.tasks.get(self.name, {}) | |||||
| # Set some wiki-related attributes: | # Set some wiki-related attributes: | ||||
| self.pagename = cfg.get("page", "Template:AFC statistics") | self.pagename = cfg.get("page", "Template:AFC statistics") | ||||
| @@ -83,7 +84,7 @@ class Task(BaseTask): | |||||
| (self.save()). We will additionally create an SQL connection with our | (self.save()). We will additionally create an SQL connection with our | ||||
| local database. | local database. | ||||
| """ | """ | ||||
| self.site = wiki.get_site() | |||||
| self.site = self.bot.wiki.get_site() | |||||
| with self.db_access_lock: | with self.db_access_lock: | ||||
| self.conn = oursql.connect(**self.conn_data) | self.conn = oursql.connect(**self.conn_data) | ||||
| @@ -206,7 +207,7 @@ class Task(BaseTask): | |||||
| replag = self.site.get_replag() | replag = self.site.get_replag() | ||||
| self.logger.debug("Server replag is {0}".format(replag)) | self.logger.debug("Server replag is {0}".format(replag)) | ||||
| if replag > 600 and not kwargs.get("ignore_replag"): | if replag > 600 and not kwargs.get("ignore_replag"): | ||||
| msg = "Sync canceled as replag ({0} secs) is greater than ten minutes." | |||||
| msg = "Sync canceled as replag ({0} secs) is greater than ten minutes" | |||||
| self.logger.warn(msg.format(replag)) | self.logger.warn(msg.format(replag)) | ||||
| return | return | ||||
| @@ -286,7 +287,7 @@ class Task(BaseTask): | |||||
| query = """DELETE FROM page, row USING page JOIN row | query = """DELETE FROM page, row USING page JOIN row | ||||
| ON page_id = row_id WHERE row_chart IN (?, ?) | ON page_id = row_id WHERE row_chart IN (?, ?) | ||||
| AND ADDTIME(page_special_time, '36:00:00') < NOW()""" | AND ADDTIME(page_special_time, '36:00:00') < NOW()""" | ||||
| cursor.execute(query, (CHART_ACCEPT, CHART_DECLINE)) | |||||
| cursor.execute(query, (self.CHART_ACCEPT, self.CHART_DECLINE)) | |||||
| def update(self, **kwargs): | def update(self, **kwargs): | ||||
| """Update a page by name, regardless of whether anything has changed. | """Update a page by name, regardless of whether anything has changed. | ||||
| @@ -333,7 +334,7 @@ class Task(BaseTask): | |||||
| namespace = self.site.get_page(title).namespace() | namespace = self.site.get_page(title).namespace() | ||||
| status, chart = self.get_status_and_chart(content, namespace) | status, chart = self.get_status_and_chart(content, namespace) | ||||
| if chart == CHART_NONE: | |||||
| if chart == self.CHART_NONE: | |||||
| msg = "Could not find a status for [[{0}]]".format(title) | msg = "Could not find a status for [[{0}]]".format(title) | ||||
| self.logger.warn(msg) | self.logger.warn(msg) | ||||
| return | return | ||||
| @@ -367,7 +368,7 @@ class Task(BaseTask): | |||||
| namespace = self.site.get_page(title).namespace() | namespace = self.site.get_page(title).namespace() | ||||
| status, chart = self.get_status_and_chart(content, namespace) | status, chart = self.get_status_and_chart(content, namespace) | ||||
| if chart == CHART_NONE: | |||||
| if chart == self.CHART_NONE: | |||||
| self.untrack_page(cursor, pageid) | self.untrack_page(cursor, pageid) | ||||
| return | return | ||||
| @@ -499,23 +500,23 @@ class Task(BaseTask): | |||||
| statuses = self.get_statuses(content) | statuses = self.get_statuses(content) | ||||
| if "R" in statuses: | if "R" in statuses: | ||||
| status, chart = "r", CHART_REVIEW | |||||
| status, chart = "r", self.CHART_REVIEW | |||||
| elif "H" in statuses: | elif "H" in statuses: | ||||
| status, chart = "p", CHART_DRAFT | |||||
| status, chart = "p", self.CHART_DRAFT | |||||
| elif "P" in statuses: | elif "P" in statuses: | ||||
| status, chart = "p", CHART_PEND | |||||
| status, chart = "p", self.CHART_PEND | |||||
| elif "T" in statuses: | elif "T" in statuses: | ||||
| status, chart = None, CHART_NONE | |||||
| status, chart = None, self.CHART_NONE | |||||
| elif "D" in statuses: | elif "D" in statuses: | ||||
| status, chart = "d", CHART_DECLINE | |||||
| status, chart = "d", self.CHART_DECLINE | |||||
| else: | else: | ||||
| status, chart = None, CHART_NONE | |||||
| status, chart = None, self.CHART_NONE | |||||
| if namespace == wiki.NS_MAIN: | if namespace == wiki.NS_MAIN: | ||||
| if not statuses: | if not statuses: | ||||
| status, chart = "a", CHART_ACCEPT | |||||
| status, chart = "a", self.CHART_ACCEPT | |||||
| else: | else: | ||||
| status, chart = None, CHART_MISPLACE | |||||
| status, chart = None, self.CHART_MISPLACE | |||||
| return status, chart | return status, chart | ||||
| @@ -614,23 +615,23 @@ class Task(BaseTask): | |||||
| returned if we cannot determine when the page was "special"-ed, or if | returned if we cannot determine when the page was "special"-ed, or if | ||||
| it was "special"-ed more than 250 edits ago. | it was "special"-ed more than 250 edits ago. | ||||
| """ | """ | ||||
| if chart ==CHART_NONE: | |||||
| if chart ==self.CHART_NONE: | |||||
| return None, None, None | return None, None, None | ||||
| elif chart == CHART_MISPLACE: | |||||
| elif chart == self.CHART_MISPLACE: | |||||
| return self.get_create(pageid) | return self.get_create(pageid) | ||||
| elif chart == CHART_ACCEPT: | |||||
| elif chart == self.CHART_ACCEPT: | |||||
| search_for = None | search_for = None | ||||
| search_not = ["R", "H", "P", "T", "D"] | search_not = ["R", "H", "P", "T", "D"] | ||||
| elif chart == CHART_DRAFT: | |||||
| elif chart == self.CHART_DRAFT: | |||||
| search_for = "H" | search_for = "H" | ||||
| search_not = [] | search_not = [] | ||||
| elif chart == CHART_PEND: | |||||
| elif chart == self.CHART_PEND: | |||||
| search_for = "P" | search_for = "P" | ||||
| search_not = [] | search_not = [] | ||||
| elif chart == CHART_REVIEW: | |||||
| elif chart == self.CHART_REVIEW: | |||||
| search_for = "R" | search_for = "R" | ||||
| search_not = [] | search_not = [] | ||||
| elif chart == CHART_DECLINE: | |||||
| elif chart == self.CHART_DECLINE: | |||||
| search_for = "D" | search_for = "D" | ||||
| search_not = ["R", "H", "P", "T"] | search_not = ["R", "H", "P", "T"] | ||||
| @@ -684,12 +685,12 @@ class Task(BaseTask): | |||||
| """ | """ | ||||
| notes = "" | notes = "" | ||||
| ignored_charts = [CHART_NONE, CHART_ACCEPT, CHART_DECLINE] | |||||
| ignored_charts = [self.CHART_NONE, self.CHART_ACCEPT, self.CHART_DECLINE] | |||||
| if chart in ignored_charts: | if chart in ignored_charts: | ||||
| return notes | return notes | ||||
| statuses = self.get_statuses(content) | statuses = self.get_statuses(content) | ||||
| if "D" in statuses and chart != CHART_MISPLACE: | |||||
| if "D" in statuses and chart != self.CHART_MISPLACE: | |||||
| notes += "|nr=1" # Submission was resubmitted | notes += "|nr=1" # Submission was resubmitted | ||||
| if len(content) < 500: | if len(content) < 500: | ||||
| @@ -706,7 +707,7 @@ class Task(BaseTask): | |||||
| if time_since_modify > max_time: | if time_since_modify > max_time: | ||||
| notes += "|no=1" # Submission hasn't been touched in over 4 days | notes += "|no=1" # Submission hasn't been touched in over 4 days | ||||
| if chart in [CHART_PEND, CHART_DRAFT]: | |||||
| if chart in [self.CHART_PEND, self.CHART_DRAFT]: | |||||
| submitter = self.site.get_user(s_user) | submitter = self.site.get_user(s_user) | ||||
| try: | try: | ||||
| if submitter.blockinfo(): | if submitter.blockinfo(): | ||||
| @@ -22,11 +22,13 @@ | |||||
| from earwigbot.tasks import BaseTask | from earwigbot.tasks import BaseTask | ||||
| __all__ = ["Task"] | |||||
| class Task(BaseTask): | class Task(BaseTask): | ||||
| """A task to clear [[Category:Undated AfC submissions]].""" | """A task to clear [[Category:Undated AfC submissions]].""" | ||||
| name = "afc_undated" | name = "afc_undated" | ||||
| def __init__(self): | |||||
| def setup(self): | |||||
| pass | pass | ||||
| def run(self, **kwargs): | def run(self, **kwargs): | ||||
| @@ -22,12 +22,14 @@ | |||||
| from earwigbot.tasks import BaseTask | from earwigbot.tasks import BaseTask | ||||
| __all__ = ["Task"] | |||||
| 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 | ||||
| {{WP Biography}}.""" | {{WP Biography}}.""" | ||||
| name = "blptag" | name = "blptag" | ||||
| def __init__(self): | |||||
| def setup(self): | |||||
| pass | pass | ||||
| def run(self, **kwargs): | def run(self, **kwargs): | ||||
| @@ -22,11 +22,13 @@ | |||||
| from earwigbot.tasks import BaseTask | from earwigbot.tasks import BaseTask | ||||
| __all__ = ["Task"] | |||||
| class Task(BaseTask): | class Task(BaseTask): | ||||
| """A task to create daily categories for [[WP:FEED]].""" | """A task to create daily categories for [[WP:FEED]].""" | ||||
| name = "feed_dailycats" | name = "feed_dailycats" | ||||
| def __init__(self): | |||||
| def setup(self): | |||||
| pass | pass | ||||
| def run(self, **kwargs): | def run(self, **kwargs): | ||||
| @@ -20,18 +20,16 @@ | |||||
| # 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.commands import BaseCommand | |||||
| from earwigbot.config import config | |||||
| from earwigbot.tasks import BaseTask | |||||
| class Command(BaseCommand): | |||||
| """Restart the bot. Only the owner can do this.""" | |||||
| name = "restart" | |||||
| __all__ = ["Task"] | |||||
| 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 | |||||
| class Task(BaseTask): | |||||
| """A task to tag talk pages with WikiProject Banners.""" | |||||
| name = "wikiproject_tagger" | |||||
| self.connection.logger.info("Restarting bot per owner request") | |||||
| self.connection.is_running = False | |||||
| def setup(self): | |||||
| pass | |||||
| def run(self, **kwargs): | |||||
| pass | |||||
| @@ -22,12 +22,14 @@ | |||||
| from earwigbot.tasks import BaseTask | from earwigbot.tasks import BaseTask | ||||
| __all__ = ["Task"] | |||||
| 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 | ||||
| type.""" | type.""" | ||||
| name = "wrongmime" | name = "wrongmime" | ||||
| def __init__(self): | |||||
| def setup(self): | |||||
| pass | pass | ||||
| def run(self, **kwargs): | def run(self, **kwargs): | ||||
| @@ -0,0 +1,88 @@ | |||||
| #! /usr/bin/env python | |||||
| # -*- 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. | |||||
| """ | |||||
| This is EarwigBot's command-line utility, enabling you to easily start the | |||||
| bot or run specific tasks. | |||||
| """ | |||||
| from argparse import ArgumentParser | |||||
| import logging | |||||
| from os import path | |||||
| from time import sleep | |||||
| from earwigbot import __version__ | |||||
| from earwigbot.bot import Bot | |||||
| __all__ = ["main"] | |||||
| def main(): | |||||
| version = "EarwigBot v{0}".format(__version__) | |||||
| parser = ArgumentParser(description=__doc__) | |||||
| parser.add_argument("path", nargs="?", metavar="PATH", default=path.curdir, | |||||
| help="path to the bot's working directory, which will be created if it doesn't exist; current directory assumed if not specified") | |||||
| parser.add_argument("-v", "--version", action="version", version=version) | |||||
| parser.add_argument("-d", "--debug", action="store_true", | |||||
| help="print all logs, including DEBUG-level messages") | |||||
| parser.add_argument("-q", "--quiet", action="store_true", | |||||
| help="don't print any logs except warnings and errors") | |||||
| parser.add_argument("-t", "--task", metavar="NAME", | |||||
| help="given the name of a task, the bot will run it instead of the main bot and then exit") | |||||
| args = parser.parse_args() | |||||
| level = logging.INFO | |||||
| if args.debug and args.quiet: | |||||
| parser.print_usage() | |||||
| print "earwigbot: error: cannot show debug messages and be quiet at the same time" | |||||
| return | |||||
| if args.debug: | |||||
| level = logging.DEBUG | |||||
| elif args.quiet: | |||||
| level = logging.WARNING | |||||
| print version | |||||
| bot = Bot(path.abspath(args.path), level=level) | |||||
| if args.task: | |||||
| thread = bot.tasks.start(args.task) | |||||
| if not thread: | |||||
| return | |||||
| try: | |||||
| while thread.is_alive(): # Keep it alive; it's a daemon | |||||
| sleep(1) | |||||
| except KeyboardInterrupt: | |||||
| pass | |||||
| finally: | |||||
| if thread.is_alive(): | |||||
| bot.tasks.logger.warn("The task is will be killed") | |||||
| else: | |||||
| try: | |||||
| bot.run() | |||||
| except KeyboardInterrupt: | |||||
| pass | |||||
| finally: | |||||
| if bot._keep_looping: # Indicates bot hasn't already been stopped | |||||
| bot.stop() | |||||
| if __name__ == "__main__": | |||||
| main() | |||||
| @@ -27,18 +27,22 @@ This is a collection of classes and functions to read from and write to | |||||
| Wikipedia and other wiki sites. No connection whatsoever to python-wikitools | Wikipedia and other wiki sites. No connection whatsoever to python-wikitools | ||||
| written by Mr.Z-man, other than a similar purpose. We share no code. | written by Mr.Z-man, other than a similar purpose. We share no code. | ||||
| Import the toolset with `from earwigbot import wiki`. | |||||
| Import the toolset directly with `from earwigbot import wiki`. If using the | |||||
| built-in integration with the rest of the bot, Bot() objects contain a `wiki` | |||||
| attribute, which is a SitesDB object tied to the sites.db file located in the | |||||
| same directory as config.yml. That object has the principal methods get_site, | |||||
| add_site, and remove_site that should handle all of your Site (and thus, Page, | |||||
| Category, and User) needs. | |||||
| """ | """ | ||||
| import logging as _log | import logging as _log | ||||
| logger = _log.getLogger("earwigbot.wiki") | logger = _log.getLogger("earwigbot.wiki") | ||||
| logger.addHandler(_log.NullHandler()) | logger.addHandler(_log.NullHandler()) | ||||
| from earwigbot.wiki.category import * | |||||
| from earwigbot.wiki.constants import * | from earwigbot.wiki.constants import * | ||||
| from earwigbot.wiki.exceptions import * | from earwigbot.wiki.exceptions import * | ||||
| from earwigbot.wiki.functions import * | |||||
| from earwigbot.wiki.category import Category | |||||
| from earwigbot.wiki.page import Page | |||||
| from earwigbot.wiki.site import Site | |||||
| from earwigbot.wiki.user import User | |||||
| from earwigbot.wiki.page import * | |||||
| from earwigbot.wiki.site import * | |||||
| from earwigbot.wiki.sitesdb import * | |||||
| from earwigbot.wiki.user import * | |||||
| @@ -22,6 +22,8 @@ | |||||
| from earwigbot.wiki.page import Page | from earwigbot.wiki.page import Page | ||||
| __all__ = ["Category"] | |||||
| class Category(Page): | class Category(Page): | ||||
| """ | """ | ||||
| EarwigBot's Wiki Toolset: Category Class | EarwigBot's Wiki Toolset: Category Class | ||||
| @@ -27,13 +27,16 @@ This module defines some useful constants: | |||||
| * USER_AGENT - our default User Agent when making API queries | * USER_AGENT - our default User Agent when making API queries | ||||
| * NS_* - default namespace IDs for easy lookup | * NS_* - default namespace IDs for easy lookup | ||||
| Import with `from earwigbot.wiki import constants` or `from earwigbot.wiki.constants import *`. | |||||
| Import directly with `from earwigbot.wiki import constants` or | |||||
| `from earwigbot.wiki.constants import *`. These are also available from | |||||
| earwigbot.wiki (e.g. `earwigbot.wiki.USER_AGENT`). | |||||
| """ | """ | ||||
| # Default User Agent when making API queries: | # Default User Agent when making API queries: | ||||
| from earwigbot import __version__ as _v | from earwigbot import __version__ as _v | ||||
| from platform import python_version as _p | from platform import python_version as _p | ||||
| USER_AGENT = "EarwigBot/{0} (Python/{1}; https://github.com/earwig/earwigbot)".format(_v, _p()) | USER_AGENT = "EarwigBot/{0} (Python/{1}; https://github.com/earwig/earwigbot)".format(_v, _p()) | ||||
| del _v, _p | |||||
| # Default namespace IDs: | # Default namespace IDs: | ||||
| NS_MAIN = 0 | NS_MAIN = 0 | ||||
| @@ -1,211 +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 Wiki Toolset: Misc Functions | |||||
| This module, a component of the wiki package, contains miscellaneous functions | |||||
| that are not methods of any class, like get_site(). | |||||
| There's no need to import this module explicitly. All functions here are | |||||
| automatically available from earwigbot.wiki. | |||||
| """ | |||||
| from cookielib import LWPCookieJar, LoadError | |||||
| import errno | |||||
| from getpass import getpass | |||||
| from os import chmod, path | |||||
| import platform | |||||
| import stat | |||||
| import earwigbot | |||||
| from earwigbot.config import config | |||||
| from earwigbot.wiki.exceptions import SiteNotFoundError | |||||
| from earwigbot.wiki.site import Site | |||||
| __all__ = ["get_site", "add_site", "del_site"] | |||||
| _cookiejar = None | |||||
| def _load_config(): | |||||
| """Called by a config-requiring function, such as get_site(), when config | |||||
| has not been loaded. This will usually happen only if we're running code | |||||
| directly from Python's interpreter and not the bot itself, because | |||||
| earwigbot.py or core/main.py will already call these functions. | |||||
| """ | |||||
| is_encrypted = config.load() | |||||
| if is_encrypted: # Passwords in the config file are encrypted | |||||
| key = getpass("Enter key to unencrypt bot passwords: ") | |||||
| config._decryption_key = key | |||||
| config.decrypt(config.wiki, "password") | |||||
| def _get_cookiejar(): | |||||
| """Returns a LWPCookieJar object loaded from our .cookies file. The same | |||||
| one is returned every time. | |||||
| The .cookies file is located in the project root, same directory as | |||||
| config.yml and bot.py. If it doesn't exist, we will create the file and set | |||||
| it to be readable and writeable only by us. If it exists but the | |||||
| information inside is bogus, we will ignore it. | |||||
| This is normally called by _get_site_object_from_dict() (in turn called by | |||||
| get_site()), and the cookiejar is passed to our Site's constructor, used | |||||
| when it makes API queries. This way, we can easily preserve cookies between | |||||
| sites (e.g., for CentralAuth), making logins easier. | |||||
| """ | |||||
| global _cookiejar | |||||
| if _cookiejar is not None: | |||||
| return _cookiejar | |||||
| cookie_file = path.join(config.root_dir, ".cookies") | |||||
| _cookiejar = LWPCookieJar(cookie_file) | |||||
| try: | |||||
| _cookiejar.load() | |||||
| except LoadError: | |||||
| pass # File contains bad data, so ignore it completely | |||||
| except IOError as e: | |||||
| if e.errno == errno.ENOENT: # "No such file or directory" | |||||
| # Create the file and restrict reading/writing only to the owner, | |||||
| # so others can't peak at our cookies: | |||||
| open(cookie_file, "w").close() | |||||
| chmod(cookie_file, stat.S_IRUSR|stat.S_IWUSR) | |||||
| else: | |||||
| raise | |||||
| return _cookiejar | |||||
| def _get_site_object_from_dict(name, d): | |||||
| """Return a Site object based on the contents of a dict, probably acquired | |||||
| through our config file, and a separate name. | |||||
| """ | |||||
| project = d.get("project") | |||||
| lang = d.get("lang") | |||||
| base_url = d.get("baseURL") | |||||
| article_path = d.get("articlePath") | |||||
| script_path = d.get("scriptPath") | |||||
| sql = d.get("sql", {}) | |||||
| namespaces = d.get("namespaces", {}) | |||||
| login = (config.wiki.get("username"), config.wiki.get("password")) | |||||
| cookiejar = _get_cookiejar() | |||||
| user_agent = config.wiki.get("userAgent") | |||||
| assert_edit = config.wiki.get("assert") | |||||
| maxlag = config.wiki.get("maxlag") | |||||
| search_config = config.wiki.get("search") | |||||
| if user_agent: | |||||
| user_agent = user_agent.replace("$1", earwigbot.__version__) | |||||
| user_agent = user_agent.replace("$2", platform.python_version()) | |||||
| return Site(name=name, project=project, lang=lang, base_url=base_url, | |||||
| article_path=article_path, script_path=script_path, sql=sql, | |||||
| namespaces=namespaces, login=login, cookiejar=cookiejar, | |||||
| user_agent=user_agent, assert_edit=assert_edit, maxlag=maxlag, | |||||
| search_config=search_config) | |||||
| def get_site(name=None, project=None, lang=None): | |||||
| """Returns a Site instance based on information from our config file. | |||||
| With no arguments, returns the default site as specified by our config | |||||
| file. This is default = config.wiki["defaultSite"]; | |||||
| config.wiki["sites"][default]. | |||||
| With `name` specified, returns the site specified by | |||||
| config.wiki["sites"][name]. | |||||
| With `project` and `lang` specified, returns the site specified by the | |||||
| member of config.wiki["sites"], `s`, for which s["project"] == project and | |||||
| s["lang"] == lang. | |||||
| We will attempt to login to the site automatically | |||||
| using config.wiki["username"] and config.wiki["password"] if both are | |||||
| defined. | |||||
| Specifying a project without a lang or a lang without a project will raise | |||||
| TypeError. If all three args are specified, `name` will be first tried, | |||||
| then `project` and `lang`. If, with any number of args, a site cannot be | |||||
| found in the config, SiteNotFoundError is raised. | |||||
| """ | |||||
| # Check if config has been loaded, and load it if it hasn't: | |||||
| if not config.is_loaded(): | |||||
| _load_config() | |||||
| # Someone specified a project without a lang (or a lang without a project)! | |||||
| if (project is None and lang is not None) or (project is not None and | |||||
| lang is None): | |||||
| e = "Keyword arguments 'lang' and 'project' must be specified together." | |||||
| raise TypeError(e) | |||||
| # No args given, so return our default site (project is None implies lang | |||||
| # is None, so we don't need to add that in): | |||||
| if name is None and project is None: | |||||
| try: | |||||
| default = config.wiki["defaultSite"] | |||||
| except KeyError: | |||||
| e = "Default site is not specified in config." | |||||
| raise SiteNotFoundError(e) | |||||
| try: | |||||
| site = config.wiki["sites"][default] | |||||
| except KeyError: | |||||
| e = "Default site specified by config is not in the config's sites list." | |||||
| raise SiteNotFoundError(e) | |||||
| return _get_site_object_from_dict(default, site) | |||||
| # Name arg given, but don't look at others unless `name` isn't found: | |||||
| if name is not None: | |||||
| try: | |||||
| site = config.wiki["sites"][name] | |||||
| except KeyError: | |||||
| if project is None: # Implies lang is None, so only name was given | |||||
| e = "Site '{0}' not found in config.".format(name) | |||||
| raise SiteNotFoundError(e) | |||||
| for sitename, site in config.wiki["sites"].items(): | |||||
| if site["project"] == project and site["lang"] == lang: | |||||
| return _get_site_object_from_dict(sitename, site) | |||||
| e = "Neither site '{0}' nor site '{1}:{2}' found in config." | |||||
| e.format(name, project, lang) | |||||
| raise SiteNotFoundError(e) | |||||
| else: | |||||
| return _get_site_object_from_dict(name, site) | |||||
| # If we end up here, then project and lang are both not None: | |||||
| for sitename, site in config.wiki["sites"].items(): | |||||
| if site["project"] == project and site["lang"] == lang: | |||||
| return _get_site_object_from_dict(sitename, site) | |||||
| e = "Site '{0}:{1}' not found in config.".format(project, lang) | |||||
| raise SiteNotFoundError(e) | |||||
| def add_site(): | |||||
| """STUB: config editing is required first. | |||||
| Returns True if the site was added successfully or False if the site was | |||||
| already in our config. Raises ConfigError if saving the updated file failed | |||||
| for some reason.""" | |||||
| pass | |||||
| def del_site(name): | |||||
| """STUB: config editing is required first. | |||||
| Returns True if the site was removed successfully or False if the site was | |||||
| not in our config originally. Raises ConfigError if saving the updated file | |||||
| failed for some reason.""" | |||||
| pass | |||||
| @@ -28,6 +28,8 @@ from urllib import quote | |||||
| from earwigbot.wiki.copyright import CopyrightMixin | from earwigbot.wiki.copyright import CopyrightMixin | ||||
| from earwigbot.wiki.exceptions import * | from earwigbot.wiki.exceptions import * | ||||
| __all__ = ["Page"] | |||||
| class Page(CopyrightMixin): | class Page(CopyrightMixin): | ||||
| """ | """ | ||||
| EarwigBot's Wiki Toolset: Page Class | EarwigBot's Wiki Toolset: Page Class | ||||
| @@ -174,7 +176,7 @@ class Page(CopyrightMixin): | |||||
| Assuming the API is sound, this should not raise any exceptions. | Assuming the API is sound, this should not raise any exceptions. | ||||
| """ | """ | ||||
| if result is None: | |||||
| if not result: | |||||
| params = {"action": "query", "rvprop": "user", "intoken": "edit", | params = {"action": "query", "rvprop": "user", "intoken": "edit", | ||||
| "prop": "info|revisions", "rvlimit": 1, "rvdir": "newer", | "prop": "info|revisions", "rvlimit": 1, "rvdir": "newer", | ||||
| "titles": self._title, "inprop": "protection|url"} | "titles": self._title, "inprop": "protection|url"} | ||||
| @@ -240,7 +242,7 @@ class Page(CopyrightMixin): | |||||
| Don't call this directly, ever - use .get(force=True) if you want to | Don't call this directly, ever - use .get(force=True) if you want to | ||||
| force content reloading. | force content reloading. | ||||
| """ | """ | ||||
| if result is None: | |||||
| if not result: | |||||
| params = {"action": "query", "prop": "revisions", "rvlimit": 1, | params = {"action": "query", "prop": "revisions", "rvlimit": 1, | ||||
| "rvprop": "content|timestamp", "titles": self._title} | "rvprop": "content|timestamp", "titles": self._title} | ||||
| result = self._site._api_query(params) | result = self._site._api_query(params) | ||||
| @@ -471,7 +473,7 @@ class Page(CopyrightMixin): | |||||
| """ | """ | ||||
| if force: | if force: | ||||
| self._load_wrapper() | self._load_wrapper() | ||||
| if self._fullurl is not None: | |||||
| if self._fullurl: | |||||
| return self._fullurl | return self._fullurl | ||||
| else: | else: | ||||
| slug = quote(self._title.replace(" ", "_"), safe="/:") | slug = quote(self._title.replace(" ", "_"), safe="/:") | ||||
| @@ -43,6 +43,8 @@ from earwigbot.wiki.exceptions import * | |||||
| from earwigbot.wiki.page import Page | from earwigbot.wiki.page import Page | ||||
| from earwigbot.wiki.user import User | from earwigbot.wiki.user import User | ||||
| __all__ = ["Site"] | |||||
| class Site(object): | class Site(object): | ||||
| """ | """ | ||||
| EarwigBot's Wiki Toolset: Site Class | EarwigBot's Wiki Toolset: Site Class | ||||
| @@ -71,18 +73,19 @@ class Site(object): | |||||
| def __init__(self, name=None, project=None, lang=None, base_url=None, | def __init__(self, name=None, project=None, lang=None, base_url=None, | ||||
| article_path=None, script_path=None, sql=None, | article_path=None, script_path=None, sql=None, | ||||
| namespaces=None, login=(None, None), cookiejar=None, | namespaces=None, login=(None, None), cookiejar=None, | ||||
| user_agent=None, assert_edit=None, maxlag=None, | |||||
| search_config=(None, None)): | |||||
| user_agent=None, use_https=False, assert_edit=None, | |||||
| maxlag=None, search_config=(None, None)): | |||||
| """Constructor for new Site instances. | """Constructor for new Site instances. | ||||
| This probably isn't necessary to call yourself unless you're building a | This probably isn't necessary to call yourself unless you're building a | ||||
| Site that's not in your config and you don't want to add it - normally | Site that's not in your config and you don't want to add it - normally | ||||
| all you need is tools.get_site(name), which creates the Site for you | all you need is tools.get_site(name), which creates the Site for you | ||||
| based on your config file. We accept a bunch of kwargs, but the only | |||||
| ones you really "need" are `base_url` and `script_path` - this is | |||||
| enough to figure out an API url. `login`, a tuple of | |||||
| (username, password), is highly recommended. `cookiejar` will be used | |||||
| to store cookies, and we'll use a normal CookieJar if none is given. | |||||
| based on your config file and the sites database. We accept a bunch of | |||||
| kwargs, but the only ones you really "need" are `base_url` and | |||||
| `script_path` - this is enough to figure out an API url. `login`, a | |||||
| tuple of (username, password), is highly recommended. `cookiejar` will | |||||
| be used to store cookies, and we'll use a normal CookieJar if none is | |||||
| given. | |||||
| First, we'll store the given arguments as attributes, then set up our | First, we'll store the given arguments as attributes, then set up our | ||||
| URL opener. We'll load any of the attributes that weren't given from | URL opener. We'll load any of the attributes that weren't given from | ||||
| @@ -99,7 +102,8 @@ class Site(object): | |||||
| self._script_path = script_path | self._script_path = script_path | ||||
| self._namespaces = namespaces | self._namespaces = namespaces | ||||
| # Attributes used for API queries: | |||||
| # Attributes used for API queries: | |||||
| self._use_https = use_https | |||||
| self._assert_edit = assert_edit | self._assert_edit = assert_edit | ||||
| self._maxlag = maxlag | self._maxlag = maxlag | ||||
| self._max_retries = 5 | self._max_retries = 5 | ||||
| @@ -112,11 +116,11 @@ class Site(object): | |||||
| self._search_config = search_config | self._search_config = search_config | ||||
| # Set up cookiejar and URL opener for making API queries: | # Set up cookiejar and URL opener for making API queries: | ||||
| if cookiejar is not None: | |||||
| if cookiejar: | |||||
| self._cookiejar = cookiejar | self._cookiejar = cookiejar | ||||
| else: | else: | ||||
| self._cookiejar = CookieJar() | self._cookiejar = CookieJar() | ||||
| if user_agent is None: | |||||
| if not user_agent: | |||||
| user_agent = USER_AGENT # Set default UA from wiki.constants | user_agent = USER_AGENT # Set default UA from wiki.constants | ||||
| self._opener = build_opener(HTTPCookieProcessor(self._cookiejar)) | self._opener = build_opener(HTTPCookieProcessor(self._cookiejar)) | ||||
| self._opener.addheaders = [("User-Agent", user_agent), | self._opener.addheaders = [("User-Agent", user_agent), | ||||
| @@ -127,9 +131,9 @@ class Site(object): | |||||
| # If we have a name/pass and the API says we're not logged in, log in: | # If we have a name/pass and the API says we're not logged in, log in: | ||||
| self._login_info = name, password = login | self._login_info = name, password = login | ||||
| if name is not None and password is not None: | |||||
| if name and password: | |||||
| logged_in_as = self._get_username_from_cookies() | logged_in_as = self._get_username_from_cookies() | ||||
| if logged_in_as is None or name != logged_in_as: | |||||
| if not logged_in_as or name != logged_in_as: | |||||
| self._login(login) | self._login(login) | ||||
| def __repr__(self): | def __repr__(self): | ||||
| @@ -137,10 +141,10 @@ class Site(object): | |||||
| res = ", ".join(( | res = ", ".join(( | ||||
| "Site(name={_name!r}", "project={_project!r}", "lang={_lang!r}", | "Site(name={_name!r}", "project={_project!r}", "lang={_lang!r}", | ||||
| "base_url={_base_url!r}", "article_path={_article_path!r}", | "base_url={_base_url!r}", "article_path={_article_path!r}", | ||||
| "script_path={_script_path!r}", "assert_edit={_assert_edit!r}", | |||||
| "maxlag={_maxlag!r}", "sql={_sql!r}", "login={0}", | |||||
| "user_agent={2!r}", "cookiejar={1})" | |||||
| )) | |||||
| "script_path={_script_path!r}", "use_https={_use_https!r}", | |||||
| "assert_edit={_assert_edit!r}", "maxlag={_maxlag!r}", | |||||
| "sql={_sql_data!r}", "login={0}", "user_agent={2!r}", | |||||
| "cookiejar={1})")) | |||||
| name, password = self._login_info | name, password = self._login_info | ||||
| login = "({0}, {1})".format(repr(name), "hidden" if password else None) | login = "({0}, {1})".format(repr(name), "hidden" if password else None) | ||||
| cookies = self._cookiejar.__class__.__name__ | cookies = self._cookiejar.__class__.__name__ | ||||
| @@ -162,7 +166,9 @@ class Site(object): | |||||
| This will first attempt to construct an API url from self._base_url and | This will first attempt to construct an API url from self._base_url and | ||||
| self._script_path. We need both of these, or else we'll raise | self._script_path. We need both of these, or else we'll raise | ||||
| SiteAPIError. | |||||
| SiteAPIError. If self._base_url is protocol-relative (introduced in | |||||
| MediaWiki 1.18), we'll choose HTTPS if self._user_https is True, | |||||
| otherwise HTTP. | |||||
| We'll encode the given params, adding format=json along the way, as | We'll encode the given params, adding format=json along the way, as | ||||
| well as &assert= and &maxlag= based on self._assert_edit and _maxlag. | well as &assert= and &maxlag= based on self._assert_edit and _maxlag. | ||||
| @@ -180,11 +186,17 @@ class Site(object): | |||||
| There's helpful MediaWiki API documentation at | There's helpful MediaWiki API documentation at | ||||
| <http://www.mediawiki.org/wiki/API>. | <http://www.mediawiki.org/wiki/API>. | ||||
| """ | """ | ||||
| if self._base_url is None or self._script_path is None: | |||||
| if not self._base_url or self._script_path is None: | |||||
| e = "Tried to do an API query, but no API URL is known." | e = "Tried to do an API query, but no API URL is known." | ||||
| raise SiteAPIError(e) | raise SiteAPIError(e) | ||||
| url = ''.join((self._base_url, self._script_path, "/api.php")) | |||||
| base_url = self._base_url | |||||
| if base_url.startswith("//"): # Protocol-relative URLs from 1.18 | |||||
| if self._use_https: | |||||
| base_url = "https:" + base_url | |||||
| else: | |||||
| base_url = "http:" + base_url | |||||
| url = ''.join((base_url, self._script_path, "/api.php")) | |||||
| params["format"] = "json" # This is the only format we understand | params["format"] = "json" # This is the only format we understand | ||||
| if self._assert_edit: # If requested, ensure that we're logged in | if self._assert_edit: # If requested, ensure that we're logged in | ||||
| @@ -193,7 +205,6 @@ class Site(object): | |||||
| params["maxlag"] = self._maxlag | params["maxlag"] = self._maxlag | ||||
| data = urlencode(params) | data = urlencode(params) | ||||
| logger.debug("{0} -> {1}".format(url, data)) | logger.debug("{0} -> {1}".format(url, data)) | ||||
| try: | try: | ||||
| @@ -231,7 +242,7 @@ class Site(object): | |||||
| e = "Maximum number of retries reached ({0})." | e = "Maximum number of retries reached ({0})." | ||||
| raise SiteAPIError(e.format(self._max_retries)) | raise SiteAPIError(e.format(self._max_retries)) | ||||
| tries += 1 | tries += 1 | ||||
| msg = 'Server says: "{0}". Retrying in {1} seconds ({2}/{3}).' | |||||
| msg = 'Server says "{0}"; retrying in {1} seconds ({2}/{3})' | |||||
| logger.info(msg.format(info, wait, tries, self._max_retries)) | logger.info(msg.format(info, wait, tries, self._max_retries)) | ||||
| sleep(wait) | sleep(wait) | ||||
| return self._api_query(params, tries=tries, wait=wait*3) | return self._api_query(params, tries=tries, wait=wait*3) | ||||
| @@ -332,15 +343,15 @@ class Site(object): | |||||
| name = ''.join((self._name, "Token")) | name = ''.join((self._name, "Token")) | ||||
| cookie = self._get_cookie(name, domain) | cookie = self._get_cookie(name, domain) | ||||
| if cookie is not None: | |||||
| if cookie: | |||||
| name = ''.join((self._name, "UserName")) | name = ''.join((self._name, "UserName")) | ||||
| user_name = self._get_cookie(name, domain) | user_name = self._get_cookie(name, domain) | ||||
| if user_name is not None: | |||||
| if user_name: | |||||
| return user_name.value | return user_name.value | ||||
| name = "centralauth_Token" | name = "centralauth_Token" | ||||
| for cookie in self._cookiejar: | for cookie in self._cookiejar: | ||||
| if cookie.domain_initial_dot is False or cookie.is_expired(): | |||||
| if not cookie.domain_initial_dot or cookie.is_expired(): | |||||
| continue | continue | ||||
| if cookie.name != name: | if cookie.name != name: | ||||
| continue | continue | ||||
| @@ -348,7 +359,7 @@ class Site(object): | |||||
| search = ''.join(("(.*?)", re_escape(cookie.domain))) | search = ''.join(("(.*?)", re_escape(cookie.domain))) | ||||
| if re_match(search, domain): # Test it against our site | if re_match(search, domain): # Test it against our site | ||||
| user_name = self._get_cookie("centralauth_User", cookie.domain) | user_name = self._get_cookie("centralauth_User", cookie.domain) | ||||
| if user_name is not None: | |||||
| if user_name: | |||||
| return user_name.value | return user_name.value | ||||
| def _get_username_from_api(self): | def _get_username_from_api(self): | ||||
| @@ -378,7 +389,7 @@ class Site(object): | |||||
| single API query for our username (or IP address) and return that. | single API query for our username (or IP address) and return that. | ||||
| """ | """ | ||||
| name = self._get_username_from_cookies() | name = self._get_username_from_cookies() | ||||
| if name is not None: | |||||
| if name: | |||||
| return name | return name | ||||
| return self._get_username_from_api() | return self._get_username_from_api() | ||||
| @@ -417,7 +428,7 @@ class Site(object): | |||||
| """ | """ | ||||
| name, password = login | name, password = login | ||||
| params = {"action": "login", "lgname": name, "lgpassword": password} | params = {"action": "login", "lgname": name, "lgpassword": password} | ||||
| if token is not None: | |||||
| if token: | |||||
| params["lgtoken"] = token | params["lgtoken"] = token | ||||
| result = self._api_query(params) | result = self._api_query(params) | ||||
| res = result["login"]["result"] | res = result["login"]["result"] | ||||
| @@ -455,10 +466,9 @@ class Site(object): | |||||
| def _sql_connect(self, **kwargs): | def _sql_connect(self, **kwargs): | ||||
| """Attempt to establish a connection with this site's SQL database. | """Attempt to establish a connection with this site's SQL database. | ||||
| oursql.connect() will be called with self._sql_data as its kwargs, | |||||
| which is usually config.wiki["sites"][self.name()]["sql"]. Any kwargs | |||||
| given to this function will be passed to connect() and will have | |||||
| precedence over the config file. | |||||
| oursql.connect() will be called with self._sql_data as its kwargs. | |||||
| Any kwargs given to this function will be passed to connect() and will | |||||
| have precedence over the config file. | |||||
| Will raise SQLError() if the module "oursql" is not available. oursql | Will raise SQLError() if the module "oursql" is not available. oursql | ||||
| may raise its own exceptions (e.g. oursql.InterfaceError) if it cannot | may raise its own exceptions (e.g. oursql.InterfaceError) if it cannot | ||||
| @@ -631,6 +641,6 @@ class Site(object): | |||||
| If `username` is left as None, then a User object representing the | If `username` is left as None, then a User object representing the | ||||
| currently logged-in (or anonymous!) user is returned. | currently logged-in (or anonymous!) user is returned. | ||||
| """ | """ | ||||
| if username is None: | |||||
| if not username: | |||||
| username = self._get_username() | username = self._get_username() | ||||
| return User(self, username) | return User(self, username) | ||||
| @@ -0,0 +1,363 @@ | |||||
| # -*- 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 cookielib import LWPCookieJar, LoadError | |||||
| import errno | |||||
| from getpass import getpass | |||||
| from os import chmod, path | |||||
| from platform import python_version | |||||
| import stat | |||||
| import sqlite3 as sqlite | |||||
| from earwigbot import __version__ | |||||
| from earwigbot.wiki.exceptions import SiteNotFoundError | |||||
| from earwigbot.wiki.site import Site | |||||
| __all__ = ["SitesDB"] | |||||
| class SitesDB(object): | |||||
| """ | |||||
| EarwigBot's Wiki Toolset: Sites Database Manager | |||||
| This class controls the sites.db file, which stores information about all | |||||
| wiki sites known to the bot. Three public methods act as bridges between | |||||
| the bot's config files and Site objects: | |||||
| get_site -- returns a Site object corresponding to a given site name | |||||
| add_site -- stores a site in the database, given connection info | |||||
| remove_site -- removes a site from the database, given its name | |||||
| There's usually no need to use this class directly. All public methods | |||||
| here are available as bot.wiki.get_site(), bot.wiki.add_site(), and | |||||
| bot.wiki.remove_site(), which use a sites.db file located in the same | |||||
| directory as our config.yml file. Lower-level access can be achieved | |||||
| by importing the manager class (`from earwigbot.wiki import SitesDB`). | |||||
| """ | |||||
| def __init__(self, config): | |||||
| """Set up the manager with an attribute for the BotConfig object.""" | |||||
| self.config = config | |||||
| self._sitesdb = path.join(config.root_dir, "sites.db") | |||||
| self._cookie_file = path.join(config.root_dir, ".cookies") | |||||
| self._cookiejar = None | |||||
| def _get_cookiejar(self): | |||||
| """Return a LWPCookieJar object loaded from our .cookies file. | |||||
| The same .cookies file is returned every time, located in the project | |||||
| root, same directory as config.yml and bot.py. If it doesn't exist, we | |||||
| will create the file and set it to be readable and writeable only by | |||||
| us. If it exists but the information inside is bogus, we'll ignore it. | |||||
| This is normally called by _make_site_object() (in turn called by | |||||
| get_site()), and the cookiejar is passed to our Site's constructor, | |||||
| used when it makes API queries. This way, we can easily preserve | |||||
| cookies between sites (e.g., for CentralAuth), making logins easier. | |||||
| """ | |||||
| if self._cookiejar: | |||||
| return self._cookiejar | |||||
| self._cookiejar = LWPCookieJar(self._cookie_file) | |||||
| try: | |||||
| self._cookiejar.load() | |||||
| except LoadError: | |||||
| pass # File contains bad data, so ignore it completely | |||||
| except IOError as e: | |||||
| if e.errno == errno.ENOENT: # "No such file or directory" | |||||
| # Create the file and restrict reading/writing only to the | |||||
| # owner, so others can't peak at our cookies: | |||||
| open(cookie_file, "w").close() | |||||
| chmod(cookie_file, stat.S_IRUSR|stat.S_IWUSR) | |||||
| else: | |||||
| raise | |||||
| return self._cookiejar | |||||
| def _create_sitesdb(self): | |||||
| """Initialize the sitesdb file with its three necessary tables.""" | |||||
| script = """ | |||||
| CREATE TABLE sites (site_name, site_project, site_lang, site_base_url, | |||||
| site_article_path, site_script_path); | |||||
| CREATE TABLE sql_data (sql_site, sql_data_key, sql_data_value); | |||||
| CREATE TABLE namespaces (ns_site, ns_id, ns_name, ns_is_primary_name); | |||||
| """ | |||||
| with sqlite.connect(self._sitesdb) as conn: | |||||
| conn.executescript(script) | |||||
| def _load_site_from_sitesdb(self, name): | |||||
| """Return all information stored in the sitesdb relating to given site. | |||||
| The information will be returned as a tuple, containing the site's | |||||
| name, project, language, base URL, article path, script path, SQL | |||||
| connection data, and namespaces, in that order. If the site is not | |||||
| found in the database, SiteNotFoundError will be raised. An empty | |||||
| database will be created before the exception is raised if none exists. | |||||
| """ | |||||
| query1 = "SELECT * FROM sites WHERE site_name = ?" | |||||
| query2 = "SELECT sql_data_key, sql_data_value FROM sql_data WHERE sql_site = ?" | |||||
| query3 = "SELECT ns_id, ns_name, ns_is_primary_name FROM namespaces WHERE ns_site = ?" | |||||
| error = "Site '{0}' not found in the sitesdb.".format(name) | |||||
| with sqlite.connect(self._sitesdb) as conn: | |||||
| try: | |||||
| site_data = conn.execute(query1, (name,)).fetchone() | |||||
| except sqlite.OperationalError: | |||||
| self._create_sitesdb() | |||||
| raise SiteNotFoundError(error) | |||||
| if not site_data: | |||||
| raise SiteNotFoundError(error) | |||||
| sql_data = conn.execute(query2, (name,)).fetchall() | |||||
| ns_data = conn.execute(query3, (name,)).fetchall() | |||||
| name, project, lang, base_url, article_path, script_path = site_data | |||||
| sql = dict(sql_data) | |||||
| namespaces = {} | |||||
| for ns_id, ns_name, ns_is_primary_name in ns_data: | |||||
| try: | |||||
| if ns_is_primary_name: # "Primary" name goes first in list | |||||
| namespaces[ns_id].insert(0, ns_name) | |||||
| else: # Ordering of the aliases doesn't matter | |||||
| namespaces[ns_id].append(ns_name) | |||||
| except KeyError: | |||||
| namespaces[ns_id] = [ns_name] | |||||
| return (name, project, lang, base_url, article_path, script_path, sql, | |||||
| namespaces) | |||||
| def _make_site_object(self, name): | |||||
| """Return a Site object associated with the site 'name' in our sitesdb. | |||||
| This calls _load_site_from_sitesdb(), so SiteNotFoundError will be | |||||
| raised if the site is not in our sitesdb. | |||||
| """ | |||||
| cookiejar = self._get_cookiejar() | |||||
| (name, project, lang, base_url, article_path, script_path, sql, | |||||
| namespaces) = self._load_site_from_sitesdb(name) | |||||
| config = self.config | |||||
| login = (config.wiki.get("username"), config.wiki.get("password")) | |||||
| user_agent = config.wiki.get("userAgent") | |||||
| use_https = config.wiki.get("useHTTPS", False) | |||||
| assert_edit = config.wiki.get("assert") | |||||
| maxlag = config.wiki.get("maxlag") | |||||
| search_config = config.wiki.get("search") | |||||
| if user_agent: | |||||
| user_agent = user_agent.replace("$1", __version__) | |||||
| user_agent = user_agent.replace("$2", python_version()) | |||||
| return Site(name=name, project=project, lang=lang, base_url=base_url, | |||||
| article_path=article_path, script_path=script_path, | |||||
| sql=sql, namespaces=namespaces, login=login, | |||||
| cookiejar=cookiejar, user_agent=user_agent, | |||||
| use_https=use_https, assert_edit=assert_edit, | |||||
| maxlag=maxlag, search_config=search_config) | |||||
| def _get_site_name_from_sitesdb(self, project, lang): | |||||
| """Return the name of the first site with the given project and lang. | |||||
| If the site is not found, return None. An empty sitesdb will be created | |||||
| if none exists. | |||||
| """ | |||||
| query = "SELECT site_name FROM sites WHERE site_project = ? and site_lang = ?" | |||||
| with sqlite.connect(self._sitesdb) as conn: | |||||
| try: | |||||
| site = conn.execute(query, (project, lang)).fetchone() | |||||
| return site[0] if site else None | |||||
| except sqlite.OperationalError: | |||||
| self._create_sitesdb() | |||||
| def _add_site_to_sitesdb(self, site): | |||||
| """Extract relevant info from a Site object and add it to the sitesdb. | |||||
| Works like a reverse _load_site_from_sitesdb(); the site's project, | |||||
| language, base URL, article path, script path, SQL connection data, and | |||||
| namespaces are extracted from the site and inserted into the sites | |||||
| database. If the sitesdb doesn't exist, we'll create it first. | |||||
| """ | |||||
| name = site.name() | |||||
| sites_data = (name, site.project(), site.lang(), site._base_url, | |||||
| site._article_path, site._script_path) | |||||
| sql_data = [(name, key, val) for key, val in site._sql_data.iteritems()] | |||||
| ns_data = [] | |||||
| for ns_id, ns_names in site._namespaces.iteritems(): | |||||
| ns_data.append((name, ns_id, ns_names.pop(0), True)) | |||||
| for ns_name in ns_names: | |||||
| ns_data.append((name, ns_id, ns_name, False)) | |||||
| with sqlite.connect(self._sitesdb) as conn: | |||||
| check_exists = "SELECT 1 FROM sites WHERE site_name = ?" | |||||
| try: | |||||
| exists = conn.execute(check_exists, (name,)).fetchone() | |||||
| except sqlite.OperationalError: | |||||
| self._create_sitesdb() | |||||
| else: | |||||
| if exists: | |||||
| conn.execute("DELETE FROM sites WHERE site_name = ?", (name,)) | |||||
| conn.execute("DELETE FROM sql_data WHERE sql_site = ?", (name,)) | |||||
| conn.execute("DELETE FROM namespaces WHERE ns_site = ?", (name,)) | |||||
| conn.execute("INSERT INTO sites VALUES (?, ?, ?, ?, ?, ?)", sites_data) | |||||
| conn.executemany("INSERT INTO sql_data VALUES (?, ?, ?)", sql_data) | |||||
| conn.executemany("INSERT INTO namespaces VALUES (?, ?, ?, ?)", ns_data) | |||||
| def _remove_site_from_sitesdb(self, name): | |||||
| """Remove a site by name from the sitesdb.""" | |||||
| with sqlite.connect(self._sitesdb) as conn: | |||||
| cursor = conn.execute("DELETE FROM sites WHERE site_name = ?", (name,)) | |||||
| if cursor.rowcount == 0: | |||||
| return False | |||||
| else: | |||||
| conn.execute("DELETE FROM sql_data WHERE sql_site = ?", (name,)) | |||||
| conn.execute("DELETE FROM namespaces WHERE ns_site = ?", (name,)) | |||||
| return True | |||||
| def get_site(self, name=None, project=None, lang=None): | |||||
| """Return a Site instance based on information from the sitesdb. | |||||
| With no arguments, return the default site as specified by our config | |||||
| file. This is config.wiki["defaultSite"]. | |||||
| With 'name' specified, return the site with that name. This is | |||||
| equivalent to the site's 'wikiid' in the API, like 'enwiki'. | |||||
| With 'project' and 'lang' specified, return the site whose project and | |||||
| language match these values. If there are multiple sites with the same | |||||
| values (unlikely), this is not a reliable way of loading a site. Call | |||||
| the function with an explicit 'name' in that case. | |||||
| We will attempt to login to the site automatically using | |||||
| config.wiki["username"] and config.wiki["password"] if both are | |||||
| defined. | |||||
| Specifying a project without a lang or a lang without a project will | |||||
| raise TypeError. If all three args are specified, 'name' will be first | |||||
| tried, then 'project' and 'lang' if 'name' doesn't work. If a site | |||||
| cannot be found in the sitesdb, SiteNotFoundError will be raised. An | |||||
| empty sitesdb will be created if none is found. | |||||
| """ | |||||
| # Someone specified a project without a lang, or vice versa: | |||||
| if (project and not lang) or (not project and lang): | |||||
| e = "Keyword arguments 'lang' and 'project' must be specified together." | |||||
| raise TypeError(e) | |||||
| # No args given, so return our default site: | |||||
| if not name and not project and not lang: | |||||
| try: | |||||
| default = self.config.wiki["defaultSite"] | |||||
| except KeyError: | |||||
| e = "Default site is not specified in config." | |||||
| raise SiteNotFoundError(e) | |||||
| return self._make_site_object(default) | |||||
| # Name arg given, but don't look at others unless `name` isn't found: | |||||
| if name: | |||||
| try: | |||||
| return self._make_site_object(name) | |||||
| except SiteNotFoundError: | |||||
| if project and lang: | |||||
| name = self._get_site_name_from_sitesdb(project, lang) | |||||
| if name: | |||||
| return self._make_site_object(name) | |||||
| raise | |||||
| # If we end up here, then project and lang are the only args given: | |||||
| name = self._get_site_name_from_sitesdb(project, lang) | |||||
| if name: | |||||
| return self._make_site_object(name) | |||||
| e = "Site '{0}:{1}' not found in the sitesdb.".format(project, lang) | |||||
| raise SiteNotFoundError(e) | |||||
| def add_site(self, project=None, lang=None, base_url=None, | |||||
| script_path="/w", sql=None): | |||||
| """Add a site to the sitesdb so it can be retrieved with get_site(). | |||||
| If only a project and a lang are given, we'll guess the base_url as | |||||
| "//{lang}.{project}.org" (which is protocol-relative, becoming 'https' | |||||
| if 'useHTTPS' is True in config otherwise 'http'). If this is wrong, | |||||
| provide the correct base_url as an argument (in which case project and | |||||
| lang are ignored). Most wikis use "/w" as the script path (meaning the | |||||
| API is located at "{base_url}{script_path}/api.php" -> | |||||
| "//{lang}.{project}.org/w/api.php"), so this is the default. If your | |||||
| wiki is different, provide the script_path as an argument. The only | |||||
| other argument to Site() that we can't get from config files or by | |||||
| querying the wiki itself is SQL connection info, so provide a dict of | |||||
| kwargs as `sql` and Site will pass it to oursql.connect(**sql), | |||||
| allowing you to make queries with site.sql_query(). | |||||
| Returns True if the site was added successfully or False if the site is | |||||
| already in our sitesdb (this can be done purposefully to update old | |||||
| site info). Raises SiteNotFoundError if not enough information has | |||||
| been provided to identify the site (e.g. a project but not a lang). | |||||
| """ | |||||
| if not base_url: | |||||
| if not project or not lang: | |||||
| e = "Without a base_url, both a project and a lang must be given." | |||||
| raise SiteNotFoundError(e) | |||||
| base_url = "//{0}.{1}.org".format(lang, project) | |||||
| cookiejar = self._get_cookiejar() | |||||
| config = self.config | |||||
| login = (config.wiki.get("username"), config.wiki.get("password")) | |||||
| user_agent = config.wiki.get("userAgent") | |||||
| use_https = config.wiki.get("useHTTPS", False) | |||||
| assert_edit = config.wiki.get("assert") | |||||
| maxlag = config.wiki.get("maxlag") | |||||
| search_config = config.wiki.get("search") | |||||
| # Create a temp Site object to log in and load the other attributes: | |||||
| site = Site(base_url=base_url, script_path=script_path, sql=sql, | |||||
| login=login, cookiejar=cookiejar, user_agent=user_agent, | |||||
| use_https=use_https, assert_edit=assert_edit, | |||||
| maxlag=maxlag, search_config=search_config) | |||||
| self._add_site_to_sitesdb(site) | |||||
| return site | |||||
| def remove_site(self, name=None, project=None, lang=None): | |||||
| """Remove a site from the sitesdb. | |||||
| Returns True if the site was removed successfully or False if the site | |||||
| was not in our sitesdb originally. If all three args (name, project, | |||||
| and lang) are given, we'll first try 'name' and then try the latter two | |||||
| if 'name' wasn't found in the database. Raises TypeError if a project | |||||
| was given but not a language, or vice versa. Will create an empty | |||||
| sitesdb if none was found. | |||||
| """ | |||||
| # Someone specified a project without a lang, or vice versa: | |||||
| if (project and not lang) or (not project and lang): | |||||
| e = "Keyword arguments 'lang' and 'project' must be specified together." | |||||
| raise TypeError(e) | |||||
| if name: | |||||
| was_removed = self._remove_site_from_sitesdb(name) | |||||
| if not was_removed: | |||||
| if project and lang: | |||||
| name = self._get_site_name_from_sitesdb(project, lang) | |||||
| if name: | |||||
| return self._remove_site_from_sitesdb(name) | |||||
| return was_removed | |||||
| if project and lang: | |||||
| name = self._get_site_name_from_sitesdb(project, lang) | |||||
| if name: | |||||
| return self._remove_site_from_sitesdb(name) | |||||
| return False | |||||
| @@ -26,6 +26,8 @@ from earwigbot.wiki.constants import * | |||||
| from earwigbot.wiki.exceptions import UserNotFoundError | from earwigbot.wiki.exceptions import UserNotFoundError | ||||
| from earwigbot.wiki.page import Page | from earwigbot.wiki.page import Page | ||||
| __all__ = ["User"] | |||||
| class User(object): | class User(object): | ||||
| """ | """ | ||||
| EarwigBot's Wiki Toolset: User Class | EarwigBot's Wiki Toolset: User Class | ||||
| @@ -0,0 +1,61 @@ | |||||
| #! /usr/bin/env python | |||||
| # -*- 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 setuptools import setup, find_packages | |||||
| from earwigbot import __version__ | |||||
| with open("README.rst") as fp: | |||||
| long_docs = fp.read() | |||||
| setup( | |||||
| name = "earwigbot", | |||||
| packages = find_packages(exclude=("tests",)), | |||||
| entry_points = {"console_scripts": ["earwigbot = earwigbot.util:main"]}, | |||||
| install_requires = ["PyYAML >= 3.10", # Config parsing | |||||
| "oursql >= 0.9.3", # Talking with MediaWiki databases | |||||
| "oauth2 >= 1.5.211", # Talking with Yahoo BOSS Search | |||||
| "GitPython >= 0.3.2.RC1", # Interfacing with git | |||||
| ], | |||||
| test_suite = "tests", | |||||
| version = __version__, | |||||
| author = "Ben Kurtovic", | |||||
| author_email = "ben.kurtovic@verizon.net", | |||||
| url = "https://github.com/earwig/earwigbot", | |||||
| description = "EarwigBot is a Python robot that edits Wikipedia and interacts with people over IRC.", | |||||
| long_description = long_docs, | |||||
| download_url = "https://github.com/earwig/earwigbot/tarball/v{0}".format(__version__), | |||||
| keywords = "earwig earwigbot irc wikipedia wiki mediawiki", | |||||
| license = "MIT License", | |||||
| classifiers = [ | |||||
| "Development Status :: 3 - Alpha", | |||||
| "Environment :: Console", | |||||
| "Intended Audience :: Developers", | |||||
| "License :: OSI Approved :: MIT License", | |||||
| "Natural Language :: English", | |||||
| "Operating System :: OS Independent", | |||||
| "Programming Language :: Python :: 2.7", | |||||
| "Topic :: Communications :: Chat :: Internet Relay Chat", | |||||
| "Topic :: Internet :: WWW/HTTP" | |||||
| ], | |||||
| ) | |||||
| @@ -23,26 +23,41 @@ | |||||
| """ | """ | ||||
| EarwigBot's Unit Tests | EarwigBot's Unit Tests | ||||
| This module __init__ file provides some support code for unit tests. | |||||
| This __init__ file provides some support code for unit tests. | |||||
| Test cases: | |||||
| -- CommandTestCase provides setUp() for creating a fake connection, plus | |||||
| some other helpful methods for testing IRC commands. | |||||
| Fake objects: | |||||
| -- FakeBot implements Bot, using the Fake* equivalents of all objects | |||||
| whenever possible. | |||||
| -- FakeBotConfig implements BotConfig with silent logging. | |||||
| -- FakeIRCConnection implements IRCConnection, using an internal string | |||||
| buffer for data instead of sending it over a socket. | |||||
| CommandTestCase is a subclass of unittest.TestCase that provides setUp() for | |||||
| creating a fake connection and some other helpful methods. It uses | |||||
| FakeConnection, a subclass of classes.Connection, but with an internal string | |||||
| instead of a socket for data. | |||||
| """ | """ | ||||
| import logging | |||||
| from os import path | |||||
| import re | import re | ||||
| from threading import Lock | |||||
| from unittest import TestCase | from unittest import TestCase | ||||
| from earwigbot.bot import Bot | |||||
| from earwigbot.commands import CommandManager | |||||
| from earwigbot.config import BotConfig | |||||
| from earwigbot.irc import IRCConnection, Data | from earwigbot.irc import IRCConnection, Data | ||||
| from earwigbot.tasks import TaskManager | |||||
| from earwigbot.wiki import SitesDBManager | |||||
| 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._connect() | |||||
| self.command = command(self.connection) | |||||
| self.bot = FakeBot(path.dirname(__file__)) | |||||
| self.command = command(self.bot) | |||||
| self.command.connection = self.connection = self.bot.frontend | |||||
| def get_single(self): | def get_single(self): | ||||
| data = self.connection._get().split("\n") | data = self.connection._get().split("\n") | ||||
| @@ -92,15 +107,38 @@ 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(IRCConnection): | |||||
| def __init__(self): | |||||
| pass | |||||
| class FakeBot(Bot): | |||||
| def __init__(self, root_dir): | |||||
| self.config = FakeBotConfig(root_dir) | |||||
| self.logger = logging.getLogger("earwigbot") | |||||
| self.commands = CommandManager(self) | |||||
| self.tasks = TaskManager(self) | |||||
| self.wiki = SitesDBManager(self.config) | |||||
| self.frontend = FakeIRCConnection(self) | |||||
| self.watcher = FakeIRCConnection(self) | |||||
| self.component_lock = Lock() | |||||
| self._keep_looping = True | |||||
| class FakeBotConfig(BotConfig): | |||||
| def _setup_logging(self): | |||||
| logger = logging.getLogger("earwigbot") | |||||
| logger.addHandler(logging.NullHandler()) | |||||
| class FakeIRCConnection(IRCConnection): | |||||
| def __init__(self, bot): | |||||
| self.bot = bot | |||||
| self._is_running = False | |||||
| self._connect() | |||||
| def _connect(self): | def _connect(self): | ||||
| self._buffer = "" | self._buffer = "" | ||||
| def _close(self): | def _close(self): | ||||
| pass | |||||
| self._buffer = "" | |||||
| def _get(self, size=4096): | def _get(self, size=4096): | ||||
| data, self._buffer = self._buffer, "" | data, self._buffer = self._buffer, "" | ||||
| @@ -23,7 +23,7 @@ | |||||
| import unittest | import unittest | ||||
| from earwigbot.commands.calc import Command | from earwigbot.commands.calc import Command | ||||
| from earwigbot.tests import CommandTestCase | |||||
| from tests import CommandTestCase | |||||
| class TestCalc(CommandTestCase): | class TestCalc(CommandTestCase): | ||||
| @@ -23,7 +23,7 @@ | |||||
| import unittest | import unittest | ||||
| from earwigbot.commands.test import Command | from earwigbot.commands.test import Command | ||||
| from earwigbot.tests import CommandTestCase | |||||
| from tests import CommandTestCase | |||||
| class TestTest(CommandTestCase): | class TestTest(CommandTestCase): | ||||
| @@ -38,12 +38,12 @@ class TestTest(CommandTestCase): | |||||
| self.assertTrue(self.command.check(self.make_msg("TEST", "foo"))) | self.assertTrue(self.command.check(self.make_msg("TEST", "foo"))) | ||||
| def test_process(self): | def test_process(self): | ||||
| def _test(): | |||||
| def test(): | |||||
| self.command.process(self.make_msg("test")) | self.command.process(self.make_msg("test")) | ||||
| self.assertSaidIn(["Hey \x02Foo\x0F!", "'sup \x02Foo\x0F?"]) | self.assertSaidIn(["Hey \x02Foo\x0F!", "'sup \x02Foo\x0F?"]) | ||||
| for i in xrange(64): | for i in xrange(64): | ||||
| _test() | |||||
| test() | |||||
| if __name__ == "__main__": | if __name__ == "__main__": | ||||
| unittest.main(verbosity=2) | unittest.main(verbosity=2) | ||||