lolbot.py 14.2 KB
Newer Older
1
#! /usr/bin/env python
Jonathan Harker's avatar
Jonathan Harker committed
2
"""
3 4 5 6 7
LOLBOT 2

 - die: Let the bot cease to exist.
 - ask: Ask a MoxQuizz question.
 - list: list some URLs
Jonathan Harker's avatar
Jonathan Harker committed
8 9
"""

10
from __future__ import print_function, unicode_literals
11

12
import sys
13 14 15
import sqlite3
import random
import time
16
import getopt
17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34
import irc.strings
from irc.bot import SingleServerIRCBot
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from models import Log, Url, Model
from pymoxquizz import QuestionBank, Question
from os import listdir, path


DEBUG = True


def debug(msg):
    if DEBUG:
        print(msg)


class LolBot(SingleServerIRCBot):
35
    """
36 37
    An IRC bot to entertain the troops with MoxQuizz questions, log URLs, and
    other shenanigans.
38 39
    """

40 41
    qb = list()

42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61
    def __init__(self, config=None):
        """
        Constructor. Instantiates a lolbot with a configuration dictionary,
        or from command-line options if none is specified.
        """

        if not config:
            config = LolBot.get_options()

        if not self.validate_config(config):
            sys.exit(1)

        (server, port, channel, nickname, database) = (
            config['irc.server'],
            config['irc.port'],
            config['irc.channel'],
            config['irc.nickname'],
            config['db.file'],
            )

62
        debug("Instantiating SingleServerIRCBot")
63
        irc.client.ServerConnection.buffer_class = irc.buffer.LenientDecodingLineBuffer
64 65
        SingleServerIRCBot.__init__(self, [(server, port)], nickname, nickname)
        self.channel = channel
Brett Wilkins's avatar
Brett Wilkins committed
66

67
        # load some MoxQuizz questions
68 69 70 71 72 73
        qfiles = [f for f in listdir('questions') if path.isfile(path.join('questions', f))]
        debug("Loading MoxQuizz questions")
        for f in qfiles:
            qfile = path.abspath(path.join('questions', f))
            debug("  - from MoxQuizz bank '%s'" % qfile)
            self.qb += QuestionBank(qfile).questions
74
        random.shuffle(self.qb)
75
        self.quiz = 0
76 77
        self.question = None

78
        # connect to the database
79 80
        debug("Connecting to SQLite database '%s'" % database)
        self.dbfile = database
Brett Wilkins's avatar
Brett Wilkins committed
81
        self.dbengine = create_engine('sqlite+pysqlite://', creator=self._get_connection)
82 83
        Model.metadata.bind = self.dbengine
        Model.metadata.create_all()
84
        self.db_session = sessionmaker(bind=self.dbengine)
85

86 87
        self.helptext = "Keeps a list of URLs. Commands: list [n|x-y] - prints the last 10 URLs (or n URLs, or x through y); <url> - adds the URL to the list; help - this message."
        debug("Exiting lolbot constructor")
88

89 90 91 92 93 94
    def _get_connection(self):
        """Creator function for SQLAlchemy."""
        connection = sqlite3.Connection(self.dbfile)
        connection.text_factory = str
        debug("Creating SQLAlchemy connection")
        return connection
95

96 97
    def on_nicknameinuse(self, connection, event):
        nick = connection.get_nickname()
98
        debug("Nick '%s' in use, trying '%s_'" % (nick, nick))
99
        connection.nick(nick + "_")
100

101 102 103
    def on_welcome(self, connection, event):
        debug("Joining channel '%s'" % self.channel)
        connection.join(self.channel)
104

105 106
    def on_privmsg(self, connection, event):
        self.do_command(event, event.arguments[0])
107

108
    def on_pubmsg(self, connection, event):
109
        """
110 111 112
        Handle an event on the channel.
        Handle commands addressed to the bot.
        If there's a question, see if it's been answered.
113 114
        """
        try:
115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136
            (nick, message) = event.arguments[0].split(":", 1)
            # handle command, if addressed
            if irc.strings.lower(nick) == irc.strings.lower(self.connection.get_nickname()):
                self.do_command(event, message.strip())
        except ValueError:
            message = event.arguments[0]
            nick = event.source.nick

        # deal with MoxQuizz question
        if self.quiz:
            self.handle_quiz(nick, message)

    def start_quiz(self, nick):
        self.quiz = 0
        self.quiz_scores = dict()
        self.connection.notice(self.channel, 'Quiz begun by %s.' % nick)
        self.quiz_get_next()

    def stop_quiz(self):
        self.quiz = 0
        self.quiz_scores = None
        self.question = None
