Commit 31000819 authored by Jonathan Harker's avatar Jonathan Harker

Refactor, add very basic support for MoxQuizz questions.

 - Add rudimentary classes for reading MoxQuizz questions.
 - Add a very simple ask command. No scores or quiz game-play yet.
 - Refactor SQL models out into a separate file.
 - Make the code more Python 3 friendly.
 - PEP8 and pyflakes.
parent 4f546f18
...@@ -9,6 +9,8 @@ Useful bot for folks stuck behind censor walls at work ...@@ -9,6 +9,8 @@ Useful bot for folks stuck behind censor walls at work
Logs a channel and collects URLs for later. Logs a channel and collects URLs for later.
""" """
from __future__ import print_function # unicode_literals
try: try:
import sys import sys
import os import os
...@@ -21,12 +23,12 @@ try: ...@@ -21,12 +23,12 @@ try:
from twisted.internet import protocol from twisted.internet import protocol
from twisted.internet import reactor from twisted.internet import reactor
from datetime import datetime from datetime import datetime
from mechanize import Browser from sqlalchemy import create_engine
from sqlalchemy import MetaData, Table, Column, String, Text, Integer, DateTime, create_engine
from sqlalchemy.orm import sessionmaker from sqlalchemy.orm import sessionmaker
from sqlalchemy.ext.declarative import declarative_base from pymoxquizz import QuestionBank, Question
from models import Url, Log, Model
except ImportError: except ImportError:
print "Some modules missing: Lolbot relies on Twisted IRC, Mechanize and SQLAlchemy.\n" print("Some modules missing: Lolbot relies on Twisted IRC, Mechanize and SQLAlchemy.\n")
sys.exit sys.exit
# Exclamations - wrong input # Exclamations - wrong input
...@@ -47,70 +49,6 @@ ponderings = [ ...@@ -47,70 +49,6 @@ ponderings = [
"No it's a week night 8pm is past my bedtime.", "No it's a week night 8pm is past my bedtime.",
] ]
SqlBase = declarative_base()
class Log(SqlBase):
"""
This class represents an event in the log table and inherits from a SQLAlchemy
convenience ORM class.
"""
__tablename__ = "log"
id = Column(Integer, primary_key=True)
timestamp = Column(DateTime)
nickname = Column(String(20))
text = Column(Text)
def __init__(self, nickname, text, timestamp=None):
"""
Creates an event log for the IRC logger.
"""
if timestamp is None:
timestamp = datetime.now()
self.timestamp = timestamp
self.nickname = nickname
self.text = text
def __repr__(self):
return "(%s) %s: %s" % (self.timestamp.strftime("%Y-%m-%d %H:%M:%S"), self.nickname, self.text)
class Url(SqlBase):
"""
This class represents a saved URL and inherits from a SQLAlchemy convenience
ORM class.
"""
__tablename__ = "url"
id = Column(Integer, primary_key=True)
timestamp = Column(DateTime)
nickname = Column(String(20))
url = Column(String(200), unique=True)
title = Column(Text)
def __init__(self, nickname, url, title=None, timestamp=None):
if timestamp is None:
timestamp = datetime.now()
self.timestamp = timestamp
self.nickname = nickname
self.url = url
self.title = title
# populate the title from the URL if not given.
if title is None:
try:
br = Browser()
br.open(self.url)
self.title = br.title()
except Exception as ex:
self.title = ''
def __repr__(self):
if not self.title:
return "%s: %s" % (self.nickname, self.url)
else:
return "%s: %s - %s" % (self.nickname, self.url, self.title)
class LolBot(irc.IRCClient): class LolBot(irc.IRCClient):
""" """
...@@ -123,10 +61,15 @@ class LolBot(irc.IRCClient): ...@@ -123,10 +61,15 @@ class LolBot(irc.IRCClient):
return connection return connection
def created(self, when): def created(self, when):
# load some MoxQuizz questions
self.qb = QuestionBank('/home/johnno/questions.doctorlard.en').questions
random.shuffle(self.qb)
self.question = None
# connect to the database # connect to the database
self.dbengine = create_engine('sqlite+pysqlite://', creator=self._get_connection) self.dbengine = create_engine('sqlite+pysqlite://', creator=self._get_connection)
SqlBase.metadata.bind = self.dbengine Model.metadata.bind = self.dbengine
SqlBase.metadata.create_all() Model.metadata.create_all()
self.get_session = sessionmaker(bind=self.dbengine) self.get_session = sessionmaker(bind=self.dbengine)
self.helptext = "Keeps a list of URLs. Commands: list [n|x-y] - prints the last 10 URLs (or n URLs, or x through y); clear - clears the list; lol - say something funny; <url> - adds the URL to the list; help - this message." self.helptext = "Keeps a list of URLs. Commands: list [n|x-y] - prints the last 10 URLs (or n URLs, or x through y); clear - clears the list; lol - say something funny; <url> - adds the URL to the list; help - this message."
...@@ -144,10 +87,10 @@ class LolBot(irc.IRCClient): ...@@ -144,10 +87,10 @@ class LolBot(irc.IRCClient):
db.commit() db.commit()
else: else:
theurl = db.query(Url).filter(Url.url == url).one() theurl = db.query(Url).filter(Url.url == url).one()
print theurl print(theurl)
title = theurl.title title = theurl.title
except Exception, ex: except Exception as ex:
print "Exception caught saving URL: %s" % ex print("Exception caught saving URL: %s" % ex)
return title return title
def log_event(self, nick, text): def log_event(self, nick, text):
...@@ -156,9 +99,9 @@ class LolBot(irc.IRCClient): ...@@ -156,9 +99,9 @@ class LolBot(irc.IRCClient):
db = self.get_session() db = self.get_session()
db.add(entry) db.add(entry)
db.commit() db.commit()
print entry print(entry)
except Exception, ex: except Exception as ex:
print "Exception caught logging event: %s" % ex print("Exception caught logging event: %s" % ex)
def _get_nickname(self): def _get_nickname(self):
return self.factory.nickname return self.factory.nickname
...@@ -174,13 +117,14 @@ class LolBot(irc.IRCClient): ...@@ -174,13 +117,14 @@ class LolBot(irc.IRCClient):
def signedOn(self): def signedOn(self):
self.join(self.channel) self.join(self.channel)
print "Signed on as %s." % (self.nickname,) print("Signed on as %s." % (self.nickname,))
def joined(self, channel): def joined(self, channel):
print "Joined %s." % (channel,) print("Joined %s." % (channel,))
def privmsg(self, user, channel, msg): def privmsg(self, user, channel, msg):
user = user.split('!')[0] user = user.split('!')[0]
if channel == self.nickname: if channel == self.nickname:
# Private /msg from a user # Private /msg from a user
self.do_command(msg, user) self.do_command(msg, user)
...@@ -188,6 +132,15 @@ class LolBot(irc.IRCClient): ...@@ -188,6 +132,15 @@ class LolBot(irc.IRCClient):
# log it # log it
self.log_event(user, msg) self.log_event(user, msg)
# deal with MoxQuizz question
answered = False
if isinstance(self.question, Question) and self.question.answer.lower() in msg.lower():
answered = True
print("%s +1" % user)
self.reply('Correct! %s scores one point.' % user)
if answered:
self.question = None
args = string.split(msg, ":", 1) args = string.split(msg, ":", 1)
if len(args) > 1 and args[0] == self.nickname: if len(args) > 1 and args[0] == self.nickname:
self.do_command(string.strip(args[1])) self.do_command(string.strip(args[1]))
...@@ -197,7 +150,7 @@ class LolBot(irc.IRCClient): ...@@ -197,7 +150,7 @@ class LolBot(irc.IRCClient):
for w in words: for w in words:
if w.startswith('http://') or w.startswith('https://'): if w.startswith('http://') or w.startswith('https://'):
title = self.save_url(user, w) title = self.save_url(user, w)
if title == False: if title is False:
self.say_public("Sorry, I'm useless at UTF-8.") self.say_public("Sorry, I'm useless at UTF-8.")
else: else:
self.say_public("URL added. %s" % title) self.say_public("URL added. %s" % title)
...@@ -236,6 +189,10 @@ class LolBot(irc.IRCClient): ...@@ -236,6 +189,10 @@ class LolBot(irc.IRCClient):
if cmd == 'help': if cmd == 'help':
self.reply(self.helptext, target) self.reply(self.helptext, target)
elif cmd == 'ask':
self.question = random.choice(self.qb)
self.reply(str(self.question.question))
elif cmd == 'lol': elif cmd == 'lol':
self.reply(self.ponder(), target) self.reply(self.ponder(), target)
...@@ -259,14 +216,14 @@ class LolBot(irc.IRCClient): ...@@ -259,14 +216,14 @@ class LolBot(irc.IRCClient):
y = abs(int(y)) y = abs(int(y))
if y < x: if y < x:
x, y = y, x x, y = y, x
except ValueError, ex: except ValueError as ex:
self.reply("Give me a number or a range of numbers, e.g. list 5 or list 11-20", target) self.reply("Give me a number or a range of numbers, e.g. list 5 or list 11-20", target)
raise ex raise ex
rows = db.query(Url).order_by(Url.timestamp.desc())[x-1:y] rows = db.query(Url).order_by(Url.timestamp.desc())[x-1:y]
else: else:
try: try:
n = abs(int(n)) n = abs(int(n))
except ValueError, ex: except ValueError as ex:
self.reply("Give me a number or a range of numbers, e.g. list 5 or list 11-20", target) self.reply("Give me a number or a range of numbers, e.g. list 5 or list 11-20", target)
raise ex raise ex
rows = db.query(Url).order_by(Url.timestamp.desc())[:n] rows = db.query(Url).order_by(Url.timestamp.desc())[:n]
...@@ -277,8 +234,8 @@ class LolBot(irc.IRCClient): ...@@ -277,8 +234,8 @@ class LolBot(irc.IRCClient):
time.sleep(1) time.sleep(1)
elif cmd.startswith('http:') or cmd.startswith('https:'): elif cmd.startswith('http:') or cmd.startswith('https:'):
title = self.save_url(from_private, cmd) title = self.save_url(target, cmd)
if title == False: if title is False:
self.say_public("Sorry, I'm useless at UTF-8.") self.say_public("Sorry, I'm useless at UTF-8.")
else: else:
self.reply('URL added. %s' % title, target) self.reply('URL added. %s' % title, target)
...@@ -286,12 +243,13 @@ class LolBot(irc.IRCClient): ...@@ -286,12 +243,13 @@ class LolBot(irc.IRCClient):
else: else:
self.reply(self.exclaim(), target) self.reply(self.exclaim(), target)
except Exception, ex: except Exception as ex:
print "Exception caught processing command: %s" % ex print("Exception caught processing command: %s" % ex)
print " command was '%s' from %s" % (cmd, target) print(" command was '%s' from %s" % (cmd, target))
self.reply("Sorry, I didn't understand: %s" % cmd, target) self.reply("Sorry, I didn't understand: %s" % cmd, target)
self.reply(self.helptext, target) self.reply(self.helptext, target)
class LolBotFactory(protocol.ClientFactory): class LolBotFactory(protocol.ClientFactory):
protocol = LolBot protocol = LolBot
...@@ -303,28 +261,30 @@ class LolBotFactory(protocol.ClientFactory): ...@@ -303,28 +261,30 @@ class LolBotFactory(protocol.ClientFactory):
self.channel = self.config['irc.channel'] self.channel = self.config['irc.channel']
def clientConnectionLost(self, connector, reason): def clientConnectionLost(self, connector, reason):
print "Lost connection (%s), reconnecting." % (reason,) print("Lost connection (%s), reconnecting." % (reason,))
connector.connect() connector.connect()
def clientConnectionFailed(self, connector, reason): def clientConnectionFailed(self, connector, reason):
print "Could not connect: %s" % (reason,) print("Could not connect: %s" % (reason,))
def get_options(): def get_options():
try: try:
(options, args) = getopt.getopt(sys.argv[1:], "hc:", ["help", "config=", ]) (options, args) = getopt.getopt(sys.argv[1:], "hc:", ["help", "config=", ])
except getopt.GetoptError, err: except getopt.GetoptError as err:
print str(err) print(str(err))
usage() usage()
sys.exit(2) sys.exit(2)
config_path = "" config_path = ""
for option,value in options: for option, value in options:
if option in ("-h", "--help"): if option in ("-h", "--help"):
usage() usage()
sys.exit(2) sys.exit(2)
if option in ("-c", "--config"): if option in ("-c", "--config"):
config_path = value config_path = value
return { 'config_path': config_path } return {'config_path': config_path}
def get_config(config_path): def get_config(config_path):
""" """
...@@ -342,7 +302,7 @@ def get_config(config_path): ...@@ -342,7 +302,7 @@ def get_config(config_path):
if not config_path: if not config_path:
config_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "lolbot.conf") config_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "lolbot.conf")
if not os.path.exists(config_path): if not os.path.exists(config_path):
print "Error: configuration file not found. By default lolbot will look for a lolbot.conf file in the same directory as the lolbot script, but you can override this by specifying a path on the command line with the --config option." print("Error: configuration file not found. By default lolbot will look for a lolbot.conf file in the same directory as the lolbot script, but you can override this by specifying a path on the command line with the --config option.")
usage() usage()
sys.exit(1) sys.exit(1)
...@@ -364,7 +324,7 @@ def get_config(config_path): ...@@ -364,7 +324,7 @@ def get_config(config_path):
# validate IRC host # validate IRC host
if "irc.server" not in config.keys(): if "irc.server" not in config.keys():
print "Error: the IRC server was not specified. Use --help for more information." print("Error: the IRC server was not specified. Use --help for more information.")
sys.exit(1) sys.exit(1)
# validate IRC port # validate IRC port
...@@ -373,12 +333,12 @@ def get_config(config_path): ...@@ -373,12 +333,12 @@ def get_config(config_path):
try: try:
config["irc.port"] = int(config["irc.port"]) config["irc.port"] = int(config["irc.port"])
except ValueError: except ValueError:
print "Error: the IRC port must be an integer. If not specified, lolbot will use the default IRC port value 6667. Use --help for more information." print("Error: the IRC port must be an integer. If not specified, lolbot will use the default IRC port value 6667. Use --help for more information.")
sys.exit(1) sys.exit(1)
# validate IRC channel # validate IRC channel
if "irc.channel" not in config.keys() or not config["irc.channel"].startswith("#"): if "irc.channel" not in config.keys() or not config["irc.channel"].startswith("#"):
print "Error: the IRC channel is not specified or incorrect. It must begin with a # - e.g. #mychatchannel. Use --help for more information." print("Error: the IRC channel is not specified or incorrect. It must begin with a # - e.g. #mychatchannel. Use --help for more information.")
sys.exit(1) sys.exit(1)
# validate bot nickname # validate bot nickname
...@@ -387,8 +347,9 @@ def get_config(config_path): ...@@ -387,8 +347,9 @@ def get_config(config_path):
return config return config
def usage(): def usage():
print """Run a lolbot. print("""Run a lolbot.
-h, --help -h, --help
This message. This message.
...@@ -416,7 +377,8 @@ Configuration: ...@@ -416,7 +377,8 @@ Configuration:
by SQL Alchemy. If not specified, lolbot will attempt to use a by SQL Alchemy. If not specified, lolbot will attempt to use a
SQLite database called lolbot.db in the same directory as the SQLite database called lolbot.db in the same directory as the
script. script.
""" """)
if __name__ == "__main__": if __name__ == "__main__":
args = get_options() args = get_options()
...@@ -425,5 +387,4 @@ if __name__ == "__main__": ...@@ -425,5 +387,4 @@ if __name__ == "__main__":
reactor.connectTCP(config['irc.server'], config['irc.port'], LolBotFactory(args['config_path'])) reactor.connectTCP(config['irc.server'], config['irc.port'], LolBotFactory(args['config_path']))
reactor.run() reactor.run()
except KeyboardInterrupt: except KeyboardInterrupt:
print "Shutting down." print("Shutting down.")
from mechanize import Browser
from datetime import datetime
from sqlalchemy import (Column, String, Text, Integer, DateTime)
from sqlalchemy.ext.declarative import (declarative_base)
Model = declarative_base()
class Log(Model):
"""
This class represents an event in the log table and inherits from a SQLAlchemy
convenience ORM class.
"""
__tablename__ = "log"
id = Column(Integer, primary_key=True)
timestamp = Column(DateTime)
nickname = Column(String(20))
text = Column(Text)
def __init__(self, nickname, text, timestamp=None):
"""
Creates an event log for the IRC logger.
"""
if timestamp is None:
timestamp = datetime.now()
self.timestamp = timestamp
self.nickname = nickname
self.text = text
def __repr__(self):
return "(%s) %s: %s" % (self.timestamp.strftime("%Y-%m-%d %H:%M:%S"), self.nickname, self.text)
class Url(Model):
"""
This class represents a saved URL and inherits from a SQLAlchemy convenience
ORM class.
"""
__tablename__ = "url"
id = Column(Integer, primary_key=True)
timestamp = Column(DateTime)
nickname = Column(String(20))
url = Column(String(200), unique=True)
title = Column(Text)
def __init__(self, nickname, url, title=None, timestamp=None):
if timestamp is None:
timestamp = datetime.now()
self.timestamp = timestamp
self.nickname = nickname
self.url = url
self.title = title
# populate the title from the URL if not given.
if title is None:
try:
br = Browser()
br.open(self.url)
self.title = br.title()
except Exception:
self.title = ''
def __repr__(self):
if not self.title:
return "%s: %s" % (self.nickname, self.url)
else:
return "%s: %s - %s" % (self.nickname, self.url, self.title)
#!/usr/bin/env python
## -*- coding: utf-8 -*-
from __future__ import unicode_literals, print_function
from io import open
class Question:
"""
Represents one MoxQuizz question.
"""
category = None
question = None
answer = None
regexp = None
author = None
level = None
comment = None
score = 0
tip = list()
tipcycle = 0
TRIVIAL = 1
EASY = 2
NORMAL = 3
HARD = 4
EXTREME = 5
LEVELS = (TRIVIAL, EASY, NORMAL, HARD, EXTREME)
def __init__(self, attributes_dict):
self.parse(attributes_dict)
def parse(self, attributes_dict):
"""
Populate fields from a dictionary of attributes (from a question bank).
"""
## Valid keys:
# ----------
# Category? (should always be on top!)
# Question (should always stand after Category)
# Answer (will be matched if no regexp is provided)
# Regexp? (use UNIX-style expressions)
# Author? (the brain behind this question)
# Level? [baby|easy|normal|hard|extreme] (difficulty)
# Comment? (comment line)
# Score? [#] (credits for answering this question)
# Tip* (provide one or more hints)
# TipCycle? [#] (Specify number of generated tips)
if 'Question' in attributes_dict.keys():
self.question = attributes_dict['Question']
else:
raise Exception("Cannot instantiate Question: 'Question' attribute required.")
if 'Category' in attributes_dict.keys():
self.category = attributes_dict['Category']
if 'Answer' in attributes_dict.keys():
self.answer = attributes_dict['Answer']
else:
raise Exception("Cannot instantiate Question: 'Answer' attribute required.")
if 'Regexp' in attributes_dict.keys():
self.regexp = attributes_dict['Regexp']
if 'Author' in attributes_dict.keys():
self.category = attributes_dict['Author']
if 'Level' in attributes_dict.keys() and attributes_dict['Level'] in self.LEVELS:
self.level = attributes_dict['level']
if 'Comment' in attributes_dict.keys():
self.comment = attributes_dict['Comment']
if 'Score' in attributes_dict.keys():
self.score = attributes_dict['Score']
if 'Tip' in attributes_dict.keys():
self.tip = attributes_dict['Tip']
if 'Tipcycle' in attributes_dict.keys():
self.tipcycle = attributes_dict['Tipcycle']
class QuestionBank:
"""
Represents a MoxQuizz question bank.
"""
filename = ''
questions = list()
# Case sensitive, to remain backwards-compatible with MoxQuizz.
KEYS = ('Answer',
'Author',
'Category',
'Comment',
'Level',
'Question',
'Regexp',
'Score',
'Tip',
'Tipcycle',
)
def __init__(self, filename):
"""
Construct a question bank from a file.
"""
self.filename = filename
self.questions = self.parse(filename)
def parse(self, filename):
"""
Read a Moxquizz question bank file into a list.
"""
questions = list()
with open(filename) as f:
key = ''
i = 0
# new question
q = dict()
q['Tip'] = list()
for line in f:
line = line.strip()
i += 1
# Ignore comments.
if line.startswith('#'):
continue
# A blank line starts a new question.