| @@ -8,7 +8,7 @@ config.json | |||||
| .cookies | .cookies | ||||
| # Ignore OS X's crud: | # Ignore OS X's crud: | ||||
| *.DS_Store | |||||
| .DS_Store | |||||
| # Ignore pydev's nonsense: | # Ignore pydev's nonsense: | ||||
| .project | .project | ||||
| @@ -1,4 +1,5 @@ | |||||
| from base_command import * | from base_command import * | ||||
| from base_task import * | |||||
| from connection import * | from connection import * | ||||
| from data import * | from data import * | ||||
| from rc import * | from rc import * | ||||
| @@ -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 <command>. | |||||
| """ | |||||
| # 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 | |||||
| @@ -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 | |||||
| @@ -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) | |||||
| @@ -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) | |||||
| @@ -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 <event>:" 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)) | |||||
| @@ -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 | |||||
| @@ -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" | |||||
| @@ -1,24 +1,22 @@ | |||||
| # -*- coding: utf-8 -*- | # -*- coding: utf-8 -*- | ||||
| """Report the status of AFC submissions, either as an automatic message on join | |||||
| or a request via !status.""" | |||||
| import re | 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): | 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 | return True | ||||
| 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 != config.irc["frontend"]["nick"]: | ||||
| @@ -28,7 +26,7 @@ class AFCStatus(BaseCommand): | |||||
| return False | return False | ||||
| def process(self, data): | def process(self, data): | ||||
| self.site = tools.get_site() | |||||
| self.site = wiki.get_site() | |||||
| if data.line[1] == "JOIN": | if data.line[1] == "JOIN": | ||||
| notice = self.get_join_notice() | notice = self.get_join_notice() | ||||
| @@ -39,41 +37,48 @@ class AFCStatus(BaseCommand): | |||||
| action = data.args[0].lower() | action = data.args[0].lower() | ||||
| if action.startswith("sub") or action == "s": | if action.startswith("sub") or action == "s": | ||||
| subs = self.count_submissions() | 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": | elif action.startswith("redir") or action == "r": | ||||
| redirs = self.count_redirects() | 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": | elif action.startswith("file") or action == "f": | ||||
| files = self.count_redirects() | 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": | elif action.startswith("agg") or action == "a": | ||||
| try: | try: | ||||
| agg_num = int(data.args[1]) | agg_num = int(data.args[1]) | ||||
| except IndexError: | 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) | agg_num = self.get_aggregate_number(agg_data) | ||||
| except ValueError: | 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 | return | ||||
| aggregate = self.get_aggregate(agg_num) | 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": | elif action.startswith("join") or action == "j": | ||||
| notice = self.get_join_notice() | notice = self.get_join_notice() | ||||
| self.connection.reply(data, notice) | self.connection.reply(data, notice) | ||||
| else: | 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: | else: | ||||
| subs = self.count_submissions() | subs = self.count_submissions() | ||||
| redirs = self.count_redirects() | redirs = self.count_redirects() | ||||
| files = self.count_files() | 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): | def get_join_notice(self): | ||||
| subs = self.count_submissions() | subs = self.count_submissions() | ||||
| @@ -81,20 +86,25 @@ class AFCStatus(BaseCommand): | |||||
| files = self.count_files() | files = self.count_files() | ||||
| agg_num = self.get_aggregate_number((subs, redirs, files)) | agg_num = self.get_aggregate_number((subs, redirs, files)) | ||||
| aggregate = self.get_aggregate(agg_num) | 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): | def count_submissions(self): | ||||
| """Returns the number of open AFC submissions (count of CAT:PEND).""" | """Returns the number of open AFC submissions (count of CAT:PEND).""" | ||||
| cat = self.site.get_category("Pending AfC submissions") | 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 | return subs | ||||
| def count_redirects(self): | def count_redirects(self): | ||||
| """Returns the number of open redirect submissions. Calculated as the | """Returns the number of open redirect submissions. Calculated as the | ||||
| total number of submissions minus the closed ones.""" | 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)) | total = len(re.findall("^\s*==(.*?)==\s*$", content, re.MULTILINE)) | ||||
| closed = content.lower().count("{{afc-c|b}}") | closed = content.lower().count("{{afc-c|b}}") | ||||
| redirs = total - closed | redirs = total - closed | ||||
| @@ -1,23 +1,14 @@ | |||||
| # -*- coding: utf-8 -*- | # -*- coding: utf-8 -*- | ||||
| # A somewhat advanced calculator: http://futureboy.us/fsp/frink.fsp. | |||||
| import re | import re | ||||
| import urllib | 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): | def process(self, data): | ||||
| if not data.args: | if not data.args: | ||||
| @@ -27,7 +18,8 @@ class Calc(BaseCommand): | |||||
| query = ' '.join(data.args) | query = ' '.join(data.args) | ||||
| query = self.cleanup(query) | 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() | result = urllib.urlopen(url).read() | ||||
| r_result = re.compile(r'(?i)<A NAME=results>(.*?)</A>') | r_result = re.compile(r'(?i)<A NAME=results>(.*?)</A>') | ||||
| @@ -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) | |||||
| @@ -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) | |||||
| @@ -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)) | |||||
| @@ -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 <command>'." | |||||
| 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) | |||||
| @@ -1,17 +1,13 @@ | |||||
| # -*- coding: utf-8 -*- | # -*- coding: utf-8 -*- | ||||
| # Convert a Wikipedia page name into a URL. | |||||
| import re | 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): | def check(self, data): | ||||
| if ((data.is_command and data.command == "link") or | if ((data.is_command and data.command == "link") or | ||||
| @@ -37,29 +33,31 @@ class Link(BaseCommand): | |||||
| self.connection.reply(data, link) | self.connection.reply(data, link) | ||||
| def parse_line(self, line): | 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: | 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: | if templates: | ||||
| templates = map(lambda x: x[1], templates) | |||||
| templates = [i[1] for i in templates] | |||||
| results.extend(map(self.parse_template, templates)) | results.extend(map(self.parse_template, templates)) | ||||
| return results | return results | ||||
| def parse_link(self, pagename): | 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): | 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) | |||||
| @@ -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 <time> <msg>." | |||||
| self.connection.reply(data, msg) | |||||
| return | |||||
| try: | |||||
| wait = int(data.args[0]) | |||||
| except ValueError: | |||||
| msg = "the time must be given as an integer, in seconds." | |||||
| self.connection.reply(data, msg) | |||||
| return | |||||
| message = ' '.join(data.args[1:]) | |||||
| if not message: | |||||
| msg = "what message do you want me to give you when time is up?" | |||||
| self.connection.reply(data, msg) | |||||
| return | |||||
| end = time.localtime(time.time() + wait) | |||||
| end_time = time.strftime("%b %d %H:%M:%S", end) | |||||
| end_time_with_timezone = time.strftime("%b %d %H:%M:%S %Z", end) | |||||
| msg = 'Set reminder for "{0}" in {1} seconds (ends {2}).' | |||||
| msg = msg.format(message, wait, end_time_with_timezone) | |||||
| self.connection.reply(data, msg) | |||||
| t_reminder = threading.Thread(target=self.reminder, | |||||
| args=(data, message, wait)) | |||||
| t_reminder.name = "reminder " + end_time | |||||
| t_reminder.daemon = True | |||||
| t_reminder.start() | |||||
| def reminder(self, data, message, wait): | |||||
| time.sleep(wait) | |||||
| self.connection.reply(data, message) | |||||
| @@ -0,0 +1,37 @@ | |||||
| # -*- coding: utf-8 -*- | |||||
| from classes import BaseCommand | |||||
| import wiki | |||||
| class Command(BaseCommand): | |||||
| """Retrieve a list of rights for a given username.""" | |||||
| name = "rights" | |||||
| def check(self, data): | |||||
| commands = ["rights", "groups", "permissions", "privileges"] | |||||
| if data.is_command and data.command in commands: | |||||
| return True | |||||
| return False | |||||
| def process(self, data): | |||||
| if not data.args: | |||||
| self.connection.reply(data, "who do you want me to look up?") | |||||
| return | |||||
| username = ' '.join(data.args) | |||||
| site = wiki.get_site() | |||||
| user = site.get_user(username) | |||||
| try: | |||||
| rights = user.groups() | |||||
| except wiki.UserNotFoundError: | |||||
| msg = "the user \x0302{0}\x0301 does not exist." | |||||
| self.connection.reply(data, msg.format(username)) | |||||
| return | |||||
| try: | |||||
| rights.remove("*") # Remove the '*' group given to everyone | |||||
| except ValueError: | |||||
| pass | |||||
| msg = "the rights for \x0302{0}\x0301 are {1}." | |||||
| self.connection.reply(data, msg.format(username, ', '.join(rights))) | |||||
| @@ -0,0 +1,16 @@ | |||||
| # -*- coding: utf-8 -*- | |||||
| import random | |||||
| from classes import BaseCommand | |||||
| class Command(BaseCommand): | |||||
| """Test the bot!""" | |||||
| name = "test" | |||||
| def process(self, data): | |||||
| hey = random.randint(0, 1) | |||||
| if hey: | |||||
| self.connection.say(data.chan, "Hey \x02%s\x0F!" % data.nick) | |||||
| else: | |||||
| self.connection.say(data.chan, "'sup \x02%s\x0F?" % data.nick) | |||||
| @@ -0,0 +1,146 @@ | |||||
| # -*- coding: utf-8 -*- | |||||
| import threading | |||||
| import re | |||||
| from classes import BaseCommand, Data, KwargParseException | |||||
| import tasks | |||||
| import config | |||||
| class Command(BaseCommand): | |||||
| """Manage wiki tasks from IRC, and check on thread status.""" | |||||
| name = "threads" | |||||
| def check(self, data): | |||||
| commands = ["tasks", "task", "threads", "tasklist"] | |||||
| if data.is_command and data.command in commands: | |||||
| return True | |||||
| return False | |||||
| def process(self, data): | |||||
| self.data = data | |||||
| if data.host not in config.irc["permissions"]["owners"]: | |||||
| msg = "at this time, you must be a bot owner to use this command." | |||||
| self.connection.reply(data, msg) | |||||
| return | |||||
| if not data.args: | |||||
| if data.command == "tasklist": | |||||
| self.do_list() | |||||
| else: | |||||
| msg = "no arguments provided. Maybe you wanted '!{0} list', '!{0} start', or '!{0} listall'?" | |||||
| self.connection.reply(data, msg.format(data.command)) | |||||
| return | |||||
| if data.args[0] == "list": | |||||
| self.do_list() | |||||
| elif data.args[0] == "start": | |||||
| self.do_start() | |||||
| elif data.args[0] in ["listall", "all"]: | |||||
| self.do_listall() | |||||
| 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 do_list(self): | |||||
| """With !tasks list (or abbreviation !tasklist), list all running | |||||
| threads. This includes the main threads, like the irc frontend and the | |||||
| watcher, and task threads.""" | |||||
| threads = threading.enumerate() | |||||
| normal_threads = [] | |||||
| task_threads = [] | |||||
| for thread in threads: | |||||
| tname = thread.name | |||||
| 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 = "\x0302{0}\x0301 (id {1})" | |||||
| normal_threads.append(t.format(tname, thread.ident)) | |||||
| elif tname.startswith("reminder"): | |||||
| tname = tname.replace("reminder ", "") | |||||
| t = "\x0302reminder\x0301 (until {0})" | |||||
| normal_threads.append(t.format(tname)) | |||||
| else: | |||||
| tname, start_time = re.findall("^(.*?) \((.*?)\)$", tname)[0] | |||||
| t = "\x0302{0}\x0301 (id {1}, since {2})" | |||||
| task_threads.append(t.format(tname, thread.ident, start_time)) | |||||
| if task_threads: | |||||
| msg = "\x02{0}\x0F threads active: {1}, and \x02{2}\x0F task threads: {3}." | |||||
| msg = msg.format(len(threads), ', '.join(normal_threads), | |||||
| len(task_threads), ', '.join(task_threads)) | |||||
| else: | |||||
| msg = "\x02{0}\x0F threads active: {1}, and \x020\x0F task threads." | |||||
| msg = msg.format(len(threads), ', '.join(normal_threads)) | |||||
| self.connection.reply(self.data, msg) | |||||
| def do_listall(self): | |||||
| """With !tasks listall or !tasks all, list all loaded tasks, and report | |||||
| whether they are currently running or idle.""" | |||||
| all_tasks = tasks.get_all().keys() | |||||
| threads = threading.enumerate() | |||||
| tasklist = [] | |||||
| all_tasks.sort() | |||||
| for task in all_tasks: | |||||
| threadlist = [t for t in threads if t.name.startswith(task)] | |||||
| ids = [str(t.ident) for t in threadlist] | |||||
| if not ids: | |||||
| tasklist.append("\x0302{0}\x0301 (idle)".format(task)) | |||||
| elif len(ids) == 1: | |||||
| t = "\x0302{0}\x0301 (\x02active\x0F as id {1})" | |||||
| tasklist.append(t.format(task, ids[0])) | |||||
| else: | |||||
| t = "\x0302{0}\x0301 (\x02active\x0F as ids {1})" | |||||
| tasklist.append(t.format(task, ', '.join(ids))) | |||||
| tasklist = ", ".join(tasklist) | |||||
| msg = "{0} tasks loaded: {1}.".format(len(all_tasks), tasklist) | |||||
| self.connection.reply(self.data, msg) | |||||
| def do_start(self): | |||||
| """With !tasks start, start any loaded task by name with or without | |||||
| kwargs.""" | |||||
| data = self.data | |||||
| try: | |||||
| task_name = data.args[1] | |||||
| except IndexError: # No task name given | |||||
| self.connection.reply(data, "what task do you want me to start?") | |||||
| return | |||||
| try: | |||||
| data.parse_kwargs() | |||||
| except KwargParseException, arg: | |||||
| msg = "error parsing argument: \x0303{0}\x0301.".format(arg) | |||||
| self.connection.reply(data, msg) | |||||
| return | |||||
| if task_name not in tasks.get_all().keys(): | |||||
| # This task does not exist or hasn't been loaded: | |||||
| msg = "task could not be found; either bot/tasks/{0}.py doesn't exist, or it wasn't loaded correctly." | |||||
| self.connection.reply(data, msg.format(task_name)) | |||||
| return | |||||
| tasks.start(task_name, **data.kwargs) | |||||
| 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" | |||||
| @@ -18,9 +18,9 @@ from within config's three global variables and one function: | |||||
| """ | """ | ||||
| import json | import json | ||||
| from os import makedirs, path | |||||
| from os import path | |||||
| from lib import blowfish | |||||
| import blowfish | |||||
| script_dir = path.dirname(path.abspath(__file__)) | script_dir = path.dirname(path.abspath(__file__)) | ||||
| root_dir = path.split(script_dir)[0] | root_dir = path.split(script_dir)[0] | ||||
| @@ -149,7 +149,6 @@ def schedule(minute, hour, month_day, month, week_day): | |||||
| def make_new_config(): | def make_new_config(): | ||||
| """Make a new config file based on the user's input.""" | """Make a new config file based on the user's input.""" | ||||
| makedirs(config_dir) | |||||
| encrypt = raw_input("Would you like to encrypt passwords stored in " + | encrypt = raw_input("Would you like to encrypt passwords stored in " + | ||||
| "config.json? [y/n] ") | "config.json? [y/n] ") | ||||
| @@ -0,0 +1,112 @@ | |||||
| # -*- coding: utf-8 -*- | |||||
| """ | |||||
| EarwigBot's IRC Frontend Component | |||||
| The IRC frontend runs on a normal IRC server and expects users to interact with | |||||
| it and give it commands. Commands are stored as "command classes", subclasses | |||||
| of BaseCommand in irc/base_command.py. All command classes are automatically | |||||
| imported by irc/command_handler.py if they are in irc/commands. | |||||
| """ | |||||
| import re | |||||
| import config | |||||
| import commands | |||||
| from classes import Connection, Data, BrokenSocketException | |||||
| __all__ = ["get_connection", "startup", "main"] | |||||
| connection = None | |||||
| sender_regex = re.compile(":(.*?)!(.*?)@(.*?)\Z") | |||||
| def get_connection(): | |||||
| """Return a new Connection() instance with information about our server | |||||
| connection, but don't actually connect yet.""" | |||||
| cf = config.irc["frontend"] | |||||
| connection = Connection(cf["host"], cf["port"], cf["nick"], cf["ident"], | |||||
| cf["realname"]) | |||||
| return connection | |||||
| def startup(conn): | |||||
| """Accept a single arg, a Connection() object, and set our global variable | |||||
| 'connection' to it. Load all command classes in irc/commands with | |||||
| command_handler, and then establish a connection with the IRC server.""" | |||||
| global connection | |||||
| connection = conn | |||||
| commands.load(connection) | |||||
| connection.connect() | |||||
| def main(): | |||||
| """Main loop for the frontend component. | |||||
| get_connection() and startup() should have already been called before this. | |||||
| """ | |||||
| read_buffer = str() | |||||
| while 1: | |||||
| try: | |||||
| read_buffer = read_buffer + connection.get() | |||||
| except BrokenSocketException: | |||||
| print "Socket has broken on front-end; restarting bot..." | |||||
| return | |||||
| lines = read_buffer.split("\n") | |||||
| read_buffer = lines.pop() | |||||
| for line in lines: | |||||
| _process_message(line) | |||||
| def _process_message(line): | |||||
| """Process a single message from IRC.""" | |||||
| line = line.strip().split() | |||||
| data = Data(line) # new Data instance to store info about this line | |||||
| if line[1] == "JOIN": | |||||
| data.nick, data.ident, data.host = sender_regex.findall(line[0])[0] | |||||
| data.chan = line[2][1:] | |||||
| # Check for 'join' hooks in our commands: | |||||
| commands.check("join", data) | |||||
| elif line[1] == "PRIVMSG": | |||||
| data.nick, data.ident, data.host = sender_regex.findall(line[0])[0] | |||||
| data.msg = ' '.join(line[3:])[1:] | |||||
| data.chan = line[2] | |||||
| if data.chan == config.irc["frontend"]["nick"]: | |||||
| # This is a privmsg to us, so set 'chan' as the nick of the, sender | |||||
| # then check for private-only command hooks: | |||||
| data.chan = data.nick | |||||
| commands.check("msg_private", data) | |||||
| else: | |||||
| # Check for public-only command hooks: | |||||
| commands.check("msg_public", data) | |||||
| # Check for command hooks that apply to all messages: | |||||
| commands.check("msg", data) | |||||
| # Hardcode the !restart command (we can't restart from within an | |||||
| # ordinary command): | |||||
| if data.msg in ["!restart", ".restart"]: | |||||
| if data.host in config.irc["permissions"]["owners"]: | |||||
| print "Restarting bot per owner request..." | |||||
| return | |||||
| # If we are pinged, pong back to the server: | |||||
| if line[0] == "PING": | |||||
| connection.send("PONG %s" % line[1]) | |||||
| # On successful connection to the server: | |||||
| if line[1] == "376": | |||||
| # If we're supposed to auth to NickServ, do that: | |||||
| try: | |||||
| username = config.irc["frontend"]["nickservUsername"] | |||||
| password = config.irc["frontend"]["nickservPassword"] | |||||
| except KeyError: | |||||
| pass | |||||
| else: | |||||
| msg = " ".join(("IDENTIFY", username, password)) | |||||
| connection.say("NickServ", msg) | |||||
| # Join all of our startup channels: | |||||
| for chan in config.irc["frontend"]["channels"]: | |||||
| connection.join(chan) | |||||
| @@ -33,17 +33,11 @@ Else, the bot will stop, as no components are enabled. | |||||
| import threading | import threading | ||||
| import time | import time | ||||
| import traceback | import traceback | ||||
| import sys | |||||
| import os | |||||
| script_dir = os.path.dirname(os.path.abspath(__file__)) | |||||
| root_dir = os.path.split(script_dir)[0] # the bot's "root" directory relative | |||||
| # to its different components | |||||
| sys.path.append(root_dir) # make sure we look in the root dir for modules | |||||
| from core import config | |||||
| from irc import frontend, watcher | |||||
| from wiki import task_manager | |||||
| import config | |||||
| import frontend | |||||
| import tasks | |||||
| import watcher | |||||
| f_conn = None | f_conn = None | ||||
| w_conn = None | w_conn = None | ||||
| @@ -70,7 +64,7 @@ def wiki_scheduler(): | |||||
| time_start = time.time() | time_start = time.time() | ||||
| now = time.gmtime(time_start) | now = time.gmtime(time_start) | ||||
| task_manager.start_tasks(now) | |||||
| tasks.schedule(now) | |||||
| time_end = time.time() | time_end = time.time() | ||||
| time_diff = time_start - time_end | time_diff = time_start - time_end | ||||
| @@ -89,7 +83,7 @@ def irc_frontend(): | |||||
| if "wiki_schedule" in config.components: | if "wiki_schedule" in config.components: | ||||
| print "\nStarting wiki scheduler..." | print "\nStarting wiki scheduler..." | ||||
| task_manager.load_tasks() | |||||
| tasks.load() | |||||
| t_scheduler = threading.Thread(target=wiki_scheduler) | t_scheduler = threading.Thread(target=wiki_scheduler) | ||||
| t_scheduler.name = "wiki-scheduler" | t_scheduler.name = "wiki-scheduler" | ||||
| t_scheduler.daemon = True | t_scheduler.daemon = True | ||||
| @@ -123,7 +117,7 @@ def run(): | |||||
| elif "wiki_schedule" in enabled: # run the scheduler on the main | elif "wiki_schedule" in enabled: # run the scheduler on the main | ||||
| print "Starting wiki scheduler..." # thread, but also run the IRC | print "Starting wiki scheduler..." # thread, but also run the IRC | ||||
| task_manager.load_tasks() # watcher on another thread iff it | |||||
| tasks.load() # watcher on another thread iff it | |||||
| if "irc_watcher" in enabled: # is enabled | if "irc_watcher" in enabled: # is enabled | ||||
| print "\nStarting IRC watcher..." | print "\nStarting IRC watcher..." | ||||
| t_watcher = threading.Thread(target=irc_watcher, args=()) | t_watcher = threading.Thread(target=irc_watcher, args=()) | ||||
| @@ -1,18 +1,15 @@ | |||||
| # -*- coding: utf-8 -*- | # -*- coding: utf-8 -*- | ||||
| """ | """ | ||||
| EarwigBot's IRC Watcher Logic | |||||
| EarwigBot's IRC Watcher Rules | |||||
| This file contains (configurable!) rules that EarwigBot's watcher uses after it | This file contains (configurable!) rules that EarwigBot's watcher uses after it | ||||
| recieves an event from IRC. | recieves an event from IRC. | ||||
| This should, ideally, be in config.json somehow, but Python code makes more | |||||
| sense for this sort of thing... so... | |||||
| """ | """ | ||||
| import re | import re | ||||
| from wiki import task_manager as tasks | |||||
| import tasks | |||||
| afc_prefix = "wikipedia( talk)?:(wikiproject )?articles for creation" | afc_prefix = "wikipedia( talk)?:(wikiproject )?articles for creation" | ||||
| @@ -39,8 +36,8 @@ def process(rc): | |||||
| chans.update(("##earwigbot", "#wikipedia-en-afc")) | chans.update(("##earwigbot", "#wikipedia-en-afc")) | ||||
| if r_page.search(page_name): | if r_page.search(page_name): | ||||
| tasks.start_task("afc_statistics", action="process_edit", page=rc.page) | |||||
| tasks.start_task("afc_copyvios", action="process_edit", page=rc.page) | |||||
| tasks.start("afc_statistics", action="process_edit", page=rc.page) | |||||
| tasks.start("afc_copyvios", action="process_edit", page=rc.page) | |||||
| chans.add("#wikipedia-en-afc") | chans.add("#wikipedia-en-afc") | ||||
| elif r_ffu.match(page_name): | elif r_ffu.match(page_name): | ||||
| @@ -50,22 +47,22 @@ def process(rc): | |||||
| chans.add("#wikipedia-en-afc") | chans.add("#wikipedia-en-afc") | ||||
| elif rc.flags == "move" and (r_move1.match(comment) or | elif rc.flags == "move" and (r_move1.match(comment) or | ||||
| r_move2.match(comment)): | |||||
| r_move2.match(comment)): | |||||
| p = r_moved_pages.findall(rc.comment)[0] | p = r_moved_pages.findall(rc.comment)[0] | ||||
| tasks.start_task("afc_statistics", action="process_move", pages=p) | |||||
| tasks.start_task("afc_copyvios", action="process_move", pages=p) | |||||
| tasks.start("afc_statistics", action="process_move", pages=p) | |||||
| tasks.start("afc_copyvios", action="process_move", pages=p) | |||||
| chans.add("#wikipedia-en-afc") | chans.add("#wikipedia-en-afc") | ||||
| elif rc.flags == "delete" and r_delete.match(comment): | elif rc.flags == "delete" and r_delete.match(comment): | ||||
| p = r_deleted_page.findall(rc.comment)[0] | p = r_deleted_page.findall(rc.comment)[0] | ||||
| tasks.start_task("afc_statistics", action="process_delete", page=p) | |||||
| tasks.start_task("afc_copyvios", action="process_delete", page=p) | |||||
| tasks.start("afc_statistics", action="process_delete", page=p) | |||||
| tasks.start("afc_copyvios", action="process_delete", page=p) | |||||
| chans.add("#wikipedia-en-afc") | chans.add("#wikipedia-en-afc") | ||||
| elif rc.flags == "restore" and r_restore.match(comment): | elif rc.flags == "restore" and r_restore.match(comment): | ||||
| p = r_restored_page.findall(rc.comment)[0] | p = r_restored_page.findall(rc.comment)[0] | ||||
| tasks.start_task("afc_statistics", action="process_restore", page=p) | |||||
| tasks.start_task("afc_copyvios", action="process_restore", page=p) | |||||
| tasks.start("afc_statistics", action="process_restore", page=p) | |||||
| tasks.start("afc_copyvios", action="process_restore", page=p) | |||||
| chans.add("#wikipedia-en-afc") | chans.add("#wikipedia-en-afc") | ||||
| elif rc.flags == "protect" and r_protect.match(comment): | elif rc.flags == "protect" and r_protect.match(comment): | ||||
| @@ -0,0 +1,109 @@ | |||||
| # -*- coding: utf-8 -*- | |||||
| """ | |||||
| EarwigBot's Wiki Task Manager | |||||
| This package provides the wiki bot "tasks" EarwigBot runs. Here in __init__, | |||||
| you can find some functions used to load and run these tasks. | |||||
| """ | |||||
| import os | |||||
| import sys | |||||
| import threading | |||||
| import time | |||||
| import traceback | |||||
| from classes import BaseTask | |||||
| import config | |||||
| __all__ = ["load", "schedule", "start", "get_all"] | |||||
| # Base directory when searching for tasks: | |||||
| base_dir = os.path.join(config.root_dir, "bot", "tasks") | |||||
| # Store loaded tasks as a dict where the key is the task name and the value is | |||||
| # an instance of the task class: | |||||
| _tasks = {} | |||||
| def _load_task(filename): | |||||
| """Try to load a specific task from a module, identified by file name.""" | |||||
| global _tasks | |||||
| # Strip .py from the end of the filename and join with our package name: | |||||
| name = ".".join(("tasks", filename[:-3])) | |||||
| try: | |||||
| __import__(name) | |||||
| except: | |||||
| print "Couldn't load file {0}:".format(filename) | |||||
| traceback.print_exc() | |||||
| return | |||||
| task = sys.modules[name].Task() | |||||
| if not isinstance(task, BaseTask): | |||||
| return | |||||
| _tasks[task.name] = task | |||||
| print "Added task {0}...".format(task.name) | |||||
| def _wrapper(task, **kwargs): | |||||
| """Wrapper for task classes: run the task and catch any errors.""" | |||||
| try: | |||||
| task.run(**kwargs) | |||||
| except: | |||||
| error = "Task '{0}' raised an exception and had to stop:" | |||||
| print error.format(task.name) | |||||
| traceback.print_exc() | |||||
| else: | |||||
| print "Task '{0}' finished without error.".format(task.name) | |||||
| def load(): | |||||
| """Load all valid tasks from bot/tasks/, into the _tasks variable.""" | |||||
| files = os.listdir(base_dir) | |||||
| files.sort() | |||||
| for filename in files: | |||||
| if filename.startswith("_") or not filename.endswith(".py"): | |||||
| continue | |||||
| try: | |||||
| _load_task(filename) | |||||
| except AttributeError: | |||||
| pass # The file is doesn't contain a task, so just move on | |||||
| print "Found {0} tasks: {1}.".format(len(_tasks), ', '.join(_tasks.keys())) | |||||
| def schedule(now=time.gmtime()): | |||||
| """Start all tasks that are supposed to be run at a given time.""" | |||||
| # Get list of tasks to run this turn: | |||||
| tasks = config.schedule(now.tm_min, now.tm_hour, now.tm_mday, now.tm_mon, | |||||
| now.tm_wday) | |||||
| for task in tasks: | |||||
| if isinstance(task, list): # they've specified kwargs | |||||
| start(task[0], **task[1]) # so pass those to start_task | |||||
| else: # otherwise, just pass task_name | |||||
| start(task) | |||||
| def start(task_name, **kwargs): | |||||
| """Start a given task in a new thread. Pass args to the task's run() | |||||
| function.""" | |||||
| print "Starting task '{0}' in a new thread...".format(task_name) | |||||
| try: | |||||
| task = _tasks[task_name] | |||||
| except KeyError: | |||||
| error = "Couldn't find task '{0}': bot/tasks/{0}.py does not exist." | |||||
| print error.format(task_name) | |||||
| return | |||||
| task_thread = threading.Thread(target=lambda: _wrapper(task, **kwargs)) | |||||
| start_time = time.strftime("%b %d %H:%M:%S") | |||||
| task_thread.name = "{0} ({1})".format(task_name, start_time) | |||||
| # Stop bot task threads automagically if the main bot stops: | |||||
| task_thread.daemon = True | |||||
| task_thread.start() | |||||
| def get_all(): | |||||
| """Return our dict of all loaded tasks.""" | |||||
| return _tasks | |||||
| @@ -1,11 +1,11 @@ | |||||
| # -*- coding: utf-8 -*- | # -*- coding: utf-8 -*- | ||||
| from wiki.base_task import BaseTask | |||||
| from classes import BaseTask | |||||
| class Task(BaseTask): | class Task(BaseTask): | ||||
| """A task to delink mainspace categories in declined [[WP:AFC]] | """A task to delink mainspace categories in declined [[WP:AFC]] | ||||
| submissions.""" | submissions.""" | ||||
| task_name = "afc_catdelink" | |||||
| name = "afc_catdelink" | |||||
| def __init__(self): | def __init__(self): | ||||
| pass | pass | ||||
| @@ -1,11 +1,11 @@ | |||||
| # -*- coding: utf-8 -*- | # -*- coding: utf-8 -*- | ||||
| from wiki.base_task import BaseTask | |||||
| from classes import BaseTask | |||||
| class Task(BaseTask): | class Task(BaseTask): | ||||
| """A task to check newly-edited [[WP:AFC]] submissions for copyright | """A task to check newly-edited [[WP:AFC]] submissions for copyright | ||||
| violations.""" | violations.""" | ||||
| task_name = "afc_copyvios" | |||||
| name = "afc_copyvios" | |||||
| def __init__(self): | def __init__(self): | ||||
| pass | pass | ||||
| @@ -1,10 +1,10 @@ | |||||
| # -*- coding: utf-8 -*- | # -*- coding: utf-8 -*- | ||||
| from wiki.base_task import BaseTask | |||||
| from classes import BaseTask | |||||
| class Task(BaseTask): | class Task(BaseTask): | ||||
| """ A task to create daily categories for [[WP:AFC]].""" | """ A task to create daily categories for [[WP:AFC]].""" | ||||
| task_name = "afc_dailycats" | |||||
| name = "afc_dailycats" | |||||
| def __init__(self): | def __init__(self): | ||||
| pass | pass | ||||
| @@ -1,14 +1,17 @@ | |||||
| # -*- coding: utf-8 -*- | # -*- coding: utf-8 -*- | ||||
| from wiki.base_task import BaseTask | |||||
| import time | |||||
| from classes import BaseTask | |||||
| class Task(BaseTask): | class Task(BaseTask): | ||||
| """A task to generate statistics for [[WP:AFC]] and save them to | """A task to generate statistics for [[WP:AFC]] and save them to | ||||
| [[Template:AFC_statistics]].""" | [[Template:AFC_statistics]].""" | ||||
| task_name = "afc_statistics" | |||||
| name = "afc_statistics" | |||||
| def __init__(self): | def __init__(self): | ||||
| pass | pass | ||||
| def run(self, **kwargs): | def run(self, **kwargs): | ||||
| time.sleep(5) | |||||
| print kwargs | print kwargs | ||||
| @@ -1,10 +1,10 @@ | |||||
| # -*- coding: utf-8 -*- | # -*- coding: utf-8 -*- | ||||
| from wiki.base_task import BaseTask | |||||
| from classes import BaseTask | |||||
| class Task(BaseTask): | class Task(BaseTask): | ||||
| """A task to clear [[Category:Undated AfC submissions]].""" | """A task to clear [[Category:Undated AfC submissions]].""" | ||||
| task_name = "afc_undated" | |||||
| name = "afc_undated" | |||||
| def __init__(self): | def __init__(self): | ||||
| pass | pass | ||||
| @@ -1,11 +1,11 @@ | |||||
| # -*- coding: utf-8 -*- | # -*- coding: utf-8 -*- | ||||
| from wiki.base_task import BaseTask | |||||
| from classes import BaseTask | |||||
| class Task(BaseTask): | class Task(BaseTask): | ||||
| """A task to add |blp=yes to {{WPB}} or {{WPBS}} when it is used along with | """A task to add |blp=yes to {{WPB}} or {{WPBS}} when it is used along with | ||||
| {{WP Biography}}.""" | {{WP Biography}}.""" | ||||
| task_name = "blptag" | |||||
| name = "blptag" | |||||
| def __init__(self): | def __init__(self): | ||||
| pass | pass | ||||
| @@ -1,10 +1,10 @@ | |||||
| # -*- coding: utf-8 -*- | # -*- coding: utf-8 -*- | ||||
| from wiki.base_task import BaseTask | |||||
| from classes import BaseTask | |||||
| class Task(BaseTask): | class Task(BaseTask): | ||||
| """A task to create daily categories for [[WP:FEED]].""" | """A task to create daily categories for [[WP:FEED]].""" | ||||
| task_name = "feed_dailycats" | |||||
| name = "feed_dailycats" | |||||
| def __init__(self): | def __init__(self): | ||||
| pass | pass | ||||
| @@ -1,11 +1,11 @@ | |||||
| # -*- coding: utf-8 -*- | # -*- coding: utf-8 -*- | ||||
| from wiki.base_task import BaseTask | |||||
| from classes import BaseTask | |||||
| class Task(BaseTask): | class Task(BaseTask): | ||||
| """A task to tag files whose extensions do not agree with their MIME | """A task to tag files whose extensions do not agree with their MIME | ||||
| type.""" | type.""" | ||||
| task_name = "wrongmime" | |||||
| name = "wrongmime" | |||||
| def __init__(self): | def __init__(self): | ||||
| pass | pass | ||||
| @@ -0,0 +1,91 @@ | |||||
| # -*- coding: utf-8 -*- | |||||
| """ | |||||
| EarwigBot's IRC Watcher Component | |||||
| The IRC watcher runs on a wiki recent-changes server and listens for edits. | |||||
| Users cannot interact with this part of the bot. When an event occurs, we run | |||||
| it through rules.py's process() function, which can result in wiki bot tasks | |||||
| being started (located in tasks/) or messages being sent to channels on the IRC | |||||
| frontend. | |||||
| """ | |||||
| import config | |||||
| from classes import Connection, RC, BrokenSocketException | |||||
| import rules | |||||
| frontend_conn = None | |||||
| def get_connection(): | |||||
| """Return a new Connection() instance with connection information. | |||||
| Don't actually connect yet. | |||||
| """ | |||||
| cf = config.irc["watcher"] | |||||
| connection = Connection(cf["host"], cf["port"], cf["nick"], cf["ident"], | |||||
| cf["realname"]) | |||||
| return connection | |||||
| def main(connection, f_conn=None): | |||||
| """Main loop for the Watcher IRC Bot component. | |||||
| get_connection() should have already been called and the connection should | |||||
| have been started with connection.connect(). Accept the frontend connection | |||||
| as well as an optional parameter in order to send messages directly to | |||||
| frontend IRC channels. | |||||
| """ | |||||
| global frontend_conn | |||||
| frontend_conn = f_conn | |||||
| read_buffer = str() | |||||
| while 1: | |||||
| try: | |||||
| read_buffer = read_buffer + connection.get() | |||||
| except BrokenSocketException: | |||||
| return | |||||
| lines = read_buffer.split("\n") | |||||
| read_buffer = lines.pop() | |||||
| for line in lines: | |||||
| _process_message(connection, line) | |||||
| def _process_message(connection, line): | |||||
| """Process a single message from IRC.""" | |||||
| line = line.strip().split() | |||||
| if line[1] == "PRIVMSG": | |||||
| chan = line[2] | |||||
| # Ignore messages originating from channels not in our list, to prevent | |||||
| # someone PMing us false data: | |||||
| if chan not in config.irc["watcher"]["channels"]: | |||||
| return | |||||
| msg = ' '.join(line[3:])[1:] | |||||
| rc = RC(msg) # new RC object to store this event's data | |||||
| rc.parse() # parse a message into pagenames, usernames, etc. | |||||
| process_rc(rc) # report to frontend channels or start tasks | |||||
| # If we are pinged, pong back to the server: | |||||
| elif line[0] == "PING": | |||||
| msg = " ".join(("PONG", line[1])) | |||||
| connection.send(msg) | |||||
| # When we've finished starting up, join all watcher channels: | |||||
| elif line[1] == "376": | |||||
| for chan in config.irc["watcher"]["channels"]: | |||||
| connection.join(chan) | |||||
| def process_rc(rc): | |||||
| """Process a recent change event from IRC (or, an RC object). | |||||
| The actual processing is configurable, so we don't have that hard-coded | |||||
| here. We simply call rules's process() function and expect a list of | |||||
| channels back, which we report the event data to. | |||||
| """ | |||||
| chans = rules.process(rc) | |||||
| if chans and frontend_conn: | |||||
| pretty = rc.prettify() | |||||
| for chan in chans: | |||||
| frontend_conn.say(chan, pretty) | |||||
| @@ -0,0 +1,20 @@ | |||||
| # -*- coding: utf-8 -*- | |||||
| """ | |||||
| EarwigBot's Wiki Toolset | |||||
| 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 | |||||
| written by Mr.Z-man, other than a similar purpose. We share no code. | |||||
| Import the toolset with `import wiki`. | |||||
| """ | |||||
| from wiki.constants import * | |||||
| from wiki.exceptions import * | |||||
| from wiki.functions import * | |||||
| from wiki.category import Category | |||||
| from wiki.page import Page | |||||
| from wiki.site import Site | |||||
| from wiki.user import User | |||||
| @@ -1,6 +1,6 @@ | |||||
| # -*- coding: utf-8 -*- | # -*- coding: utf-8 -*- | ||||
| from wiki.tools.page import Page | |||||
| from wiki.page import Page | |||||
| class Category(Page): | class Category(Page): | ||||
| """ | """ | ||||
| @@ -24,7 +24,7 @@ class Category(Page): | |||||
| up to 500, and bots can go up to 5,000 on a single API query. | up to 500, and bots can go up to 5,000 on a single API query. | ||||
| """ | """ | ||||
| params = {"action": "query", "list": "categorymembers", | params = {"action": "query", "list": "categorymembers", | ||||
| "cmlimit": limit, "cmtitle": self.title} | |||||
| "cmlimit": limit, "cmtitle": self._title} | |||||
| result = self._site._api_query(params) | result = self._site._api_query(params) | ||||
| members = result['query']['categorymembers'] | members = result['query']['categorymembers'] | ||||
| return [member["title"] for member in members] | return [member["title"] for member in members] | ||||
| @@ -6,7 +6,7 @@ EarwigBot's Wiki Toolset: Constants | |||||
| This module defines some useful constants, such as default namespace IDs for | This module defines some useful constants, such as default namespace IDs for | ||||
| easy lookup and our user agent. | easy lookup and our user agent. | ||||
| Import with `from wiki.tools.constants import *`. | |||||
| Import with `from wiki.constants import *`. | |||||
| """ | """ | ||||
| import platform | import platform | ||||
| @@ -3,7 +3,7 @@ | |||||
| """ | """ | ||||
| EarwigBot's Wiki Toolset: Exceptions | EarwigBot's Wiki Toolset: Exceptions | ||||
| This module contains all exceptions used by the wiki.tools package. | |||||
| This module contains all exceptions used by the wiki package. | |||||
| """ | """ | ||||
| class WikiToolsetError(Exception): | class WikiToolsetError(Exception): | ||||
| @@ -3,11 +3,11 @@ | |||||
| """ | """ | ||||
| EarwigBot's Wiki Toolset: Misc Functions | EarwigBot's Wiki Toolset: Misc Functions | ||||
| This module, a component of the wiki.tools package, contains miscellaneous | |||||
| functions that are not methods of any class, like get_site(). | |||||
| 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 | There's no need to import this module explicitly. All functions here are | ||||
| automatically available from wiki.tools. | |||||
| automatically available from wiki. | |||||
| """ | """ | ||||
| from cookielib import LWPCookieJar, LoadError | from cookielib import LWPCookieJar, LoadError | ||||
| @@ -16,9 +16,9 @@ from getpass import getpass | |||||
| from os import chmod, path | from os import chmod, path | ||||
| import stat | import stat | ||||
| from core import config | |||||
| from wiki.tools.exceptions import SiteNotFoundError | |||||
| from wiki.tools.site import Site | |||||
| import config | |||||
| from wiki.exceptions import SiteNotFoundError | |||||
| from wiki.site import Site | |||||
| __all__ = ["get_site"] | __all__ = ["get_site"] | ||||
| @@ -84,10 +84,18 @@ def _get_site_object_from_dict(name, d): | |||||
| article_path = d.get("articlePath") | article_path = d.get("articlePath") | ||||
| script_path = d.get("scriptPath") | script_path = d.get("scriptPath") | ||||
| sql = (d.get("sqlServer"), d.get("sqlDB")) | sql = (d.get("sqlServer"), d.get("sqlDB")) | ||||
| namespaces = d.get("namespaces") | |||||
| namespaces = d.get("namespaces", {}) | |||||
| login = (config.wiki.get("username"), config.wiki.get("password")) | login = (config.wiki.get("username"), config.wiki.get("password")) | ||||
| cookiejar = _get_cookiejar() | cookiejar = _get_cookiejar() | ||||
| for key, value in namespaces.items(): # Convert string keys to integers | |||||
| del namespaces[key] | |||||
| try: | |||||
| namespaces[int(key)] = value | |||||
| except ValueError: # Data is broken, ignore it | |||||
| namespaces = None | |||||
| break | |||||
| return Site(name=name, project=project, lang=lang, base_url=base_url, | return Site(name=name, project=project, lang=lang, base_url=base_url, | ||||
| article_path=article_path, script_path=script_path, sql=sql, | article_path=article_path, script_path=script_path, sql=sql, | ||||
| namespaces=namespaces, login=login, cookiejar=cookiejar) | namespaces=namespaces, login=login, cookiejar=cookiejar) | ||||
| @@ -3,7 +3,7 @@ | |||||
| import re | import re | ||||
| from urllib import quote | from urllib import quote | ||||
| from wiki.tools.exceptions import * | |||||
| from wiki.exceptions import * | |||||
| class Page(object): | class Page(object): | ||||
| """ | """ | ||||
| @@ -20,6 +20,7 @@ class Page(object): | |||||
| url -- returns the page's URL | url -- returns the page's URL | ||||
| namespace -- returns the page's namespace as an integer | namespace -- returns the page's namespace as an integer | ||||
| protection -- returns the page's current protection status | protection -- returns the page's current protection status | ||||
| creator -- returns the page's creator (first user to edit) | |||||
| is_talkpage -- returns True if the page is a talkpage, else False | is_talkpage -- returns True if the page is a talkpage, else False | ||||
| is_redirect -- returns True if the page is a redirect, else False | is_redirect -- returns True if the page is a redirect, else False | ||||
| toggle_talk -- returns a content page's talk page, or vice versa | toggle_talk -- returns a content page's talk page, or vice versa | ||||
| @@ -51,6 +52,7 @@ class Page(object): | |||||
| self._protection = None | self._protection = None | ||||
| self._fullurl = None | self._fullurl = None | ||||
| self._content = None | self._content = None | ||||
| self._creator = None | |||||
| # Try to determine the page's namespace using our site's namespace | # Try to determine the page's namespace using our site's namespace | ||||
| # converter: | # converter: | ||||
| @@ -122,15 +124,17 @@ class Page(object): | |||||
| """Loads various data from the API in a single query. | """Loads various data from the API in a single query. | ||||
| Loads self._title, ._exists, ._is_redirect, ._pageid, ._fullurl, | Loads self._title, ._exists, ._is_redirect, ._pageid, ._fullurl, | ||||
| ._protection, ._namespace, ._is_talkpage, and ._lastrevid using the | |||||
| API. It will do a query of its own unless `result` is provided, in | |||||
| which case we'll pretend `result` is what the query returned. | |||||
| ._protection, ._namespace, ._is_talkpage, ._creator, and ._lastrevid | |||||
| using the API. It will do a query of its own unless `result` is | |||||
| provided, in which case we'll pretend `result` is what the query | |||||
| returned. | |||||
| 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 result is None: | ||||
| params = {"action": "query", "prop": "info", "titles": self._title, | |||||
| "inprop": "protection|url"} | |||||
| params = {"action": "query", "rvprop": "user", "rvdir": "newer", | |||||
| "prop": "info|revisions", "rvlimit": 1, | |||||
| "titles": self._title, "inprop": "protection|url"} | |||||
| result = self._site._api_query(params) | result = self._site._api_query(params) | ||||
| res = result["query"]["pages"].values()[0] | res = result["query"]["pages"].values()[0] | ||||
| @@ -169,9 +173,10 @@ class Page(object): | |||||
| self._namespace = res["ns"] | self._namespace = res["ns"] | ||||
| self._is_talkpage = self._namespace % 2 == 1 # talkpages have odd IDs | self._is_talkpage = self._namespace % 2 == 1 # talkpages have odd IDs | ||||
| # This last field will only be specified if the page exists: | |||||
| # These last two fields will only be specified if the page exists: | |||||
| self._lastrevid = res.get("lastrevid") | |||||
| try: | try: | ||||
| self._lastrevid = res["lastrevid"] | |||||
| self._creator = res['revisions'][0]['user'] | |||||
| except KeyError: | except KeyError: | ||||
| pass | pass | ||||
| @@ -287,6 +292,27 @@ class Page(object): | |||||
| self._force_validity() # invalid pages cannot be protected | self._force_validity() # invalid pages cannot be protected | ||||
| return self._protection | return self._protection | ||||
| def creator(self, force=False): | |||||
| """Returns the page's creator (i.e., the first user to edit the page). | |||||
| Makes an API query if force is True or if we haven't already made one. | |||||
| Normally, we can get the creator along with everything else (except | |||||
| content) in self._load_attributes(). However, due to a limitation in | |||||
| the API (can't get the editor of one revision and the content of | |||||
| another at both ends of the history), if our other attributes were only | |||||
| loaded from get(), we'll have to do another API query. This is done | |||||
| by calling ourselves again with force=True. | |||||
| Raises InvalidPageError or PageNotFoundError if the page name is | |||||
| invalid or the page does not exist, respectively. | |||||
| """ | |||||
| if self._exists == 0 or force: | |||||
| self._load_wrapper() | |||||
| self._force_existence() | |||||
| if not self._creator and not force: | |||||
| self.creator(force=True) | |||||
| return self._creator | |||||
| def is_talkpage(self, force=False): | def is_talkpage(self, force=False): | ||||
| """Returns True if the page is a talkpage, else False. | """Returns True if the page is a talkpage, else False. | ||||
| @@ -9,11 +9,11 @@ from urllib import unquote_plus, urlencode | |||||
| from urllib2 import build_opener, HTTPCookieProcessor, URLError | from urllib2 import build_opener, HTTPCookieProcessor, URLError | ||||
| from urlparse import urlparse | from urlparse import urlparse | ||||
| from wiki.tools.category import Category | |||||
| from wiki.tools.constants import * | |||||
| from wiki.tools.exceptions import * | |||||
| from wiki.tools.page import Page | |||||
| from wiki.tools.user import User | |||||
| from wiki.category import Category | |||||
| from wiki.constants import * | |||||
| from wiki.exceptions import * | |||||
| from wiki.page import Page | |||||
| from wiki.user import User | |||||
| class Site(object): | class Site(object): | ||||
| """ | """ | ||||
| @@ -96,8 +96,9 @@ class Site(object): | |||||
| We'll encode the given params, adding format=json along the way, and | We'll encode the given params, adding format=json along the way, and | ||||
| make the request through self._opener, which has built-in cookie | make the request through self._opener, which has built-in cookie | ||||
| support via self._cookiejar, a User-Agent | |||||
| (wiki.tools.constants.USER_AGENT), and Accept-Encoding set to "gzip". | |||||
| support via self._cookiejar, a User-Agent (wiki.constants.USER_AGENT), | |||||
| and Accept-Encoding set to "gzip". | |||||
| Assuming everything went well, we'll gunzip the data (if compressed), | Assuming everything went well, we'll gunzip the data (if compressed), | ||||
| load it as a JSON object, and return it. | load it as a JSON object, and return it. | ||||
| @@ -153,7 +154,7 @@ class Site(object): | |||||
| params = {"action": "query", "meta": "siteinfo"} | params = {"action": "query", "meta": "siteinfo"} | ||||
| if self._namespaces is None or force: | |||||
| if not self._namespaces or force: | |||||
| params["siprop"] = "general|namespaces|namespacealiases" | params["siprop"] = "general|namespaces|namespacealiases" | ||||
| result = self._api_query(params) | result = self._api_query(params) | ||||
| self._load_namespaces(result) | self._load_namespaces(result) | ||||
| @@ -2,9 +2,9 @@ | |||||
| from time import strptime | from time import strptime | ||||
| from wiki.tools.constants import * | |||||
| from wiki.tools.exceptions import UserNotFoundError | |||||
| from wiki.tools.page import Page | |||||
| from wiki.constants import * | |||||
| from wiki.exceptions import UserNotFoundError | |||||
| from wiki.page import Page | |||||
| class User(object): | class User(object): | ||||
| """ | """ | ||||
| @@ -94,7 +94,10 @@ class User(object): | |||||
| self._blockinfo = False | self._blockinfo = False | ||||
| self._groups = res["groups"] | self._groups = res["groups"] | ||||
| self._rights = res["rights"].values() | |||||
| try: | |||||
| self._rights = res["rights"].values() | |||||
| except AttributeError: | |||||
| self._rights = res["rights"] | |||||
| self._editcount = res["editcount"] | self._editcount = res["editcount"] | ||||
| reg = res["registration"] | reg = res["registration"] | ||||
| @@ -20,7 +20,7 @@ from os import path | |||||
| from sys import executable | from sys import executable | ||||
| from time import sleep | from time import sleep | ||||
| from core.config import verify_config | |||||
| from bot import config | |||||
| __author__ = "Ben Kurtovic" | __author__ = "Ben Kurtovic" | ||||
| __copyright__ = "Copyright (C) 2009, 2010, 2011 by Ben Kurtovic" | __copyright__ = "Copyright (C) 2009, 2010, 2011 by Ben Kurtovic" | ||||
| @@ -28,12 +28,12 @@ __license__ = "MIT License" | |||||
| __version__ = "0.1-dev" | __version__ = "0.1-dev" | ||||
| __email__ = "ben.kurtovic@verizon.net" | __email__ = "ben.kurtovic@verizon.net" | ||||
| bot_script = path.join(path.dirname(path.abspath(__file__)), "core", "main.py") | |||||
| bot_script = path.join(path.dirname(path.abspath(__file__)), "bot", "main.py") | |||||
| def main(): | def main(): | ||||
| print "EarwigBot v{0}\n".format(__version__) | print "EarwigBot v{0}\n".format(__version__) | ||||
| is_encrypted = verify_config() | |||||
| is_encrypted = config.verify_config() | |||||
| if is_encrypted: # passwords in the config file are encrypted | if is_encrypted: # passwords in the config file are encrypted | ||||
| key = getpass("Enter key to unencrypt bot passwords: ") | key = getpass("Enter key to unencrypt bot passwords: ") | ||||
| else: | else: | ||||
| @@ -1,33 +0,0 @@ | |||||
| # -*- coding: utf-8 -*- | |||||
| # A base class for commands on IRC. | |||||
| class BaseCommand(object): | |||||
| def __init__(self, connection): | |||||
| """A base class for commands on IRC.""" | |||||
| self.connection = connection | |||||
| def get_hooks(self): | |||||
| """Hooks are: 'msg', 'msg_private', 'msg_public', and 'join'. Return | |||||
| the hooks you want this command to be called on.""" | |||||
| return [] | |||||
| def get_help(self, command): | |||||
| """Return help information for the command, used by !help. return None | |||||
| for no help. If a given class handles multiple commands, the command | |||||
| variable can be used to return different help for each one.""" | |||||
| return None | |||||
| def check(self, data): | |||||
| """Given a Data() object, return True if we should respond to this | |||||
| activity, or False if we should ignore it/it doesn't apply to us. Most | |||||
| commands return True if data.command == 'command_name', otherwise | |||||
| they return False.""" | |||||
| return False | |||||
| def process(self, data): | |||||
| """Handle an activity (usually a message) on IRC. At this point, thanks | |||||
| to self.check() which is called automatically by command_handler, we | |||||
| know this is something we should respond to, so (usually) a | |||||
| 'if data.command != "command_name": return' is unnecessary.""" | |||||
| pass | |||||
| @@ -1,75 +0,0 @@ | |||||
| # -*- coding: utf-8 -*- | |||||
| # A class to interface with IRC. | |||||
| import socket | |||||
| import threading | |||||
| class BrokenSocketException(Exception): | |||||
| """A socket has broken, because it is not sending data.""" | |||||
| pass | |||||
| class Connection(object): | |||||
| def __init__(self, host=None, port=None, nick=None, ident=None, realname=None): | |||||
| """a class to interface with IRC""" | |||||
| self.host = host | |||||
| self.port = port | |||||
| self.nick = nick | |||||
| self.ident = ident | |||||
| self.realname = realname | |||||
| def connect(self): | |||||
| """connect to IRC""" | |||||
| 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 IRC""" | |||||
| 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 (get) data from the server""" | |||||
| data = self.sock.recv(4096) | |||||
| if not data: # socket giving us no data, so it is dead/broken | |||||
| raise BrokenSocketException() | |||||
| return data | |||||
| def send(self, msg): | |||||
| """send data to the server""" | |||||
| lock = threading.Lock() | |||||
| lock.acquire() # ensure that we only send one message at a time (blocking) | |||||
| try: | |||||
| self.sock.sendall(msg + "\r\n") | |||||
| print " %s" % msg | |||||
| finally: | |||||
| lock.release() | |||||
| def say(self, target, msg): | |||||
| """send a message""" | |||||
| self.send("PRIVMSG %s :%s" % (target, msg)) | |||||
| def reply(self, data, msg): | |||||
| """send a message as a reply""" | |||||
| self.say(data.chan, "%s%s%s: %s" % (chr(2), data.nick, chr(0x0f), msg)) | |||||
| def action(self, target, msg): | |||||
| """send a message as an action""" | |||||
| self.say(target,"%sACTION %s%s" % (chr(1), msg, chr(1))) | |||||
| def notice(self, target, msg): | |||||
| """send a notice""" | |||||
| self.send("NOTICE %s :%s" % (target, msg)) | |||||
| def join(self, chan): | |||||
| """join a channel""" | |||||
| self.send("JOIN %s" % chan) | |||||
| def mode(self, chan, level, msg): | |||||
| """send a mode message""" | |||||
| self.send("MODE %s %s %s" % (chan, level, msg)) | |||||
| @@ -1,55 +0,0 @@ | |||||
| # -*- coding: utf-8 -*- | |||||
| # A class to store data from an individual line received on IRC. | |||||
| 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): | |||||
| def __init__(self): | |||||
| """store data from an individual line received on IRC""" | |||||
| self.line = str() | |||||
| self.chan = str() | |||||
| self.nick = str() | |||||
| self.ident = str() | |||||
| self.host = str() | |||||
| self.msg = str() | |||||
| def parse_args(self): | |||||
| """parse command arguments from self.msg into self.command and self.args""" | |||||
| args = self.msg.strip().split(' ') # strip out extra whitespace and split the message into a list | |||||
| while '' in args: # remove any empty arguments | |||||
| args.remove('') | |||||
| self.args = args[1:] # the command arguments | |||||
| self.is_command = False # whether this is a real command or not | |||||
| try: | |||||
| self.command = args[0] # the command itself | |||||
| except IndexError: | |||||
| self.command = None | |||||
| try: | |||||
| if self.command.startswith('!') or self.command.startswith('.'): | |||||
| self.is_command = True | |||||
| self.command = self.command[1:] # strip '!' or '.' | |||||
| self.command = self.command.lower() # lowercase command name | |||||
| except AttributeError: | |||||
| pass | |||||
| def parse_kwargs(self): | |||||
| """parse command arguments from self.args, given as !command key1=value1 key2=value2..., into a dict self.kwargs: {'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 not key or not value: | |||||
| raise KwargParseException(arg) | |||||
| self.kwargs[key] = value | |||||
| @@ -1,57 +0,0 @@ | |||||
| # -*- coding: utf-8 -*- | |||||
| # A class to store data on an individual event received from our IRC watcher. | |||||
| import re | |||||
| class RC(object): | |||||
| def __init__(self, msg): | |||||
| """store data on an individual event received from our IRC watcher""" | |||||
| self.msg = msg | |||||
| def parse(self): | |||||
| """parse recent changes log into some variables""" | |||||
| msg = self.msg | |||||
| msg = re.sub("\x03([0-9]{1,2}(,[0-9]{1,2})?)?", "", msg) # strip IRC color codes; we don't want/need 'em | |||||
| msg = msg.strip() | |||||
| self.msg = msg | |||||
| self.is_edit = True | |||||
| # flags: 'M' for minor edit, 'B' for bot edit, 'create' for a user creation log entry... | |||||
| try: | |||||
| page, flags, url, user, comment = re.findall("\A\[\[(.*?)\]\]\s(.*?)\s(http://.*?)\s\*\s(.*?)\s\*\s(.*?)\Z", 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 = re.findall("\A\[\[(.*?)\]\]\s(.*?)\s\*\s(.*?)\s\*\s(.*?)\Z", msg)[0] | |||||
| url = "http://en.wikipedia.org/wiki/{}".format(page) | |||||
| flags = flags.strip() # flag tends to have a extraneous whitespace character at the end when it's a log entry | |||||
| self.is_edit = False # this is a log entry, not edit | |||||
| self.page, self.flags, self.url, self.user, self.comment = page, flags, url, user, comment | |||||
| def get_pretty(self): | |||||
| """make a nice, colorful message from self.msg to send to the front-end""" | |||||
| flags = self.flags | |||||
| event_type = flags # "New <event>:" if we don't know exactly what happened | |||||
| 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: | |||||
| event_type = "bot {}".format(event_type) # "New bot edit:" | |||||
| if "M" in flags: | |||||
| event_type = "minor {}".format(event_type) # "New minor edit:" OR "New minor bot edit:" | |||||
| if self.is_edit: | |||||
| pretty = "\x02New {0}\x0F: \x0314[[\x0307{1}\x0314]]\x0306 *\x0303 {2}\x0306 *\x0302 {3}\x0306 *\x0310 {4}".format(event_type, self.page, self.user, self.url, self.comment) | |||||
| else: | |||||
| pretty = "\x02New {0}\x0F: \x0303{1}\x0306 *\x0302 {2}\x0306 *\x0310 {3}".format(event_type, self.user, self.url, self.comment) | |||||
| return pretty | |||||
| @@ -1,66 +0,0 @@ | |||||
| # -*- coding: utf-8 -*- | |||||
| # A module to manage IRC commands. | |||||
| import os | |||||
| import traceback | |||||
| commands = [] | |||||
| def load_commands(connection): | |||||
| """load all valid command classes from irc/commmands/ into the commands variable""" | |||||
| files = os.listdir(os.path.join("irc", "commands")) # get all files in irc/commands/ | |||||
| files.sort() # alphabetically sort list of files | |||||
| for f in files: | |||||
| if f.startswith("_") or not f.endswith(".py"): # ignore non-python files or files beginning with "_" | |||||
| continue | |||||
| module = f[:-3] # strip .py from end | |||||
| try: | |||||
| exec "from irc.commands import %s" % module | |||||
| except: # importing the file failed for some reason... | |||||
| print "Couldn't load file %s:" % f | |||||
| traceback.print_exc() | |||||
| continue | |||||
| process_module(connection, eval(module)) # 'module' is a string, so get the actual object for processing by eval-ing it | |||||
| pretty_cmnds = map(lambda c: c.__class__.__name__, commands) | |||||
| print "Found %s command classes: %s." % (len(commands), ', '.join(pretty_cmnds)) | |||||
| def process_module(connection, module): | |||||
| """go through all objects in a module and add valid command classes to the commands variable""" | |||||
| global commands | |||||
| objects = dir(module) | |||||
| for this_obj in objects: # go through everything in the file | |||||
| obj = eval("module.%s" % this_obj) # this_obj is a string, so get the actual object corresponding to that string | |||||
| try: | |||||
| bases = obj.__bases__ | |||||
| except AttributeError: # object isn't a valid class, so ignore it | |||||
| continue | |||||
| for base in bases: | |||||
| if base.__name__ == "BaseCommand": # this inherits BaseCommand, so it must be a command class | |||||
| command = obj(connection) # initialize a new command object | |||||
| commands.append(command) | |||||
| print "Added command class %s from %s..." % (this_obj, module.__name__) | |||||
| continue | |||||
| def get_commands(): | |||||
| """get our commands""" | |||||
| return commands | |||||
| def check(hook, data): | |||||
| """given an event on IRC, check if there's anything we can respond to by calling each command class""" | |||||
| data.parse_args() # parse command arguments into data.command and data.args | |||||
| for command in commands: | |||||
| if hook in command.get_hooks(): | |||||
| if command.check(data): | |||||
| try: | |||||
| command.process(data) | |||||
| except: | |||||
| print "Error executing command '{}':".format(data.command) | |||||
| traceback.print_exc() # catch exceptions and print them | |||||
| break | |||||
| @@ -1,104 +0,0 @@ | |||||
| # -*- coding: utf-8 -*- | |||||
| """ | |||||
| Get information about an AFC submission by name. | |||||
| """ | |||||
| import json | |||||
| import re | |||||
| import urllib | |||||
| from irc.classes import BaseCommand | |||||
| class AFCReport(BaseCommand): | |||||
| def get_hooks(self): | |||||
| return ["msg"] | |||||
| def get_help(self, command): | |||||
| return "Get information about an AFC submission by name." | |||||
| def check(self, data): | |||||
| if data.is_command and data.command in ["report", "afc_report"]: | |||||
| return True | |||||
| return False | |||||
| def process(self, data): | |||||
| self.data = data | |||||
| if not data.args: | |||||
| self.connection.reply(data, "what submission do you want me to give information about?") | |||||
| return | |||||
| pagename = ' '.join(data.args) | |||||
| pagename = pagename.replace("http://en.wikipedia.org/wiki/", "").replace("http://enwp.org/", "").replace("_", " ") | |||||
| pagename = pagename.strip() | |||||
| if self.page_exists(pagename): # given '!report Foo', first try [[Foo]] | |||||
| self.report(pagename) | |||||
| else: # if that doesn't work, try [[Wikipedia:Articles for creation/Foo]] | |||||
| if self.page_exists("Wikipedia:Articles for creation/" + pagename): | |||||
| self.report("Wikipedia:Articles for creation/" + pagename) | |||||
| else: # if that doesn't work, try [[Wikipedia talk:Articles for creation/Foo]] | |||||
| if self.page_exists("Wikipedia talk:Articles for creation/" + pagename): | |||||
| self.report("Wikipedia talk:Articles for creation/" + pagename) | |||||
| else: | |||||
| self.connection.reply(data, "submission \x0302{0}\x0301 not found.".format(pagename)) | |||||
| def report(self, pagename): | |||||
| data = self.data | |||||
| shortname = pagename.replace("Wikipedia:Articles for creation/", "").replace("Wikipedia talk:Articles for creation/", "") | |||||
| url = "http://enwp.org/" + urllib.quote(pagename.replace(" ", "_")) | |||||
| status = self.get_status(pagename) | |||||
| user, user_url = self.get_creator(pagename) | |||||
| self.connection.reply(data, "AfC submission report for \x0302{0}\x0301 ({1}):".format(shortname, url)) | |||||
| self.connection.say(data.chan, "Status: \x0303{0}\x0301".format(status)) | |||||
| if status == "accepted": # the first edit will be the redirect [[WT:AFC/Foo]] -> [[Foo]], NOT the creation of the submission | |||||
| self.connection.say(data.chan, "Reviewed by \x0302{0}\x0301 ({1})".format(user, user_url)) | |||||
| else: | |||||
| self.connection.say(data.chan, "Submitted by \x0302{0}\x0301 ({1})".format(user, user_url)) | |||||
| def page_exists(self, pagename): | |||||
| params = {'action': 'query', 'format': 'json', 'titles': pagename} | |||||
| data = urllib.urlencode(params) | |||||
| raw = urllib.urlopen("http://en.wikipedia.org/w/api.php", data).read() | |||||
| res = json.loads(raw) | |||||
| try: | |||||
| res['query']['pages'].values()[0]['missing'] # this key will appear if the page does not exist | |||||
| return False | |||||
| except KeyError: # if it's not there, the page exists | |||||
| return True | |||||
| def get_status(self, pagename): | |||||
| params = {'action': 'query', 'prop': 'revisions', 'rvprop':'content', 'rvlimit':'1', 'format': 'json'} | |||||
| params['titles'] = pagename | |||||
| data = urllib.urlencode(params) | |||||
| raw = urllib.urlopen("http://en.wikipedia.org/w/api.php", data).read() | |||||
| res = json.loads(raw) | |||||
| pageid = res['query']['pages'].keys()[0] | |||||
| content = res['query']['pages'][pageid]['revisions'][0]['*'] | |||||
| lcontent = content.lower() | |||||
| if re.search("\{\{afc submission\|r\|(.*?)\}\}", lcontent): | |||||
| return "being reviewed" | |||||
| elif re.search("\{\{afc submission\|\|(.*?)\}\}", lcontent): | |||||
| return "pending" | |||||
| elif re.search("\{\{afc submission\|d\|(.*?)\}\}", lcontent): | |||||
| try: | |||||
| reason = re.findall("\{\{afc submission\|d\|(.*?)(\||\}\})", lcontent)[0][0] | |||||
| return "declined with reason \"{0}\"".format(reason) | |||||
| except IndexError: | |||||
| return "declined" | |||||
| else: | |||||
| if "#redirect" in content: | |||||
| return "accepted" | |||||
| else: | |||||
| return "unkown" | |||||
| def get_creator(self, pagename): | |||||
| params = {'action': 'query', 'prop': 'revisions', 'rvprop': 'user', 'rvdir': 'newer', 'rvlimit': '1', 'format': 'json'} | |||||
| params['titles'] = pagename | |||||
| data = urllib.urlencode(params) | |||||
| raw = urllib.urlopen("http://en.wikipedia.org/w/api.php", data).read() | |||||
| res = json.loads(raw) | |||||
| user = res['query']['pages'].values()[0]['revisions'][0]['user'] | |||||
| user_url = "http://enwp.org/User_talk:" + urllib.quote(user.replace(" ", "_")) | |||||
| return user, user_url | |||||
| @@ -1,31 +0,0 @@ | |||||
| # -*- coding: utf-8 -*- | |||||
| # Voice/devoice/op/deop users in the channel. | |||||
| from irc.classes import BaseCommand | |||||
| from core import config | |||||
| class ChanOps(BaseCommand): | |||||
| def get_hooks(self): | |||||
| return ["msg"] | |||||
| def get_help(self, command): | |||||
| action = command.capitalize() | |||||
| return "%s users in the channel." % action | |||||
| def check(self, data): | |||||
| if data.is_command and data.command in ["voice", "devoice", "op", "deop"]: | |||||
| return True | |||||
| return False | |||||
| def process(self, data): | |||||
| if data.host not in config.irc["permissions"]["admins"]: | |||||
| self.connection.reply(data, "you must be a bot admin to use this command.") | |||||
| return | |||||
| if not data.args: # if it is just !op/!devoice/whatever without arguments, assume they want to do this to themselves | |||||
| target = data.nick | |||||
| else: | |||||
| target = data.args[0] | |||||
| self.connection.say("ChanServ", "%s %s %s" % (data.command, data.chan, target)) | |||||
| @@ -1,71 +0,0 @@ | |||||
| # -*- coding: utf-8 -*- | |||||
| """ | |||||
| Cryptography functions (hashing and cyphers) for EarwigBot IRC. | |||||
| """ | |||||
| import hashlib | |||||
| from irc.classes import BaseCommand | |||||
| from lib import blowfish | |||||
| class Cryptography(BaseCommand): | |||||
| def get_hooks(self): | |||||
| return ["msg"] | |||||
| def get_help(self, command): | |||||
| if command == "hash": | |||||
| return ("Return the hash of a string using a given algorithm, " + | |||||
| "e.g. '!hash sha512 Hello world!'. Use '!hash list' for " + | |||||
| "a list of supported algorithms.") | |||||
| elif command == "encrypt": | |||||
| return ("Encrypt any string with a given key using an " + | |||||
| "implementation of Blowfish, e.g. '!encrypt some_key " + | |||||
| "Hello!'.") | |||||
| else: | |||||
| return ("Decrypt any string with a given key using an " + | |||||
| "implementation of Blowfish, e.g. '!decrypt some_key " + | |||||
| "762cee8a5239548af18275d6c1184f16'.") | |||||
| 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: | |||||
| self.connection.reply(data, "what do you want me to {0}?".format( | |||||
| data.command)) | |||||
| return | |||||
| if data.command == "hash": | |||||
| algo = data.args[0] | |||||
| if algo == "list": | |||||
| algos = ', '.join(hashlib.algorithms) | |||||
| self.connection.reply(data, "supported algorithms: " + algos + | |||||
| ".") | |||||
| elif algo in hashlib.algorithms: | |||||
| string = ' '.join(data.args[1:]) | |||||
| result = eval("hashlib.{0}(string)".format(algo)).hexdigest() | |||||
| self.connection.reply(data, result) | |||||
| else: | |||||
| self.connection.reply(data, "unknown algorithm: '{0}'.".format( | |||||
| algo)) | |||||
| else: | |||||
| key = data.args[0] | |||||
| text = ' '.join(data.args[1:]) | |||||
| if not text: | |||||
| self.connection.reply(data, ("a key was provided, but text " + | |||||
| "to {0} was not.").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: | |||||
| self.connection.reply(data, "{0}: {1}.".format( | |||||
| error.__class__.__name__, error)) | |||||
| @@ -1,163 +0,0 @@ | |||||
| # -*- coding: utf-8 -*- | |||||
| # Commands to interface with the bot's git repository; use '!git help' for sub-command list. | |||||
| import shlex | |||||
| import subprocess | |||||
| import re | |||||
| from irc.classes import BaseCommand | |||||
| from core import config | |||||
| class Git(BaseCommand): | |||||
| def get_hooks(self): | |||||
| return ["msg"] | |||||
| def get_help(self, command): | |||||
| return "Commands to interface with the bot's git repository; use '!git help' for sub-command list." | |||||
| def check(self, data): | |||||
| if data.is_command and data.command == "git": | |||||
| return True | |||||
| return False | |||||
| def process(self, data): | |||||
| self.data = data | |||||
| if data.host not in config.irc["permissions"]["owners"]: | |||||
| self.connection.reply(data, "you must be a bot owner to use this command.") | |||||
| return | |||||
| if not data.args: | |||||
| self.connection.reply(data, "no arguments provided. Maybe you wanted '!git help'?") | |||||
| 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 | |||||
| self.connection.reply(data, "unknown argument: \x0303%s\x0301." % data.args[0]) | |||||
| 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") | |||||
| self.connection.reply(self.data, "currently on branch \x0302%s\x0301." % branch) | |||||
| def do_branches(self): | |||||
| """get list of branches""" | |||||
| branches = self.exec_shell("git branch") | |||||
| branches = branches.replace('\n* ', ', ') # cleanup extraneous characters | |||||
| branches = branches.replace('* ', ' ') | |||||
| branches = branches.replace('\n ', ', ') | |||||
| branches = branches.strip() | |||||
| self.connection.reply(self.data, "branches: \x0302%s\x0301." % branches) | |||||
| 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: | |||||
| self.connection.reply(self.data, "already on \x0302%s\x0301!" % branch) | |||||
| else: | |||||
| self.connection.reply(self.data, "switched from branch \x0302%s\x0301 to \x0302%s\x0301." % (current_branch, branch)) | |||||
| except subprocess.CalledProcessError: # git couldn't switch branches | |||||
| self.connection.reply(self.data, "branch \x0302%s\x0301 doesn't exist!" % branch) | |||||
| def do_delete(self): | |||||
| """delete a branch, while making sure that we are not 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: | |||||
| self.connection.reply(self.data, "you're currently on this branch; please checkout to a different branch before deleting.") | |||||
| return | |||||
| try: | |||||
| self.exec_shell("git branch -d %s" % delete_branch) | |||||
| self.connection.reply(self.data, "branch \x0302%s\x0301 has been deleted locally." % delete_branch) | |||||
| except subprocess.CalledProcessError: # git couldn't delete | |||||
| self.connection.reply(self.data, "branch \x0302%s\x0301 doesn't exist!" % delete_branch) | |||||
| def do_pull(self): | |||||
| """pull from remote repository""" | |||||
| branch = self.exec_shell("git name-rev --name-only HEAD") | |||||
| self.connection.reply(self.data, "pulling from remote (currently on \x0302%s\x0301)..." % branch) | |||||
| result = self.exec_shell("git pull") | |||||
| if "Already up-to-date." in result: | |||||
| self.connection.reply(self.data, "done; no new changes.") | |||||
| else: | |||||
| changes = re.findall("\s*((.*?)\sfile(.*?)tions?\(-\))", result)[0][0] # find the changes | |||||
| try: | |||||
| remote = self.exec_shell("git config --get branch.%s.remote" % branch) | |||||
| url = self.exec_shell("git config --get remote.%s.url" % remote) | |||||
| self.connection.reply(self.data, "done; %s [from %s]." % (changes, url)) | |||||
| except subprocess.CalledProcessError: # something in .git/config is not specified correctly, so we cannot get the remote's url | |||||
| 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 | |||||
| self.connection.reply(self.data, "last commit was %s. Local copy is \x02up-to-date\x0F with remote." % last) | |||||
| else: | |||||
| self.connection.reply(self.data, "last local commit was %s. Remote is \x02ahead\x0F of local copy." % last) | |||||
| @@ -1,54 +0,0 @@ | |||||
| # -*- coding: utf-8 -*- | |||||
| # Generates help information. | |||||
| from irc.classes import BaseCommand, Data | |||||
| from irc import command_handler | |||||
| class Help(BaseCommand): | |||||
| def get_hooks(self): | |||||
| return ["msg"] | |||||
| def get_help(self, command): | |||||
| return "Generates help information." | |||||
| def check(self, data): | |||||
| if data.is_command and data.command == "help": | |||||
| return True | |||||
| return False | |||||
| def process(self, data): | |||||
| if not data.args: | |||||
| self.do_general_help(data) | |||||
| else: | |||||
| if data.args[0] == "list": | |||||
| self.do_list_help(data) | |||||
| else: | |||||
| self.do_command_help(data) | |||||
| def do_general_help(self, data): | |||||
| self.connection.reply(data, "I am a bot! You can get help for any command with '!help <command>', or a list of all loaded modules with '!help list'.") | |||||
| def do_list_help(self, data): | |||||
| commands = command_handler.get_commands() | |||||
| cmnds = map(lambda c: c.__class__.__name__, commands) | |||||
| pretty_cmnds = ', '.join(cmnds) | |||||
| self.connection.reply(data, "%s command classes loaded: %s." % (len(cmnds), pretty_cmnds)) | |||||
| def do_command_help(self, data): | |||||
| command = data.args[0] | |||||
| commands = command_handler.get_commands() | |||||
| dummy = Data() # dummy message to test which command classes pick up this command | |||||
| dummy.command = command.lower() # lowercase command name | |||||
| dummy.is_command = True | |||||
| for cmnd in commands: | |||||
| if cmnd.check(dummy): | |||||
| help = cmnd.get_help(command) | |||||
| break | |||||
| try: | |||||
| self.connection.reply(data, "info for command \x0303%s\x0301: \"%s\"" % (command, help)) | |||||
| except UnboundLocalError: | |||||
| self.connection.reply(data, "sorry, no help for \x0303%s\x0301." % command) | |||||
| @@ -1,50 +0,0 @@ | |||||
| # -*- coding: utf-8 -*- | |||||
| """ | |||||
| Set a message to be repeated to you in a certain amount of time. | |||||
| """ | |||||
| import threading | |||||
| import time | |||||
| from irc.classes import BaseCommand | |||||
| class Remind(BaseCommand): | |||||
| def get_hooks(self): | |||||
| return ["msg"] | |||||
| def get_help(self, command): | |||||
| return "Set a message to be repeated to you in a certain amount of time." | |||||
| 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: | |||||
| self.connection.reply(data, "please specify a time (in seconds) and a message in the following format: !remind <time> <msg>.") | |||||
| return | |||||
| try: | |||||
| wait = int(data.args[0]) | |||||
| except ValueError: | |||||
| self.connection.reply(data, "the time must be given as an integer, in seconds.") | |||||
| return | |||||
| message = ' '.join(data.args[1:]) | |||||
| if not message: | |||||
| self.connection.reply(data, "what message do you want me to give you when time is up?") | |||||
| return | |||||
| end_time = time.strftime("%b %d %H:%M:%S", time.localtime(time.time() + wait)) | |||||
| end_time_with_timezone = time.strftime("%b %d %H:%M:%S %Z", time.localtime(time.time() + wait)) | |||||
| self.connection.reply(data, 'Set reminder for "{0}" in {1} seconds (ends {2}).'.format(message, wait, end_time_with_timezone)) | |||||
| t_reminder = threading.Thread(target=self.reminder, args=(data, message, wait)) | |||||
| t_reminder.name = "reminder " + end_time | |||||
| t_reminder.daemon = True | |||||
| t_reminder.start() | |||||
| def reminder(self, data, message, wait): | |||||
| time.sleep(wait) | |||||
| self.connection.reply(data, message) | |||||
| @@ -1,38 +0,0 @@ | |||||
| # -*- coding: utf-8 -*- | |||||
| """ | |||||
| Retrieve a list of user rights for a given username via the API. | |||||
| """ | |||||
| from irc.classes import BaseCommand | |||||
| from wiki import tools | |||||
| class Rights(BaseCommand): | |||||
| def get_hooks(self): | |||||
| return ["msg"] | |||||
| def get_help(self, command): | |||||
| return "Retrieve a list of rights for a given username." | |||||
| def check(self, data): | |||||
| if data.is_command and data.command in ["rights", "groups", "permissions", "privileges"]: | |||||
| return True | |||||
| return False | |||||
| def process(self, data): | |||||
| if not data.args: | |||||
| self.connection.reply(data, "what user do you want me to look up?") | |||||
| return | |||||
| username = ' '.join(data.args) | |||||
| site = tools.get_site() | |||||
| user = site.get_user(username) | |||||
| rights = user.groups() | |||||
| if rights: | |||||
| try: | |||||
| rights.remove("*") # remove the implicit '*' group given to everyone | |||||
| except ValueError: | |||||
| pass | |||||
| self.connection.reply(data, "the rights for \x0302{0}\x0301 are {1}.".format(username, ', '.join(rights))) | |||||
| else: | |||||
| self.connection.reply(data, "the user \x0302{0}\x0301 has no rights, or does not exist.".format(username)) | |||||
| @@ -1,133 +0,0 @@ | |||||
| # -*- coding: utf-8 -*- | |||||
| # Manage wiki tasks from IRC, and check on thread status. | |||||
| import threading | |||||
| import re | |||||
| from irc.classes import BaseCommand, Data, KwargParseException | |||||
| from wiki import task_manager | |||||
| from core import config | |||||
| class Tasks(BaseCommand): | |||||
| def get_hooks(self): | |||||
| return ["msg"] | |||||
| def get_help(self, command): | |||||
| return "Manage wiki tasks from IRC, and check on thread status." | |||||
| def check(self, data): | |||||
| if data.is_command and data.command in ["tasks", "task", "threads", "tasklist"]: | |||||
| return True | |||||
| return False | |||||
| def process(self, data): | |||||
| self.data = data | |||||
| if data.host not in config.irc["permissions"]["owners"]: | |||||
| self.connection.reply(data, "at this time, you must be a bot owner to use this command.") | |||||
| return | |||||
| if not data.args: | |||||
| if data.command == "tasklist": | |||||
| self.do_list() | |||||
| else: | |||||
| self.connection.reply(data, "no arguments provided. Maybe you wanted '!{cmnd} list', '!{cmnd} start', or '!{cmnd} listall'?".format(cmnd=data.command)) | |||||
| return | |||||
| if data.args[0] == "list": | |||||
| self.do_list() | |||||
| elif data.args[0] == "start": | |||||
| self.do_start() | |||||
| elif data.args[0] in ["listall", "all"]: | |||||
| self.do_listall() | |||||
| else: # they asked us to do something we don't know | |||||
| self.connection.reply(data, "unknown argument: \x0303{0}\x0301.".format(data.args[0])) | |||||
| def do_list(self): | |||||
| """With !tasks list (or abbreviation !tasklist), list all running | |||||
| threads. This includes the main threads, like the irc frontend and the | |||||
| watcher, and task threads.""" | |||||
| threads = threading.enumerate() | |||||
| normal_threads = [] | |||||
| task_threads = [] | |||||
| for thread in threads: | |||||
| tname = thread.name | |||||
| if tname == "MainThread": | |||||
| tname = self.get_main_thread_name() | |||||
| normal_threads.append("\x0302{0}\x0301 (as main thread, id {1})".format(tname, thread.ident)) | |||||
| elif tname in ["irc-frontend", "irc-watcher", "wiki-scheduler"]: | |||||
| normal_threads.append("\x0302{0}\x0301 (id {1})".format(tname, thread.ident)) | |||||
| elif tname.startswith("reminder"): | |||||
| normal_threads.append("\x0302reminder\x0301 (until {0})".format(tname.replace("reminder ", ""))) | |||||
| else: | |||||
| tname, start_time = re.findall("^(.*?) \((.*?)\)$", tname)[0] | |||||
| task_threads.append("\x0302{0}\x0301 (id {1}, since {2})".format(tname, thread.ident, start_time)) | |||||
| if task_threads: | |||||
| msg = "\x02{0}\x0F threads active: {1}, and \x02{2}\x0F task threads: {3}.".format(len(threads), ', '.join(normal_threads), len(task_threads), ', '.join(task_threads)) | |||||
| else: | |||||
| msg = "\x02{0}\x0F threads active: {1}, and \x020\x0F task threads.".format(len(threads), ', '.join(normal_threads)) | |||||
| self.connection.reply(self.data, msg) | |||||
| def do_listall(self): | |||||
| """With !tasks listall or !tasks all, list all loaded tasks, and report | |||||
| whether they are currently running or idle.""" | |||||
| tasks = task_manager.task_list.keys() | |||||
| threads = threading.enumerate() | |||||
| tasklist = [] | |||||
| tasks.sort() | |||||
| for task in tasks: | |||||
| threads_running_task = [t for t in threads if t.name.startswith(task)] | |||||
| ids = map(lambda t: str(t.ident), threads_running_task) | |||||
| if not ids: | |||||
| tasklist.append("\x0302{0}\x0301 (idle)".format(task)) | |||||
| elif len(ids) == 1: | |||||
| tasklist.append("\x0302{0}\x0301 (\x02active\x0F as id {1})".format(task, ids[0])) | |||||
| else: | |||||
| tasklist.append("\x0302{0}\x0301 (\x02active\x0F as ids {1})".format(task, ', '.join(ids))) | |||||
| tasklist = ", ".join(tasklist) | |||||
| msg = "{0} tasks loaded: {1}.".format(len(tasks), tasklist) | |||||
| self.connection.reply(self.data, msg) | |||||
| def do_start(self): | |||||
| """With !tasks start, start any loaded task by name with or without | |||||
| kwargs.""" | |||||
| data = self.data | |||||
| try: | |||||
| task_name = data.args[1] | |||||
| except IndexError: # no task name given | |||||
| self.connection.reply(data, "what task do you want me to start?") | |||||
| return | |||||
| try: | |||||
| data.parse_kwargs() | |||||
| except KwargParseException, arg: | |||||
| self.connection.reply(data, "error parsing argument: \x0303{0}\x0301.".format(arg)) | |||||
| return | |||||
| if task_name not in task_manager.task_list.keys(): # this task does not exist or hasn't been loaded | |||||
| self.connection.reply(data, "task could not be found; either wiki/tasks/{0}.py doesn't exist, or it wasn't loaded correctly.".format(task_name)) | |||||
| return | |||||
| task_manager.start_task(task_name, **data.kwargs) | |||||
| self.connection.reply(data, "task \x0302{0}\x0301 started.".format(task_name)) | |||||
| def get_main_thread_name(self): | |||||
| """Return the "proper" name of the MainThread; e.g. "irc-frontend" or | |||||
| "irc-watcher".""" | |||||
| if "irc_frontend" in config.components: | |||||
| return "irc-frontend" | |||||
| elif "wiki_schedule" in config.components: | |||||
| return "wiki-scheduler" | |||||
| else: | |||||
| return "irc-watcher" | |||||
| @@ -1,26 +0,0 @@ | |||||
| # -*- coding: utf-8 -*- | |||||
| # A very simple command to test the bot. | |||||
| import random | |||||
| from irc.classes import BaseCommand | |||||
| class Test(BaseCommand): | |||||
| def get_hooks(self): | |||||
| return ["msg"] | |||||
| def get_help(self, command): | |||||
| return "Test the bot!" | |||||
| def check(self, data): | |||||
| if data.is_command and data.command == "test": | |||||
| return True | |||||
| return False | |||||
| def process(self, data): | |||||
| hey = random.randint(0, 1) | |||||
| if hey: | |||||
| self.connection.say(data.chan, "Hey \x02%s\x0F!" % data.nick) | |||||
| else: | |||||
| self.connection.say(data.chan, "'sup \x02%s\x0F?" % data.nick) | |||||
| @@ -1,104 +0,0 @@ | |||||
| # -*- coding: utf-8 -*- | |||||
| """ | |||||
| EarwigBot's IRC Front-end Component | |||||
| The IRC frontend runs on a normal IRC server and expects users to interact with | |||||
| it and give it commands. Commands are stored as "command classes", subclasses | |||||
| of BaseCommand in irc/base_command.py. All command classes are automatically | |||||
| imported by irc/command_handler.py if they are in irc/commands. | |||||
| """ | |||||
| from re import findall | |||||
| from core import config | |||||
| from irc import command_handler | |||||
| from irc.classes import Connection, Data, BrokenSocketException | |||||
| connection = None | |||||
| def get_connection(): | |||||
| """Return a new Connection() instance with information about our server | |||||
| connection, but don't actually connect yet.""" | |||||
| cf = config.irc["frontend"] | |||||
| connection = Connection(cf["host"], cf["port"], cf["nick"], cf["ident"], | |||||
| cf["realname"]) | |||||
| return connection | |||||
| def startup(conn): | |||||
| """Accept a single arg, a Connection() object, and set our global variable | |||||
| 'connection' to it. Load all command classes in irc/commands with | |||||
| command_handler, and then establish a connection with the IRC server.""" | |||||
| global connection | |||||
| connection = conn | |||||
| command_handler.load_commands(connection) | |||||
| connection.connect() | |||||
| def main(): | |||||
| """Main loop for the Frontend IRC Bot component. get_connection() and | |||||
| startup() should have already been called.""" | |||||
| read_buffer = str() | |||||
| while 1: | |||||
| try: | |||||
| read_buffer = read_buffer + connection.get() | |||||
| except BrokenSocketException: | |||||
| print "Socket has broken on front-end; restarting bot..." | |||||
| return | |||||
| lines = read_buffer.split("\n") | |||||
| read_buffer = lines.pop() | |||||
| for line in lines: # handle a single message from IRC | |||||
| line = line.strip().split() | |||||
| data = Data() # new Data() instance to store info about this line | |||||
| data.line = line | |||||
| if line[1] == "JOIN": | |||||
| data.nick, data.ident, data.host = findall( | |||||
| ":(.*?)!(.*?)@(.*?)\Z", line[0])[0] | |||||
| data.chan = line[2][1:] | |||||
| command_handler.check("join", data) # check for 'join' hooks in | |||||
| # our commands | |||||
| if line[1] == "PRIVMSG": | |||||
| data.nick, data.ident, data.host = findall( | |||||
| ":(.*?)!(.*?)@(.*?)\Z", line[0])[0] | |||||
| data.msg = ' '.join(line[3:])[1:] | |||||
| data.chan = line[2] | |||||
| if data.chan == config.irc["frontend"]["nick"]: | |||||
| # this is a privmsg to us, so set 'chan' as the nick of the | |||||
| # sender, then check for private-only command hooks | |||||
| data.chan = data.nick | |||||
| command_handler.check("msg_private", data) | |||||
| else: | |||||
| # check for public-only command hooks | |||||
| command_handler.check("msg_public", data) | |||||
| # check for command hooks that apply to all messages | |||||
| command_handler.check("msg", data) | |||||
| # hardcode the !restart command (we can't restart from within | |||||
| # an ordinary command) | |||||
| if data.msg in ["!restart", ".restart"]: | |||||
| if data.host in config.irc["permissions"]["owners"]: | |||||
| print "Restarting bot per owner request..." | |||||
| return | |||||
| if line[0] == "PING": # if we are pinged, pong back to the server | |||||
| connection.send("PONG %s" % line[1]) | |||||
| if line[1] == "376": # we've successfully connected to the network | |||||
| try: # if we're supposed to auth to nickserv, do that | |||||
| ns_username = config.irc["frontend"]["nickservUsername"] | |||||
| ns_password = config.irc["frontend"]["nickservPassword"] | |||||
| except KeyError: | |||||
| pass | |||||
| else: | |||||
| connection.say("NickServ", "IDENTIFY {0} {1}".format( | |||||
| ns_username, ns_password)) | |||||
| # join all of our startup channels | |||||
| for chan in config.irc["frontend"]["channels"]: | |||||
| connection.join(chan) | |||||
| @@ -1,78 +0,0 @@ | |||||
| # -*- coding: utf-8 -*- | |||||
| """ | |||||
| EarwigBot's IRC Watcher Component | |||||
| The IRC watcher runs on a wiki recent-changes server and listens for edits. | |||||
| Users cannot interact with this part of the bot. When an event occurs, run it | |||||
| through irc/watcher_logic.py's process() function, which can result in either | |||||
| wiki bot tasks being started (listed in wiki/tasks/) or messages being sent to | |||||
| channels in the IRC frontend. | |||||
| """ | |||||
| from core import config | |||||
| from irc.classes import Connection, RC, BrokenSocketException | |||||
| from irc import watcher_logic | |||||
| frontend_conn = None | |||||
| def get_connection(): | |||||
| """Return a new Connection() instance with information about our server | |||||
| connection, but don't actually connect yet.""" | |||||
| cf = config.irc["watcher"] | |||||
| connection = Connection(cf["host"], cf["port"], cf["nick"], cf["ident"], | |||||
| cf["realname"]) | |||||
| return connection | |||||
| def main(connection, f_conn=None): | |||||
| """Main loop for the Watcher IRC Bot component. get_connection() should | |||||
| have already been called and the connection should have been started with | |||||
| connection.connect(). Accept the frontend connection as well as an optional | |||||
| parameter in order to send messages directly to frontend IRC channels.""" | |||||
| global frontend_conn | |||||
| frontend_conn = f_conn | |||||
| read_buffer = str() | |||||
| while 1: | |||||
| try: | |||||
| read_buffer = read_buffer + connection.get() | |||||
| except BrokenSocketException: | |||||
| return | |||||
| lines = read_buffer.split("\n") | |||||
| read_buffer = lines.pop() | |||||
| for line in lines: | |||||
| line = line.strip().split() | |||||
| if line[1] == "PRIVMSG": | |||||
| chan = line[2] | |||||
| # ignore messages originating from channels not in our list, to | |||||
| # prevent someone PMing us false data | |||||
| if chan not in config.irc["watcher"]["channels"]: | |||||
| continue | |||||
| msg = ' '.join(line[3:])[1:] | |||||
| rc = RC(msg) # new RC object to store this event's data | |||||
| rc.parse() # parse a message into pagenames, usernames, etc. | |||||
| process(rc) # report to frontend channels or start tasks | |||||
| if line[0] == "PING": # if we are pinged, pong back to the server | |||||
| connection.send("PONG %s" % line[1]) | |||||
| # when we've finished starting up, join all watcher channels | |||||
| if line[1] == "376": | |||||
| for chan in config.irc["watcher"]["channels"]: | |||||
| connection.join(chan) | |||||
| def process(rc): | |||||
| """Process a message from IRC (technically, an RC object). The actual | |||||
| processing is configurable, so we don't have that hard-coded here. We | |||||
| simply call irc/watcher_logic.py's process() function and expect a list of | |||||
| channels back, which we report the event data to.""" | |||||
| chans = watcher_logic.process(rc) | |||||
| if chans and frontend_conn: | |||||
| pretty = rc.get_pretty() | |||||
| for chan in chans: | |||||
| frontend_conn.say(chan, pretty) | |||||
| @@ -1,19 +0,0 @@ | |||||
| # -*- coding: utf-8 -*- | |||||
| class BaseTask(object): | |||||
| """A base class for bot tasks that edit Wikipedia.""" | |||||
| task_name = None | |||||
| def __init__(self): | |||||
| """This is called once immediately after the task class is loaded by | |||||
| the task manager (in wiki.task_manager.load_class_from_file()).""" | |||||
| pass | |||||
| def run(self, **kwargs): | |||||
| """This is called directly by task_manager.start_task() and is the main | |||||
| way to make a task do stuff. kwargs will be any keyword arguments | |||||
| passed to start_task(), which are (of course) optional. The same task | |||||
| instance is preserved between runs, so you can theoretically store data | |||||
| in self (e.g. start_task('mytask', action='store', data='foo')) and | |||||
| then use it later (e.g. start_task('mytask', action='save')).""" | |||||
| pass | |||||
| @@ -1,92 +0,0 @@ | |||||
| # -*- coding: utf-8 -*- | |||||
| """ | |||||
| EarwigBot's Wiki Bot Task Manager | |||||
| This module provides some functions to run and load bot tasks from wiki/tasks/. | |||||
| """ | |||||
| import time | |||||
| import traceback | |||||
| import threading | |||||
| import os | |||||
| from core import config | |||||
| # store loaded tasks as a dict where the key is the task name and the value is | |||||
| # an instance of the task class (wiki.tasks.task_file.Task()) | |||||
| task_list = dict() | |||||
| def load_tasks(): | |||||
| """Load all valid task classes from wiki/tasks/, and add them to the | |||||
| task_list variable.""" | |||||
| files = os.listdir(os.path.join("wiki", "tasks")) | |||||
| files.sort() # alphabetically sort all files in wiki/tasks/ | |||||
| for f in files: | |||||
| if not os.path.isfile(os.path.join("wiki", "tasks", f)): | |||||
| continue # ignore non-files | |||||
| if f.startswith("_") or not f.endswith(".py"): | |||||
| continue # ignore non-python files or files beginning with an _ | |||||
| load_class_from_file(f) | |||||
| print "Found %s tasks: %s." % (len(task_list), ', '.join(task_list.keys())) | |||||
| def load_class_from_file(f): | |||||
| """Look in a given file for the task class.""" | |||||
| global task_list | |||||
| module = f[:-3] # strip .py from end | |||||
| try: | |||||
| exec "from wiki.tasks import %s as m" % module | |||||
| except: # importing the file failed for some reason... | |||||
| print "Couldn't load task file %s:" % f | |||||
| traceback.print_exc() | |||||
| return | |||||
| try: | |||||
| task_class = m.Task | |||||
| except: | |||||
| print "Couldn't find or get task class in file %s:" % f | |||||
| traceback.print_exc() | |||||
| return | |||||
| task_name = task_class.task_name | |||||
| task_list[task_name] = task_class() | |||||
| print "Added task %s from wiki/tasks/%s..." % (task_name, f) | |||||
| def start_tasks(now=time.gmtime()): | |||||
| """Start all tasks that are supposed to be run at a given time.""" | |||||
| tasks = config.schedule(now.tm_min, now.tm_hour, now.tm_mday, now.tm_mon, | |||||
| now.tm_wday) # get list of tasks to run this turn | |||||
| for task in tasks: | |||||
| if isinstance(task, list): # they've specified kwargs | |||||
| start_task(task[0], **task[1]) # so pass those to start_task | |||||
| else: # otherwise, just pass task_name | |||||
| start_task(task) | |||||
| def start_task(task_name, **kwargs): | |||||
| """Start a given task in a new thread. Pass args to the task's run() | |||||
| function.""" | |||||
| print "Starting task '{0}' in a new thread...".format(task_name) | |||||
| try: | |||||
| task = task_list[task_name] | |||||
| except KeyError: | |||||
| print ("Couldn't find task '{0}': wiki/tasks/{0}.py does not exist.").format(task_name) | |||||
| return | |||||
| task_thread = threading.Thread(target=lambda: task_wrapper(task, **kwargs)) | |||||
| task_thread.name = "{0} ({1})".format(task_name, time.strftime("%b %d %H:%M:%S")) | |||||
| # stop bot task threads automagically if the main bot stops | |||||
| task_thread.daemon = True | |||||
| task_thread.start() | |||||
| def task_wrapper(task, **kwargs): | |||||
| """Wrapper for task classes: run the task and catch any errors.""" | |||||
| try: | |||||
| task.run(**kwargs) | |||||
| except: | |||||
| print "Task '{0}' raised an exception and had to stop:".format(task.task_name) | |||||
| traceback.print_exc() | |||||
| else: | |||||
| print "Task '{0}' finished without error.".format(task.task_name) | |||||
| @@ -1,20 +0,0 @@ | |||||
| # -*- coding: utf-8 -*- | |||||
| """ | |||||
| EarwigBot's Wiki Toolset | |||||
| 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 | |||||
| written by Mr.Z-man, other than a similar purpose. We share no code. | |||||
| Import the toolset with `from wiki import tools`. | |||||
| """ | |||||
| from wiki.tools.constants import * | |||||
| from wiki.tools.exceptions import * | |||||
| from wiki.tools.functions import * | |||||
| from wiki.tools.category import Category | |||||
| from wiki.tools.page import Page | |||||
| from wiki.tools.site import Site | |||||
| from wiki.tools.user import User | |||||