Limnoria/docs/PLUGIN-EXAMPLE
2004-12-10 07:22:46 +00:00

450 lines
20 KiB
Plaintext

*******************************************************************************
The plugins/Random.py included with the distribution has been
updated to use an argument-parsing module recently added to Supybot
but not yet documented. Still, all of the functions used in this
tutorial are available, so the code (as shown in this tutorial*
should still work.
*******************************************************************************
Ok, so you want to write a callback for Supybot. Good, then this is
the place to be. We're going to start from the top (the highest
level, where Supybot code does the most work for you) and move lower
after that.
So have you used Supybot? If not, you need to go use it, get a feel
for it, see how the various commands work and such.
So now that we know you've used Supybot, we'll start getting into
details.
First, the easiest way to start writing a module is to use the wizard
provided, scripts/newplugin.py. Here's an example session:
-----
functor% supybot-newplugin
What should the name of the plugin be? Random
Supybot offers two major types of plugins: command-based and regexp-
based. Command-based plugins are the kind of plugins you've seen most
when you've used supybot. They're also the most featureful and
easiest to write. Commands can be nested, for instance, whereas
regexp-based callbacks can't do nesting. That doesn't mean that
you'll never want regexp-based callbacks. They offer a flexibility
that command-based callbacks don't offer; however, they don't tie into
the whole system as well. If you need to combine a command-based
callback with some regexp-based methods, you can do so by subclassing
callbacks.PrivmsgCommandAndRegexp and then adding a class-level
attribute "regexps" that is a sets.Set of methods that are regexp-
based. But you'll have to do that yourself after this wizard is
finished :)
Do you want a command-based plugin or a regexp-based plugin? [command/
regexp] command
Sometimes you'll want a callback to be threaded. If its methods
(command or regexp-based, either one) will take a signficant amount
of time to run, you'll want to thread them so they don't block
the entire bot.
Does your plugin need to be threaded? [y/n] n
Your new plugin template is Random.py
functor%
-----
So that's what it looks like. Now let's look at the source code (if
you'd like to look at it in your programming editor, the whole plugin
is available as examples/Random.py):
-----
#!/usr/bin/env python
###
# Copyright (c) 2004, Jeremiah Fincher
# All rights reserved.
#
# Redistribution and use in source and binary forms, with or without
# modification, are permitted provided that the following conditions are met:
#
# * Redistributions of source code must retain the above copyright notice,
# this list of conditions, and the following disclaimer.
# * Redistributions in binary form must reproduce the above copyright notice,
# this list of conditions, and the following disclaimer in the
# documentation and/or other materials provided with the distribution.
# * Neither the name of the author of this software nor the name of
# contributors to this software may be used to endorse or promote products
# derived from this software without specific prior written consent.
#
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE
# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
# POSSIBILITY OF SUCH DAMAGE.
###
"""
Add the module docstring here. This will be used by the setup.py script.
"""
__revision__ = "$Id$"
__author__ = ''
import supybot.plugins as plugins
import supybot.conf as conf
import supybot.utils as utils
import supybot.privmsgs as privmsgs
import supybot.callbacks as callbacks
def configure(advanced):
# This will be called by setup.py to configure this module. Advanced is
# a bool that specifies whether the user identified himself as an advanced
# user or not. You should effect your configuration by manipulating the
# registry as appropriate.
from questions import expect, anything, something, yn
conf.registerPlugin('Random', True)
class Random(callbacks.Privmsg):
pass
Class = Random
# vim:set shiftwidth=4 tabstop=8 expandtab textwidth=78:
-----
So a few notes, before we customize it.
You'll probably want to change the copyright notice to be your name.
It wouldn't stick even if you kept my name, so you might as well :)
Describe what you want the plugin to do in the docstring. This is
used in supybot-wizard in order to explain to the user the purpose of
the module. It's also returned when someone asks the bot for help for
a given module (instead of help for a certain command). We'll change
this one to "Lots of stuff relating to random numbers."
Then there are the imports. The callbacks module is used (the class
you're given subclasses callbacks.Privmsg) but the privmsgs module
isn't used. That's alright; we can almost guarantee you'll use it, so
we go ahead and add the import to the template.
Then you see a "configure" function. This is the function that's
called when users decide to add your module in supybot-wizard. You'll
note that by default it simply registers the plugin to be
automatically loaded on startup. For many plugins this is all you
need; for more complex plugins, you might need to ask questions and
add commands based on the answers.
Now comes the meat of the plugin: the plugin class.
What you're given is a skeleton: a simple subclass of
callbacks.Privmsg for you to start with. Now let's add a command.
I don't know what you know about random number generators, but the
short of it is that they start at a certain number (a seed) and they
continue (via some somewhat complicated/unpredictable algorithm) from
there. This seed (and the rest of the sequence, really) is all nice
and packaged up in Python's random module, the Random object. So the
first thing we're going to have to do is give our plugin a Random
object.
Normally, when we want to give instances of a class an object, we'll
do so in the __init__ method. And that works great for plugins, too.
The one thing you have to be careful of is that you call the
superclass __init__ method at the end of your own __init__. So to add
this random.Random object to our plugin, we can replace the "pass"
statement with this:
def __init__(self):
self.rng = random.Random()
callbacks.Privmsg.__init__(self)
(rng is an abbreviation for "random number generator," in case you
were curious)
Do be careful not to give your __init__ any arguments (other than
self, of course). There's no way anything will ever get to them! If
you have some sort of initial values you need to get to your plugin
before it can do anything interesting, you should get those values
from the registry.
There's an easier way to get our plugin to have its own rng than to
define an __init__. Plugins are unique among classes because we're
always certain that there will only be one instance -- supybot doesn't
allow us to load multiple instances of a single plugin. So instead of
adding the rng in __init__, we can just add it as a attribute to the
class itself. Like so (replacing the "pass" statement again):
rng = random.Random()
And we save two lines of code and make our code a little more clear :)
Now that we have an RNG, we need some way to get random numbers. So
first, we'll add a command that simply gets the next random number and
gives it back to the user. It takes no arguments, of course (what
would you give it?). Here's the command, and I'll follow that with
the explanation of what each part means.
def random(self, irc, msg, args):
"""takes no arguments
Returns the next random number generated by the random number
generator.
"""
irc.reply(str(self.rng.random()))
And that's it! Pretty simple, huh? Anyway, you're probably wondering
what all that *means*. We'll start with the def statement:
def random(self, irc, msg, args):
What that does is define a command "random". You can call it by
saying "@random" (or whatever prefix character your specific bot
uses). The arguments are a bit less obvious. Self is self-evident
(hah!). irc is the Irc object passed to the command; msg is the
original IrcMsg object. But you're really not going to have to deal
with either of these too much (with the exception of calling irc.reply
or irc.error). What you're *really* interested in is the args arg.
That is a list of all the arguments passed to your command, pre-parsed
and already evaluated (i.e., you never have to worry about nested
commands, or handling double quoted strings, or splitting on
whitespace -- the work has already been done for you). You can read
about the Irc object in irclib.py (you won't find .reply or .error
there, though, because you're actually getting an IrcObjectProxy, but
that's beyond the level we want to describe here :)). You can read
about the msg object in ircmsgs.py. But again, you'll very rarely be
using these objects.
(In case you're curious, the answer is yes, you *must* name your
arguments (self, irc, msg, args). The names of those arguments is one
of the ways that supybot uses to determine which methods in a plugin
class are commands and which aren't. And while we're talking about
naming restrictions, all your commands should be named in
all-lowercase with no underscores. Before calling a command, supybot
always converts the command name to lowercase and removes all dashes
and underscores. On the other hand, you now know an easy way to make
sure a method is never called (even if its arguments are (self, irc,
msg, args), however unlikely that may be). Just name it with an
underscore or an uppercase letter in it :))
You'll also note that the docstring is odd. The wonderful thing about
the supybot framework is that it's easy to write complete commands
with help and everything: the docstring *IS* the help! Given the
above docstring, this is what a supybot does:
<jemfinch> @help random
<angryman> jemfinch: (random takes no arguments) -- Returns the
next random number from the random number generator.
Now on to the actual body of the function:
irc.reply(str(self.rng.random()))
irc.reply takes one simple argument: a string. The string is the
reply to be sent. Don't worry about length restrictions or anything
-- if the string you want to send is too big for an IRC message (and
oftentimes that turns out to be the case :)) the Supybot framework
handles that entirely transparently to you. Do make sure, however,
that you give irc.reply a string. It doesn't take anything else
(sometimes even unicode fails!). That's why we have
"str(self.rng.random())" instead of simply "self.rng.random()" -- we
had to give irc.reply a string.
Anyway, now that we have an RNG, we have a need for seed! Of course,
Python gives us a good seed already (it uses the current time as a
seed if we don't give it one) but users might want to be able to
repeat "random" sequences, so letting them set the seed is a good
thing. So we'll add a seed command to give the RNG a specific seed:
def seed(self, irc, msg, args):
"""<seed>
Sets the seed of the random number generator. <seed> must be
an int or a long.
"""
seed = privmsgs.getArgs(args)
try:
seed = long(seed)
except ValueError:
# It wasn't a valid long!
irc.error('<seed> must be a valid int or long.')
return
self.rng.seed(seed)
irc.replySuccess()
So this one's a bit more complicated. But it's still pretty simple.
The method name is "seed" so that'll be the command name. The
arguments are the same, the docstring is of the same form, so we don't
need to go over that again. The body of the function, however, is
significantly different.
privmsgs.getArgs is a function you're going to be seeing a lot of when
you write plugins for Supybot. What it does is basically give you the
right number of arguments for your comamnd. In this case, we want one
argument. But we might have been given any number of arguments by the
user. So privmsgs.getArgs joins them appropriately, leaving us with
one single "seed" argument (by default, it returns one argument as a
single value; more arguments are returned in a tuple/list). Yes, we
could've just said "seed = args[0]" and gotten the first argument, but
what if the user didn't pass us an argument at all? Then we've got to
catch the IndexError from args[0] and complain to the user about it.
privmsgs.getArgs, on the other hand, handles all that for us. If the
user didn't give us enough arguments, it'll reply with the help string
for the command, thus saving us the effort.
So we have the seed from privmsgs.getArgs. But it's a string. The
next three lines are pretty darn obvious: we're just converting the
string to a int of some sort. But if it's not, that's when we're
going to call irc.error. It has the same interface as we saw before
in irc.reply, but it makes sure to remind the user that an error has
been encountered (currently, that means it puts "Error: " at the
beginning of the message). After erroring, we return. It's important
to remember this return here; otherwise, we'll just keep going down
through the function and try to use this "seed" variable that never
got assigned. A good general rule of thumb is that any time you use
irc.error, you'll want to return immediately afterwards.
Then we set the seed -- that's a simple function on our rng object.
Assuming that succeeds (and doesn't raise an exception, which it
shouldn't, because we already read the documentation and know that it
should work) we reply to say that everything worked fine. That's what
irc.replySuccess says. By default, it has the very dry (and
appropriately robot-like) "The operation succeeded." but you're
perfectly welcome to customize it yourself -- the registry was written to
be modified!
So that's a bit more complicated command. But we still haven't dealt
with multiple arguments. Let's do that next.
So these random numbers are useful, but they're not the kind of random
numbers we usually want in Real Life. In Real Life, we like to tell
someone to "pick a number between 1 and 10." So let's write a
function that does that. Of course, we won't hardcode the 1 or the 10
into the function, but we'll take them as arguments. First the
function:
def range(self, irc, msg, args):
"""<start> <end>
Returns a number between <start> and <end>, inclusive (i.e., the number
can be either of the endpoints.
"""
(start, end) = privmsgs.getArgs(args, required=2)
try:
end = int(end)
start = int(start)
except ValueError:
irc.error('<start> and <end> must both be integers.')
return
# .randrange() doesn't include the endpoint, so we use end+1.
irc.reply(str(self.rng.randrange(start, end+1)))
Pretty simple. This is becoming old hat by now. The only new thing
here is the call to privmsgs.getArgs. We have to make sure, since we
want two values, to pass a keyword parameter "required" into
privmsgs.getArgs. Of course, privmsgs.getArgs handles all the
checking for missing arguments and whatnot so we don't have to.
The Random object we're using offers us a "sample" method that takes a
sequence and a number (we'll call it N) and returns a list of N items
taken randomly from the sequence. So I'll show you an example that
takes advantage of multiple arguments but doesn't use privmsgs.getArgs
(and thus has to handle its own errors if the number of arguments
isn't right). Here's the code:
def sample(self, irc, msg, args):
"""<number of items> [<text> ...]
Returns a sample of the <number of items> taken from the remaining
arguments. Obviously <number of items> must be less than the number
of arguments given.
"""
try:
n = int(args.pop(0))
except IndexError: # raised by .pop(0)
raise callbacks.ArgumentError
except ValueError:
irc.error('<number of items> must be an integer.')
return
if n > len(args):
irc.error('<number of items> must be less than the number '
'of arguments.')
return
sample = self.rng.sample(args, n)
irc.reply(utils.commaAndify(map(repr, sample)))
Most everything here is familiar. The difference between this and the
previous examples is that we're dealing with args directly, rather
than through getArgs. Since we already have the arguments in a list,
it doesn't make any sense to have privmsgs.getArgs smush them all
together into a big long string that we'll just have to re-split. But
we still want the nice error handling of privmsgs.getArgs. So what do
we do? We raise callbacks.ArgumentError! That's the secret juju that
privmsgs.getArgs is doing; now we're just doing it ourself. Someone
up our callchain knows how to handle it so a neat error message is
returned. So in this function, if .pop(0) fails, we weren't given
enough arguments and thus need to tell the user how to call us.
So we have the args, we have the number, we do a simple call to
random.sample and then we do this funky utils.commaAndify to it.
Yeah, so I was running low on useful names :) Anyway, what it does is
take a list of strings and return a string with them joined by a
comma, the last one being joined with a comma and "and". So the list
['foo', 'bar', 'baz'] becomes "foo, bar, and baz". It's pretty useful
for showing the user lists in a useful form. We map the strings with
repr() first just to surround them with quotes.
So we have one more example. Yes, I hear your groans, but it's
pedagogically useful :) This time we're going to write a command that
makes the bot roll a die. It'll take one argument (the number of
sides on the die) and will respond with the equivalent of "/me rolls a
__" where __ is the number the bot rolled. So here's the code:
def diceroll(self, irc, msg, args):
"""[<number of sides>]
Rolls a die with <number of sides> sides. The default number
of sides is 6.
"""
try:
n = privmsgs.getArgs(args, required=0, optional=1)
if not n:
n = 6
n = int(n)
except ValueError:
irc.error('Dice have integer numbers of sides. Use one.')
return
s = 'rolls a %s' % self.rng.randrange(1, n+1)
irc.reply(s, action=True)
There's a lot of stuff you haven't seen before in there. The most
important, though, is the first thing you'll notice that's different:
the privmsg.getArgs call. Here we're offering a default argument in
case the user is too lazy to supply one (or just wants a nice,
standard six-sided die :)) privmsgs.getArgs supports that; we'll just
tell it that we don't *need* any arguments (via required=0) and that
we *might like* one argument (optional=1). If the user provides an
argument, we'll get it -- if they don't, we'll just get an empty
string. Hence the "if not n: n = 6", where we provide the default.
You'll also note that irc.reply was given a keyword argument here,
"action". This means that the reply is to be made as an action rather
than a normal reply.
So that's our plugin. 5 commands, each building in complexity. You
should now be able to write most anything you want to do in Supybot.
Except regexp-based plugins, but that's a story for another day (and
those aren't nearly as cool as these command-based callbacks anyway
:)). Now we need to flesh it out to make it a full-fledged plugin.
TODO: Describe the registry and how to write a proper plugin configure
function.
We've written our own plugin from scratch (well, from the boilerplate
that we got from scripts/newplugin.py :)) and survived! Now go write
more plugins for supybot, and send them to me so I can use them too :)