Bot commands with parameters

Last week we talked about the basic structure of a discord.py cog, and built a very simple command. Fun! The one downside there was the command didn’t take any parameters. There’s quite a lot you can do with those kinds of commands, but “quite a lot” is still pretty limiting. This time we’re going to talk about how to actually get data into our command so we can do stuff.

Simple parameters

We’ll start with the very basics — just getting in a parameter. That turns out to be easy. discord.py does Very Clever Things and snoops the declaration of your command function, and if it sees that it has parameters then it chunks up the command the user issued and passes those chunks into your function. So, if you want a command that takes a single parameter, you just declare it like:

@commands.command(name=“echoer”)
async def echoer(self, ctx: commands.Context, param):
  await ctx.send(param)

Run that and the bot will send off the first word you gave to the command. Success!

If you try it and give the bot two words… it sends back just the first. Why is that?

Well, what’s happening here is that the discord.py library is tokenizing the command message the user sent, and sending each of those tokens into your command function. If you send extra tokens, things your command doesn’t say it wants, those extra tokens are just discarded. This is a little different than with python, where if you call a function with too many parameters you’ll get yelled at, but that’s fine, it’s handy.

You may be thinking that the command maps words to parameters, but it doesn’t — the previous paragraph said tokens for a reason. discord.py respects quotes as it splits the command message into pieces — a quoted phrase counts as one token, so it’s common to throw quotes around multi-word phrases that should count as a single parameter. The library is generally good about quote types, and does what you’d think was the right thing. (Which is surprisingly annoying in practice, so it’s nice that it’s done for us) This isn’t a big deal now but is worth mentioning because it’s important in basically all the rest of the sections.

Typed parameters

Just taking in a string is fine, and you can do a lot with those since ultimately everything is just a string with some magic layered on top. Still, being a little more specific than “I need a string” is often handy, for example in many commands you’ll want one of those parameters to be a channel or a server member, or an emoji. The “easy” thing to do is take in a string and then do the conversion in our command code, which is 100% OK but a lot of work and kind of annoying.

You might think about using python’s typed parameter system, which let you note what type of thing a function parameter is — that seems promising. You might also just discard that thought, since if you’ve tried typed parameters in regular python code you’ve probably noticed that they don’t actually do anything. That is, if you mark a parameter as being an int but pass in a string (or a bool, or a Bot object, or whatever) python is 100% good with that and doesn’t complain at all. Definitely annoying, and a little surprising.

Because of this you may have mostly written them off. Fair! (There are type checker programs you can run to see if you’ve done the right thing. We don’t use them, but they exist) But… turns out that in one specific circumstance type annotations have meaning and actually work!

The discord.py library does parameter type sniffing, and provides free typing for command functions. If you mark a parameter as an int, the discord library will auto convert it to an int for you. It means that you can define a command like:

@commands.command(name=“double”)
async def doubler(self, ctx: commands.Context, param:int):
  await ctx.send(f”double that is {param*2}”)

And when your user invokes the command you know you’ve gotten an int so you don’t have to mess around with converting it. If the user passes in something that can’t be turned into an integer then the bot will complain at the user and our function won’t even get called.

You may be thinking here “that’s handy. Does this work for fancier things?” and the answer is yes. Yes it does. If you want an emoji, for example, you can do this:

@commands.command(name=“emojicheck”)
async def emojicheck(self, ctx: commands.Context, emoji:Emoji):
  await ctx.send(f”You gave me an emoji. Woo”)

The emoji variable here will have a discord.Emoji object in it, which is awesome and useful. This works for pretty much all the discord.py objects you’d want — emojis, users, members, channels, threads, and servers. We can even set things up for objects that aren’t discord.py things using the converters API, but that’s a bit more advanced and the topic for another post.

You may have noticed that the error messages that pop up when the user gives us the wrong kind or parameter are kind of not great. There are ways to do better than that, but alas we don’t use them (not that we shouldn’t use them — we should! We just don’t) and that’s a bit of a more complex topic so we’ll cover it another time.

