Custom pmxbot actions

Posted: April 7th, 2010 | Author: | Filed under: Nerdery | Tags: , , | 3 Comments »

My employer just open sourced a fork of the IRC bot we’ve been using internally for years, pmxbot. jamwt and some others are responsible for all the good parts of it, but I’ve been responsible for most of the feature bloat for the past few years. The open source version strips out internal code that or didn’t need to be shared, and overall makes it much more flexible for others to use.

It’s really a pretty great system, and I’m going to share some of the actions that we didn’t release, but that you might get some value or inspiration from. Of note, a few of these will reference internal libraries, so you’ll need to switch ’em to use urllib2 or similar.

The basic way you extend pmxbot is with two decorators. The first is @command, and the second is @contains. @commands are commands you explicitly call with “!command” at the beginning of a line. @contains is a very simple pattern match – if the phrase you register is in the line (literally “name in lc_msg”).

When you use the decorators on a function you’re adding it to pmxbot’s handler_registry. Every line of chat it sees will then be checked to see if an appropriate action exists in the registry, and if so the function is called. It goes through the registry in a certain order – first commands, then aliases, then contains. Within each group it also sorts by descending length order – so if you have two contains – “rama lama ding dong” and “ram” – if a line had “rama lama ding dong” it would execute that one. pmxbot will execute exactly 0 or 1 actions for any line.

The decorators are fairly simple – @command(“google”, aliases=(‘g’,), doc=”Look a phrase up on google, new method”). First is the name of the command “google”, which you trigger by entering “!google.” Second is an optional iterator of aliases, in this case only one, “!g.” You could have several in here, such as aliases=(‘shiv’, ‘stab’, ‘shank’,). Last is an optional help/documentation string that will be displayed when someone does “!help google”. The contains decorator is the same, but uses @contains and doesn’t support aliases.

A command is called when it’s picked out of the handler registry to handle the action. Any handler will be called with the arguments – client, event, channel, nick and rest. You can ignore client and event for 99% of cases, they’re passed through from the underlying irc library. Channel is a string containing the channel the command was made in. Nick is the nickname of the person who made the call. Rest is the rest of the message, after the command prefix is removed if it’s a command. For example if we saw the following line in #pmxbot: “<chmullig> !g wikipedia irc bots” the google function would be called with channel == “#pmxbot”, nick == “chmullig” and rest == “wikipedia irc bots”.

A basic command

Putting it all together, let’s look at a basic command – !google.

@command("google", aliases=('g',), doc="Look a phrase up on google")
def google(client, event, channel, nick, rest):
	BASE_URL = 'http://ajax.googleapis.com/ajax/services/search/web?v=1.0&amp;'
	url = BASE_URL + urllib.urlencode({'q' : rest.strip()})
	raw_res = urllib.urlopen(url).read()
	results = json.loads(raw_res)
	hit1 = results['responseData']['results'][0]
	return ' - '.join((urllib.unquote(hit1['url']), hit1['titleNoFormatting']))

It registers the function google, under the command google, with an alias g. Note that the google function doesn’t have to match the name in the decorator, it can be anything. Within this function we can do anything you could want to do in python – we use urllib to call google’s ajax apis, and use simplejson to parse it and return the URL and title of the first hit. Anything returned or yielded from the function is passed back to the channel it was called from. If you want to have pmxbot perform an action, just return text that begins with “/me.”

Now let’s do a short contains example – simple enough.

@contains("sqlonrails")
def yay_sor(client, event, channel, nick, rest):
	karmaChange(botbase.logger.db, 'sql on rails', 1)
	return "Only 76,417 lines..."

This one has no doc (you can’t get help on contains ATM) and it’s pretty simple. That karmaChange line increases the karma of “sql on rails,” but we’re not talking about karma.

Yahoo

You’ll need the BOSS library for this, and you’ll need to register an API key and put a config file where the library expects it. However it works fine once you do all that.

@command("yahoo", aliases=('y',), doc="Look a phrase up on Yahoo!")
def yahoo(client, event, channel, nick, rest):
	from yos.boss import ysearch
	searchres = ysearch.search(rest.strip(), count=1)
	hit1 = searchres['ysearchresponse']['resultset_web'][0]
	return hit1['url']

Trac

