Browse Source

restructuring core bot components

setting up threading and two IRC sockets
setting up watcher to report recent changes to a given list of channels on the main srver
code cleanup
tags/v0.1
Ben Kurtovic 13 years ago
parent
commit
f9a1a9e1d1
17 changed files with 356 additions and 179 deletions
  1. +0
    -69
      bot.py
  2. +11
    -10
      config/irc_config.py
  3. +0
    -0
     
  4. +50
    -0
      core/main.py
  5. +15
    -0
      earwigbot.py
  6. +0
    -40
      irc/actions.py
  7. +24
    -24
      irc/commands/git.py
  8. +8
    -8
      irc/commands/help.py
  9. +6
    -6
      irc/commands/test.py
  10. +67
    -0
      irc/connection.py
  11. +1
    -4
      irc/data.py
  12. +66
    -0
      irc/frontend.py
  13. +33
    -0
      irc/rc.py
  14. +4
    -4
      irc/triggers.py
  15. +71
    -0
      irc/watcher.py
  16. +0
    -14
      main.py
  17. +0
    -0
     

+ 0
- 69
bot.py View File

@@ -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()

+ 11
- 10
config/irc_config.py View File

@@ -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"]

+ 0
- 0
View File


+ 50
- 0
core/main.py View File

@@ -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()

+ 15
- 0
earwigbot.py View File

@@ -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

+ 0
- 40
irc/actions.py View File

@@ -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)

+ 24
- 24
irc/commands/git.py View File

@@ -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.")

+ 8
- 8
irc/commands/help.py View File

@@ -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 <command>'.")
connection.reply(data.chan, data.nick, "I am a bot! You can get help for any command by typing '!help <command>'.")

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)

+ 6
- 6
irc/commands/test.py View File

@@ -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)

+ 67
- 0
irc/connection.py View File

@@ -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)

+ 1
- 4
irc/data.py View File

@@ -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

+ 66
- 0
irc/frontend.py View File

@@ -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)

+ 33
- 0
irc/rc.py View File

@@ -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

+ 4
- 4
irc/triggers.py View File

@@ -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)

+ 71
- 0
irc/watcher.py View File

@@ -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)

+ 0
- 14
main.py View File

@@ -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)

+ 0
- 0
View File


Loading…
Cancel
Save