Introduction
dpymenus
is an unofficial extension for the discord.py
library that aims to help developers build dynamic menus for
their bots easily.
Features
- Handles text & button inputs
- Easy-to-build menus with paginated data, multiple choices, and polls
- Template system for quickly defining a cohesive style for your menus
- User-defined callbacks & event hooks for complex use-cases
- Awesome examples and documentation to get rolling quickly
This book will provide complete coverage of the libraries functions, how to use them, and exists alongside a full set of runnable examples, so you can see every feature! API Documentation (see below) exists as a reference to the public API.
Links
Installation
Installing dpymenus
is very simple. First, ensure you are running Python 3.8 or higher.
Version Support
As of v2, support for 3.7 was dropped due to various internal preferences. I try to work with modern versions of the language, so supporting versions far behind would be a hindrance to myself and my enjoyment of programming.
Second, make sure you are in your virtual environment (if you are not using Poetry). This trips up many newcomers to the language, but it's a critical step.
Last, run this in your command-line:
pip install dpymenus
If you use Poetry:
poetry add dpymenus
Development Branch
If you are interested in using the development branch, you can run:
pip install git+https://github.com/robertwayne/dpymenus.git@next
poetry add git+https://github.com/robertwayne/dpymenus.git#next
Please note that there are no stability or documentation guarantees for this branch. It is NOT recommended using the development branch unless you know what you are doing.
Running Examples
dpymenus
contains a working example for almost every feature most developers will be using. All the examples run as separate cogs on a bot that you can load up yourself.
All of these instructions should work on Linux (Ubuntu-flavors) & Windows 10.
Windows Users
Windows can be pretty finicky with PATH and Python installations sometimes. If you are having issues installing Poetry, ensure that your PATH contains a
%USERPROFILE%\.poetry\bin\
string.In addition, you probably want to go into App Execution Aliases and turn off any options relating to Python and install Python from python.org.
In your command line (PowerShell if you are on Windows):
-
Clone the repository.
git clone https://github.com/robertwayne/dpymenus
-
Move into the directory you just cloned.
cd dpymenus
-
We need to create a
.env
file and add a token to it.echo "DPYMENUS_BOT_TOKEN=your_private_token_here" > .env
Windows Users
Due to the way Windows creates new files, the simplest solution is to just create a .env in VSCode or your IDE of choice. Using the above will cause you to receive an encoding error, as the file will not be in UTF-8.
Replace the
your_private_token_here
text with the token from your Discord Developers bot page. If you are unsure what to use, please see the discord.py guide. -
Install Poetry and the project dependencies.
pip -m install poetry && poetry install
Please note that this is not the preferred way to install Poetry. Instead, you should follow the guide on their site. I highly recommend Poetry as a package manager for Python, so it is not wasted effort to download and learn properly.
-
Run the bot!
poetry run examples
At this point, the bot should be up and running. All the cogs are loaded automatically thanks to cogwatch, so you can add the bot to your server and start using commands.
Text-Based Menus
Text-based menus are useful when you need a way to parse user text, or just as a means to limit your requests against the Discord API (button-based menus have inherent overhead because of this).
Let's look at the official text_menu_example.py
.
Imports
from dpymenus import Page, TextMenu
from dpymenus.constants import CONFIRM
We always want to import the Page
class, as it is an enhanced form of the Embed
. In addition, we will import the
type of menu we are creating. We will also make use of some constants provided by the library to make validating input
easier. You'll read more on that later.
Cog Core
class MyTextMenu(commands.Cog):
def __init__(self, client):
self.client = client
def setup(client):
client.add_cog(MyTextMenu(client))
The 'core' of a cog, or a discord.py
command file, generally looks like this. We inherit from the commands.Cog
class, and we always supply a setup
function, which adds the cog to your bot instance. Inside the core, we will be
able to add our command and relevant menu code.
Command
@commands.command()
async def text(self, ctx):
page1 = Page(title='Ping Menu', description='Are you absolutely sure you want to send a ping command?', )
page1.set_footer(text='Type `yes` if you are sure.\nType `quit` to cancel this menu.')
page1.on_next(self.confirm)
page2 = Page(title='Ping Menu', description='Pong!')
menu = TextMenu(ctx)
menu.add_pages([page1, page2])
menu.normalize_responses()
await menu.open()
The first lines, the decorator and definition, define our command. Next, we set up several pages. A Page inherits from
Embed, so you have access to all the regular methods and attributes on Embeds here. We do have additional methods
though, which you would notice as on_next
in this example.
on_next
is a method which takes a callable (function or method) reference and sets it up internally as a page
callback. We will look at the specific callback, self.confirm
, in a moment.
You'll notice page2
does not have an on_next
. This signifies the end of a menu, or the last page.
Menu Breakdown
menu = TextMenu(ctx)
This is how a menu is initialized. It always takes your command context as an argument, and optionally can take a template. You will read about templates in a later chapter.
menu.add_pages([page1, page2])
This is how pages are added to a menu instance. It takes a list of Page or Embed objects. In this case, we assigned our pages to variables (page1 and page2) and put those variables in the list argument.
menu.normalize_responses()
This is a special method for text-based menus that will normalize user input. It strips unnessecary whitespace and converts it to lowercase. This makes it easier to process the input for you.
await menu.open()
This method should be the final thing you call on your menu. It will start the menu up, which means running through the pre-validation steps, creating a user-menu session, and starting the main menu loop. At this point, your menu will be intractable in Discord.
Callback Breakdown
@staticmethod
async def confirm(menu):
if menu.response_in(CONFIRM):
await menu.next()
This is a normal static method. Note that a menu callback must always be async. It takes a menu instance as the argument, so you can access various menu methods and data.
In this example, we will use a special method on the text-based menu class called response_in
, which checks if the
user input is in the supplied list of data. CONFIRM
is a list containing values like 'y' and 'yes', so if the user
types a 'y' in chat, this will return true and proceed to the next line.
next
is a method available on all menu types which simply proceeds to the next available page.
When the menu updates, it will always check if the current page has a callback. Because our second page did not define one, an internal method will run that closes our menu and ends the user-menu session.
Configuring Your Menu
Menus instances can be configured with various methods that exist on each type. All of these methods return themselves, so they can be chained.
See the API Docs for all of the methods available on each menu.
Templates
Templates are objects you can apply to a menu which applies a style or attribute across all pages. Some examples include color, footers, and fields.
Usage
from dpymenus import PaginatedMenu, Template
template = Template(...)
menu = PaginatedMenu(ctx)
menu.add_pages([...], template=template)
We want to import the Template
class, which is exposed in the base dpymenus namespace. We can then create a new Template instance.
The template takes a variety of keyword arguments:
-
title: string
-
description: string
-
color: string
-
footer: dictionary -> { text: string, icon_url: string }
-
image: string
-
url: string
-
thumbnail: string
-
author: dictionary -> { name: string, url: string, icon_url: string }
-
fields: list -> dictionary -> { name: string, value: string, inline: boolean }
-
field_style*
-
field_sort*
*see the next page for details
Most of these are self-explanatory, as they mirror the same attributes on an Embed or Page. An important distinction is what values they take -- typically favoring a single string representing the data.
Footer, Author, & Fields
The Footer and Author arguments take a dictionary containing the same keys one would normally use when adding these to an Embed or Page.
Fields are a bit different, as you generally create them by calling a separate .add_field()
function every time. In this case, you pass a list as the argument containing a dictionary for each field. The dictionary, again, contains the same values used when creating them on a standard Embed or Page.
Field Overrides
When adding fields in with a template, you might want to control how they are displayed in relation to existing fields on your Embeds or Pages. In this case, we can use field overrides.
Usage
from dpymenus import Template, FieldStyle, FieldSort
template = Template(...,
field_style=FieldStyle.COMBINE,
field_sort=FieldSort.LAST
)
Both of the override options are enumerated values.
Field Styles
IGNORE: templated fields will NOT be added if there is an existing field (default)
COMBINE: templated fields will be added if there is an existing field or not
OVERRIDE: templated fields will overwrite existing fields
Field Sorts
Sorting only takes effect when using the FieldStyle.COMBINE
on your template, as this determines how the fields will actually be combined.
FIRST: templated fields will be displayed before existing fields
LAST: templated fields will be displayed after existing fields (default)
Creating Pages
Pages are integral to creating menus. A page is really just an Embed with extra properties, such as various callback functionality. Any valid Embed is a valid Page, but not the other way around.
Callbacks
When you are using pages in menus other than a PaginatedMenu, you must define callbacks on each page so the menu knows how to generate and what to do when user input is processed. EVERY page other than your last, has to include an on_next()
callback. The others are optional.
A callback in our case is a reference to a function that you define, which is passed into the .on_next()
method. It is important that you only pass a reference in, which means exclude the parenthesis.
If you added the parenthesis, it would call the function immediately instead of deferring it for later when the menu executes its next functions.
See the API Docs for specific callback functions available on each page.
Sending Pages in a Message
If you are using pages in complex or non-standard ways, you should always call the .as_safe_embed()
method on the instance before sending it via a Discord message. This method ensures it is stripped of any Page-specific attributes, which would otherwise raise an error.
Using pyproject.toml to Configure Menus
General global settings within the library can be configured with a pyproject.toml
file in your root directory.
Everything should be listed under a [dpymenus]
header.
General
Variable | Type | Default | Description |
---|---|---|---|
history-cache-limit | int | 10 | Limit on the history cache. 0 disables. |
hide-warnings | bool | False | Shows/hides library warnings in the console. |
reply-as-default | bool | False | Enables/disables the Discord reply feature. |
button-delay | float | 0.35 | Delay, in milliseconds, between adding buttons to a page. |
timeout | int | 120 | Duration, in seconds, before a menu is timed out and closed. |
Sessions
Variable | Type | Default | Description |
---|---|---|---|
allow-session-restore | bool | False | Enables/disabled the session restore feature. |
sessions-per-channel | int | 1 | Limits sessions per channel. |
sessions-per-guild | int | 3 | Limits sessions per guild. |
sessions-per-user | int | 10 | Limits sessions per user. |
session-timeout | int | 3600 | Duration, in seconds, before sessions are removed from the store. |
Constants
Variable | Type | Default |
---|---|---|
constants-confirm | List[str] | ['y', 'yes', 'ok', 'k', 'kk', 'ready', 'rdy', 'r', 'confirm', 'okay'] |
constants-deny | List[str] | ['n', 'no', 'deny', 'negative', 'back', 'return'] |
constants-quit' | List[str] | ['e', 'exit', 'q', 'quit', 'stop', 'x', 'cancel', 'c'] |
constants-buttons | List[str] | ['⏮️', '◀️', '⏹️', '▶️', '⏭️'] |
*See the **next page *for detailed information on constants.
Example File
[dpymenus]
history-cache-limit = 10
sessions-per-channel = 1
sessions-per-guild = 3
sessions-per-user = 10
session-timeout = 3600
allow-session-restore = false
hide-warnings = false
reply-as-default = false
button-delay = 0.35
timeout = 120
constants-confirm = ['yes', 'y']
constants-deny = ['no', 'n']
constants-quit = ['quit', 'q']
constants-buttons = ['⏮️', '⬅️️', '🛑', '➡️️', '⏭️']
Constant Variables
The library comes with several pre-defined variables in the constants import to make working with menus a bit more seamless. While I try to provide sane defaults, each of the options can be overriden.
As you saw on the previous page, they are simply lists of strings.
Text Constants
from dpymenus.constants import CONFIRM, DENY, QUIT
When creating text-based menus that require users to enter responses, you'll often need to check for values such as 'yes', 'no', and 'quit'. By default, text-based menus all use the QUIT constants internally to allow users to close out of menus.
Button Constants
from dpymenus.constants import GENERIC_BUTTONS
In general, you shouldn't have to import these directly and they should be overriden using the pyproject.toml
file. This is used internally for the
PaginatedMenu to display its reaction buttons.
When overriding this value, make sure you place your buttons in the order they will be displayed on the PaginatedMenu:
first_page, back, close, next, last_page
Also, note that PagiantedMenu provides a general method for managing this: add_buttons([...])
. You should prefer to use this method unless you have multiple menus which should have their default buttons overridden.
Passing Dynamic Data Between Pages
Oftentimes you will need to pass data around a menu that could change. For example, let's say you had a menu allowing a user to answer several questions and then confirm them all on the last page. You can just add all of the values onto your menu instance itself if they are static, but if they are dynamic, that often means using setattr()
and getattr()
, validation checks, etc.
In addition, if the cog loading in your command has various menus which can be instantiated within it, you may be polluting the class with lots of attributes.
There's not neccesarily a right or wrong way to handle this, but the library provides a minor abstraction over this for ease of use. Benefits include not accessing data which your menu shouldn't have access to and simplicity of working with just a dictionary.
To use this abstraction, just call the .set_data()
method on a menu instance and pass in a dictionary containing the values you will be using.
From there, you can access that instances data with menu.data.get(val, [default])
as you would with any other dictionary. Scoped and clean!
Using Session Restoration Settings
Work In Progress -- Feature Incomplete
Sessions are, basically, a menu instance. Sessions internally are actually IDs linked to a specific menu instance, but on the developer end you likely don't need to know that.
Sessions allow you to control how many menus a single user can open at once before they start closing themselves.
Previous Versions
If you used dpymenus prior to v2, you would be familiar with menus not opening at all when you reached a limit. This will never happen now. There was also the method
.allow_multisessions()
, which would let you open up multiple menus per user. This is now default functionality, configured via settings.
Keeping Multiple Menus Alive
All menus open concurrently and run alongside eachother. This setting is controlled with the sessions-per-user
value as an upper limit on menus a single user can have open. By default, 10 menus can be opened at once.
If this value is reached, say 11 menus are opened by a single user without being closed, the earliest menu will close automatically.
In general, you should think about how many menus your users realistically need open at once and adjust accordingly. This is intended to prevent too many menus existing and eating up system resources.
Managing Session Limits
Per User
Per Channel
Per Guild
Lifecycle
Menus consist of three basic stages: open, update, and close. Most of the time is spent in the update stage, which processes user input, handles transitions, and manages any state data. In general, you don't really need to know anything about menu state management unless you are using hooks (in the next chapter) or overriding the BaseMenu
class.
This page can be used as a reference for the menu lifecycle.
Open
When a menu is opened, it will always execute these steps first:
- Attempt to create or acquire (if enabled) a user session
- Initialize the history and starting page
- Execute any open hooks
- Send the initial message (and initializes the
output
attribute) - Sets the
input
attribute - Updates the history
- Deletes the input message
- (Optional) Executes specific menu methods
- Executes any post-open hooks
- Executes any pre-update hooks
Update
An update cycle is performed on every iteration of a menu, whether a page change is executed or not. The steps vary per menu, but generally look as follows:
- Wait for user input
- Assign input to a variable
- Execute methods based on input checks
- Execute any post-update hooks
- Execute transition method (whether it's next, close, or wait)
Close
When a menu is closed, it goes through the following steps:
- Execute any pre-close hooks
- Closes out user session
- Deletes the Discord message
- Executes any post-close hooks
Event Hooks
The library offers extensive hook capabilities for all stages of a menus' lifecycle. Let's look at the official
menu_hooks_example.py
. In particular, we will zoom in on the method adding our hooks.
from dpymenus import hooks
...
menu.add_hook(hooks.BEFORE, hooks.UPDATE, self.counter)
menu.add_hook(hooks.AFTER, hooks.CLOSE, self.show_total)
...
First, we want to import hooks from the library, so we can access the HookEvent and HookWhen enums.
add_hook
is available on every menu type, and takes 3 arguments: when to call the hook, on which event, and a callback function.
Hook Enums
HookWhen
HookWhen | Description |
---|---|
BEFORE | Executes before the specified event runs. |
AFTER | Executes after the specified event runs. |
The HookWhen enums are self-explanatory, they just define whether to run your hook before or after the specified event.
HookEvent
HookEvent | Description |
---|---|
OPEN | Attaches to the open stage. |
UPDATE | Attaches to the open stage. |
CLOSE | Attaches to the open stage. |
TIMEOUT | Attaches to a timeout event. |
For most of the events, you can see where the hook would be executed on the previous chapter on lifecycles. Each event is explicitly listed where its hook will be executed in the call stack. Timeouts are a special case, however. This event is called when with a user-defined timeout expires, resulting in the menu closing out.
There are currently no hooks for user cancels, failures, and session state changes.
Contributing
I am open to all contributions, so long as it fits within the scope of the library. Large or breaking changes should be discussed via issue before submitting a PR, but smaller changes can be submitted directly for review.
Scope
The overall scope for the project is as follows:
- Implement a simple-to-use API for creating menus with discord.py
- Support various styles of commonly-used menus out of the box
- Supply easy-to-override abstractions and settings across menus
- Supply generally useful default settings & constants
- Offer full documentation on all features via the book, examples, and docstrings.
Building
This library is built against a minimum version of Python 3.8.
dpymenus
uses the Poetry package manager to manage dependencies and builds. Make sure
you have Poetry installed via the official installation instructions.
-
Clone the repository and checkout the
next
branch:git clone https://github.com/robertwayne/dpymenus
cd dpymenus
git checkout next
-
(Optional) Set up the example runner, so you can test your changes live. Create a
.env
file:echo "DPYMENUS_BOT_TOKEN=your_private_token_here" > .env
Windows Users
Due to the way Windows creates new files, the simplest solution is to just create a .env in VSCode or your IDE of choice. Using the above will cause you to receive an encoding error, as the file will not be in UTF-8.
Note: Replace the
your_private_token_here
text with the token from your Discord Developers bot page. If you are unsure what to use, please see the discord.py guide. -
Install the dependencies:
poetry install
-
(Optional) Test that the runner is working with:
poetry run examples
If the bot starts, and you can run commands in whatever server you have added the bot to, you are ready to go!
Pull Requests
- Fork the repository and create your branch from
next
. - Ensure any public API changes are reflected in the book, the relevant example, and docstrings.
- Ensure you have tested your changes, and added any tests for functions which are not directly using
discord.py
. - Submit the PR! ✨
Coding Style
dpymenus
uses a built-in black configuration to handle formatting. Your code will
automatically be formatted on submission via GitHub actions, but you can run poetry run fmt
to manually format it.
- Follow PEP8 naming conventions.
- Break up dense chunks of code where it makes sense (if/else try/catch, returns).
- Ensure docstrings follow existing docstring structure.
- All library code should be type annotated. Examples should not.
- See the
dpymenus/types/__init__.py
file for existing built-in library types.
- See the
License
By contributing, you agree that your contributions will be licensed under the MIT License.
Other Projects
If you enjoy using dpymenus
, you may like another library of mine called
cogwatch. Cogwatch adds automatic hot-reloading to your command files, so you never have to call a custom reload function again (mostly)!