Writing Your First Limnoria Plugin

Introduction

This page is a top-down guide on how to write new plugins for Limnoria.

Before you start, you should be more-or-less familiar with how to use and manage a Limnoria instance (loading plugins, configuring options, etc.). You should also install a copy of Limnoria on the machine you intend to develop plugins on, as it includes some additional scripts like supybot-plugin-create to generate the plugin skeleton.

We’ll go through this tutorial by actually writing a new plugin, named Random with just a few commands.

Generating the Plugin template

The recommended way to start writing a plugin is to use the supybot-plugin-create wizard. You can run this from within your bot’s plugins directory, or make a separate directory for all your own plugins and run it there. (You can add additional plugin directories to your bot config using config directories.plugins). The latter approach is probably easier if you intend to publish your code afterwards, as it keeps your code separate from any other plugins you’ve installed.

Here’s an example session:

$ supybot-plugin-create
What should the name of the plugin be? Random

Sometimes you'll want a callback to be threaded.  If its methods
(command or regexp-based, either one) will take a significant 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

What is your name, so I can fill in the copyright and license
appropriately? John Doe

Do you wish to use Supybot's license for your plugin? [y/n] y

Please provide a short description of the plugin: This plugin contains
commands relating to random numbers, including random sampling from a list
and a simple dice roller.

README.md

This is the README page people will see when they download your plugin or view it from a source control website. It’s helpful to include a brief summary of what the plugin does here, as well as list any third-party dependencies.

The supybot-plugin-create wizard should have already filled in the README with the summary you provided.

__init__.py

The next file we’ll look at is __init__.py. If you’re not so familiar with the Python import mechanism, think of it as sort of the “glue” file that pulls all the files in the plugin directory together when you load it. There are also a few administrative items here that can be queried from the bot, such as the plugin’s author and contact info.

At the top of the file you’ll see the copyright header, with your name added as prompted in supybot-plugin-create. Feel free to use whatever license you choose: the default is the bot’s 3-clause BSD. For our example, we’ll leave it as is.

Here is a list of attributes you should usually look at:

  • __version__: the plugin version. We’ll make ours “0.1”

  • __author__ should be an instance of the supybot.Author class. This optionally includes a full name, a short name (usually IRC nick), and an e-mail address:

    __author__ = supybot.Author(name='Daniel DiPaolo', nick='Strike',
                                email='somewhere@someplace.xxx')
    
  • __contributors__ is a dictionary mapping supybot.Author instances to lists of things they contributed. See e.g. in the Plugin plugin. For now we have no contributors, so we’ll leave it blank.

  • __url__ references the download URL for the plugin. Since this is just an example, we’ll leave this blank.

The rest of __init__.py shouldn’t be touched unless you are using third-party modules in your plugin. If you are, then you need to add additional import statements and reload calls to all those modules, so that they get reloaded with the rest of the plugin:

from . import config
from . import plugin
from importlib import reload
reload(plugin) # In case we're being reloaded.
# Add more reloads here if you add third-party modules and want them
# to be reloaded when this plugin is reloaded.  Don't forget to
# import them as well!

config.py

config.py is, unsurprisingly, where all the configuration stuff related to your plugin goes. For this tutorial, the Random plugin is simple enough that it doesn’t need any config variables, so this file can be left as is.

To briefly outline this file’s structure: the configure function is used by the supybot-wizard wizard and allows users to configure the plugin further if it’s present when the bot is first installed. (In practice though, this is seldomly used by third-party plugins as they’re generally installed after configuring the bot.)

The following line registers an entry for the plugin in Limnoria’s config registry, followed by any configuration groups and variable definitions:

Random = conf.registerPlugin('Random')
# This is where your configuration variables (if any) should go.  For example:
# conf.registerGlobalValue(Random, 'someConfigVariableName',
#     registry.Boolean(False, _("""Help for someConfigVariableName.""")))

Writing plugin configuration is explained in depth in the Advanced Plugin Config Tutorial.

plugin.py

plugin.py includes the core code for the plugin. For most plugins this will include command handlers, as well as anything else that’s relevant to its particular use case (database queries, HTTP server endpoints, IRC command triggers, etc.)

As with any Python module, you’ll need to import any dependencies you want, in addition to the standard supybot imports included in the plugin template:

import random

The bulk of the plugin definition then resides in a subclass of callbacks.Plugin. By convention, the class name is equal to the name of the plugin, though this is not strictly required (the actual linkage is done by the Class = Random statement at the end of the file). It is helpful to fill in the plugin docstring with some more details on how to actually use the plugin too: this info can be shown on a live bot using the plugin help <plugin name> command.

