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.

API Documentation

GitHub Repository

PyPi

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):

  1. Clone the repository.

    git clone https://github.com/robertwayne/dpymenus
    
  2. Move into the directory you just cloned.

    cd dpymenus
    
  3. 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.

  4. 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.

  5. 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 = 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.

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

VariableTypeDefaultDescription
history-cache-limitint10Limit on the history cache. 0 disables.
hide-warningsboolFalseShows/hides library warnings in the console.
reply-as-defaultboolFalseEnables/disables the Discord reply feature.
button-delayfloat0.35Delay, in milliseconds, between adding buttons to a page.
timeoutint120Duration, in seconds, before a menu is timed out and closed.

Sessions

VariableTypeDefaultDescription
allow-session-restoreboolFalseEnables/disabled the session restore feature.
sessions-per-channelint1Limits sessions per channel.
sessions-per-guildint3Limits sessions per guild.
sessions-per-userint10Limits sessions per user.
session-timeoutint3600Duration, in seconds, before sessions are removed from the store.

Constants

VariableTypeDefault
constants-confirmList[str]['y', 'yes', 'ok', 'k', 'kk', 'ready', 'rdy', 'r', 'confirm', 'okay']
constants-denyList[str]['n', 'no', 'deny', 'negative', 'back', 'return']
constants-quit'List[str]['e', 'exit', 'q', 'quit', 'stop', 'x', 'cancel', 'c']
constants-buttonsList[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:

  1. Attempt to create or acquire (if enabled) a user session
  2. Initialize the history and starting page
  3. Execute any open hooks
  4. Send the initial message (and initializes the output attribute)
  5. Sets the input attribute
  6. Updates the history
  7. Deletes the input message
  8. (Optional) Executes specific menu methods
  9. Executes any post-open hooks
  10. 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:

  1. Wait for user input
  2. Assign input to a variable
  3. Execute methods based on input checks
  4. Execute any post-update hooks
  5. Execute transition method (whether it's next, close, or wait)

Close

When a menu is closed, it goes through the following steps:

  1. Execute any pre-close hooks
  2. Closes out user session
  3. Deletes the Discord message
  4. 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

HookWhenDescription
BEFOREExecutes before the specified event runs.
AFTERExecutes 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

HookEventDescription
OPENAttaches to the open stage.
UPDATEAttaches to the open stage.
CLOSEAttaches to the open stage.
TIMEOUTAttaches 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.

  1. Clone the repository and checkout the next branch:

    git clone https://github.com/robertwayne/dpymenus
    
    cd dpymenus
    
    git checkout next
    
  2. (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.

  3. Install the dependencies:

    poetry install
    
  4. (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

  1. Fork the repository and create your branch from next.
  2. Ensure any public API changes are reflected in the book, the relevant example, and docstrings.
  3. Ensure you have tested your changes, and added any tests for functions which are not directly using discord.py.
  4. 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.

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)!