137

138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166
    def quiz_get_next(self):
        self.quiz += 1
        self.question = random.choice(self.qb)
        print(str(self.question.question))
        self.connection.notice(self.channel, str(self.question.question))

    def quiz_award_points(self, nick):
        score = "%s point" % self.question.score
        if self.question.score != 1:
            score += "s"

        self.connection.notice(self.channel, 'Correct! The answer was %s. %s scores %s.' % (self.question.answer, nick, score))
        if nick not in self.quiz_scores.keys():
            self.quiz_scores[nick] = 0
        self.quiz_scores[nick] += self.question.score

    def quiz_check_win(self, nick):
        if self.quiz_scores[nick] == 10:
            self.connection.notice(self.channel, '%s wins with 10 points!' % nick)
            self.quiz_scoreboard()
            self.stop_quiz()

    def quiz_scoreboard(self):
        self.connection.notice(self.channel, 'Scoreboard:')
        for nick in self.quiz_scores.keys():
            score = "%s point" % self.quiz_scores[nick]
            if self.quiz_scores[nick] != 1:
                score += "s"
            self.connection.notice(self.channel, '%s has %s.' % (nick, score))
167 168
        if not len(self.quiz_scores):
            self.connection.notice(self.channel, 'So far, nobody has got anything right.')
169 170 171 172 173 174 175 176 177 178

    def handle_quiz(self, nick, message):
        # bail if there's no quiz or unanswered question.
        if not self.quiz or not isinstance(self.question, Question):
            return

        # see if anyone answered correctly.
        if self.question.attempt(message):
            self.quiz_award_points(nick)
            self.quiz_check_win(nick)
179

180 181
            # if nobody has won, carry on
            if self.quiz:
182 183 184 185 186

                # scores every 10 questions.
                if self.quiz % 10 == 0:
                    self.quiz_scoreboard()

187 188 189 190 191 192 193 194
                self.quiz_get_next()

    def do_command(self, e, cmd):
        """
        Handle bot commands.
        """
        nick = e.source.nick
        c = self.connection
195

196 197
        if cmd == "die":
            self.die()
198

199 200
        elif cmd == 'help':
            c.notice(nick, self.helptext)
201

202
        elif cmd == 'status':
203 204 205 206
            c.notice(self.channel, "I know %s questions." % len(self.qb))
            if self.quiz:
                c.notice(self.channel, "I am currently running a quiz.")
                self.quiz_scoreboard()
207

208 209 210 211 212
        elif cmd == 'halt' or cmd == 'quit':
            if self.quiz:
                self.quiz_scoreboard()
                self.stop_quiz()
                c.notice(self.channel, "Quiz halted by %s. Use ask to start a new one." % nick)
213
            else:
214 215
                c.notice(self.channel, "No quiz running.")

216 217 218 219
        elif cmd == 'scores':
            if self.quiz:
                self.quiz_scoreboard()

220 221 222 223 224 225 226 227 228
        elif cmd == 'ask':
            if self.quiz:
                c.notice(self.channel, "Quiz is running. Use halt or quit to stop.")
                c.notice(self.channel, str(self.question.question))
            elif isinstance(self.question, Question):
                c.notice(self.channel, "There is an unanswered question.")
                c.notice(self.channel, str(self.question.question))
            else:
                self.start_quiz(nick)
229

230 231 232 233
        elif cmd == 'revolt':
            if isinstance(self.question, Question):
                c.notice(self.channel, "Fine, the answer is: %s" % self.question.answer)
                self.quiz_get_next()
234

235 236
        elif cmd.startswith('urls') or cmd.startswith('list'):
            db = self.db_session()
237
            try:
238
                (listcmd, n) = cmd.split(" ", 1)
239
            except ValueError:
240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267
                n = '5'

            n = n.strip()
            if n == "all":
                rows = db.query(Url).order_by(Url.timestamp.desc())
            elif n.find("-") > 0:
                (x, y) = n.split("-", 1)
                try:
                    x = abs(int(x))
                    y = abs(int(y))
                    if y < x:
                        x, y = y, x
                except ValueError as ex:
                    c.notice(nick, "Give me a number or a range of numbers, e.g. list 5 or list 11-20")
                    raise ex
                rows = db.query(Url).order_by(Url.timestamp.desc())[x - 1: y]
            else:
                try:
                    n = abs(int(n))
                except ValueError as ex:
                    c.notice(nick, "Give me a number or a range of numbers, e.g. list 5 or list 11-20")
                    raise ex
                rows = db.query(Url).order_by(Url.timestamp.desc())[:n]

            for url in rows:
                line = "%s %s" % (url.url, url.title)
                c.notice(nick, line)
                time.sleep(1)
