diff --git a/.gitignore b/.gitignore index 1884197..ab78225 100644 --- a/.gitignore +++ b/.gitignore @@ -8,7 +8,7 @@ config.json .cookies # Ignore OS X's crud: -*.DS_Store +.DS_Store # Ignore pydev's nonsense: .project diff --git a/core/__init__.py b/bot/__init__.py similarity index 100% rename from core/__init__.py rename to bot/__init__.py diff --git a/lib/blowfish.py b/bot/blowfish.py similarity index 100% rename from lib/blowfish.py rename to bot/blowfish.py diff --git a/irc/classes/__init__.py b/bot/classes/__init__.py similarity index 78% rename from irc/classes/__init__.py rename to bot/classes/__init__.py index b92db69..95da576 100644 --- a/irc/classes/__init__.py +++ b/bot/classes/__init__.py @@ -1,4 +1,5 @@ from base_command import * +from base_task import * from connection import * from data import * from rc import * diff --git a/bot/classes/base_command.py b/bot/classes/base_command.py new file mode 100644 index 0000000..f323345 --- /dev/null +++ b/bot/classes/base_command.py @@ -0,0 +1,48 @@ +# -*- coding: utf-8 -*- + +class BaseCommand(object): + """A base class for commands on IRC. + + This docstring is reported to the user when they use !help . + """ + # This is the command's name, as reported to the user when they use !help: + name = "base_command" + + # Hooks are "msg", "msg_private", "msg_public", and "join". "msg" is the + # default behavior; if you wish to override that, change the value in your + # command subclass: + hooks = ["msg"] + + def __init__(self, connection): + """Constructor for new commands. + + This is called once when the command is loaded (from + commands._load_command()). `connection` is a Connection object, + allowing us to do self.connection.say(), self.connection.send(), etc, + from within a method. + """ + self.connection = connection + + def check(self, data): + """Returns whether this command should be called in response to 'data'. + + Given a Data() instance, return True if we should respond to this + activity, or False if we should ignore it or it doesn't apply to us. + + Most commands return True if data.command == self.name, otherwise they + return False. This is the default behavior of check(); you need only + override it if you wish to change that. + """ + if data.is_command and data.command == self.name: + return True + return False + + def process(self, data): + """Main entry point for doing a command. + + Handle an activity (usually a message) on IRC. At this point, thanks + to self.check() which is called automatically by the command handler, + we know this is something we should respond to, so (usually) something + like 'if data.command != "command_name": return' is unnecessary. + """ + pass diff --git a/bot/classes/base_task.py b/bot/classes/base_task.py new file mode 100644 index 0000000..fcffa00 --- /dev/null +++ b/bot/classes/base_task.py @@ -0,0 +1,27 @@ +# -*- coding: utf-8 -*- + +class BaseTask(object): + """A base class for bot tasks that edit Wikipedia.""" + name = None + + def __init__(self): + """Constructor for new tasks. + + This is called once immediately after the task class is loaded by + the task manager (in tasks._load_task()). + """ + pass + + def run(self, **kwargs): + """Main entry point to run a given task. + + This is called directly by tasks.start() and is the main way to make a + task do stuff. kwargs will be any keyword arguments passed to start() + which are entirely optional. + + The same task instance is preserved between runs, so you can + theoretically store data in self (e.g. + start('mytask', action='store', data='foo')) and then use it later + (e.g. start('mytask', action='save')). + """ + pass diff --git a/bot/classes/connection.py b/bot/classes/connection.py new file mode 100644 index 0000000..8e2774a --- /dev/null +++ b/bot/classes/connection.py @@ -0,0 +1,88 @@ +# -*- coding: utf-8 -*- + +import socket +import threading + +class BrokenSocketException(Exception): + """A socket has broken, because it is not sending data. Raised by + Connection.get().""" + pass + +class Connection(object): + """A class to interface with IRC.""" + + def __init__(self, host=None, port=None, nick=None, ident=None, + realname=None): + self.host = host + self.port = port + self.nick = nick + self.ident = ident + self.realname = realname + + # A lock to prevent us from sending two messages at once: + self.lock = threading.Lock() + + def connect(self): + """Connect to our IRC server.""" + self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + self.sock.connect((self.host, self.port)) + self.send("NICK %s" % self.nick) + self.send("USER %s %s * :%s" % (self.ident, self.host, self.realname)) + + def close(self): + """Close our connection with the IRC server.""" + try: + self.sock.shutdown(socket.SHUT_RDWR) # shut down connection first + except socket.error: + pass # ignore if the socket is already down + self.sock.close() + + def get(self, size=4096): + """Receive (i.e. get) data from the server.""" + data = self.sock.recv(4096) + if not data: + # Socket isn't giving us any data, so it is dead or broken: + raise BrokenSocketException() + return data + + def send(self, msg): + """Send data to the server.""" + # Ensure that we only send one message at a time with a blocking lock: + with self.lock: + self.sock.sendall(msg + "\r\n") + print " %s" % msg + + def say(self, target, msg): + """Send a private message to a target on the server.""" + message = "".join(("PRIVMSG ", target, " :", msg)) + self.send(message) + + def reply(self, data, msg): + """Send a private message as a reply to a user on the server.""" + message = "".join((chr(2), data.nick, chr(0x0f), ": ", msg)) + self.say(data.chan, message) + + def action(self, target, msg): + """Send a private message to a target on the server as an action.""" + message = "".join((chr(1), "ACTION ", msg, chr(1))) + self.say(target, message) + + def notice(self, target, msg): + """Send a notice to a target on the server.""" + message = "".join(("NOTICE ", target, " :", msg)) + self.send(message) + + def join(self, chan): + """Join a channel on the server.""" + message = " ".join(("JOIN", chan)) + self.send(message) + + def part(self, chan): + """Part from a channel on the server.""" + message = " ".join(("PART", chan)) + self.send(message) + + def mode(self, chan, level, msg): + """Send a mode message to the server.""" + message = " ".join(("MODE", chan, level, msg)) + self.send(message) diff --git a/bot/classes/data.py b/bot/classes/data.py new file mode 100644 index 0000000..7445097 --- /dev/null +++ b/bot/classes/data.py @@ -0,0 +1,57 @@ +# -*- coding: utf-8 -*- + +import re + +class KwargParseException(Exception): + """Couldn't parse a certain keyword argument in self.args, probably because + it was given incorrectly: e.g., no value (abc), just a value (=xyz), just + an equal sign (=), instead of the correct (abc=xyz).""" + pass + +class Data(object): + """Store data from an individual line received on IRC.""" + + def __init__(self, line): + self.line = line + self.chan = self.nick = self.ident = self.host = self.msg = "" + + def parse_args(self): + """Parse command args from self.msg into self.command and self.args.""" + args = self.msg.strip().split(" ") + + while "" in args: + args.remove("") + + # Isolate command arguments: + self.args = args[1:] + self.is_command = False # is this message a command? + + try: + self.command = args[0] + except IndexError: + self.command = None + + try: + if self.command.startswith('!') or self.command.startswith('.'): + self.is_command = True + self.command = self.command[1:] # Strip the '!' or '.' + self.command = self.command.lower() + except AttributeError: + pass + + def parse_kwargs(self): + """Parse keyword arguments embedded in self.args. + + Parse a command given as "!command key1=value1 key2=value2..." into a + dict, self.kwargs, like {'key1': 'value2', 'key2': 'value2'...}. + """ + self.kwargs = {} + for arg in self.args[2:]: + try: + key, value = re.findall("^(.*?)\=(.*?)$", arg)[0] + except IndexError: + raise KwargParseException(arg) + if key and value: + self.kwargs[key] = value + else: + raise KwargParseException(arg) diff --git a/bot/classes/rc.py b/bot/classes/rc.py new file mode 100644 index 0000000..a2d23b6 --- /dev/null +++ b/bot/classes/rc.py @@ -0,0 +1,73 @@ +# -*- coding: utf-8 -*- + +import re + +class RC(object): + """A class to store data on an event received from our IRC watcher.""" + re_color = re.compile("\x03([0-9]{1,2}(,[0-9]{1,2})?)?") + re_edit = re.compile("\A\[\[(.*?)\]\]\s(.*?)\s(http://.*?)\s\*\s(.*?)\s\*\s(.*?)\Z") + re_log = re.compile("\A\[\[(.*?)\]\]\s(.*?)\s\*\s(.*?)\s\*\s(.*?)\Z") + + def __init__(self, msg): + self.msg = msg + + def parse(self): + """Parse a recent change event into some variables.""" + # Strip IRC color codes; we don't want or need 'em: + self.msg = self.re_color.sub("", self.msg).strip() + msg = self.msg + self.is_edit = True + + # Flags: 'M' for minor edit, 'B' for bot edit, 'create' for a user + # creation log entry, etc: + try: + page, self.flags, url, user, comment = self.re_edit.findall(msg)[0] + except IndexError: + # We're probably missing the http:// part, because it's a log + # entry, which lacks a URL: + page, flags, user, comment = self.re_log.findall(msg)[0] + url = "".join(("http://en.wikipedia.org/wiki/", page)) + + self.is_edit = False # This is a log entry, not edit + + # Flags tends to have extra whitespace at the end when they're + # log entries: + self.flags = flags.strip() + + self.page, self.url, self.user, self.comment = page, url, user, comment + + def prettify(self): + """Make a nice, colorful message to send back to the IRC front-end.""" + flags = self.flags + # "New :" if we don't know exactly what happened: + event_type = flags + if "N" in flags: + event_type = "page" # "New page:" + elif flags == "delete": + event_type = "deletion" # "New deletion:" + elif flags == "protect": + event_type = "protection" # "New protection:" + elif flags == "create": + event_type = "user" # "New user:" + if self.page == "Special:Log/move": + event_type = "move" # New move: + else: + event_type = "edit" # "New edit:" + if "B" in flags: + # "New bot edit:" + event_type = "bot {}".format(event_type) + if "M" in flags: + # "New minor edit:" OR "New minor bot edit:" + event_type = "minor {}".format(event_type) + + # Example formatting: + # New edit: [[Page title]] * User name * http://en... * edit summary + if self.is_edit: + return "".join(("\x02New ", event_type, "\x0F: \x0314[[\x0307", + self.page, "\x0314]]\x0306 *\x0303 ", self.user, + "\x0306 *\x0302 ", self.url, "\x0306 *\x0310 ", + self.comment)) + + return "".join(("\x02New ", event_type, "\x0F: \x0303", self.user, + "\x0306 *\x0302 ", self.url, "\x0306 *\x0310 ", + self.comment)) diff --git a/bot/commands/__init__.py b/bot/commands/__init__.py new file mode 100644 index 0000000..aad4936 --- /dev/null +++ b/bot/commands/__init__.py @@ -0,0 +1,90 @@ +# -*- coding: utf-8 -*- + +""" +EarwigBot's IRC Command Manager + +This package provides the IRC "commands" used by the bot's front-end component. +In __init__, you can find some functions used to load and run these commands. +""" + +import os +import sys +import traceback + +from classes import BaseCommand +import config + +__all__ = ["load", "get_all", "check"] + +# Base directory when searching for commands: +base_dir = os.path.join(config.root_dir, "bot", "commands") + +# Store commands in a dict, where the key is the command's name and the value +# is an instance of the command's class: +_commands = {} + +def _load_command(connection, filename): + """Try to load a specific command from a module, identified by file name. + + Given a Connection object and a filename, we'll first try to import it, + and if that works, make an instance of the 'Command' class inside (assuming + it is an instance of BaseCommand), add it to _commands, and report the + addition to the user. Any problems along the way will either be ignored or + reported. + """ + global _commands + + # Strip .py from the end of the filename and join with our package name: + name = ".".join(("commands", filename[:-3])) + try: + __import__(name) + except: + print "Couldn't load file {0}:".format(filename) + traceback.print_exc() + return + + command = sys.modules[name].Command(connection) + if not isinstance(command, BaseCommand): + return + + _commands[command.name] = command + print "Added command {0}...".format(command.name) + +def load(connection): + """Load all valid commands into the _commands global variable. + + `connection` is a Connection object that is given to each command's + constructor. + """ + files = os.listdir(base_dir) + files.sort() + + for filename in files: + if filename.startswith("_") or not filename.endswith(".py"): + continue + try: + _load_command(connection, filename) + except AttributeError: + pass # The file is doesn't contain a command, so just move on + + msg = "Found {0} commands: {1}." + print msg.format(len(_commands), ", ".join(_commands.keys())) + +def get_all(): + """Return our dict of all loaded commands.""" + return _commands + +def check(hook, data): + """Given an event on IRC, check if there's anything we can respond to.""" + # Parse command arguments into data.command and data.args: + data.parse_args() + + for command in _commands.values(): + if hook in command.hooks: + if command.check(data): + try: + command.process(data) + except: + print "Error executing command '{0}':".format(data.command) + traceback.print_exc() + break diff --git a/irc/commands/_old.py b/bot/commands/_old.py similarity index 100% rename from irc/commands/_old.py rename to bot/commands/_old.py diff --git a/bot/commands/afc_report.py b/bot/commands/afc_report.py new file mode 100644 index 0000000..7f4abf7 --- /dev/null +++ b/bot/commands/afc_report.py @@ -0,0 +1,87 @@ +# -*- coding: utf-8 -*- + +import re + +from classes import BaseCommand +import wiki + +class Command(BaseCommand): + """Get information about an AFC submission by name.""" + name = "report" + + def process(self, data): + self.site = wiki.get_site() + self.data = data + + if not data.args: + msg = "what submission do you want me to give information about?" + self.connection.reply(data, msg) + return + + title = ' '.join(data.args) + title = title.replace("http://en.wikipedia.org/wiki/", "") + title = title.replace("http://enwp.org/", "").strip() + + # Given '!report Foo', first try [[Foo]]: + if self.report(title): + return + + # Then try [[Wikipedia:Articles for creation/Foo]]: + title2 = "".join(("Wikipedia:Articles for creation/", title)) + if self.report(title2): + return + + # Then try [[Wikipedia talk:Articles for creation/Foo]]: + title3 = "".join(("Wikipedia talk:Articles for creation/", title)) + if self.report(title3): + return + + msg = "submission \x0302{0}\x0301 not found.".format(title) + self.connection.reply(data, msg) + + def report(self, title): + data = self.data + page = self.site.get_page(title, follow_redirects=False) + if not page.exists()[0]: + return + + url = page.url().replace("en.wikipedia.org/wiki", "enwp.org") + short = re.sub(r"wikipedia( talk)?:articles for creation/", "", title, + re.IGNORECASE) + status = self.get_status(page) + user = self.site.get_user(page.creator()) + user_name = user.name() + user_url = user.get_talkpage().url() + + msg1 = "AfC submission report for \x0302{0}\x0301 ({1}):" + msg2 = "Status: \x0303{0}\x0301" + msg3 = "Submitted by \x0302{0}\x0301 ({1})" + if status == "accepted": + msg3 = "Reviewed by \x0302{0}\x0301 ({1})" + + self.connection.reply(data, msg1.format(short, url)) + self.connection.say(data.chan, msg2.format(status)) + self.connection.say(data.chan, msg3.format(user_name, user_url)) + + return True + + def get_status(self, page): + content = page.get() + + if page.is_redirect(): + target = page.get_redirect_target() + if self.site.get_page(target).namespace() == 0: + return "accepted" + return "redirect" + if re.search("\{\{afc submission\|r\|(.*?)\}\}", content, re.I): + return "being reviewed" + if re.search("\{\{afc submission\|\|(.*?)\}\}", content, re.I): + return "pending" + if re.search("\{\{afc submission\|d\|(.*?)\}\}", content, re.I): + regex = "\{\{afc submission\|d\|(.*?)(\||\}\})" + try: + reason = re.findall(regex, content, re.I)[0][0] + except IndexError: + return "declined" + return "declined with reason \"{0}\"".format(reason) + return "unkown" diff --git a/irc/commands/afc_status.py b/bot/commands/afc_status.py similarity index 67% rename from irc/commands/afc_status.py rename to bot/commands/afc_status.py index 0f5722e..d476daf 100644 --- a/irc/commands/afc_status.py +++ b/bot/commands/afc_status.py @@ -1,24 +1,22 @@ # -*- coding: utf-8 -*- -"""Report the status of AFC submissions, either as an automatic message on join -or a request via !status.""" - import re -from core import config -from irc.classes import BaseCommand -from wiki import tools - -class AFCStatus(BaseCommand): - def get_hooks(self): - return ["join", "msg"] +from classes import BaseCommand +import config +import wiki - def get_help(self, command): - return "Get the number of pending AfC submissions, open redirect requests, and open file upload requests." +class Command(BaseCommand): + """Get the number of pending AfC submissions, open redirect requests, and + open file upload requests.""" + name = "status" + hooks = ["join", "msg"] def check(self, data): - if data.is_command and data.command in ["status", "count", "num", "number", "afc_status"]: + commands = ["status", "count", "num", "number"] + if data.is_command and data.command in commands: return True + try: if data.line[1] == "JOIN" and data.chan == "#wikipedia-en-afc": if data.nick != config.irc["frontend"]["nick"]: @@ -28,7 +26,7 @@ class AFCStatus(BaseCommand): return False def process(self, data): - self.site = tools.get_site() + self.site = wiki.get_site() if data.line[1] == "JOIN": notice = self.get_join_notice() @@ -39,41 +37,48 @@ class AFCStatus(BaseCommand): action = data.args[0].lower() if action.startswith("sub") or action == "s": subs = self.count_submissions() - self.connection.reply(data, "there are currently %s pending AfC submissions." % subs) + msg = "there are currently {0} pending AfC submissions." + self.connection.reply(data, msg.format(subs)) elif action.startswith("redir") or action == "r": redirs = self.count_redirects() - self.connection.reply(data, "there are currently %s open redirect requests." % redirs) + msg = "there are currently {0} open redirect requests." + self.connection.reply(data, msg.format(redirs)) elif action.startswith("file") or action == "f": files = self.count_redirects() - self.connection.reply(data, "there are currently %s open file upload requests." % files) + msg = "there are currently {0} open file upload requests." + self.connection.reply(data, msg.format(files)) elif action.startswith("agg") or action == "a": try: agg_num = int(data.args[1]) except IndexError: - agg_data = (self.count_submissions(), self.count_redirects(), self.count_files()) + agg_data = (self.count_submissions(), + self.count_redirects(), self.count_files()) agg_num = self.get_aggregate_number(agg_data) except ValueError: - self.connection.reply(data, "\x0303%s\x0301 isn't a number!" % data.args[1]) + msg = "\x0303{0}\x0301 isn't a number!" + self.connection.reply(data, msg.format(data.args[1])) return aggregate = self.get_aggregate(agg_num) - self.connection.reply(data, "aggregate is currently %s (AfC %s)." % (agg_num, aggregate)) + msg = "aggregate is currently {0} (AfC {1})." + self.connection.reply(data, msg.format(agg_num, aggregate)) elif action.startswith("join") or action == "j": notice = self.get_join_notice() self.connection.reply(data, notice) else: - self.connection.reply(data, "unknown argument: \x0303%s\x0301. Valid args are 'subs', 'redirs', 'files', 'agg', and 'join'." % data.args[0]) + msg = "unknown argument: \x0303{0}\x0301. Valid args are 'subs', 'redirs', 'files', 'agg', and 'join'." + self.connection.reply(data, msg.format(data.args[0])) else: subs = self.count_submissions() redirs = self.count_redirects() files = self.count_files() - self.connection.reply(data, "there are currently %s pending submissions, %s open redirect requests, and %s open file upload requests." - % (subs, redirs, files)) + msg = "there are currently {0} pending submissions, {1} open redirect requests, and {2} open file upload requests." + self.connection.reply(data, msg.format(subs, redirs, files)) def get_join_notice(self): subs = self.count_submissions() @@ -81,20 +86,25 @@ class AFCStatus(BaseCommand): files = self.count_files() agg_num = self.get_aggregate_number((subs, redirs, files)) aggregate = self.get_aggregate(agg_num) - return ("\x02Current status:\x0F Articles for Creation %s (\x0302AFC\x0301: \x0305%s\x0301; \x0302AFC/R\x0301: \x0305%s\x0301; \x0302FFU\x0301: \x0305%s\x0301)" - % (aggregate, subs, redirs, files)) + + msg = "\x02Current status:\x0F Articles for Creation {0} (\x0302AFC\x0301: \x0305{1}\x0301; \x0302AFC/R\x0301: \x0305{2}\x0301; \x0302FFU\x0301: \x0305{3}\x0301)" + return msg.format(aggregate, subs, redirs, files) def count_submissions(self): """Returns the number of open AFC submissions (count of CAT:PEND).""" cat = self.site.get_category("Pending AfC submissions") - subs = cat.members(limit=500) - subs -= 2 # remove [[Wikipedia:Articles for creation/Redirects]] and [[Wikipedia:Files for upload]], which aren't real submissions + subs = len(cat.members(limit=500)) + + # Remove [[Wikipedia:Articles for creation/Redirects]] and + # [[Wikipedia:Files for upload]], which aren't real submissions: + subs -= 2 return subs def count_redirects(self): """Returns the number of open redirect submissions. Calculated as the total number of submissions minus the closed ones.""" - content = self.site.get_page("Wikipedia:Articles for creation/Redirects").get() + title = "Wikipedia:Articles for creation/Redirects" + content = self.site.get_page(title).get() total = len(re.findall("^\s*==(.*?)==\s*$", content, re.MULTILINE)) closed = content.lower().count("{{afc-c|b}}") redirs = total - closed diff --git a/irc/commands/calc.py b/bot/commands/calc.py similarity index 74% rename from irc/commands/calc.py rename to bot/commands/calc.py index fbd26a1..0fc0fbd 100644 --- a/irc/commands/calc.py +++ b/bot/commands/calc.py @@ -1,23 +1,14 @@ # -*- coding: utf-8 -*- -# A somewhat advanced calculator: http://futureboy.us/fsp/frink.fsp. - import re import urllib -from irc.classes import BaseCommand - -class Calc(BaseCommand): - def get_hooks(self): - return ["msg"] - - def get_help(self, command): - return "A somewhat advanced calculator: see http://futureboy.us/fsp/frink.fsp for details." +from classes import BaseCommand - def check(self, data): - if data.is_command and data.command == "calc": - return True - return False +class Command(BaseCommand): + """A somewhat advanced calculator: see http://futureboy.us/fsp/frink.fsp + for details.""" + name = "calc" def process(self, data): if not data.args: @@ -27,7 +18,8 @@ class Calc(BaseCommand): query = ' '.join(data.args) query = self.cleanup(query) - url = "http://futureboy.us/fsp/frink.fsp?fromVal=%s" % urllib.quote(query) + url = "http://futureboy.us/fsp/frink.fsp?fromVal={0}" + url = url.format(urllib.quote(query)) result = urllib.urlopen(url).read() r_result = re.compile(r'(?i)(.*?)') diff --git a/bot/commands/chanops.py b/bot/commands/chanops.py new file mode 100644 index 0000000..727ebbf --- /dev/null +++ b/bot/commands/chanops.py @@ -0,0 +1,30 @@ +# -*- coding: utf-8 -*- + +from classes import BaseCommand +import config + +class Command(BaseCommand): + """Voice, devoice, op, or deop users in the channel.""" + name = "chanops" + + def check(self, data): + commands = ["voice", "devoice", "op", "deop"] + if data.is_command and data.command in commands: + return True + return False + + def process(self, data): + 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) + 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 + else: + target = data.args[0] + + msg = " ".join((data.command, data.chan, target)) + self.connection.say("ChanServ", msg) diff --git a/bot/commands/crypt.py b/bot/commands/crypt.py new file mode 100644 index 0000000..8727c6d --- /dev/null +++ b/bot/commands/crypt.py @@ -0,0 +1,54 @@ +# -*- coding: utf-8 -*- + +import hashlib + +from classes import BaseCommand +import blowfish + +class Command(BaseCommand): + """Provides hash functions with !hash (!hash list for supported algorithms) + and blowfish encryption with !encrypt and !decrypt.""" + name = "cryptography" + + def check(self, data): + if data.is_command and data.command in ["hash", "encrypt", "decrypt"]: + return True + return False + + def process(self, data): + if not data.args: + msg = "what do you want me to {0}?".format(data.command) + self.connection.reply(data, msg) + return + + if data.command == "hash": + algo = data.args[0] + if algo == "list": + algos = ', '.join(hashlib.algorithms) + msg = algos.join(("supported algorithms: ", ".")) + self.connection.reply(data, msg) + elif algo in hashlib.algorithms: + string = ' '.join(data.args[1:]) + result = getattr(hashlib, algo)(string).hexdigest() + self.connection.reply(data, result) + else: + msg = "unknown algorithm: '{0}'.".format(algo) + self.connection.reply(data, msg) + + else: + key = data.args[0] + text = ' '.join(data.args[1:]) + + if not text: + msg = "a key was provided, but text to {0} was not." + self.connection.reply(data, msg.format(data.command)) + return + + try: + if data.command == "encrypt": + self.connection.reply(data, blowfish.encrypt(key, text)) + else: + self.connection.reply(data, blowfish.decrypt(key, text)) + except blowfish.BlowfishError as error: + msg = "{0}: {1}.".format(error.__class__.__name__, error) + self.connection.reply(data, msg) diff --git a/bot/commands/git.py b/bot/commands/git.py new file mode 100644 index 0000000..90d7bdc --- /dev/null +++ b/bot/commands/git.py @@ -0,0 +1,178 @@ +# -*- coding: utf-8 -*- + +import shlex +import subprocess +import re + +from classes import BaseCommand +import config + +class Command(BaseCommand): + """Commands to interface with the bot's git repository; use '!git help' for + a sub-command list.""" + name = "git" + + def process(self, data): + self.data = 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 + + if not data.args: + msg = "no arguments provided. Maybe you wanted '!git help'?" + self.connection.reply(data, msg) + return + + if data.args[0] == "help": + self.do_help() + + elif data.args[0] == "branch": + self.do_branch() + + elif data.args[0] == "branches": + self.do_branches() + + elif data.args[0] == "checkout": + self.do_checkout() + + elif data.args[0] == "delete": + self.do_delete() + + elif data.args[0] == "pull": + self.do_pull() + + elif data.args[0] == "status": + self.do_status() + + 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) + + def exec_shell(self, command): + """Execute a shell command and get the output.""" + command = shlex.split(command) + result = subprocess.check_output(command, stderr=subprocess.STDOUT) + if result: + result = result[:-1] # Strip newline + return result + + def do_help(self): + """Display all commands.""" + help_dict = { + "branch": "get current branch", + "branches": "get all branches", + "checkout": "switch branches", + "delete": "delete an old branch", + "pull": "update everything from the remote server", + "status": "check if we are up-to-date", + } + keys = help_dict.keys() + keys.sort() + help = "" + for key in keys: + help += "\x0303%s\x0301 (%s), " % (key, help_dict[key]) + help = help[:-2] # trim last comma and space + self.connection.reply(self.data, "sub-commands are: %s." % help) + + 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) + + def do_branches(self): + """Get a list of branches.""" + branches = self.exec_shell("git branch") + # Remove extraneous characters: + branches = branches.replace('\n* ', ', ') + branches = branches.replace('* ', ' ') + branches = branches.replace('\n ', ', ') + branches = branches.strip() + msg = "branches: \x0302{0}\x0301.".format(branches) + self.connection.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?") + return + + current_branch = self.exec_shell("git name-rev --name-only HEAD") + + try: + 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) + else: + ms = "switched from branch \x0302{1}\x0301 to \x0302{1}\x0301." + msg = ms.format(current_branch, branch) + self.connection.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) + + 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?") + 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) + 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)) + 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) + + 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)) + + result = self.exec_shell("git pull") + + if "Already up-to-date." in result: + self.connection.reply(self.data, "done; no new changes.") + else: + regex = "\s*((.*?)\sfile(.*?)tions?\(-\))" + changes = re.findall(regex, result)[0][0] + try: + cmnd_remt = "git config --get branch.{0}.remote".format(branch) + remote = self.exec_shell(cmnd_rmt) + 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) + 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) + + def do_status(self): + """Check whether we have anything to pull.""" + last = self.exec_shell('git log -n 1 --pretty="%ar"') + 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)) + else: + msg = "last local commit was {0}. Remote is \x02ahead\x0F of local copy." + self.connection.reply(self.data, msg.format(last)) diff --git a/bot/commands/help.py b/bot/commands/help.py new file mode 100644 index 0000000..484dcb4 --- /dev/null +++ b/bot/commands/help.py @@ -0,0 +1,49 @@ +# -*- coding: utf-8 -*- + +import re + +from classes import BaseCommand, Data +import commands + +class Command(BaseCommand): + """Displays help information.""" + name = "help" + + def process(self, data): + self.cmnds = commands.get_all() + if not data.args: + self.do_main_help(data) + else: + self.do_command_help(data) + + def do_main_help(self, data): + """Give the user a general help message with a list of all commands.""" + msg = "I am a bot! I have {0} commands loaded: {1}. You can get help for any command with '!help '." + cmnds = self.cmnds.keys() + cmnds.sort() + msg = msg.format(len(cmnds), ', '.join(cmnds)) + self.connection.reply(data, msg) + + def do_command_help(self, data): + """Give the user help for a specific command.""" + command = data.args[0] + + # Create a dummy message to test which commands pick up the user's + # input: + dummy = Data("PRIVMSG #fake-channel :Fake messsage!") + dummy.command = command.lower() + dummy.is_command = True + + for cmnd in self.cmnds.values(): + 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)) + return + break + + msg = "sorry, no help for \x0303{0}\x0301.".format(command) + self.connection.reply(data, msg) diff --git a/irc/commands/link.py b/bot/commands/link.py similarity index 50% rename from irc/commands/link.py rename to bot/commands/link.py index b45f4cd..749da14 100644 --- a/irc/commands/link.py +++ b/bot/commands/link.py @@ -1,17 +1,13 @@ # -*- coding: utf-8 -*- -# Convert a Wikipedia page name into a URL. - import re +from urllib import quote -from irc.classes import BaseCommand - -class Link(BaseCommand): - def get_hooks(self): - return ["msg"] +from classes import BaseCommand - def get_help(self, command): - return "Convert a Wikipedia page name into a URL." +class Command(BaseCommand): + """Convert a Wikipedia page name into a URL.""" + name = "link" def check(self, data): if ((data.is_command and data.command == "link") or @@ -37,29 +33,31 @@ class Link(BaseCommand): self.connection.reply(data, link) def parse_line(self, line): - results = list() + results = [] - line = re.sub("\{\{\{(.*?)\}\}\}", "", line) # destroy {{{template parameters}}} + # Destroy {{{template parameters}}}: + line = re.sub("\{\{\{(.*?)\}\}\}", "", line) - links = re.findall("(\[\[(.*?)(\||\]\]))", line) # find all [[links]] + # Find all [[links]]: + links = re.findall("(\[\[(.*?)(\||\]\]))", line) if links: - links = map(lambda x: x[1], links) # re.findall() returns a list of tuples, but we only want the 2nd item in each tuple - results.extend(map(self.parse_link, links)) + # re.findall() returns a list of tuples, but we only want the 2nd + # item in each tuple: + links = [i[1] for i in links] + results = map(self.parse_link, links) - templates = re.findall("(\{\{(.*?)(\||\}\}))", line) # find all {{templates}} + # Find all {{templates}} + templates = re.findall("(\{\{(.*?)(\||\}\}))", line) if templates: - templates = map(lambda x: x[1], templates) + templates = [i[1] for i in templates] results.extend(map(self.parse_template, templates)) return results def parse_link(self, pagename): - pagename = pagename.strip() - link = "http://enwp.org/" + pagename - link = link.replace(" ", "_") - return link + link = quote(pagename.replace(" ", "_"), safe="/:") + return "".join(("http://enwp.org/", link)) def parse_template(self, pagename): - pagename = "Template:%s" % pagename # TODO: implement an actual namespace check - link = self.parse_link(pagename) - return link + pagename = "".join(("Template:", pagename)) + return self.parse_link(pagename) diff --git a/bot/commands/remind.py b/bot/commands/remind.py new file mode 100644 index 0000000..fd0234d --- /dev/null +++ b/bot/commands/remind.py @@ -0,0 +1,51 @@ +# -*- coding: utf-8 -*- + +import threading +import time + +from classes import BaseCommand + +class Command(BaseCommand): + """Set a message to be repeated to you in a certain amount of time.""" + name = "remind" + + def check(self, data): + if data.is_command and data.command in ["remind", "reminder"]: + return True + return False + + def process(self, data): + if not data.args: + msg = "please specify a time (in seconds) and a message in the following format: !remind