bot-menus

Commands often feel one-sided to users, especially since most text commands only give a response in turn. However, you can easily improve your bot with menu responses.

Example command menu, from the anime command in Tatsumaki

Command Menus reverse the roles between bot and user - the bot now prompts a response from the user, such as in the number menu shown above. This feature is advantageous in many ways, as it:

  • Creates a more user-friendly experience with simplified interaction
  • Allows for argument specificness through user choices
  • Can serve as a confirmation step for important commands
  • Increases engagement, participation and average bot usage time

Designing a menu system

The concept behind creating a menu is simple, and the system really only consists of the following parts:

  1. Message listener (included in most Discord libraries)
  2. Message collector(s)

In order to understand how menus should function and what they should expect from users, let’s begin by creating a simple text menu like this one:

Example text menu

Here, the command +credits give fews 10 created a confirmation text menu that expects the number code 2604, but also accepts a cancel response in order to leave the menu.

The decision for a command menu for this command is justifiable as it fulfills various purposes:

  • It provides me with information of my account balance before and after the transaction, enhancing user experience
  • It serves as a verification step in case I accidentally input the wrong amount of credits
  • It prevents spamming

Menu states

So what does a menu require?

First of all, the menu definitely has to be resolvable once a proper acceptable response is received.

Likewise, since there are only 2 responses the menu accepts, the menu then needs to be able to reject unwanted responses - replies that aren’t “2604”, nor “cancel” - which also includes commands.

The menu must be user-specific, in other words, cannot be answered by other users who did not run the command to create the menu.

The menu must also be channel-specific - it cannot be completed by a user in a different channel or server entirely. In other words, the channel and user ID must be stored as a state.

As a rule of thumb, all menus should also be able to be exited from, so that users aren’t forced to go through a process they might have entered on accident, or do not want to continue - since some command menus may be chained and take up time to continue.

Optimally, menus should also have a timeout for users who don’t reply after a certain length of time, in order not to leave any menus open on accident - users might also fail to resolve a menu, which may leave it running forever; timeouts also help carry out garbage collection (GC) for menu states stored in memory.

In summary, the following are stored and required for each menu creation:

  • Resolve and reject (Promises)
  • User-specific ID
  • Channel-specific ID
  • Cancellable / deletable
  • Timer to cancel menu

Creating a new menu

To begin, we shall create a command, +powerup, that creates a text menu expecting either a yes or no response from the user.

Most if not all bots should have a message listener to handle text commands.

The listener function usually takes in a single message object, parses the message to verify if the content begins with with an acceptable prefix, before executing the appropriate command as a response.

An example in JS might resemble something like this:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
const prefix = process.env['PREFIX']
// listener function
client.on('messageCreate', msg => {
// check for prefix
if (!msg.content.startsWith(prefix)) return
const args = msg.content.substring(prefix.length).split(' ')
// handle message object
return handle(args[0], msg, args)
})
// collector creator
function handle (trigger, message, args) {
// check if command is powerup
if (trigger.toLowerCase() !== 'powerup') return
// do something
}

I’ll assume that you have a better functioning command handler system that is able to execute the appropriate methods when the corresponding text command is sent.

What we first need to try to do is to create a menu collector object that will store several states for the listener function to check. It also needs to fulfill all of the requirements mentioned above. This is a rudimentary design:

1
2
3
4
5
6
7
8
const collector = {
id: msg.author.id + msg.channel.id,
resolve: promiseResolve,
reject: promiseReject,
timer: setTimeoutFunction
}
// const collectorID = msg.author.id + msg.channel.id

We will store all the collectors object in a Map, where the listener function can use get() to get the collector object that directly matches the collector ID (which is both user and channel-specific).

Alternatively, an array works as well to store the collector ID, but it may require looping which, on large bots, may not be desirable.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
function handle (trigger, msg, args) {
if (trigger.toLowerCase() !== 'powerup') return
const collectorID = msg.author.id + msg.channel.id
let promise = new Promise((resolve, reject) => {
const collector = {
// filter check
// true -> resolve(reply)
// false -> reject(reason)
check: (msg) =>
['yes', 'no'].includes(msg.content.toLowerCase()),
// resolve and reject are passed
resolve: (msg) => {
resolve(msg)
clearTimeout(collector._timer)
},
reject: (reason) => {
reject(reason || 'ERROR')
clearTimeout(collector._timer)
},
// timeout 60s
_timer: setTimeout(() => reject('TIMEOUT'), 60000)
}
collectors.set(collectorID, collector)
})
promise.then(reply =>
msg.channel.createMessage(
reply.content ? 'yes' : 'Powered up!' : 'Not powered...'
)
).catch(err => msg.channel.createMessage('Rejected! ' + err))
}

This is an example of how the handle method should create a menu.

First of all, the collector object contains a check method that the listener function will use to discern whether the message reply is a valid response. If it’s a valid response, it will resolve the reply; else, it will reject with the supplied reason.

A timer is set to automatically reject with TIMEOUT after 60 seconds, which will be cleared upon resolve or reject.

What’s left is modifying the original message listener:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
const collectors = new Map()
client.on('messageCreate', msg => {
// check if menu exists first
const collector = collectors.get(msg.author.id + msg.channel.id)
if (collector) {
if (collector.check(msg)) {
collector.resolve(msg)
} else {
collector.reject('WRONG_RESPONSE')
}
return
}
// then check for command
if (!msg.content.startsWith(prefix)) return
const args = msg.content.substring(prefix.length).split(' ')
// handle message object
return handle(args[0], msg, args)
})

We have to check that the menu with the collector ID exists before a command is run to prevent the user from running commands while he/she is still in a menu.

Afterwards, it’s rather straightforward - if the message passes the collector check, resolve the message; else reject with the reason - WRONG_RESPONSE in this case.


For an example of how I handled menus, feel free to view the source code for Sylphy‘s Bridge class.