Optional or alternate parameters

The parameter sniffing that discord.py does is clever enough to pay attention to parameters you’ve annotated using the typing module. The two we use most are Optional and Union.

A parameter you’ve marked Optional is, as it says on the tin, optional. If the user leaves it out then it’s not an error, and the parameter is just set to None. For example:

@commands.command(name=“emojicheck”)
async def emojicheck(self, ctx: commands.Context, emoji:Optional[Emoji], word):
  if emoji is not None:
    await ctx.send(f”You gave me an emoji: {emoji}. Woo”)
  await ctx.send(f”You gave a parameter of {word}”)

In this case if the user does something like `emojicheck :hammer: time then the emoji variable will have the hammer emoji in it. If they just did something like `emojicheck time then the emoji variable will be set to None.

Some parameter types, like str, match pretty much everything and you won’t see them set to None very often. Other types, like Emoji or Member, can often be None. One of the ways we use optional parameters are those cases where we might take a destination, or might not:

@commands.command(name=“say”)
async def say(self, ctx: commands.Context, where: Optional[TextChannel], what):
  if where is None:
    where = ctx.channel
  await where.send(what)

This example command has the bot say something. If the first parameter is a channel then that’s where the thing will be said, otherwise the bot will say it in the channel the command was issued in.

The other common type thing we use is the Union type. This lets you say “this parameter is one of these types” and the discord library will figure out what the user gave for us. Which is great! For example:

@commands.command(name=“tell”)
async def tell(self, ctx: commands.Context, where: Union[Member, TextChannel], what):
  await where.send(what)

What happens here is that if the user gave us a channel then we’ll send the message to that channel, while if they gave us a Member instead then we send the message to that server member. (Which turns into a DM, a handy thing to know)

Ordering of a Union is important, because the discord library tries each type in turn until the parameter matches properly. This means that more general types like int or str should come at the end of the list while more specialized types should come first. This is obviously true for str (everything’s a str) but is surprisingly true for int, because it’s entirely fine to refer to a user, or a member, or a channel, or a server, or a message, as a plain int. (Everything in discord has a unique integer attached to it, and no two things have the same integer) Not a big deal, just if a parameter could be a member or an int the type annotation should be Union[Member,int] otherwise there are cases where you’ll get an int when the user actually gave you a member ID or something.

Parameter Collapsing

Up until now we’ve let discord.py split the command message into pieces for us, and we’ve taken one piece per parameter. But what if we don’t want the command message split up? Or what if we do but we want all the extra bits in one parameter?

Turns out that, for this, discord.py does just what regular python does, which is handy. So, for example, if you want a parameter that’s a list of all the remaining tokens, then prefix the parameter with an asterisk:

@commands.command(name=“echoer”)
async def echoer(self, ctx: commands.Context, *param):
  await ctx.send(param)

If you do this then discord.py will tokenize the whole command message and splat any remaining tokens into that starred parameter where you can access them using regular array access syntax. (It’s technically a tuple, but close enough) The parameter will still be a tuple even if no values are available to fill in (because the user didn’t type that many in, for example) so it’s always safe to assume you can use array syntax to access the stuff that’s not in it.

The starred parameter must be the last parameter in the parameter list, because it gathers up all the tokens left. You can, if you really, want, put something after your starred parameter but it’s generally kind of pointless as nothing will ever go in it. (Strictly speaking it could be a named parameter, but we don’t actually use those in our bot)

What, though, if you don’t want discord.py to tokenize things for you? Maybe because whitespace is important (the standard tokenization eats newlines), or because the quote-detection system sometimes freaks out (it doesn’t like at least one of the smart quote styles that iOS tends to use), or just because it’s easier to grab everything as a single string since that’s what you need and are just going to .join(“ “) anyway?

