diff --git a/.gitignore b/.gitignore index 5c965b9..93e373c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,19 +1,3 @@ -# Ignore python bytecode: *.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 - -# Ignore pydev's nonsense: -.project -.pydevproject -.settings/ diff --git a/bot.py b/bot.py deleted file mode 100755 index d8f2d21..0000000 --- a/bot.py +++ /dev/null @@ -1,70 +0,0 @@ -#! /usr/bin/env python -# -*- coding: utf-8 -*- -# -# Copyright (C) 2009-2012 by Ben Kurtovic -# -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to deal -# in the Software without restriction, including without limitation the rights -# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -# copies of the Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions: -# -# The above copyright notice and this permission notice shall be included in -# all copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -# SOFTWARE. - -""" -EarwigBot - -This is a thin wrapper for EarwigBot's main bot code, specified by bot_script. -The wrapper will automatically restart the bot when it shuts down (from -!restart, for example). It requests the bot's password at startup and reuses it -every time the bot restarts internally, so you do not need to re-enter the -password after using !restart. - -For information about the bot as a whole, see the attached README.md file (in -markdown format!), the docs/ directory, and the LICENSE file for licensing -information. EarwigBot is released under the MIT license. -""" -from getpass import getpass -from subprocess import Popen, PIPE -from os import path -from sys import executable -from time import sleep - -import earwigbot - -bot_script = path.join(earwigbot.__path__[0], "runner.py") - -def main(): - print "EarwigBot v{0}\n".format(earwigbot.__version__) - - is_encrypted = earwigbot.config.config.load() - if is_encrypted: # Passwords in the config file are encrypted - key = getpass("Enter key to unencrypt bot passwords: ") - else: - key = None - - while 1: - bot = Popen([executable, bot_script], stdin=PIPE) - print >> bot.stdin, path.dirname(path.abspath(__file__)) - if is_encrypted: - print >> bot.stdin, key - return_code = bot.wait() - if return_code == 1: - exit() # Let critical exceptions in the subprocess cause us to - # exit as well - else: - sleep(5) # Sleep between bot runs following a non-critical - # subprocess exit - -if __name__ == "__main__": - main() diff --git a/earwigbot/__init__.py b/earwigbot/__init__.py index 4dab7da..39f0938 100644 --- a/earwigbot/__init__.py +++ b/earwigbot/__init__.py @@ -21,16 +21,32 @@ # 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. """ __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" __version__ = "0.1.dev" __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) diff --git a/earwigbot/bot.py b/earwigbot/bot.py new file mode 100644 index 0000000..bc8fbc4 --- /dev/null +++ b/earwigbot/bot.py @@ -0,0 +1,188 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2009-2012 by Ben Kurtovic +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +import logging +from 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() diff --git a/earwigbot/commands/__init__.py b/earwigbot/commands/__init__.py index d5543bb..1dd745d 100644 --- a/earwigbot/commands/__init__.py +++ b/earwigbot/commands/__init__.py @@ -21,21 +21,16 @@ # 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 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): """A base class for commands on IRC. @@ -50,114 +45,65 @@ class BaseCommand(object): # command subclass: hooks = ["msg"] - def __init__(self, connection): + def __init__(self, bot): """Constructor for new commands. This is called once when the command is loaded (from - commands._load_command()). `connection` is a Connection object, - allowing us to do self.connection.say(), self.connection.send(), etc, - from within a method. + 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): - """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 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 return False. This is the default behavior of check(); you need only override it if you wish to change that. """ - if data.is_command and data.command == self.name: - return True - return False + return data.is_command and data.command == self.name def process(self, data): """Main entry point for doing a command. Handle an activity (usually a message) on IRC. At this point, thanks to self.check() which is called automatically by the command handler, - we know this is something we should respond to, so (usually) something - like 'if data.command != "command_name": return' is unnecessary. + we know this is something we should respond to, so something like + `if data.command != "command_name": return` is usually unnecessary. + Note that """ 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() diff --git a/earwigbot/commands/afc_report.py b/earwigbot/commands/afc_report.py index c6a5840..f44ae2b 100644 --- a/earwigbot/commands/afc_report.py +++ b/earwigbot/commands/afc_report.py @@ -24,27 +24,28 @@ import re from earwigbot import wiki from earwigbot.commands import BaseCommand -from earwigbot.tasks import task_manager class Command(BaseCommand): """Get information about an AFC submission by name.""" name = "report" def process(self, data): - self.site = wiki.get_site() + self.site = self.bot.wiki.get_site() self.site._maxlag = None self.data = data try: - self.statistics = task_manager.get("afc_statistics") + self.statistics = self.bot.tasks.get("afc_statistics") 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) + msg = "command requires afc_statistics task (from earwigbot_plugins)" + self.reply(data, msg) return if not data.args: msg = "what submission do you want me to give information about?" - self.connection.reply(data, msg) + self.reply(data, msg) return title = " ".join(data.args) @@ -68,8 +69,7 @@ class Command(BaseCommand): if 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): page = self.site.get_page(title, follow_redirects=False) @@ -90,9 +90,9 @@ class Command(BaseCommand): if status == "accepted": 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): if page.is_redirect(): diff --git a/earwigbot/commands/afc_status.py b/earwigbot/commands/afc_status.py index a475caf..49d2a78 100644 --- a/earwigbot/commands/afc_status.py +++ b/earwigbot/commands/afc_status.py @@ -22,9 +22,7 @@ import re -from earwigbot import wiki from earwigbot.commands import BaseCommand -from earwigbot.config import config class Command(BaseCommand): """Get the number of pending AfC submissions, open redirect requests, and @@ -39,19 +37,19 @@ class Command(BaseCommand): try: 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 except IndexError: pass return False def process(self, data): - self.site = wiki.get_site() + self.site = self.bot.wiki.get_site() self.site._maxlag = None if data.line[1] == "JOIN": status = " ".join(("\x02Current status:\x0F", self.get_status())) - self.connection.notice(data.nick, status) + self.notice(data.nick, status) return if data.args: @@ -59,17 +57,17 @@ class Command(BaseCommand): if action.startswith("sub") or action == "s": subs = self.count_submissions() 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": redirs = self.count_redirects() 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": files = self.count_redirects() 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": try: @@ -80,21 +78,21 @@ class Command(BaseCommand): agg_num = self.get_aggregate_number(agg_data) except ValueError: 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 aggregate = self.get_aggregate(agg_num) 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": - self.connection.reply(data, self.get_status(color=False)) + self.reply(data, self.get_status(color=False)) else: 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: - self.connection.reply(data, self.get_status()) + self.reply(data, self.get_status()) def get_status(self, color=True): subs = self.count_submissions() diff --git a/earwigbot/commands/calc.py b/earwigbot/commands/calc.py index f6d3177..1f7f834 100644 --- a/earwigbot/commands/calc.py +++ b/earwigbot/commands/calc.py @@ -32,7 +32,7 @@ class Command(BaseCommand): def process(self, data): 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 query = ' '.join(data.args) @@ -47,7 +47,7 @@ class Command(BaseCommand): match = r_result.search(result) if not match: - self.connection.reply(data, "Calculation error.") + self.reply(data, "Calculation error.") return result = match.group(1) @@ -62,7 +62,7 @@ class Command(BaseCommand): result += " " + query.split(" in ", 1)[1] res = "%s = %s" % (query, result) - self.connection.reply(data, res) + self.reply(data, res) def cleanup(self, query): fixes = [ diff --git a/earwigbot/commands/chanops.py b/earwigbot/commands/chanops.py index dd59353..9783411 100644 --- a/earwigbot/commands/chanops.py +++ b/earwigbot/commands/chanops.py @@ -21,35 +21,73 @@ # SOFTWARE. from earwigbot.commands import BaseCommand -from earwigbot.config import config 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" 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 False def process(self, data): 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 - - 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 - # 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: - 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) diff --git a/earwigbot/commands/crypt.py b/earwigbot/commands/crypt.py index e71e139..cf7c369 100644 --- a/earwigbot/commands/crypt.py +++ b/earwigbot/commands/crypt.py @@ -39,12 +39,12 @@ class Command(BaseCommand): def process(self, data): if data.command == "crypt": msg = "available commands are !hash, !encrypt, and !decrypt." - self.connection.reply(data, msg) + self.reply(data, msg) return if not data.args: msg = "what do you want me to {0}?".format(data.command) - self.connection.reply(data, msg) + self.reply(data, msg) return if data.command == "hash": @@ -52,14 +52,14 @@ class Command(BaseCommand): if algo == "list": algos = ', '.join(hashlib.algorithms) msg = algos.join(("supported algorithms: ", ".")) - self.connection.reply(data, msg) + self.reply(data, msg) elif algo in hashlib.algorithms: string = ' '.join(data.args[1:]) result = getattr(hashlib, algo)(string).hexdigest() - self.connection.reply(data, result) + self.reply(data, result) else: msg = "unknown algorithm: '{0}'.".format(algo) - self.connection.reply(data, msg) + self.reply(data, msg) else: key = data.args[0] @@ -67,14 +67,14 @@ class Command(BaseCommand): if not text: 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 try: if data.command == "encrypt": - self.connection.reply(data, blowfish.encrypt(key, text)) + self.reply(data, blowfish.encrypt(key, text)) else: - self.connection.reply(data, blowfish.decrypt(key, text)) + self.reply(data, blowfish.decrypt(key, text)) except blowfish.BlowfishError as error: msg = "{0}: {1}.".format(error.__class__.__name__, error) - self.connection.reply(data, msg) + self.reply(data, msg) diff --git a/earwigbot/commands/ctcp.py b/earwigbot/commands/ctcp.py index 80b56e2..45455b7 100644 --- a/earwigbot/commands/ctcp.py +++ b/earwigbot/commands/ctcp.py @@ -25,11 +25,10 @@ import time from earwigbot import __version__ from earwigbot.commands import BaseCommand -from earwigbot.config import config 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" hooks = ["msg_private"] @@ -53,17 +52,17 @@ class Command(BaseCommand): if command == "PING": msg = " ".join(data.line[4:]) if msg: - self.connection.notice(target, "\x01PING {0}\x01".format(msg)) + self.notice(target, "\x01PING {0}\x01".format(msg)) else: - self.connection.notice(target, "\x01PING\x01") + self.notice(target, "\x01PING\x01") elif command == "TIME": 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": 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("$2", platform.python_version()) - self.connection.notice(target, "\x01VERSION {0}\x01".format(vers)) + self.notice(target, "\x01VERSION {0}\x01".format(vers)) diff --git a/earwigbot/commands/editcount.py b/earwigbot/commands/editcount.py index 9c58726..b503d8a 100644 --- a/earwigbot/commands/editcount.py +++ b/earwigbot/commands/editcount.py @@ -41,7 +41,7 @@ class Command(BaseCommand): else: name = ' '.join(data.args) - site = wiki.get_site() + site = self.bot.wiki.get_site() site._maxlag = None user = site.get_user(name) @@ -49,10 +49,10 @@ class Command(BaseCommand): count = user.editcount() except wiki.UserNotFoundError: msg = "the user \x0302{0}\x0301 does not exist." - self.connection.reply(data, msg.format(name)) + self.reply(data, msg.format(name)) return safe = quote_plus(user.name()) url = "http://toolserver.org/~tparis/pcount/index.php?name={0}&lang=en&wiki=wikipedia" 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))) diff --git a/earwigbot/commands/git.py b/earwigbot/commands/git.py index dfd9aba..c22d719 100644 --- a/earwigbot/commands/git.py +++ b/earwigbot/commands/git.py @@ -25,7 +25,6 @@ import subprocess import re from earwigbot.commands import BaseCommand -from earwigbot.config import config class Command(BaseCommand): """Commands to interface with the bot's git repository; use '!git' for a @@ -34,9 +33,9 @@ class Command(BaseCommand): def process(self, 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." - self.connection.reply(data, msg) + self.reply(data, msg) return if not data.args: @@ -66,7 +65,7 @@ class Command(BaseCommand): else: # They asked us to do something we don't know msg = "unknown argument: \x0303{0}\x0301.".format(data.args[0]) - self.connection.reply(data, msg) + self.reply(data, msg) def exec_shell(self, command): """Execute a shell command and get the output.""" @@ -90,13 +89,13 @@ class Command(BaseCommand): for key in sorted(help.keys()): msg += "\x0303{0}\x0301 ({1}), ".format(key, help[key]) 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): """Get our current branch.""" branch = self.exec_shell("git name-rev --name-only HEAD") msg = "currently on branch \x0302{0}\x0301.".format(branch) - self.connection.reply(self.data, msg) + self.reply(self.data, msg) def do_branches(self): """Get a list of branches.""" @@ -107,14 +106,14 @@ class Command(BaseCommand): branches = branches.replace('\n ', ', ') branches = branches.strip() msg = "branches: \x0302{0}\x0301.".format(branches) - self.connection.reply(self.data, msg) + self.reply(self.data, msg) def do_checkout(self): """Switch branches.""" try: branch = self.data.args[1] except IndexError: # no branch name provided - self.connection.reply(self.data, "switch to which branch?") + self.reply(self.data, "switch to which branch?") return 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) if "Already on" in result: msg = "already on \x0302{0}\x0301!".format(branch) - self.connection.reply(self.data, msg) + self.reply(self.data, msg) else: ms = "switched from branch \x0302{1}\x0301 to \x0302{1}\x0301." msg = ms.format(current_branch, branch) - self.connection.reply(self.data, msg) + self.reply(self.data, msg) except subprocess.CalledProcessError: # Git couldn't switch branches; assume the branch doesn't exist: 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): """Delete a branch, while making sure that we are not already on it.""" try: delete_branch = self.data.args[1] except IndexError: # no branch name provided - self.connection.reply(self.data, "delete which branch?") + self.reply(self.data, "delete which branch?") return current_branch = self.exec_shell("git name-rev --name-only HEAD") if current_branch == delete_branch: 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 try: self.exec_shell("git branch -d %s" % delete_branch) 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: # Git couldn't switch branches; assume the branch doesn't exist: 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): """Pull from our remote repository.""" branch = self.exec_shell("git name-rev --name-only HEAD") 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") 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: regex = "\s*((.*?)\sfile(.*?)tions?\(-\))" changes = re.findall(regex, result)[0][0] @@ -177,11 +176,11 @@ class Command(BaseCommand): cmnd_url = "git config --get remote.{0}.url".format(remote) url = self.exec_shell(cmnd_url) msg = "done; {0} [from {1}].".format(changes, url) - self.connection.reply(self.data, msg) + self.reply(self.data, msg) except subprocess.CalledProcessError: # Something in .git/config is not specified correctly, so we # 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): """Check whether we have anything to pull.""" @@ -189,7 +188,7 @@ class Command(BaseCommand): result = self.exec_shell("git fetch --dry-run") 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." - self.connection.reply(self.data, msg.format(last)) + self.reply(self.data, msg.format(last)) else: 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)) diff --git a/earwigbot/commands/help.py b/earwigbot/commands/help.py index 5a6f9dd..6897484 100644 --- a/earwigbot/commands/help.py +++ b/earwigbot/commands/help.py @@ -22,7 +22,7 @@ import re -from earwigbot.commands import BaseCommand, command_manager +from earwigbot.commands import BaseCommand from earwigbot.irc import Data class Command(BaseCommand): @@ -30,7 +30,6 @@ class Command(BaseCommand): name = "help" def process(self, data): - self.cmnds = command_manager.get_all() if not data.args: self.do_main_help(data) else: @@ -39,9 +38,9 @@ class Command(BaseCommand): def do_main_help(self, data): """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 '." - cmnds = sorted(self.cmnds.keys()) + cmnds = sorted(self.bot.commands) msg = msg.format(len(cmnds), ', '.join(cmnds)) - self.connection.reply(data, msg) + self.reply(data, msg) def do_command_help(self, data): """Give the user help for a specific command.""" @@ -53,16 +52,17 @@ class Command(BaseCommand): dummy.command = command.lower() 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): continue if cmnd.__doc__: doc = cmnd.__doc__.replace("\n", "") 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 break msg = "sorry, no help for \x0303{0}\x0301.".format(command) - self.connection.reply(data, msg) + self.reply(data, msg) diff --git a/earwigbot/commands/link.py b/earwigbot/commands/link.py index 675096e..6f84d44 100644 --- a/earwigbot/commands/link.py +++ b/earwigbot/commands/link.py @@ -43,15 +43,15 @@ class Command(BaseCommand): if re.search("(\[\[(.*?)\]\])|(\{\{(.*?)\}\})", msg): links = self.parse_line(msg) links = " , ".join(links) - self.connection.reply(data, links) + self.reply(data, links) elif data.command == "link": 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 pagename = ' '.join(data.args) link = self.parse_link(pagename) - self.connection.reply(data, link) + self.reply(data, link) def parse_line(self, line): results = [] diff --git a/earwigbot/commands/praise.py b/earwigbot/commands/praise.py index c9e3950..fb611f5 100644 --- a/earwigbot/commands/praise.py +++ b/earwigbot/commands/praise.py @@ -45,7 +45,7 @@ class Command(BaseCommand): msg = "You use this command to praise certain people. Who they are is a secret." else: msg = "You're doing it wrong." - self.connection.reply(data, msg) + self.reply(data, msg) return - self.connection.say(data.chan, msg) + self.say(data.chan, msg) diff --git a/earwigbot/commands/quit.py b/earwigbot/commands/quit.py new file mode 100644 index 0000000..9ca1f38 --- /dev/null +++ b/earwigbot/commands/quit.py @@ -0,0 +1,67 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2009-2012 by Ben Kurtovic +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +from earwigbot.commands import BaseCommand + +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.") diff --git a/earwigbot/commands/registration.py b/earwigbot/commands/registration.py index 55b762f..ad42269 100644 --- a/earwigbot/commands/registration.py +++ b/earwigbot/commands/registration.py @@ -30,7 +30,7 @@ class Command(BaseCommand): name = "registration" def check(self, data): - commands = ["registration", "age"] + commands = ["registration", "reg", "age"] if data.is_command and data.command in commands: return True return False @@ -41,7 +41,7 @@ class Command(BaseCommand): else: name = ' '.join(data.args) - site = wiki.get_site() + site = self.bot.wiki.get_site() site._maxlag = None user = site.get_user(name) @@ -49,7 +49,7 @@ class Command(BaseCommand): reg = user.registration() except wiki.UserNotFoundError: msg = "the user \x0302{0}\x0301 does not exist." - self.connection.reply(data, msg.format(name)) + self.reply(data, msg.format(name)) return date = time.strftime("%b %d, %Y at %H:%M:%S UTC", reg) @@ -64,7 +64,7 @@ class Command(BaseCommand): gender = "They're" 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): parts = {"years": 31536000, "days": 86400, "hours": 3600, diff --git a/earwigbot/commands/remind.py b/earwigbot/commands/remind.py index 115cb4c..930deda 100644 --- a/earwigbot/commands/remind.py +++ b/earwigbot/commands/remind.py @@ -37,19 +37,19 @@ class Command(BaseCommand): def process(self, data): if not data.args: msg = "please specify a time (in seconds) and a message in the following format: !remind