class Random(callbacks.Plugin):
    """This plugin contains commands relating to random numbers, including random sampling from a list and a simple dice roller."""

    def __init__(self, irc):
        # Make sure to call the superclass' constructor when you define a custom one
        super().__init__(irc)
        self.rng = random.Random()  # create our rng
        self.rng.seed() # automatically seeds with current time

For this sample plugin, we define a custom constructor (__init__) that instantiates a random number generator instance and pre-seeds it. This isn’t technically necessary for Python’s random module, but it helps outline how to write a similar constructor. Notice in particular how you must pass in an irc argument in addition to self.

Warning

Because Limnoria is a multi-network bot, you should generally ignore the irc instance passed to the plugin constructor. On a manual load call to a live bot, this will be set to the network the command was run on, but on bot startup, irc will be (arbitrarily) set to the first network that the bot decides to connect to.

Basic command handler

Our first command definition can immediately follow:

    @wrap
    def random(self, irc, msg, args):
        """takes no arguments

        Returns the next random number from the random number generator.
        """
        irc.reply(str(self.rng.random()))

Note

All functions used as commands must have an all lowercase name.

A command function taking in no arguments from IRC will still require 4 arguments; they are as follows:

  • self: refers to the class instance. It is common to keep local state for the plugin as instance variables within the plugin class.

  • irc: a supybot.callbacks.ReplyIrcProxy instance; refers to the IRC network instance the command was called on

  • msg: a supybot.ircmsgs.IrcMsg instance; refers to the IRC message that triggered this command.

  • args: a raw list of remaining unconverted arguments; new plugins that use @wrap for automatic argument type conversion should never need to interact with args directly.

The function docstring is expected to be in a particular format. First, the very first line dictates the argument list to be displayed when someone calls the help command on this command (i.e., help random). Then, leave a blank line and start the actual help string for the function. Don’t worry about the fact that it’s tabbed in or anything like that, as the help command normalizes it to make it look nice. This part should be fairly brief but sufficient to explain the function and what (if any) arguments it requires. Remember that this should fit in one IRC message which is typically around a 450 character limit.

The irc.reply call is a bit of magic: it issues a reply the same place as the message that triggered the command. i.e. this may be in a channel or in a private conversation with the bot.

Lastly, notice that commands go through the @wrap decorator for automatic argument type conversion. For commands that require no parameters, calling @wrap with no arguments is enough.

Command handler with parameters

Now let’s create a command with some arguments and see how we use those in our plugin commands. This seed command lets the user pick a specific RNG seed:

    @wrap(['float'])
    def seed(self, irc, msg, args, seed):
        """<seed>

        Sets the internal RNG's seed value to <seed>.  <seed> must be a
        floating point number.
        """
        self.rng.seed(seed)
        irc.replySuccess()

For functions that use @wrap (described further in the Using commands.wrap tutorial), additional command arguments are handled by:

  1. Adding type converters, one for each parameter, to the list passed into @wrap

  2. Adding one function parameter per argument to the command function definition. (i.e. def seed(self, irc, msg, args, seed) instead of def seed(self, irc, msg, args))

We also modify the docstring to document this function. Note the syntax on the first line: by convention, required arguments go in <> and optional arguments should be surrounded by [].

The function body includes a new method irc.replySuccess. This is just a generic “I succeeded” command which responds with whatever the bot owner has configured in config supybot.replies.success. Also, by using @wrap, we don’t need to do any type checking inside the function itself - this is handled separately, and invalid argument values will cause the command to error before it reaches the wrapped function.

With this alone you’d be able to make a range of useful plugin commands, but we’ll go include some more examples to illustrate common patterns.

Command handler with list-type arguments

The next sample command is named sample (no pun intended): it takes a random sample of arbitrary size from a list provided by the user:

    @wrap(['int', many('anything')])
    def sample(self, irc, msg, args, n, items):
        """<number of items> <item1> [<item2> ...]

        Returns a sample of the <number of items> taken from the remaining
        arguments.  <number of items> must be less than the number
        of arguments given.
        """
        if n > len(items):
            # Calling irc.error with Raise=True is an alternative early return
            irc.error('<number of items> must be less than the number '
                      'of arguments.', Raise=True)
        sample = self.rng.sample(items, n)
        sample.sort()
        irc.reply(utils.str.commaAndify(sample))

The important thing to note is that list type arguments are rolled into one parameter in the command function by the many filter. Similar “multiplicity” handlers are documented here.

We also update the docstring to use the [] convention when surrounding optional arguments.

For this function’s body, irc.error is like irc.replySuccess but for error messages. We prefer using this instead of irc.reply for error signaling because its behaviour can be configured specially. For example, you can force all errors to go in private by setting the reply.error.inPrivate option, and this can help reduce noise on a busy channel. Also, irc.error() with no text will return a generic error message configured in supybot.replies.error, but this is not a valid call to irc.reply.