That’s where rhe other potential way to get an ‘everything else’ parameter comes in. For this you throw a bare star in the parameter list with a single parameter after, and discord.py will throw everything left into that last parameter. More importantly, the library won’t tokenize anything, mangle anything, or strip anything — you’ll get exactly what the user typed, including any extra spaces or newlines or quotes or whatever.

Defaults

Python allows you to assign default values to a function parameter, and that’s carried over into discord.py command handlers. You can, if you choose, assign a default value to a parameter:

@commands.command(name="foo")
async def foo_cmd(self, ctx: commands.Context, param, otherparam="foobar"):
  await ctx.send(f"you have foo'd with {param} {otherparam}")

Regular python rules apply — parameters with defaults must come at the end of the list of parameters. They may be typed if you want, that’s fine.

If your default value is None then don’t actually do something like param: Emoji=None. Well, you can, but it’s much better to just mark optional parameters, which are None if not specified, as Optional, as we saw in the section on optional parameters.

Generally there aren’t that many reasons to have parameters with defaults for command functions. The “best” one, for some value of best, is the case where a function is both a registered command function and a function your code calls directly. In that case it’s not unreasonable to have a default parameter or two. (We do this for some of our commands that can be called either in their normal form or in a test form, though in retrospect that may have not been the best decision)

Sniffing the message

We’ll mention this because it is an option, just in nearly every case it’s not a great one.

Whenever a command handler is called, the discord.py library passes in a context parameter. We’ve seen this in all the examples so far. That context has a lot of different bits of information attached to it, and one of them is the message (an object of type Message) that triggered the command. You can, if you want, look and see exactly what the user sent and pull anything out of it that you like.

The context also has a few other bits of useful information, such as the command string that the framework thinks invoked the command (handy if a command has aliases and you want to see if they were what the user used), parent command strings for multi-word commands, the actual Command object attached to the command, and more.

You’ll almost never need any of this stuff, and generally re-doing the command line parsing is unnecessary, but it’s there if you need to be Very Clever. (Please don’t be Very Clever, though, it rarely ends well)

What about custom converters

You may, at this point, be thinking “wait, if discord.py has Magic to turn something into a Member object does that mean we can have our own magic to convert into, I dunno, a datetime object or something?”

And the answer is yes! Yes we can, there are absolutely ways to do this. We even do them in a couple of places, but frankly it turns out to be more trouble than it’s worth most of the time, and also way beyond what we’re covering here. So we’re skipping that for now. (But if you want to read up on it, feel free)

So that’s parameters

This is the basics of parameters, and with this you can set up command functions so the discord library does 95% of the drudgework of typing for us. It doesn’t mean we don’t have to skip validation — just because we got a correctly constructed object doesn’t mean that what we got is actually fine — but at least it means we can skip having to do manual string-to-whatever conversions, which is nice.

Discord bots ahoy!

Hi there!

Hey! Welcome to the first part in a series of indefinite length where I go off about writing a discord bot in python using the discord.py discord library and framework. This series is geared at getting folks who work on the bot I’m responsible for up to speed on what it does and how to work on it. If you’re one of those people, awesome! Hi! And if you found this some other way (maybe from the mastodon auto-post, or from Google or something) well, also hi, but also also don’t be too surprised if there’s context missing. These things happen. You’re clever, though, I’m sure you can figure it out.

If you’re wondering “why are you posting stuff about one specific discord bot for one specific server on your blog”, the answer is it’s either this or a shared google doc and, well… I prefer this way, I have more control over it. (And no, actually doing this on discord would be silly and ridiculous. Discord’s a chat system not a reference system. Seriously, don’t do that)

And if you’re wondering “hey, where’s the actual bot code you’re working with?” the answer there is “eh, too much work to strip it down to its basics” but it’s not hard to go search around and find examples of how to do this. Maybe it’ll be useful to have that as a topic for a later post but for now we’re just assuming either you can see the actual bot source (because you have access to it) or can fake it well enough.

Continue reading