268

269 270
        else:
            c.notice(nick, "Not understood: " + cmd)
271

272 273 274 275 276 277 278 279 280 281
    def validate_config(self, config):
        """
        Basic checks for configuration parameters. Returns a Boolean indicating
        success or failure.
        """

        # validate IRC host
        if 'irc.server' not in config.keys():
            print("Error: the IRC server was not specified. Use --help for more information.")
            return False
282

283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311
        # validate IRC port
        if 'irc.port' not in config.keys():
            config['irc.port'] = '6667'
            try:
                config['irc.port'] = int(config['irc.port'])
            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.")
                return False

        # validate IRC channel
        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.")
            return False

        # validate bot nickname
        if 'irc.nickname' not in config.keys():
            config['irc.nickname'] = 'lolbot'

        # validate bot nickname
        if 'db.file' not in config.keys():
            config['db.file'] = 'lolbot.db'

        return True

    @staticmethod
    def get_options():
        """
        Set up configuration from the script arguments.
        """
312

313
        try:
314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363
            (options, args) = getopt.getopt(sys.argv[1:], 'hc:s:p:j:n:d:', ['help', 'config=', 'server=', 'port=', 'join=', 'nick=', 'database=', ])
        except getopt.GetoptError as err:
            print(str(err))
            LolBot.usage()
            sys.exit(2)

        config = {}
        for option, value in options:
            # Display help text.
            if option in ('-h', '--help'):
                LolBot.usage()
                sys.exit(2)

            # Get configuration from a file.
            if option in ('-c', '--config'):
                config = load_config(value)
                break

            # Individually specified settings.
            if option in ('-s', '--server'):
                config['irc.server'] = value
            if option in ('-p', '--port'):
                config['irc.port'] = value
            if option in ('-j', '--join', '--channel', '--join-channel'):
                config['irc.channel'] = value
            if option in ('-n', '--nickname'):
                config['irc.nickname'] = value
            if option in ('-d', '--database'):
                config['db.file'] = value

        return config

    @staticmethod
    def load_config(config_path=''):
        """
        This method loads configuration options from a lolbot.conf file. The file
        should look something like this::

        irc.server = irc.yourdomain.com
        irc.port = 6667
        irc.channel = #lolbottest
        irc.nickname = lolbot
        db.file = lolbot.db
        """

        if config_path == '':
            config_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'lolbot.conf')
        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.")
            LolBot.usage()
364
            sys.exit(1)
365

366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417
        # open the configuration file and grab all param=value declarations.
        config = {}
        with open(config_path) as f:
            for line in f:
                # skip comments
                if line.strip().startswith('#') or line.strip() == '':
                    continue

                # collect up param = value
                try:
                    (param, value) = line.strip().split('=', 1)
                    if param.strip() != '':
                        config[param.strip()] = value.strip()
                except ValueError:
                    continue

        return config

    @staticmethod
    def usage():
        """
        Spits out CLI help.
        """

        print("""Run a lolbot.

      -h, --help
             This message.

      -s, --server=<hostname>
             The IRC server, e.g. irc.freenode.net

      -p, --port=<port>
             The IRC server port. Default: 6667

      -j, --join=<channel>
             The chat channel to join, e.g. #trainspotting

      -n, --nickname=<nickname>
             The nickname for your lolbot. Default: "lolbot"

      -d, --database=<path>
             The path to a SQLite lolbot database. If not specified, lolbot will
             attempt to use a SQLite database called lolbot.db in the same
             directory as the script.

    Configuration file:

      -c, --config=<filename>
             Specify a configuration file. Ignores any options specified from the
             command-line. Defaults to lolbot.conf in the same directory as the
             script. File layout:
418

419 420 421 422 423 424
               irc.server = <host>
               irc.port = <port>
               irc.channel = <channel>
               irc.nickname = <nickname>
               db.file = <path>
""".strip())
425

426

Jonathan Harker's avatar
Jonathan Harker committed
427
if __name__ == "__main__":
428
    try:
429
        LolBot().start()
430 431
    except KeyboardInterrupt:
        pass