This is one of my favorites. We use Trac internally for ticketing. We have two commands that use the XMLRPC plugin for Trac to make it accessible for pmxbot. The first finds any possible ticket number (eg #12345) and provides a link & ticket summary. Note that we put a hack into the handler registry to make this the first contains command it checks, you could do something similar if you wanted to modify it.

@contains('#', doc='Prints the ticket URL when you use #1234')
def ticket_link(client, event, channel, nick, rest):
	res = []
	matches = re.finditer(r'#(?P<ticket>\d{4,5})\b', rest)
	if matches:
		tracrpc = xmlrpclib.Server('https://user:pass@trac/xmlrpc')
	for match in matches:
		ticket = match.groupdict().get('ticket', None)
		if ticket:
			res.append('https://trac/ticket/%s' % ticket)
			try:
				res.append(tracrpc.ticket.get(int(ticket))[3]['summary'])
			except:
				pass
	if res:
		return ' '.join(res)

The second uses the RPC to search trac for a ticket or wikipage that might be relevant.

@command("tsearch", aliases=('tracsearch',), doc="Search trac for something")
def tsearch(client, event, channel, nick, rest):
	rest = rest.strip()
	url = 'https://trac/search?' + urllib.urlencode({'q' : rest})
	tracrpc = xmlrpclib.Server('https://user:pass@trac/xmlrpc')
	searchres = tracrpc.search.performSearch(rest)
	return '%s |Results: %s' % (url, ' | '.join(['%s %s' % (x[0], plaintext(x[1])) for x in searchres[:2]]))

Notify

This one is a total hack, but we were having issues with coordinating a certain team. We added a !notify command to help folks easily let everyone, online & off, know what they were up to. It simple sent an email to a distribution list.

@command("notify", doc="Send an email to list@domain.com, let them know you accidentally wiped a server.")
def notify(client, event, channel, nick, rest):
	server = smtplib.SMTP('mail.domain.com')
	notification = '%s: %s' % (nick, rest.strip())
	try:
		sigraw = rand_bot(client, event, '!notify', nick, rest)
		if type(sigraw) == GeneratorType:
			sigraw = '\r\n'.join(sigraw)
		signature = '\r\n\r\n--\r\n%s' % sigraw
	except:
		signature = ''
	msg = 'From: pmxbot@domain.com \r\n'\
		'Reply-To: list@domain.com \r\n'\
		'To: list@domain.com \r\n'\
		'Subject: !notify: %s \r\n\r\n'\
		'%s \r\n\r\n'\
		'Hugs & Kisses,\r\npmxbot'\
		'%s\r\n\r\n' % (notification, notification, signature)
	server.sendmail('pmxbot@domain.com', ['list@domain.com',], msg)
	server.quit()

Invite

This one is a little weird, but as more people got on IRC we wanted to send them a little email to let them know how to access it, etc. It’s also useful to harass people who aren’t online, but should be.

@command("invite", aliases=('spam',), doc="Send an email to an invitee, asking them to join irc.")
def invite(client, event, channel, nick, rest):
	server = smtplib.SMTP('mail.domain.com')
	if rest:
		try:
			inviteText = rest.split(' ', 1)[1]
		except:
			inviteText = ''
		invitee = rest.split(' ', 1)[0] + '@domain.com'
		try:
			sigraw = rand_bot(client, event, '!notify', nick, rest)
			if type(sigraw) == GeneratorType:
				sigraw = '\r\n'.join(sigraw)
			signature = '\r\n\r\n--\r\n%s' % sigraw
		except:
			signature = ''
		msg = 'From: pmxbot@domain.com \r\n'\
			'Reply-To: noreply@domain.com \r\n'\
			'To: %s \r\n'\
			'Subject: join us in irc! \r\n\r\n'\
			'%s \r\nRemember, you can find about irc here: https://intranet/IRC\r\n'\
			'You can access IRC via your web browser at https://domain.com/irc \r\n\r\n'\
			'Hugs & Kisses,\r\n%s & pmxbot'\
			'%s\r\n\r\n' % (invitee, inviteText, nick, signature)
		server.sendmail('pmxbot@domain.com', [invitee,], msg)
		server.quit()

Personal Responses

We have a ton of these, this is just one example. They watch for people being referenced, and occasionally (the randomness is key for many of them, to keep from being obnoxious) respond.

@contains("elarson")
def elarsonthemachine(client, event, channel, nick, rest):
	if nick == 'elarson' and 'http://' not in rest and 'https://' not in rest:
		return 'elarson - The Machine!!!'

Other RPCs

I won’t show you the code, because removing the specific stuff would make it boring. But we have about a half dozen internal RPCs we can call with it. Some use Pyro, others XMLRPC. That trac search example is pretty representative.

RSS/Atom

We have pmxbot monitoring about a half dozen RSS feeds. The intranet and dev site both have them, we monitor twitter search for a bunch of keywords, as well as google news. It’s a pretty sweet feature, if you ask me.

In conclusion, pmxbot is awesome. I’d like to make it even easier for people to add their own features. Maybe include a setting in the YAML conf file that’s a python file which is imported? What else do you have (all zero of you using pmxbot)?

3 Comments »