utils.str.commaAndify is a simple helper that takes a list of strings and turns it into “item1, item2, item3, item4, and item5” for an arbitrary length. Limnoria has accumulated many such helpers in its lifetime, many of which are described in the Using Utils page.

Command handler with optional arguments

Now for the last command that we will add to our plugin.py. This diceroll command will allow the bot users to roll an arbitrary n-sided die, with n defaulting to 6:

    @wrap([additional(('int', 'number of sides'), 6)])
    def diceroll(self, irc, msg, args, n):
        """[<number of sides>]

        Rolls a die with <number of sides> sides.  The default number of sides
        is 6.
        """
        s = 'rolls a %s' % self.rng.randrange(1, n)
        irc.reply(s, action=True)

The only new thing described here is that irc.reply(..., action=True) makes the bot perform a /me. There are some other flags described in the irc.reply documentation too: common ones include private=True, which forces a private message, and notice=True, which forces the reply to use NOTICE instead of PRIVMSG.

test.py

The easy way to test any plugin would be to start up a bot, load the plugin, and run all the commands a few times to verify that they work. But this takes time, and as a project grows larger, starts to be a tedious and error-prone process…

This is where automated testing comes in. Limnoria has a test harness built upon the Python unittest library that abstracts away all the dependencies of live testing (i.e. the IRC client and server) and allows you to cover your entire plugin’s functionality within a few seconds.

How it works

Plugin test cases inherit from supybot.test.PluginTestCase or supybot.test.ChannelPluginTestCase and include several methods to interact with a simulated instance of the bot, in addition to the standard assertion functions provided by the unittest library.

Running the tests for a Limnoria plugin is done using the supybot-test command: i.e. supybot-test /path/to/your/Plugin

The structure of these test classes, as well as interactions with features like Limnoria’s config system are described in detail in the Advanced Plugin Testing guide.

Functional testing examples

For a command where we don’t care about the exact output, the usual approach is to check that invocations raise or don’t raise an error. For a command that generates a purely random output, this applies too since we can’t predict what the result will be:

class RandomTestCase(PluginTestCase):
    # This tuple determines which plugins to load in the test case
    plugins = ('Random',)

    def testRandom(self):
        self.assertNotError('random')

        # This throws, because the command doesn't expect any arguments
        self.assertError('random abcdef')

However, this is less true if you pre-seed the RNG, as then you’re guaranteed a repeatable result. The following snippet introduces assertResponse(commandPlusArgs, expectedOutput), where commandPlusArgs is the full bot command including arguments, all as one string:

    def testSeed(self):
        self.assertNotError('seed 20')
        self.assertResponse('random', '0.9056396761745207')
        self.assertResponse('random', '0.6862541570267026')
        self.assertNotError('seed 20')
        self.assertResponse('random', '0.9056396761745207')
        self.assertNotError('seed 1234')
        self.assertResponse('random', '0.9664535356921388')

Alternatively, you can use getMsg(command) to fetch the output of a bot command as a string and reuse it:

    def testSeed(self):
        self.assertNotError('seed 20')
        num1 = self.getMsg('random')
        num2 = self.getMsg('random')

        self.assertNotError('seed 20')
        num1_again = self.getMsg('random')

        self.assertEqual(num1, num1_again)
        self.assertNotEqual(num1, num2)

Another common practice is to use regular expressions to match the output of a command:

Note

The assertRegexp() defined in Limnoria is not the same as assertRegex() from the standard unittest library. The latter compares a regexp against a bare string, not the output of a bot command. (For historical reasons, we have this confusing name.)

    def testSample(self):
        self.assertError('sample 20 foo')  # can't sample 20 from only 1 element
        self.assertResponse('sample 1 foo', 'foo')
        self.assertRegexp('sample 2 foo bar', '... and ...')
        self.assertRegexp('sample 3 foo bar baz', '..., ..., and ...')
        # assertNotRegexp(commandWithArgs, regexp) also works as expected

    def testDiceRoll(self):
        self.assertActionRegexp('diceroll', 'rolls a \d')

Conclusion

You are now well prepared to write Limnoria plugins. A few words of wisdom:

  • Read other people’s plugins, especially the included plugins and ones by the core developers. We can’t possibly document all the things that Limnoria can do, though we try our best.

  • Hack new functionality into existing plugins first if writing a new plugin is too daunting.

  • Come ask us questions in #limnoria on Libera. Going back to the first point above, the developers themselves can help you even more than the docs can (though we prefer you read the docs first).

  • Share your plugins with the world and make Limnoria all that more attractive for other users so they will want to write their plugins for Limnoria as well.

  • And of course, have fun!