diff --git a/bot.py b/bot.py deleted file mode 100644 index 1ce08f7..0000000 --- a/bot.py +++ /dev/null @@ -1,69 +0,0 @@ -# -*- coding: utf-8 -*- - -## Imports -import socket, re, time - -from config.irc_config import * -from config.secure_config import * - -from irc import triggers -from irc.actions import * -from irc.data import * - -def main(): - read_buffer = str() - - while 1: - try: - read_buffer = read_buffer + actions.get() - except RuntimeError: # socket broke - print "socket has broken, sleeping for a minute and restarting..." - time.sleep(60) # sleep for sixty seconds - return # then exit our loop and restart the bot - - lines = read_buffer.split("\n") - read_buffer = lines.pop() - - for line in lines: - line = line.strip().split() - data = Data() - - if line[1] == "JOIN": - data.nick, data.ident, data.host = re.findall(":(.*?)!(.*?)@(.*?)\Z", line[0])[0] - data.chan = line[2][1:] - - triggers.check(actions, data, "join") # check if there's anything we can respond to, and if so, respond - - if line[1] == "PRIVMSG": - data.nick, data.ident, data.host = re.findall(":(.*?)!(.*?)@(.*?)\Z", line[0])[0] - data.msg = ' '.join(line[3:])[1:] - data.chan = line[2] - - if data.chan == NICK: # this is a privmsg to us, so set 'chan' as the nick of the sender - data.chan = data.nick - triggers.check(actions, data, "msg_private") # only respond if it's a private message - else: - triggers.check(actions, data, "msg_public") # only respond if it's a public (channel) message - - triggers.check(actions, data, "msg") # check for general messages - - if data.msg == "!restart": # hardcode the !restart command (we can't return from within actions.py) - if data.host in ADMINS: - return True - - if line[0] == "PING": # If we are pinged, pong back to the server - actions.send("PONG %s" % line[1]) - - if line[1] == "376": - if NS_AUTH: # if we're supposed to auth to nickserv, do that - actions.say("NickServ", "IDENTIFY %s %s" % (NS_USER, NS_PASS)) - for chan in CHANS: # join all of our startup channels - actions.join(chan) - -if __name__ == "__main__": - sock = socket.socket() - sock.connect((HOST, PORT)) - actions = Actions(sock) - actions.send("NICK %s" % NICK) - actions.send("USER %s %s bla :%s" % (IDENT, HOST, REALNAME)) - main() diff --git a/config/irc_config.py b/config/irc_config.py index 96e6080..9f97b36 100644 --- a/config/irc_config.py +++ b/config/irc_config.py @@ -3,23 +3,24 @@ # EarwigBot Configuration File # This file contains information that the bot uses to connect to IRC. -# our server's hostname +# our main (front-end) server's hostname and port HOST = "irc.freenode.net" - -# our server's port PORT = 6667 -# our nick -NICK = "EarwigBot" +# our watcher server's hostname, port, and RC channel +WATCHER_HOST = "irc.wikimedia.org" +WATCHER_PORT = 6667 +WATCHER_CHAN = "#en.wikipedia" -# our ident +# our nick, ident, and real name, used on both servers +NICK = "EarwigBot" IDENT = "earwigbot" - -# our real name REALNAME = "[[w:en:User:EarwigBot]]" -# channel to join on startup +# channels to join on main server's startup CHANS = ["##earwigbot", "##earwig", "#wikipedia-en-afc"] +AFC_CHANS = ["#wikipedia-en-afc"] # report recent AFC changes +BOT_CHANS = ["##earwigbot", "#wikipedia-en-afc"] # report edits containing "!earwigbot" -# hostnames of users who can update/restart the bot with !update +# hardcoded hostnames of users who can use !restart and !git ADMINS = ["wikipedia/The-Earwig"] diff --git a/core/__init__.py b/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/core/main.py b/core/main.py new file mode 100644 index 0000000..e04272e --- /dev/null +++ b/core/main.py @@ -0,0 +1,50 @@ +# -*- coding: utf-8 -*- + +## EarwigBot's Core +## Basically, this creates threads for our IRC watcher component and Wikipedia component, and then runs the main IRC bot on the main thread. + +## The IRC bot component of EarwigBot has two parts: a front-end and a watcher. +## The front-end runs on a normal IRC server and expects users to interact with it/give it commands. +## The watcher runs on a wiki recent-changes server and listens for edits. Users cannot interact with this part of the bot. + +import threading +import time +import traceback +import sys +import os + +parent_dir = os.path.split(sys.path[0])[0] +sys.path.append(parent_dir) # make sure we look in the parent directory for modules + +from irc import frontend, watcher + +f_conn = None +w_conn = None + +def irc_watcher(f_conn): + global w_conn + while 1: # restart the watcher component if (just) it breaks + w_conn = watcher.get_connection() + try: + watcher.main(w_conn, f_conn) + except: + traceback.print_exc() + time.sleep(5) # sleep a bit before restarting watcher + print "restarting watcher component..." + +def run(): + global f_conn + f_conn = frontend.get_connection() + + t_watcher = threading.Thread(target=irc_watcher, args=(f_conn,)) + t_watcher.daemon = True + t_watcher.start() + + frontend.main(f_conn) + +if __name__ == "__main__": + try: + run() + finally: + f_conn.close() + w_conn.close() diff --git a/earwigbot.py b/earwigbot.py new file mode 100644 index 0000000..47e0ff1 --- /dev/null +++ b/earwigbot.py @@ -0,0 +1,15 @@ +# -*- coding: utf-8 -*- + +import time +from subprocess import * + +try: + from config import irc_config, secure_config +except ImportError: + print """Missing a config file! Make sure you have configured the bot. All *.py.default files in config/ +should have their .default extension removed, and the info inside should be corrected.""" + exit() + +while 1: + call(['python', 'core/main.py']) + time.sleep(5) # sleep for five seconds between bot runs diff --git a/irc/actions.py b/irc/actions.py deleted file mode 100644 index 7cd4930..0000000 --- a/irc/actions.py +++ /dev/null @@ -1,40 +0,0 @@ -# -*- coding: utf-8 -*- - -# Actions/commands to interface with IRC. - -class Actions: - def __init__(self, sock): - """actions/commands to interface with IRC""" - self.sock = sock - - def get(self, size = 4096): - """receive (get) data from the server""" - data = self.sock.recv(4096) - if not data: - raise RuntimeError("socket is dead") - return data - - def send(self, msg): - """send data to the server""" - self.sock.send(msg + "\r\n") - print " %s" % msg - - def say(self, target, msg): - """send a message""" - self.send("PRIVMSG %s :%s" % (target, msg)) - - def reply(self, target, nick, msg): - """send a message as a reply""" - self.say(target, "%s%s%s: %s" % (chr(2), 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) diff --git a/irc/commands/git.py b/irc/commands/git.py index d7e38bd..03d579e 100644 --- a/irc/commands/git.py +++ b/irc/commands/git.py @@ -5,18 +5,18 @@ import shlex, subprocess, re from config.irc_config import * -actions, data = None, None +connection, data = None, None -def call(a, d): - global actions, data - actions, data = a, d +def call(c, d): + global connection, data + connection, data = c, d if data.host not in ADMINS: - actions.reply(data.chan, data.nick, "you must be a bot admin to use this command.") + connection.reply(data.chan, data.nick, "you must be a bot admin to use this command.") return if not data.args: - actions.reply(data.chan, data.nick, "no arguments provided.") + connection.reply(data.chan, data.nick, "no arguments provided.") return if data.args[0] == "help": @@ -41,7 +41,7 @@ def call(a, d): do_status() else: # they asked us to do something we don't know - actions.reply(data.chan, data.nick, "unknown argument: \x0303%s\x0301." % data.args[0]) + connection.reply(data.chan, data.nick, "unknown argument: \x0303%s\x0301." % data.args[0]) def exec_shell(command): """execute a shell command and get the output""" @@ -68,14 +68,14 @@ def do_help(): help += "\x0303%s\x0301 (%s), " % (key, help_dict[key]) help = help[:-2] # trim last comma - actions.reply(data.chan, data.nick, "sub-commands are: %s." % help) + connection.reply(data.chan, data.nick, "sub-commands are: %s." % help) def do_branch(): """get our current branch""" branch = exec_shell("git name-rev --name-only HEAD") branch = branch[:-1] # strip newline - actions.reply(data.chan, data.nick, "currently on branch \x0302%s\x0301." % branch) + connection.reply(data.chan, data.nick, "currently on branch \x0302%s\x0301." % branch) def do_branches(): """get list of branches""" @@ -87,66 +87,66 @@ def do_branches(): branches = branches.replace('\n ', ', ') branches = branches.strip() - actions.reply(data.chan, data.nick, "branches: \x0302%s\x0301." % branches) + connection.reply(data.chan, data.nick, "branches: \x0302%s\x0301." % branches) def do_checkout(): """switch branches""" try: branch = data.args[1] except IndexError: # no branch name provided - actions.reply(data.chan, data.nick, "switch to which branch?") + connection.reply(data.chan, data.nick, "switch to which branch?") return try: result = exec_shell("git checkout %s" % branch) if "Already on" in result: - actions.reply(data.chan, data.nick, "already on \x0302%s\x0301!" % branch) + connection.reply(data.chan, data.nick, "already on \x0302%s\x0301!" % branch) else: - actions.reply(data.chan, data.nick, "switched to branch \x0302%s\x0301." % branch) + connection.reply(data.chan, data.nick, "switched to branch \x0302%s\x0301." % branch) except subprocess.CalledProcessError: # git couldn't switch branches - actions.reply(data.chan, data.nick, "branch \x0302%s\x0301 doesn't exist!" % branch) + connection.reply(data.chan, data.nick, "branch \x0302%s\x0301 doesn't exist!" % branch) def do_delete(): """delete a branch, while making sure that we are not on it""" try: delete_branch = data.args[1] except IndexError: # no branch name provided - actions.reply(data.chan, data.nick, "delete which branch?") + connection.reply(data.chan, data.nick, "delete which branch?") return current_branch = exec_shell("git name-rev --name-only HEAD") current_branch = current_branch[:-1] # strip newline if current_branch == delete_branch: - actions.reply(data.chan, data.nick, "you're currently on this branch; please checkout to a different branch before deleting.") + connection.reply(data.chan, data.nick, "you're currently on this branch; please checkout to a different branch before deleting.") return try: exec_shell("git branch -d %s" % delete_branch) - actions.reply(data.chan, data.nick, "branch \x0302%s\x0301 has been deleted locally." % delete_branch) + connection.reply(data.chan, data.nick, "branch \x0302%s\x0301 has been deleted locally." % delete_branch) except subprocess.CalledProcessError: # git couldn't delete - actions.reply(data.chan, data.nick, "branch \x0302%s\x0301 doesn't exist!" % delete_branch) + connection.reply(data.chan, data.nick, "branch \x0302%s\x0301 doesn't exist!" % delete_branch) def do_pull(): """pull from remote repository""" branch = exec_shell("git name-rev --name-only HEAD") branch = branch[:-1] # strip newline - actions.reply(data.chan, data.nick, "pulling from remote (currently on \x0302%s\x0301)..." % branch) + connection.reply(data.chan, data.nick, "pulling from remote (currently on \x0302%s\x0301)..." % branch) result = exec_shell("git pull") if "Already up-to-date." in result: - actions.reply(data.chan, data.nick, "done; no new changes.") + connection.reply(data.chan, data.nick, "done; no new changes.") else: changes = re.findall("\s*((.*?)\sfile(.*?)tions?\(-\))", result)[0][0] # find the changes - actions.reply(data.chan, data.nick, "done; %s." % changes) + connection.reply(data.chan, data.nick, "done; %s." % changes) def do_status(): """check whether we have anything to pull""" - actions.reply(data.chan, data.nick, "checking remote for updates...") + connection.reply(data.chan, data.nick, "checking remote for updates...") result = exec_shell("git fetch --dry-run") if not result: - actions.reply(data.chan, data.nick, "local copy is up-to-date with remote.") + connection.reply(data.chan, data.nick, "local copy is up-to-date with remote.") else: - actions.reply(data.chan, data.nick, "remote is ahead of local copy.") + connection.reply(data.chan, data.nick, "remote is ahead of local copy.") diff --git a/irc/commands/help.py b/irc/commands/help.py index 372339b..bde45d9 100644 --- a/irc/commands/help.py +++ b/irc/commands/help.py @@ -2,11 +2,11 @@ """Generates help information.""" -actions, data = None, None +connection, data = None, None -def call(a, d): - global actions, data - actions, data = a, d +def call(c, d): + global connection, data + connection, data = c, d if not data.args: do_general_help() @@ -15,7 +15,7 @@ def call(a, d): do_command_help() def do_general_help(): - actions.reply(data.chan, data.nick, "I am a bot! You can get help for any command by typing '!help '.") + connection.reply(data.chan, data.nick, "I am a bot! You can get help for any command by typing '!help '.") def do_command_help(): command = data.args[0] @@ -23,12 +23,12 @@ def do_command_help(): try: exec "from irc.commands import %s as this_command" % command except ImportError: - actions.reply(data.chan, data.nick, "command \x0303%s\x0301 not found!" % command) + connection.reply(data.chan, data.nick, "command \x0303%s\x0301 not found!" % command) return info = this_command.__doc__ if info: - actions.reply(data.chan, data.nick, "info for command \x0303%s\x0301: \"%s\"" % (command, info)) + connection.reply(data.chan, data.nick, "info for command \x0303%s\x0301: \"%s\"" % (command, info)) else: - actions.reply(data.chan, data.nick, "sorry, no information for \x0303%s\x0301." % command) + connection.reply(data.chan, data.nick, "sorry, no information for \x0303%s\x0301." % command) diff --git a/irc/commands/test.py b/irc/commands/test.py index 6f2680e..48421aa 100644 --- a/irc/commands/test.py +++ b/irc/commands/test.py @@ -4,17 +4,17 @@ import random -actions, data = None, None +connection, data = None, None -def call(a, d): - global actions, data - actions, data = a, d +def call(c, d): + global connection, data + connection, data = c, d choices = ("say_hi()", "say_sup()") exec random.choice(choices) def say_hi(): - actions.say(data.chan, "Hey \x02%s\x0F!" % data.nick) + connection.say(data.chan, "Hey \x02%s\x0F!" % data.nick) def say_sup(): - actions.say(data.chan, "'sup \x02%s\x0F?" % data.nick) + connection.say(data.chan, "'sup \x02%s\x0F?" % data.nick) diff --git a/irc/connection.py b/irc/connection.py new file mode 100644 index 0000000..a2c2ac0 --- /dev/null +++ b/irc/connection.py @@ -0,0 +1,67 @@ +# -*- coding: utf-8 -*- + +# A class to interface with IRC. + +import socket +import threading + +class Connection: + def __init__(self, host, port, nick, ident, realname): + """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 RuntimeError("socket is dead") + 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, target, nick, msg): + """send a message as a reply""" + self.say(target, "%s%s%s: %s" % (chr(2), 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) diff --git a/irc/data.py b/irc/data.py index a717d0e..092e709 100644 --- a/irc/data.py +++ b/irc/data.py @@ -22,7 +22,4 @@ class Data: except IndexError: self.command = None - try: - self.args = args[1:] # the command arguments - except IndexError: - self.args = None + self.args = args[1:] # the command arguments diff --git a/irc/frontend.py b/irc/frontend.py new file mode 100644 index 0000000..39f3299 --- /dev/null +++ b/irc/frontend.py @@ -0,0 +1,66 @@ +# -*- coding: utf-8 -*- + +## Imports +import re + +from config.irc_config import * +from config.secure_config import * + +from irc import triggers +from irc.connection import Connection +from irc.data import Data + +def get_connection(): + connection = Connection(HOST, PORT, NICK, IDENT, REALNAME) + return connection + +def main(connection): + connection.connect() + read_buffer = str() + + while 1: + try: + read_buffer = read_buffer + connection.get() + except RuntimeError: # socket broke + print "socket has broken on front-end; restarting bot..." + return + + lines = read_buffer.split("\n") + read_buffer = lines.pop() + + for line in lines: + line = line.strip().split() + data = Data() + + if line[1] == "JOIN": + data.nick, data.ident, data.host = re.findall(":(.*?)!(.*?)@(.*?)\Z", line[0])[0] + data.chan = line[2][1:] + + triggers.check(connection, data, "join") # check if there's anything we can respond to, and if so, respond + + if line[1] == "PRIVMSG": + data.nick, data.ident, data.host = re.findall(":(.*?)!(.*?)@(.*?)\Z", line[0])[0] + data.msg = ' '.join(line[3:])[1:] + data.chan = line[2] + + if data.chan == NICK: # this is a privmsg to us, so set 'chan' as the nick of the sender + data.chan = data.nick + triggers.check(connection, data, "msg_private") # only respond if it's a private message + else: + triggers.check(connection, data, "msg_public") # only respond if it's a public (channel) message + + triggers.check(connection, data, "msg") # check for general messages + + if data.msg.startswith("!restart"): # hardcode the !restart command (we can't restart from within an ordinary command) + if data.host in ADMINS: + print "restarting bot per admin 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": + if NS_AUTH: # if we're supposed to auth to nickserv, do that + connection.say("NickServ", "IDENTIFY %s %s" % (NS_USER, NS_PASS)) + for chan in CHANS: # join all of our startup channels + connection.join(chan) diff --git a/irc/rc.py b/irc/rc.py new file mode 100644 index 0000000..f1c4213 --- /dev/null +++ b/irc/rc.py @@ -0,0 +1,33 @@ +# -*- coding: utf-8 -*- + +# A class to store data on an individual event received from our IRC watcher. + +import re + +class RC: + 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 + + # page name of the modified page + # '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/%s" % page + flags = flags.strip() # flag tends to have a extraneous whitespace character at the end when it's a log entry + + self.page, self.flags, self.url, self.user, self.comment = page, flags, url, user, comment + + def pretty(self): + """make a nice, colorful message from self.msg to send to the front-end""" + pretty = self.msg + return pretty diff --git a/irc/triggers.py b/irc/triggers.py index 67786f6..43f91e2 100644 --- a/irc/triggers.py +++ b/irc/triggers.py @@ -4,7 +4,7 @@ from irc.commands import test, help, git -def check(actions, data, hook): +def check(connection, data, hook): data.parse_args() # parse command arguments into data.command and data.args if hook == "join": @@ -18,10 +18,10 @@ def check(actions, data, hook): if hook == "msg": if data.command == "!test": - test.call(actions, data) + test.call(connection, data) elif data.command == "!help": - help.call(actions, data) + help.call(connection, data) elif data.command == "!git": - git.call(actions, data) + git.call(connection, data) diff --git a/irc/watcher.py b/irc/watcher.py new file mode 100644 index 0000000..1ab3a79 --- /dev/null +++ b/irc/watcher.py @@ -0,0 +1,71 @@ +# -*- coding: utf-8 -*- + +## Imports +import re + +from config.irc_config import * + +from irc.connection import Connection +from irc.rc import RC + +global frontend_conn + +def get_connection(): + connection = Connection(WATCHER_HOST, WATCHER_PORT, NICK, IDENT, REALNAME) + return connection + +def main(connection, f_conn): + global frontend_conn + frontend_conn = f_conn + connection.connect() + read_buffer = str() + + while 1: + try: + read_buffer = read_buffer + connection.get() + except RuntimeError: # socket broke + print "socket has broken on watcher, restarting component..." + 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] + if chan != WATCHER_CHAN: # if we're getting a msg from another channel, ignore it + continue + + msg = ' '.join(line[3:])[1:] + rc = RC(msg) # create a new RC object to store this change's data + rc.parse() + check(rc) + + if line[0] == "PING": # If we are pinged, pong back to the server + connection.send("PONG %s" % line[1]) + + if line[1] == "376": # Join the recent changes channel when we've finished starting up + connection.join(WATCHER_CHAN) + +def report(msg, chans): + """send a message to a list of report channels on our front-end server""" + for chan in chans: + frontend_conn.say(chan, msg) + +def check(rc): + """check to see if """ + page_name = rc.page.lower() + pretty_msg = rc.pretty() + + if "!earwigbot" in rc.msg.lower(): + report(pretty_msg, chans=BOT_CHANS) + if re.match("wikipedia( talk)?:(wikiproject )?articles for creation", page_name): + report(pretty_msg, chans=AFC_CHANS) + elif re.match("wikipedia( talk)?:files for upload", page_name): + report(pretty_msg, chans=AFC_CHANS) + elif page_name.startswith("template:afc submission"): + report(pretty_msg, chans=AFC_CHANS) + if rc.flags == "delete" and re.match("deleted \"\[\[wikipedia( talk)?:(wikiproject )?articles for creation", rc.comment.lower()): + report(pretty_msg, chans=AFC_CHANS) diff --git a/main.py b/main.py deleted file mode 100644 index 74ee1bb..0000000 --- a/main.py +++ /dev/null @@ -1,14 +0,0 @@ -# -*- coding: utf-8 -*- - -from subprocess import * - -try: - from config.secure_config import * -except ImportError: - print "Can't find a secure_config file!" - print "Make sure you have configured the bot by moving 'config/secure_config.py.default' to 'config/secure_config.py' and by filling out the information inside." - exit() - -while 1: - cmd = ['python', 'bot.py'] - call(cmd) \ No newline at end of file diff --git a/wiki/__init__.py b/wiki/__init__.py new file mode 100644 index 0000000..e69de29