Permalink
Browse files

Major refactoring, the rough cut:

* Refactored the bot into a delegation model. The main bot is just a driver,
  with individual commands delegated out to a "mode". Modes can be switched
  with ",mode".

* Store data centrally with Mongo. We'll use a MongoHQ account to have central
  data available.

* Added the basics to make this work on Heroku.

The "modes" themselves have only barely been refactored, and the various
scripts haven't even been touched. That's next.
  • Loading branch information...
1 parent 6308c65 commit a290c974590a48053610b0a982f0d1885051a5fb @jacobian jacobian committed Aug 27, 2012
View
@@ -0,0 +1 @@
+irc: python runbot.py
View
@@ -0,0 +1,166 @@
+"""
+The bot "driver" is the main twisted bit that actually runs the bot. It supports
+a few basic commands, but in most cases it delegates commands to a "mode" so
+that the bot can be switched among different running modes without restarting.
+"""
+
+import os
+import importlib
+from twisted.internet import defer, protocol, reactor
+from twisted.python import log
+from twisted.words.protocols import irc
+
+class PyConBot(irc.IRCClient):
+ accepted_users = ["Alex_Gaynor", "VanL", "tlesher", "jacobkm"]
+
+ def __init__(self):
+ self.state_handler = None
+ self._namescallback = {}
+ self.timer = None
+ self.mode = None
+
+ # Would be nice for this to be an argument instead of reading from env
+ # directly. Dunno how else that'd work with Twisted's indirection though.
+ self.superusers = set(os.environ.get('PYCONBOT_SUPERUSERS', '').split(','))
+
+ #
+ # "Public" API - stuff to be called by drivers.
+ #
+
+ @property
+ def nickname(self):
+ return self.factory.nickname
+
+ def set_timer(self, channel, seconds, msg="Time has ended."):
+ """
+ Set a timer, saying `msg` after `seconds` seconds.
+ """
+ def say_time(channel):
+ self.timer = None
+ self.msg(channel, "=== %s ===" % msg)
+ self.clear_timer()
+ self.timer = reactor.callLater(seconds, say_time, channel)
+
+ def clear_timer(self):
+ """
+ Clear an already-set timer.
+ """
+ if self.timer:
+ self.timer.cancel()
+ self.timer = None
+
+ def names(self, channel):
+ """
+ List names in the channel.
+
+ Returns a deferred. Because THIS IS TWISTED!
+ """
+ channel = channel.lower()
+ d = defer.Deferred()
+ self._namescallback.setdefault(channel, [[], []])[0].append(d)
+ self.sendLine("NAMES %s" % channel)
+ return d
+
+ #
+ # Bot commands supported by the driver itself
+ #
+
+ def handle_mode(self, channel, *args):
+ """
+ Handle switching modes.
+ """
+ if not args:
+ mname = (self.mode.__class__.__name__ if self.mode else "(none)")
+ self.msg(channel, "Current mode: %s" % mname)
+ return
+
+ newmode = args[0]
+ try:
+ mod = importlib.import_module('pycon_bot.modes.%s' % newmode)
+ self.mode = getattr(mod, '%sMode' % newmode.title())
+ except (ImportError, AttributeError) as e:
+ self.msg(channel, "Can't load mode %s: %s" % (newmode, e))
+
+ #
+ # Internals
+ #
+
+ # Support functions for the NAMES command.
+
+ def irc_RPL_NAMREPLY(self, prefix, params):
+ channel = params[2].lower()
+ if channel not in self._namescallback:
+ return
+ nicklist = [name.strip('@+') for name in params[3].split(' ')]
+ self._namescallback[channel][1] += nicklist
+
+ def irc_RPL_ENDOFNAMES(self, prefix, params):
+ channel = params[1].lower()
+ if channel not in self._namescallback:
+ return
+ callbacks, namelist = self._namescallback[channel]
+ for cb in callbacks:
+ cb.callback(namelist)
+ del self._namescallback[channel]
+
+ # Twisted callbacks and such.
+
+ def signedOn(self):
+ for channel in self.factory.channels:
+ self.join(channel)
+
+ def joined(self, channel):
+ log.msg("Joined %s" % channel)
+ self.msg(channel, "Hello denizens of %s, I am your god." % channel)
+ self.msg(channel, "To contribute to me: https://github.com/alex/THUNDERDOME-BOT")
+
+ def privmsg(self, user, channel, message):
+ """
+ Called whenever a message goes into a channel.
+
+ if the message starts with ",<cmd>", then dispatch to a `handle_<cmd>`
+ function, either on self or on the bot mode object, but only if the
+ user is a superuser.
+ """
+ user = user.split("!")[0]
+
+ # Some times - voting - we want to record every command. In those cases,
+ # the botmode will set state_handler and we'll call that. Othwewise,
+ # we only care about ,-prefixed commands.
+ if not message.startswith(","):
+ if self.state_handler:
+ self.state_handler(channel, user, message)
+ # TODO: callback for logging every message.
+ return
+
+ if user not in self.superusers:
+ return
+
+ # Find the callback. First look on self, then look at the mode.
+ message = message[1:]
+ command_parts = message.split()
+ command, command_args = command_parts[0], command_parts[1:]
+ callback_name = 'handle_%s' % command
+ if hasattr(self, callback_name):
+ action = getattr(self, callback_name)
+ elif hasattr(self.mode, callback_name):
+ action = getattr(self.mode, callback_name)
+ else:
+ self.msg(channel, "%s: I don't recognize that command" % user)
+ return
+
+ action(channel, *command_args)
+
+class PyConBotFactory(protocol.ClientFactory):
+ protocol = PyConBot
+
+ def __init__(self, channels, nickname):
+ self.channels = channels
+ self.nickname = nickname
+
+ def clientConnectionLost(self, connector, reason):
+ log.msg("Lost connection: %s" % reason)
+ connector.connect()
+
+ def clientConnectionFailed(self, connector, reason):
+ log.msg("Connection failed: %s" % reason)
View
@@ -0,0 +1,36 @@
+import mongoengine
+
+class SiteVotes(mongoengine.EmbeddedDocument):
+ """
+ Votes on a talk on the site. Duplicated here for reporting purposes, should
+ be considered read-only for the bots.
+ """
+ plus_1 = mongoengine.IntField(min_value=0)
+ plus_0 = mongoengine.IntField(min_value=0)
+ minus_0 = mongoengine.IntField(min_value=0)
+ minus_1 = mongoengine.IntField(min_value=0)
+
+class KittendomeVotes(mongoengine.EmbeddedDocument):
+ """
+ Records the votes on a talk in a Kittendome session.
+ """
+ yay = mongoengine.IntField(min_value=0)
+ nay = mongoengine.IntField(min_value=0)
+ abstain = mongoengine.IntField(min_value=0)
+
+class TalkProposal(mongoengine.Document):
+ STATUSES = [
+ ('unreviewed', 'Unreviewed'),
+ ('pre-rejected', 'Rejected at pre-season Kittendome'),
+ ('rejected', 'Rejected'),
+ ('thunderdome', 'Accepted into Thunderdome'),
+ ('accepted', 'Accepted'),
+ ('damaged', 'Damaged'),
+ ]
+
+ id = mongoengine.IntField(unique=True)
+ title = mongoengine.StringField()
+ status = mongoengine.StringField(choices=STATUSES)
+ site_votes = mongoengine.EmbeddedDocumentField(SiteVotes)
+ kittendome_votes = mongoengine.EmbeddedDocumentField(KittendomeVotes)
+ debate_transcript = mongoengine.StringField()
No changes.
View
@@ -0,0 +1,46 @@
+class BaseBotMode(object):
+ """
+ Base class for all modes, handling all the base commands.
+ """
+ def __init__(self, bot):
+ self.bot = bot
+ self.nonvoters = set()
+
+ @property
+ def nonvoter_list(self):
+ return ', '.join(self.nonvoters) if self.nonvoters else 'none'
+
+ def handle_nonvoter(self, channel, *users):
+ users = set(users)
+ users.discard(self.nickname)
+ if not users:
+ self.bot.msg(channel, "Nonvoters: %s." % self.nonvoter_list)
+ return
+ self.nonvoters.update(users)
+ self.bot.msg(channel, "Will no longer pester %s." % ', '.join(users))
+
+ def handle_voter(self, channel, *users):
+ users = set(users)
+ users.discard(self.nickname)
+ if not users:
+ self.bot.msg(channel, "Nonvoters: %s." % self.nonvoter_list)
+ return
+ if '*' in users:
+ self.nonvoters.clear()
+ self.bot.msg(channel, "Will now pester everyone.")
+ else:
+ self.nonvoters.difference_update(users)
+ self.bot.msg(channel, "Will now pester %s." % ', '.join(users))
+
+ def handle_pester(self, channel):
+ def names_callback(names):
+ laggards = (set(names) - set(self.current_votes.keys()) - self.nonvoters)
+ laggards.remove(self.nickname)
+ if laggards:
+ self.bot.msg(channel, "Didn't vote: %s." % (", ".join(laggards)))
+ else:
+ self.bot.msg(channel, "Everyone voted.")
+ self.bot.names(channel).addCallback(names_callback)
+
+ def talk_url(self, talk_id):
+ return "http://us.pycon.org/2012/review/%s/" % talk_id
Oops, something went wrong.

0 comments on commit a290c97

Please sign in to comment.