├── requirements.txt
├── docs
├── requirements.txt
├── source
│ ├── images
│ │ ├── slash
│ │ │ ├── autogenerate.gif
│ │ │ ├── invite_scope.png
│ │ │ ├── test_default.png
│ │ │ ├── allowed_command.png
│ │ │ ├── forbidden_command.png
│ │ │ ├── test_param_choices.gif
│ │ │ ├── test_param_choices.png
│ │ │ ├── test_param_optional.png
│ │ │ ├── hello_world_subcommand.png
│ │ │ ├── test_param_optional_usage_1.gif
│ │ │ ├── test_param_options_required.gif
│ │ │ ├── test_param_optional_usage_none.gif
│ │ │ └── hello_beautiful_world_subcommandgroup.png
│ │ ├── context
│ │ │ ├── user_command.gif
│ │ │ └── message_command.gif
│ │ └── components
│ │ │ ├── press_button_example.gif
│ │ │ ├── select_menu_example.gif
│ │ │ ├── hello_world_all_components.png
│ │ │ └── hello_world_all_components_select_menu.png
│ ├── ext.rst
│ ├── listeners.rst
│ ├── _static
│ │ ├── js
│ │ │ ├── keyboard.js
│ │ │ └── override_page.js
│ │ └── css
│ │ │ └── main.css
│ ├── ui.rst
│ ├── interactions.rst
│ ├── cogs.rst
│ ├── index.rst
│ ├── slash.rst
│ ├── conf.py
│ ├── components.rst
│ └── usage.rst
├── Makefile
└── make.bat
├── .gitattributes
├── .readthedocs.yaml
├── discord_ui
├── slash
│ ├── ext
│ │ ├── __init__.py
│ │ ├── command_decorators.py
│ │ └── builder.py
│ ├── __init__.py
│ ├── errors.py
│ ├── ext.py
│ ├── http.py
│ └── tools.py
├── __init__.py
├── errors.py
├── override.py
├── http.py
├── enums.py
├── tools.py
├── listener.py
└── components.py
├── changelog.md
├── LICENSE
├── examples
├── README.md
├── context_commands.py
├── permissions.py
├── generate_linkbutton.py
├── cogs.py
├── staff_message.py
├── role_picker.py
└── calculator.py
├── .github
└── workflows
│ ├── main.yml
│ └── codeql-analysis.yml
├── setup.py
└── .gitignore
/requirements.txt:
--------------------------------------------------------------------------------
1 | aiohttp
--------------------------------------------------------------------------------
/docs/requirements.txt:
--------------------------------------------------------------------------------
1 | discord.py
2 | sphinx-copybutton
--------------------------------------------------------------------------------
/.gitattributes:
--------------------------------------------------------------------------------
1 | # Auto detect text files and perform LF normalization
2 | * text=auto
3 |
--------------------------------------------------------------------------------
/docs/source/images/slash/autogenerate.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/discord-py-ui/discord-ui/HEAD/docs/source/images/slash/autogenerate.gif
--------------------------------------------------------------------------------
/docs/source/images/slash/invite_scope.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/discord-py-ui/discord-ui/HEAD/docs/source/images/slash/invite_scope.png
--------------------------------------------------------------------------------
/docs/source/images/slash/test_default.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/discord-py-ui/discord-ui/HEAD/docs/source/images/slash/test_default.png
--------------------------------------------------------------------------------
/docs/source/images/context/user_command.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/discord-py-ui/discord-ui/HEAD/docs/source/images/context/user_command.gif
--------------------------------------------------------------------------------
/docs/source/images/slash/allowed_command.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/discord-py-ui/discord-ui/HEAD/docs/source/images/slash/allowed_command.png
--------------------------------------------------------------------------------
/docs/source/images/context/message_command.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/discord-py-ui/discord-ui/HEAD/docs/source/images/context/message_command.gif
--------------------------------------------------------------------------------
/docs/source/images/slash/forbidden_command.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/discord-py-ui/discord-ui/HEAD/docs/source/images/slash/forbidden_command.png
--------------------------------------------------------------------------------
/docs/source/images/slash/test_param_choices.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/discord-py-ui/discord-ui/HEAD/docs/source/images/slash/test_param_choices.gif
--------------------------------------------------------------------------------
/docs/source/images/slash/test_param_choices.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/discord-py-ui/discord-ui/HEAD/docs/source/images/slash/test_param_choices.png
--------------------------------------------------------------------------------
/docs/source/images/slash/test_param_optional.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/discord-py-ui/discord-ui/HEAD/docs/source/images/slash/test_param_optional.png
--------------------------------------------------------------------------------
/docs/source/ext.rst:
--------------------------------------------------------------------------------
1 | .. currentmodule:: discord_ui
2 |
3 | ==========
4 | Extension
5 | ==========
6 |
7 | .. automodule:: discord_ui.slash.ext
8 | :members:
--------------------------------------------------------------------------------
/docs/source/images/slash/hello_world_subcommand.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/discord-py-ui/discord-ui/HEAD/docs/source/images/slash/hello_world_subcommand.png
--------------------------------------------------------------------------------
/docs/source/images/components/press_button_example.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/discord-py-ui/discord-ui/HEAD/docs/source/images/components/press_button_example.gif
--------------------------------------------------------------------------------
/docs/source/images/components/select_menu_example.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/discord-py-ui/discord-ui/HEAD/docs/source/images/components/select_menu_example.gif
--------------------------------------------------------------------------------
/docs/source/images/slash/test_param_optional_usage_1.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/discord-py-ui/discord-ui/HEAD/docs/source/images/slash/test_param_optional_usage_1.gif
--------------------------------------------------------------------------------
/docs/source/images/slash/test_param_options_required.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/discord-py-ui/discord-ui/HEAD/docs/source/images/slash/test_param_options_required.gif
--------------------------------------------------------------------------------
/docs/source/images/slash/test_param_optional_usage_none.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/discord-py-ui/discord-ui/HEAD/docs/source/images/slash/test_param_optional_usage_none.gif
--------------------------------------------------------------------------------
/docs/source/images/components/hello_world_all_components.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/discord-py-ui/discord-ui/HEAD/docs/source/images/components/hello_world_all_components.png
--------------------------------------------------------------------------------
/docs/source/images/slash/hello_beautiful_world_subcommandgroup.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/discord-py-ui/discord-ui/HEAD/docs/source/images/slash/hello_beautiful_world_subcommandgroup.png
--------------------------------------------------------------------------------
/docs/source/images/components/hello_world_all_components_select_menu.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/discord-py-ui/discord-ui/HEAD/docs/source/images/components/hello_world_all_components_select_menu.png
--------------------------------------------------------------------------------
/docs/source/listeners.rst:
--------------------------------------------------------------------------------
1 | .. currentmodule:: discord_ui
2 |
3 | ==========
4 | Listeners
5 | ==========
6 |
7 | .. automodule:: discord_ui.listener
8 | :members:
9 | :exclude-members: NoListenerFound, WrongUser
--------------------------------------------------------------------------------
/docs/source/_static/js/keyboard.js:
--------------------------------------------------------------------------------
1 | document.addEventListener('keydown', function(event) {
2 | if (event.ctrlKey && event.key === 'k') {
3 | event.preventDefault()
4 | document.getElementById("rtd-search-form").children[0].focus()
5 | }
6 | });
--------------------------------------------------------------------------------
/.readthedocs.yaml:
--------------------------------------------------------------------------------
1 | # File: .readthedocs.yaml
2 |
3 | version: 2
4 |
5 | # Build from the docs/ directory with Sphinx
6 | sphinx:
7 | configuration: docs/source/conf.py
8 |
9 | python:
10 | version: 3.8
11 | install:
12 | - requirements: docs/requirements.txt
13 | - method: setuptools
14 | path: ./
--------------------------------------------------------------------------------
/discord_ui/slash/ext/__init__.py:
--------------------------------------------------------------------------------
1 | """
2 | discord_ui.slash.ext
3 | ~~~~~~~~~~~~~~~~~~~~~~
4 | An extension module to the libary that has some useful decorators and functions
5 | for application-commands.
6 |
7 | .. code-block::
8 |
9 | from discord_ui import ext
10 |
11 | """
12 |
13 | from .builder import *
14 | from .command_decorators import *
--------------------------------------------------------------------------------
/docs/source/ui.rst:
--------------------------------------------------------------------------------
1 | .. currentmodule:: discord_ui
2 |
3 | ====================
4 | UI
5 | ====================
6 |
7 |
8 | This is the most important part in this libary, because this class has most of the features you need.
9 |
10 | .. note::
11 |
12 | In this documentation, :class:`~Components` and :class:`~Slash` are initalized and accesable by ``ui.slash`` and ``ui.components`` attributes
13 |
14 |
15 | UI
16 | ~~~~~~~~~
17 |
18 | .. autoclass:: UI
19 | :members:
20 |
21 | Parse-Methods
22 | ~~~~~~~~~~~~~
23 |
24 | .. autoclass:: ParseMethod
25 | :members:
26 |
--------------------------------------------------------------------------------
/discord_ui/slash/__init__.py:
--------------------------------------------------------------------------------
1 | """
2 | discord_ui.slash
3 | ~~~~~~~~~~~~~~~~~~~
4 |
5 | The slash module for this libary
6 |
7 | - - -
8 |
9 | Here is everything about slashcommmands.
10 | You shouldn't need to import this module unless you really now what you're doing.
11 |
12 | The only thing you might wanna import is `.ext`, but you can import that from `discord_ui.ext`.
13 | Everything else here could only be needed for intellisense.
14 |
15 | - - -
16 |
17 | [Github](https://github.com/discord-py-ui/discord-ui/tree/main/discord_ui/slash)
18 | """
19 |
20 | from . import ext
21 | from .types import SlashOption, SlashPermission, OptionType
--------------------------------------------------------------------------------
/changelog.md:
--------------------------------------------------------------------------------
1 | # Discord UI
2 |
3 | ## 5.2.0
4 |
5 | ### Breaking changes
6 |
7 | - `delete_unused` keyword for `Slash` class and `Slash.commands.sync`.
8 |
9 | ### Changed
10 |
11 | - `Slash.commands.sync` should be way faster now since it uses bulk requests now.
12 | Note that these will automatically overwrite all commands. This method should take about 1 to 2 seconds now (if no ratelimit occurs)
13 |
14 | - Improved `Slash.commands.nuke`. This method should take about under 1 seconds (if no ratelimit occurs)
15 |
16 | ## 5.1.6
17 | ### Fixed
18 |
19 | - guild permissions not being applied due to an comparison issue with the api permissions and the local guild permissions
--------------------------------------------------------------------------------
/docs/Makefile:
--------------------------------------------------------------------------------
1 | # Minimal makefile for Sphinx documentation
2 | #
3 |
4 | # You can set these variables from the command line, and also
5 | # from the environment for the first two.
6 | SPHINXOPTS ?=
7 | SPHINXBUILD ?= sphinx-build
8 | SOURCEDIR = source
9 | BUILDDIR = build
10 |
11 | # Put it first so that "make" without argument is like "make help".
12 | help:
13 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
14 |
15 | .PHONY: help Makefile
16 |
17 | # Catch-all target: route all unknown targets to Sphinx using the new
18 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS).
19 | %: Makefile
20 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
21 |
--------------------------------------------------------------------------------
/docs/make.bat:
--------------------------------------------------------------------------------
1 | @ECHO OFF
2 |
3 | pushd %~dp0
4 |
5 | REM Command file for Sphinx documentation
6 |
7 | if "%SPHINXBUILD%" == "" (
8 | set SPHINXBUILD=sphinx-build
9 | )
10 | set SOURCEDIR=source
11 | set BUILDDIR=build
12 |
13 | if "%1" == "" goto help
14 |
15 | %SPHINXBUILD% >NUL 2>NUL
16 | if errorlevel 9009 (
17 | echo.
18 | echo.The 'sphinx-build' command was not found. Make sure you have Sphinx
19 | echo.installed, then set the SPHINXBUILD environment variable to point
20 | echo.to the full path of the 'sphinx-build' executable. Alternatively you
21 | echo.may add the Sphinx directory to PATH.
22 | echo.
23 | echo.If you don't have Sphinx installed, grab it from
24 | echo.http://sphinx-doc.org/
25 | exit /b 1
26 | )
27 |
28 | %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O%
29 | goto end
30 |
31 | :help
32 | %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O%
33 |
34 | :end
35 | popd
36 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | Copyright (c) 2021, 404Kuso and RedstoneZockt
2 |
3 | Permission is hereby granted, free of charge, to any person obtaining
4 | a copy of this software and associated documentation files (the
5 | "Software"), to deal in the Software without restriction, including
6 | without limitation the rights to use, copy, modify, merge, publish,
7 | distribute, sublicense, and/or sell copies of the Software, and to
8 | permit persons to whom the Software is furnished to do so, subject to
9 | the following conditions:
10 |
11 | The above copyright notice and this permission notice shall be
12 | included in all copies or substantial portions of the Software.
13 |
14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
17 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
18 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
19 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
20 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
--------------------------------------------------------------------------------
/docs/source/interactions.rst:
--------------------------------------------------------------------------------
1 | .. currentmodule:: discord_ui
2 |
3 | =====================
4 | Interactions
5 | =====================
6 |
7 | You can receive general interactions of all possible types with the client event ``interaction_received``.
8 | This event passes a :class:`Interaction` object which you can defer, respond to or whatever you want
9 |
10 | Example
11 |
12 | .. code-block::
13 |
14 | # client stuff before
15 | from discord_ui import Interaction
16 |
17 | @client.listen("on_interaction_received")
18 | async def on_interaction(interaction: Interaction):
19 | await interaction.respond("houston we got an interaction")
20 |
21 |
22 | Interaction
23 | ~~~~~~~~~~~~
24 |
25 | .. autoclass:: Interaction()
26 | :members:
27 |
28 |
29 | Application commands
30 | ---------------------
31 |
32 | .. autoclass:: SlashInteraction()
33 | :members:
34 |
35 | .. autoclass:: ContextInteraction()
36 | :members:
37 |
38 | .. autoclass:: AutocompleteInteraction()
39 | :members:
40 |
41 |
42 | Components
43 | -----------
44 |
45 | .. autoclass:: ButtonInteraction()
46 | :members:
47 |
48 | .. autoclass:: SelectInteraction()
49 | :members:
50 |
--------------------------------------------------------------------------------
/examples/README.md:
--------------------------------------------------------------------------------
1 |
2 |
3 |
Examples
4 | Here is a list of possible examples of how to use our package
5 |
6 |
7 | - [`calculator`](https://github.com/discord-py-ui/discord-ui/tree/main/examples/calculator.py)
8 | : A slash command which will send a working calculator controlled by the user with buttons
9 |
10 | - [`context-commands`](https://github.com/discord-py-ui/discord-ui/tree/main/examples/context_commands.py)
11 | : Two context commands, one will quote the message and the other one will send the avatar of the user
12 |
13 | - [`linkbutton-generator`](https://github.com/discord-py-ui/discord-ui/tree/main/examples/generate_linkbutton.py)
14 | : A slash command that will generate a link button visible to everyone
15 |
16 | - [`permission-changer`](https://github.com/discord-py-ui/discord-ui/tree/main/examples/permissions.py)
17 | : A slashcommand that will change the permissions to limit the usage to a role
18 |
19 | - [`role-picker`](https://github.com/discord-py-ui/discord-ui/tree/main/examples/role_picker.py)
20 | : Sends a hidden select menu to a user, who can choose between roles which he will get upon selecting
21 |
22 | - [`staff messager`](https://github.com/discord-py-ui/discord-ui/tree/main/examples/staff_message.py)
23 | : slashcommand with autocomplete for sending messages to a staff mmember
24 |
25 | - [`cog example`](https://github.com/discord-py-ui/discord-ui/tree/main/examples/cogs.py)
26 | : simple example for using application commands in cogs
--------------------------------------------------------------------------------
/discord_ui/__init__.py:
--------------------------------------------------------------------------------
1 | """
2 | discord-ui extension
3 | ~~~~~~~~~~~~~~~~~~~~
4 |
5 | A discord.py extension for discord's ui features like Buttons, SelectMenus, LinkButtons,
6 | slash-commands and context-commands (message-commands and user-commands).
7 |
8 |
9 | - - -
10 |
11 | Links
12 | [**Docs**](https://discord-ui.rtfd.io/) | [**Github**](https://github.com/discord-py-ui/discord-ui/) | [**PyPi**](https://pypi.org/project/discord-ui/) | [**License**](https://github.com/git/git-scm.com/blob/main/MIT-LICENSE.txt)
13 |
14 | - Made by [404kuso](https://github.com/404kuso) and [RedstoneZockt](https://github.com/RedstoneZockt)
15 | - Made for [discord.py](https://github.com/Rapptz/discord.py) and you
16 |
17 | - - -
18 |
19 | ### Issues, Bugs, etc.
20 |
21 | If you find any issues, bugs, problems or anything, please report them to our [github](https://github.com/discord-py-ui/discord-ui/issues/) so we can fix them
22 |
23 | ### Ideas
24 |
25 | If you have ideas for this package, plz feel free to tell us
26 |
27 | ### Help
28 |
29 | If you need any help or assist, join our [discord](https://discord.gg/bDJCGD994p)
30 |
31 | """
32 |
33 |
34 | from .client import *
35 | from .components import *
36 | from .slash.types import *
37 | from .slash.tools import *
38 | from .tools import *
39 | from .slash.tools import *
40 | from .receive import *
41 | from .listener import *
42 | from .slash import ext
43 | from .enums import ButtonStyle, OptionType, Channel, Mentionable
44 |
45 |
46 | from .override import override_dpy
47 |
48 |
49 | __title__ = "discord-ui"
50 | __version__ = "5.2.0"
51 | __author__ = "404kuso, RedstoneZockt"
52 |
--------------------------------------------------------------------------------
/docs/source/cogs.rst:
--------------------------------------------------------------------------------
1 | .. currentmodule:: discord_ui
2 |
3 | ======
4 | Cogs
5 | ======
6 |
7 | Setup
8 | ===============
9 |
10 | To use cog tools, you have to import the module
11 |
12 | .. code-block::
13 |
14 | from discord_ui.cogs import slash_command, subslash_command, context_cog, listening_component
15 |
16 | .. important::
17 |
18 | You need a :class:`~Slash` instance for slashcommands cogs and a :class:`~Component` instance for listening components.
19 | The best would be to initialze a :class:`~UI` instance, because it will initialize a component instance and a slash instance
20 |
21 |
22 | Example
23 |
24 | .. code-block::
25 |
26 | from discord.ext import commands
27 | from discord_ui import UI
28 | from discord_ui.cogs import slash_command, subslash_command
29 |
30 |
31 | bot = commands.Bot(" ")
32 | ui = UI(bot)
33 |
34 |
35 | class Example(commands.Cog):
36 | def __init__(self, bot):
37 | self.bot = bot
38 |
39 | @slash_command(guild_ids=[785567635802816595])
40 | async def name(self, ctx):
41 | """Responds with the name of the bot"""
42 | await ctx.send("my name is _" + self.bot.user.name + "_")
43 |
44 | bot.add_cog(Example(bot))
45 | bot.run("token")
46 |
47 |
48 | slash_command
49 | =============
50 |
51 | .. automethod:: cogs.slash_command
52 |
53 | subslash_command
54 | ================
55 |
56 | .. automethod:: cogs.subslash_command
57 |
58 | context_command
59 | ================
60 |
61 | .. automethod:: cogs.context_command
62 |
63 | listening_component
64 | ========================
65 |
66 | .. automethod:: cogs.listening_component
--------------------------------------------------------------------------------
/examples/context_commands.py:
--------------------------------------------------------------------------------
1 | # Example by 404kuso
2 | # https://github.com/discord-py-ui/discord-ui/tree/main/examples/context_commands.py
3 | #
4 | # This example will use the two new ui context commands,
5 | # one will quote the message and one will send their avatar
6 | # Note:
7 | # If you want to test this, replace '785567635802816595' in guild_ids=[] with a guild id of
8 | # your choice, because guild slash commands are way faster than globals
9 |
10 | import discord
11 | from discord.ext import commands
12 | from discord_ui import UI
13 |
14 | # The main bot client
15 | client = commands.Bot(" ")
16 | # Initialize the extension
17 | ui = UI(client)
18 |
19 |
20 | # Create the command
21 | @ui.slash.message_command("quote", guild_ids=[785567635802816595])
22 | # register the callback
23 | async def quote(ctx, message):
24 | # respond to the interaction so no error will show up
25 | await ctx.respond("aight", hidden=True)
26 | # Create a webhook with the same name as the message author
27 | webhook = await ctx.channel.create_webhook(name=message.author.display_name)
28 | # Send the message content
29 | await webhook.send(message.content)
30 | # delete the webhook
31 | await webhook.delete()
32 |
33 | # Create the command
34 | @ui.slash.user_command("avatar", guild_ids=[785567635802816595])
35 | # register the callback
36 | async def avatar(ctx, user):
37 | # send a embed with the user's avatar
38 | await ctx.respond(embed=discord.Embed(description=user.display_name).set_image(url=user._user.avatar_url))
39 |
40 | # Start the bot with the token, replace token_here with your bot token generated at https://discord.com/developers/applications
41 | client.run("token_here")
42 |
--------------------------------------------------------------------------------
/examples/permissions.py:
--------------------------------------------------------------------------------
1 | # Example by 404kuso
2 | # https://github.com/discord-py-ui/discord-ui/tree/main/examples/permissions.py
3 | #
4 | # This example will create two slash commands, one that can't be used by default
5 | # and one that will allow a specific role to use the command
6 | # Note:
7 | # If you want to test this, replace '785567635802816595' in guild_ids=[] with a guild id of
8 | # your choice, because guild slash commands are way faster to register than globals
9 |
10 | import discord
11 | from discord.ext import commands
12 | from discord_ui import UI, SlashOption, SlashPermission
13 |
14 | client = commands.Bot(" ")
15 | ui = UI(client)
16 |
17 | # Create a command that can't be used by default
18 | @ui.slash.command("cool_command", "only cool people can use this command", default_permission=False, guild_ids=[785567635802816595])
19 | async def callback(ctx):
20 | await ctx.send("you are a mod")
21 |
22 | # Create a command for updating the permissions of the "cool_command"
23 | @ui.slash.command("set_cool_people", options=[SlashOption(discord.Role, "role", "the role", True)], guild_ids=[785567635802816595])
24 | # Register the callback
25 | async def callback(ctx, role):
26 | """Sets the role for the cool people"""
27 | # Defer the interaction
28 | await ctx.defer()
29 | # Update the permissions for the command
30 | await ui.slash.update_permissions(name="cool_command", typ="slash", guild_id=ctx.guild.id, permissions=SlashPermission({role.id: SlashPermission.Role}))
31 | # Send a response
32 | await ctx.send("the cool role was set to " + role.name)
33 |
34 |
35 | # Start the bot with the token, replace "your_token_here" with your token
36 | client.run("your_token_here")
--------------------------------------------------------------------------------
/.github/workflows/main.yml:
--------------------------------------------------------------------------------
1 | # This is a basic workflow to help you get started with Actions
2 |
3 | name: CI
4 |
5 | # Controls when the workflow will run
6 | on:
7 | # Triggers the workflow on push or pull request events but only for the main branch
8 | release:
9 | types: [created, gpublished]
10 |
11 | # Allows you to run this workflow manually from the Actions tab
12 | workflow_dispatch:
13 |
14 | # A workflow run is made up of one or more jobs that can run sequentially or in parallel
15 | jobs:
16 | # This workflow contains a single job called "build"
17 | pypi-upload:
18 | # The type of runner that the job will run on
19 | runs-on: ubuntu-latest
20 | # Steps represent a sequence of tasks that will be executed as part of the job
21 | steps:
22 | # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it
23 | - uses: actions/checkout@v2
24 | - name: Setup Python
25 | uses: actions/setup-python@v2.2.2
26 | with:
27 | python-version: 3.9
28 | - name: Install Packages
29 | run: |
30 | python3 -m pip install build
31 | python3 -m pip install twine
32 | - name: Build Artifact
33 | run: |
34 | python3 -m build
35 |
36 | - name: Upload Artifact to PyPI
37 | env:
38 | TWINE_PASSWORD: ${{ secrets.PYPI_TOKEN }}
39 | TWINE_USERNAME: __token__
40 | run: |
41 | python3 -m twine upload dist/*
42 | - name: Upload a Build Artifact
43 | uses: actions/upload-artifact@v2.2.4
44 | with:
45 | # Artifact name
46 | name: "discord-ui"
47 | # A file, directory or wildcard pattern that describes what to upload
48 | path: "dist/*"
49 |
50 | - name: Download Artifact
51 | uses: actions/download-artifact@v2.0.10
52 | with:
53 | path: dist/
54 | # - name: pypi-publish
55 | # uses: pypa/gh-action-pypi-publish@v1.4.2
56 | # with:
57 | # user: __token__
58 | # password: ${{ secrets.PYPI_TEST_TOKEN }}
59 | # repository_url: https://test.pypi.org/project/discord-ui/
60 |
--------------------------------------------------------------------------------
/docs/source/index.rst:
--------------------------------------------------------------------------------
1 | ======================================================
2 | Home
3 | ======================================================
4 |
5 | Welcome to the discord-ui docs!
6 |
7 | This libary is an extension to `discord.py `__ which helps you
8 | using ui and interaction features in discord.
9 | With this libary, you can send message components like Buttons, LinkButtons and SelectMenus,
10 | receive data like who pressed the button and who selected which value in a menu, create
11 | application-commands like slash-commands, message-commands and user-commands, receive their interaction and
12 | the used options.
13 |
14 |
15 | Installation
16 | ---------------------
17 |
18 | To install this package, open your terminal or command line and type
19 |
20 | Windows
21 |
22 | .. code-block::
23 |
24 | > py -m pip install discord-ui
25 |
26 | Linux
27 |
28 | .. code-block::
29 |
30 | $ python3 -m pip install discord-ui
31 |
32 |
33 | Examples
34 | ---------------------
35 |
36 | We got some examples `here `__
37 |
38 |
39 | Links
40 | ---------------------
41 |
42 | * `Github `__
43 | * `Pypi `__
44 | * `404kuso `__
45 | * `RedstoneZockt `__
46 | * `discord.py `__
47 | * `Message-Component docs `__
48 | * `Application-Commands docs `__
49 | * `Alternatives to our lib `__
50 |
51 |
52 | Index
53 | ~~~~~~
54 |
55 | .. toctree::
56 | :maxdepth: 1
57 | :caption: General
58 |
59 | usage.rst
60 | ui.rst
61 |
62 | .. toctree::
63 | :maxdepth: 1
64 | :caption: Interactions
65 |
66 | interactions.rst
67 | components.rst
68 | slash.rst
69 |
70 | .. toctree::
71 | :maxdepth: 1
72 | :caption: Extension
73 |
74 | cogs.rst
75 | ext.rst
76 | listeners.rst
--------------------------------------------------------------------------------
/examples/generate_linkbutton.py:
--------------------------------------------------------------------------------
1 | # Example by 404kuso
2 | # https://github.com/discord-py-ui/discord-ui/tree/main/examples/generate_linkbutton.py
3 | #
4 | # This example will use a slash subcommand group and will generate a
5 | # linkbutton with a name, link and emoji which the user can specify
6 | # Note:
7 | # If you want to test this, replace '785567635802816595' in guild_ids=[] with a guild id of
8 | # your choice, because guild slash commands are way faster than globals
9 |
10 | from discord.ext import commands
11 | from discord_ui import SlashOption, UI, LinkButton
12 |
13 | # The main bot client
14 | client = commands.Bot(" ")
15 | # Initialize the extension
16 | ui = UI(client)
17 |
18 | # Creating the command
19 | @ui.slash.subcommand_group(base_names=["generate", "link"],
20 | name="button", description="sends a button and a linkbutton", options=[
21 | # The user can specify the message content
22 | SlashOption(str, "message content", "the content of the message"),
23 | # The name of the linkbutton
24 | SlashOption(str, "name", "the name of the button"),
25 | # The link of the linkbutton
26 | SlashOption(str, "link", "the link for the button"),
27 | # The eomji of the linkbutton
28 | SlashOption(str, "emoji", "a emoji appearing before the text")
29 | ],
30 | # If you want to test the command, use guild_ids, because this is way faster than global commands
31 | guild_ids=[785567635802816595])
32 | async def command(ctx, message_content="cool, right?", name="click me", link="https://github.com/discord-py-ui/discord-ui", emoji=None):
33 | # Check if the link is valid
34 | if not link.startswith("http://") and not link.startswith("https://"):
35 | # send hidden response that the link is invalid
36 | return await ctx.respond("The link has to start with `http://` or `https://`", hidden=True)
37 |
38 | # Send a link button with the link, name and emoji argument
39 | await ctx.respond(content=message_content, components=[LinkButton(link, label=name, emoji=emoji)])
40 |
41 |
42 | # Start the bot with the token, replace token_here with your bot token generated at https://discord.com/developers/applications
43 | client.run("token_here")
44 |
--------------------------------------------------------------------------------
/discord_ui/errors.py:
--------------------------------------------------------------------------------
1 | from discord.ext.commands import BadArgument
2 |
3 | class InvalidLength(BadArgument):
4 | """This exception is thrown whenever a invalid length was provided"""
5 | def __init__(self, my_name, _min=None, _max=None, *args: object) -> None:
6 | if _min is not None and _max is not None:
7 | err = "Length of '" + my_name + "' must be between " + str(_min) + " and " + str(_max)
8 | elif _min is None and _max is not None:
9 | err = "Length of '" + my_name + "' must be less than " + str(_max)
10 | elif _min is not None and _max is None:
11 | err = "Lenght of '" + my_name + "' must be more than " + str(_min)
12 | super().__init__(err)
13 | class OutOfValidRange(BadArgument):
14 | """This exception is thrown whenever a value was ot of its valid range"""
15 | def __init__(self, name, _min, _max, *args: object) -> None:
16 | super().__init__("'" + name + "' must be in range " + str(_min) + " and " + str(_max))
17 | class WrongType(BadArgument):
18 | """This exception is thrown whenever a value is of the wrong type"""
19 | def __init__(self, name, me, valid_type, *args: object) -> None:
20 | super().__init__("'" + name + "' must be of type " + (str(valid_type) if not isinstance(valid_type, list) else ' or '.join(valid_type)) + ", not " + str(type(me)))
21 | class InvalidEvent(BadArgument):
22 | """This exception is thrown whenever a invalid eventname was passed"""
23 | def __init__(self, name, events, *args: object) -> None:
24 | super().__init__("Invalid event name, event must be " + " or ".join(events) + ", not " + str(name))
25 | class MissingListenedComponentParameters(BadArgument):
26 | """This exception is thrown whenever a callback for a listening component is missing parameters"""
27 | def __init__(self, *args: object) -> None:
28 | super().__init__("Callback function for listening components needs to accept one parameter (the used component)", *args)
29 | class CouldNotParse(BadArgument):
30 | """This exception is thrown whenever the libary was unable to parse the data with the given method"""
31 | def __init__(self, data, type, method, *args: object) -> None:
32 | super().__init__("Could not parse '" + str(data) + " [" + str(type) + "]' with method " + str(method), *args)
--------------------------------------------------------------------------------
/setup.py:
--------------------------------------------------------------------------------
1 | import setuptools
2 |
3 | def get_version():
4 | """returns the version of the package"""
5 | with open("./discord_ui/__init__.py", "r", encoding="utf-8") as f:
6 | return [f for f in f.readlines() if f.startswith("__version__")][0].split('"')[1].split('"')[0]
7 | def get_name():
8 | """returns the name of the package"""
9 | with open("./discord_ui/__init__.py", "r", encoding="utf-8") as f:
10 | return [f for f in f.readlines() if f.startswith("__title__")][0].split('"')[1].split('"')[0]
11 | def get_readme():
12 | """returns the readme content for the package"""
13 | with open("./README.md", "r", encoding="utf-8") as f:
14 | return f.read()
15 | def get_author():
16 | """returns the name of the authors"""
17 | with open("./README.md", "r", encoding="utf-8") as f:
18 | with open("./discord_ui/__init__.py", "r", encoding="utf-8") as f:
19 | return [f for f in f.readlines() if f.startswith("__author__")][0].split('"')[1].split('"')[0]
20 |
21 | setuptools.setup(
22 | name=get_name(),
23 | version=get_version(),
24 | project_urls={
25 | "Documentation": "https://discord-ui.rtfd.io/en/latest/",
26 | "Issue tracker": "https://github.com/discord-py-ui/discord-ui/issues",
27 | },
28 | author=get_author(),
29 | author_email="bellou9022@gmail.com, redstoneprofihd@gmail.com",
30 | description="A disord.py extension for discord's ui/interaction features",
31 | long_description=get_readme(),
32 | long_description_content_type="text/markdown",
33 | url="https://github.com/discord-py-ui/discord-ui/",
34 | packages=setuptools.find_packages(),
35 | python_requires='>=3.6',
36 | classifiers=[
37 | "Programming Language :: Python :: 3",
38 | 'Intended Audience :: Developers',
39 | 'Natural Language :: English',
40 | 'Operating System :: OS Independent',
41 | 'Programming Language :: Python :: 3.6',
42 | 'Programming Language :: Python :: 3.7',
43 | 'Programming Language :: Python :: 3.8',
44 | 'Programming Language :: Python :: 3.9',
45 | 'Topic :: Internet',
46 | 'Topic :: Software Development :: Libraries',
47 | 'Topic :: Software Development :: Libraries :: Python Modules',
48 | 'Topic :: Utilities'
49 | ]
50 | )
51 |
--------------------------------------------------------------------------------
/docs/source/slash.rst:
--------------------------------------------------------------------------------
1 | .. currentmodule:: discord_ui
2 |
3 | =====================
4 | Commands
5 | =====================
6 |
7 |
8 | Slash
9 | =====
10 |
11 |
12 | .. autoclass:: Slash
13 | :members:
14 |
15 | .. code-block::
16 |
17 | import discord
18 | from discord.ext import commands
19 | from discord_ui import Slash
20 |
21 | client = commands.Bot(" ")
22 | slash = Slash(client)
23 |
24 |
25 | Events
26 | ================
27 |
28 | We got 2 events to listen for your client
29 |
30 | ``slash_command``
31 | ~~~~~~~~~~~~~~~~~~~~~~
32 |
33 | This event will be dispatched whenever a normal slash command was used
34 |
35 | One parameter will be passed
36 |
37 | * :class:`~SlashInteraction` | :class:`~SlashedSubCommand`
38 |
39 | .. code-block::
40 |
41 | @client.listen('on_slash_command')
42 | def slash_command(ctx: SlashInteraction):
43 | ...
44 |
45 | .. code-block::
46 |
47 | await client.wait_for('slash_command', check=lambda ctx: ...)
48 |
49 |
50 | ``context_command``
51 | ~~~~~~~~~~~~~~~~~~~~~~
52 |
53 | This event will be dispatched whenever a context command was used
54 |
55 | Two parameters will be passed
56 |
57 | * :class:`~ContextInteraction`
58 | * :class:`~Message` | :class:`discord.User`
59 |
60 | .. code-block::
61 |
62 | @client.listen('context_command')
63 | def on_context(ctx: ContextInteraction, param):
64 | ...
65 |
66 | .. code-block::
67 |
68 | await client.wait_for('context_command', check=lambda ctx, param: ...)
69 |
70 |
71 | SlashOption
72 | ============
73 |
74 | .. autoclass:: SlashOption
75 | :members:
76 |
77 |
78 | SlashPermission
79 | ===============
80 |
81 | .. autoclass:: SlashPermission
82 | :members:
83 |
84 | SlashInteraction
85 | =================
86 |
87 | .. autoclass:: SlashInteraction()
88 | :members:
89 | :inherited-members:
90 |
91 |
92 | Ephemeral
93 | ==========
94 |
95 | EphemeralMessage
96 | ~~~~~~~~~~~~~~~~
97 |
98 | .. autoclass:: EphemeralMessage()
99 | :members:
100 |
101 | EphemeralResponseMessage
102 | ~~~~~~~~~~~~~~~~~~~~~~~~
103 |
104 | .. autoclass:: EphemeralResponseMessage()
105 | :members:
106 |
107 | Tools
108 | =========
109 |
110 | .. autofunction:: create_choice
--------------------------------------------------------------------------------
/examples/cogs.py:
--------------------------------------------------------------------------------
1 | # Example by 404kuso
2 | # https://github.com/discord-py-ui/discord-ui/tree/main/examples/cpgs.py
3 | #
4 | # This is just a simple cog command example for application commands
5 | # Note:
6 | # If you want to test this, replace '785567635802816595' in guild_ids=[] with a guild id of
7 | # your choice, because guild slash commands are way faster than globals
8 |
9 | # import discord package
10 | import discord
11 | # import commands extention
12 | from discord.ext import commands
13 | from discord_ui import (
14 | UI, cogs, SlashOption,
15 | # interaction types for type hinting
16 | SlashInteraction, AutocompleteInteraction, ContextInteraction,
17 | # overridden message object for type hinting
18 | Message
19 | )
20 |
21 | # initialize bot
22 | bot = commands.Bot(" ")
23 |
24 |
25 | # create the cog
26 | class ExampleCog(commands.Cog):
27 | # add slashcommand to cog
28 | @cogs.slash_command("my_command", "this is an example cog command", guild_ids=[785567635802816595])
29 | # callback for the command
30 | async def my_command(self, ctx: SlashInteraction):
31 | ...
32 |
33 |
34 | # method that generates choices for the 'hello world' command
35 | async def my_generator(self, ctx: AutocompleteInteraction):
36 | return [("hello", "1"), ("world", "2")]
37 |
38 | # add subslash command to the cog
39 | @cogs.subslash_command("hello", "world", "example subcommand", [
40 | # simple option that uses autocompletion
41 | SlashOption(str, "argument", "option that autogenerates somethning", choice_generator=my_generator)
42 | ])
43 | # callback for the commmand
44 | async def my_subcommand(self, ctx: SlashInteraction, argument: str):
45 | ...
46 |
47 |
48 | # add message command to cog
49 | @cogs.message_command("message", guild_ids=[785567635802816595])
50 | # callbackfor the command
51 | async def my_message_command(self, ctx: ContextInteraction, message: Message):
52 | ...
53 |
54 |
55 | # add user command to cog
56 | @cogs.user_command("user", guild_ids=[785567635802816595])
57 | # callback for the command
58 | async def my_user_command(self, ctx: ContextInteraction, member: discord.Member):
59 | ...
60 |
61 |
62 | # add the cog to the bot
63 | bot.add_cog(ExampleCog())
64 |
65 | # login
66 | bot.run("your token")
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Byte-compiled / optimized / DLL files
2 | __pycache__/
3 | *.py[cod]
4 | *$py.class
5 |
6 | # C extensions
7 | *.so
8 |
9 | # Distribution / packaging
10 | .Python
11 | build/
12 | develop-eggs/
13 | dist/
14 | downloads/
15 | eggs/
16 | .eggs/
17 | lib/
18 | lib64/
19 | parts/
20 | sdist/
21 | var/
22 | wheels/
23 | pip-wheel-metadata/
24 | share/python-wheels/
25 | *.egg-info/
26 | .installed.cfg
27 | *.egg
28 | MANIFEST
29 |
30 | # PyInstaller
31 | # Usually these files are written by a python script from a template
32 | # before PyInstaller builds the exe, so as to inject date/other infos into it.
33 | *.manifest
34 | *.spec
35 |
36 | # Installer logs
37 | pip-log.txt
38 | pip-delete-this-directory.txt
39 |
40 | # Unit test / coverage reports
41 | htmlcov/
42 | .tox/
43 | .nox/
44 | .coverage
45 | .coverage.*
46 | .cache
47 | nosetests.xml
48 | coverage.xml
49 | *.cover
50 | *.py,cover
51 | .hypothesis/
52 | .pytest_cache/
53 |
54 | # Translations
55 | *.mo
56 | *.pot
57 |
58 | # Django stuff:
59 | *.log
60 | local_settings.py
61 | db.sqlite3
62 | db.sqlite3-journal
63 |
64 | # Flask stuff:
65 | instance/
66 | .webassets-cache
67 |
68 | # Scrapy stuff:
69 | .scrapy
70 |
71 | # Sphinx documentation
72 | docs/_build/
73 |
74 | # PyBuilder
75 | target/
76 |
77 | # Jupyter Notebook
78 | .ipynb_checkpoints
79 |
80 | # IPython
81 | profile_default/
82 | ipython_config.py
83 |
84 | # pyenv
85 | .python-version
86 |
87 | # pipenv
88 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
89 | # However, in case of collaboration, if having platform-specific dependencies or dependencies
90 | # having no cross-platform support, pipenv may install dependencies that don't work, or not
91 | # install all needed dependencies.
92 | #Pipfile.lock
93 |
94 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow
95 | __pypackages__/
96 |
97 | # Celery stuff
98 | celerybeat-schedule
99 | celerybeat.pid
100 |
101 | # SageMath parsed files
102 | *.sage.py
103 |
104 | # Environments
105 | .env
106 | .venv
107 | env/
108 | venv/
109 | ENV/
110 | env.bak/
111 | venv.bak/
112 |
113 | # Spyder project settings
114 | .spyderproject
115 | .spyproject
116 |
117 | # Rope project settings
118 | .ropeproject
119 |
120 | # mkdocs documentation
121 | /site
122 |
123 | # mypy
124 | .mypy_cache/
125 | .dmypy.json
126 | dmypy.json
127 |
128 | # Pyre type checker
129 | .pyre/
130 |
131 | # visual studio
132 | .vscode/
133 | .vs/
134 |
135 | internal/
136 | test*.py
137 | discord_ui/ext.py
--------------------------------------------------------------------------------
/.github/workflows/codeql-analysis.yml:
--------------------------------------------------------------------------------
1 | # For most projects, this workflow file will not need changing; you simply need
2 | # to commit it to your repository.
3 | #
4 | # You may wish to alter this file to override the set of languages analyzed,
5 | # or to provide custom queries or build logic.
6 | #
7 | # ******** NOTE ********
8 | # We have attempted to detect the languages in your repository. Please check
9 | # the `language` matrix defined below to confirm you have the correct set of
10 | # supported CodeQL languages.
11 | #
12 | name: "CodeQL"
13 |
14 | on:
15 | push:
16 | branches: [ main ]
17 | pull_request:
18 | # The branches below must be a subset of the branches above
19 | branches: [ main ]
20 | schedule:
21 | - cron: '44 10 * * 3'
22 |
23 | jobs:
24 | analyze:
25 | name: Analyze
26 | runs-on: ubuntu-latest
27 | permissions:
28 | actions: read
29 | contents: read
30 | security-events: write
31 |
32 | strategy:
33 | fail-fast: false
34 | matrix:
35 | language: [ 'python' ]
36 | # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python' ]
37 | # Learn more:
38 | # https://docs.github.com/en/free-pro-team@latest/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#changing-the-languages-that-are-analyzed
39 |
40 | steps:
41 | - name: Checkout repository
42 | uses: actions/checkout@v2
43 |
44 | # Initializes the CodeQL tools for scanning.
45 | - name: Initialize CodeQL
46 | uses: github/codeql-action/init@v1
47 | with:
48 | languages: ${{ matrix.language }}
49 | # If you wish to specify custom queries, you can do so here or in a config file.
50 | # By default, queries listed here will override any specified in a config file.
51 | # Prefix the list here with "+" to use these queries and those in the config file.
52 | # queries: ./path/to/local/query, your-org/your-repo/queries@main
53 |
54 | # Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
55 | # If this step fails, then you should remove it and run the build manually (see below)
56 | - name: Autobuild
57 | uses: github/codeql-action/autobuild@v1
58 |
59 | # ℹ️ Command-line programs to run using the OS shell.
60 | # 📚 https://git.io/JvXDl
61 |
62 | # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines
63 | # and modify them (or add more) to build your code if your project
64 | # uses a compiled language
65 |
66 | #- run: |
67 | # make bootstrap
68 | # make release
69 |
70 | - name: Perform CodeQL Analysis
71 | uses: github/codeql-action/analyze@v1
72 |
--------------------------------------------------------------------------------
/examples/staff_message.py:
--------------------------------------------------------------------------------
1 | # Example by 404kuso
2 | # https://github.com/discord-py-ui/discord-ui/tree/main/examples/staff_messagee.py
3 | #
4 | # This example will use a slashcommand and a choice generator that will generaete choices
5 | # based on a role that was selected. This command will send a message with a content that
6 | # was passed in an option to a user from a staff role.
7 | #
8 | # Note: Replace 785567635802816595 in `guild_ids=[785567635802816595]` with a guild_id of your choice
9 | # where you want the command to be available
10 |
11 | # region imports
12 | import discord
13 | from discord.ext import commands
14 | from discord_ui import UI, SlashOption, AutocompleteInteraction
15 | # endregion
16 |
17 | # initalize bot with intents for the `fetch_members` function
18 | bot = commands.Bot(" ", intents=discord.Intents(members=True))
19 | # initalize ui instnace
20 | ui = UI(bot)
21 |
22 | # Function to generate the choices
23 | async def staff_generartor(ctx: AutocompleteInteraction):
24 | # get the value of the previous selected 'staff' option
25 | role: discord.Role = ctx.selected_options["staff"]
26 | # get all members that have that role
27 | members = role.guild.fetch_members().filter(predicate=lambda x: x.get_role(role.id))
28 | # return a choice with the name and the id as the value for every user
29 | return [(x.name, str(x.id)) async for x in members]
30 |
31 | # slash command for sending messages to "staff"
32 | @ui.slash.command(options=[
33 | # user has to select a staff role for which the choices will be generated
34 | SlashOption(discord.Role, "staff", required=True),
35 | # A autogenerated option for the
36 | SlashOption(str, "user", required=True, choice_generator=staff_generartor),
37 | # A message that will be send to the user
38 | SlashOption(str, "message", required=True)
39 | # guilds where the command is available
40 | ], guild_ids=[785567635802816595])
41 | # the callback function
42 | async def send_to_staff(ctx, staff: discord.Role, user: str, message: str):
43 | # defer the interaction in case we'll need longer than 3 seconds to respond
44 | await ctx.defer()
45 | # get the target user by the passed 'user' argument that will contain the target user id
46 | target_user = (await ctx.guild.fetch_members().find(lambda x: x.id == int(user)))
47 | # send a message to the target user with the content of the 'message' argument
48 | await target_user.send("**" + str(ctx.author) + "**```\n" + message + "```")
49 | # send a hidden response to the coommand that a message was sent
50 | await ctx.send("send message to " + target_user, hidden=True)
51 |
52 | # login into the bot. Replace "token_here" with your token
53 | bot.run("token_here")
--------------------------------------------------------------------------------
/discord_ui/slash/errors.py:
--------------------------------------------------------------------------------
1 | from discord import ClientException
2 |
3 | class NoCommandFound(ClientException):
4 | """Exception that is raised when you try to get a command with a name that doesn't exists"""
5 | class AlreadyDeferred(ClientException):
6 | """Exception that is raised when you try to defer an interaction that was already deferred."""
7 | def __init__(self, *args: object) -> None:
8 | super().__init__("Interaction was already deferred")
9 | class EphemeralDeletion(ClientException):
10 | """Exception that is raised when you try to delete an ephemeral message.
11 |
12 | Ephemeral messages can not be deleted"""
13 | def __init__(self, *args: object) -> None:
14 | super().__init__("Cannot delete an ephemeral message")
15 | class MissingOptionParameter(ClientException):
16 | """Exception that is raised when a callback is missing a parameter which was
17 | specified in the slash command.
18 |
19 | If you have a slashcommand with ``role`` as the name, your callback has to
20 | accept a parameter with the name ``role``.
21 |
22 | For example
23 |
24 | .. code-block::
25 |
26 | @ui.slash.command(..., options=[SlashOption(SomeType, role, required=True)])
27 | async def callback(ctx, role): # role is the name of the option
28 | ...
29 | """
30 | def __init__(self, option_name, *args: object) -> None:
31 | super().__init__("Missing parameter '" + option_name + "' in callback function")
32 | class OptionalOptionParameter(ClientException):
33 | """Exception that is rarised when a callback function has a required parameter which
34 | is marked optional in the slash command.
35 |
36 | If you want to have an optional option in your callback, you need to specify a default value
37 | for it: ``async def callback(ctx, my_option=None)``
38 | """
39 | def __init__(self, param_name, *args: object) -> None:
40 | super().__init__("Parameter '" + param_name + "' in callback function needs to be optional (" + param_name + "=None)")
41 | class NoAsyncCallback(ClientException):
42 | """Exception that is raised when a sync callback was provided
43 |
44 | Callbacks have to be async
45 | """
46 | def __init__(self, name) -> None:
47 | if name:
48 | msg = f"callback for command '{name}'' has to be async"
49 | else:
50 | msg = "callback has to be async"
51 | super().__init__(msg)
52 | class CallbackMissingContextCommandParameters(ClientException):
53 | """Exception that is raised when a callback for a context command is missing parmeters.
54 |
55 | A context-command callback has to accept two parameters, one for the interaction context
56 | and the other one for the passed parameter.
57 | """
58 | def __init__(self, *args: object) -> None:
59 | super().__init__("Callback function for context commands has to accept 2 parameters (the used command, the message/user on which the interaction was used)")
60 |
--------------------------------------------------------------------------------
/docs/source/conf.py:
--------------------------------------------------------------------------------
1 | # Configuration file for the Sphinx documentation builder.
2 | #
3 | # This file only contains a selection of the most common options. For a full
4 | # list see the documentation:
5 | # https://www.sphinx-doc.org/en/master/usage/configuration.html
6 |
7 | # -- Path setup --------------------------------------------------------------
8 |
9 | # If extensions (or modules to document with autodoc) are in another directory,
10 | # add these directories to sys.path here. If the directory is relative to the
11 | # documentation root, use os.path.abspath to make it absolute, like shown here.
12 | #
13 | import os
14 | import sys
15 | sys.path.insert(0, os.path.abspath('../..'))
16 |
17 |
18 | # -- Project information -----------------------------------------------------
19 |
20 | project = 'discord-ui'
21 | copyright = '2021, 404kuso, RedstoneZockt'
22 | author = '404kuso, RedstoneZockt'
23 |
24 | def get_version():
25 | """returns the version of the package"""
26 | with open("../../discord_ui/__init__.py", "r", encoding="utf-8") as f:
27 | return [f for f in f.readlines() if f.startswith("__version__")][0].split('"')[1].split('"')[0]
28 |
29 | # The full version, including alpha/beta/rc tags
30 | release = get_version()
31 |
32 |
33 | # -- General configuration ---------------------------------------------------
34 |
35 | # Add any Sphinx extension module names here, as strings. They can be
36 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom
37 | # ones.
38 | extensions = ['sphinx.ext.autodoc', 'sphinx.ext.autosummary', 'sphinx_rtd_theme', 'sphinx_copybutton']
39 | autosummary_generate = True
40 |
41 | # Add any paths that contain templates here, relative to this directory.
42 | templates_path = ['_templates']
43 |
44 |
45 | # List of patterns, relative to source directory, that match files and
46 | # directories to ignore when looking for source files.
47 | # This pattern also affects html_static_path and html_extra_path.
48 | exclude_patterns = []
49 |
50 |
51 | # -- Options for copy-button -------------------------------------------------
52 |
53 | copybutton_prompt_text = r">>> |\.\.\. |\$ |In \[\d*\]: |> "
54 | copybutton_prompt_is_regexp = True
55 |
56 | # -- Options for HTML output -------------------------------------------------
57 |
58 | # The theme to use for HTML and HTML Help pages. See the documentation for
59 | # a list of builtin themes.
60 | html_theme = 'sphinx_rtd_theme'
61 |
62 | # Add any paths that contain custom static files (such as style sheets) here,
63 | # relative to this directory. They are copied after the builtin static files,
64 | # so a file named "default.css" will overwrite the builtin "default.css".
65 | html_static_path = ['_static']
66 |
67 | # def setup(app):
68 | # app.add_css_file('css/main.css')
69 |
70 | # html_context = {
71 | # 'css_files': ['css/main.css'],
72 | # 'js_files': ['js/override_page.js', 'js/keyboard.js'],
73 | # }
74 |
75 | def setup(app):
76 | app.add_css_file('css/main.css')
77 | app.add_js_file("js/override_page.js")
78 | app.add_js_file("js/keyboard.js")
--------------------------------------------------------------------------------
/docs/source/components.rst:
--------------------------------------------------------------------------------
1 | .. currentmodule:: discord_ui
2 |
3 | ====================
4 | Components
5 | ====================
6 |
7 |
8 | Components
9 | ===========
10 |
11 | .. autoclass:: Components
12 | :members:
13 |
14 | .. code-block::
15 |
16 | import discord
17 | from discord.ext import commands
18 | from discord_ui import Components
19 |
20 | client = commands.Bot(" ")
21 | components = Components(client)
22 |
23 | Events
24 | ================
25 |
26 | We got 3 events to listen for your client
27 |
28 | ``component``
29 | ~~~~~~~~~~~~~~
30 |
31 | This event will be dispatched whenever a component was invoked
32 |
33 | A sole parameter will be passed
34 |
35 | * :class:`~ComponentContext`: The used component
36 |
37 | .. code-block::
38 |
39 | @client.listen()
40 | async on_componet(component: ComponentContext):
41 | ...
42 |
43 | .. code-block::
44 |
45 | await client.wait_for('component', check=lambda com: ...)
46 |
47 |
48 | ``button``
49 | ~~~~~~~~~~~~~~~~~~~~~~
50 |
51 | This event will be dispatched whenever a button was pressed
52 |
53 | A sole parameter will be passed:
54 |
55 | * :class:`~ButtonInteraction`: The pressed button
56 |
57 | .. code-block::
58 |
59 | @client.listen()
60 | def on_button(btn: ButtonInteraction):
61 | ...
62 |
63 | .. code-block::
64 |
65 | await client.wait_for('button', check=lambda btn: ...)
66 |
67 |
68 | ``select``
69 | ~~~~~~~~~~~~~~~~~~~~~~
70 |
71 | This event will be dispatched whenever a value was selected in a :class:`~SelectInteraction`
72 |
73 | A sole paremeter will be passed
74 |
75 | * :class:`~SelectInteraction`: The menu where a value was selected
76 |
77 | .. code-block::
78 |
79 | @client.listen()
80 | def on_select(menu: SelectInteraction):
81 | ...
82 |
83 | .. code-block::
84 |
85 | await client.wait_for('select', check=lambda menu: ...)
86 |
87 |
88 | Components
89 | ====================
90 |
91 | Button
92 | ~~~~~~~~~~~~~~~~~~~~~~
93 |
94 | .. autoclass:: Button
95 | :members:
96 | :exclude-members: to_dict
97 |
98 | LinkButton
99 | ~~~~~~~~~~~~~~~~~~~~~~
100 |
101 | .. autoclass:: LinkButton
102 | :members:
103 | :inherited-members:
104 | :exclude-members: to_dict
105 |
106 |
107 | ButtonStyle
108 | ~~~~~~~~~~~~~~~~~~~~~~
109 |
110 | .. autoclass:: ButtonStyle
111 |
112 |
113 | SelectMenu
114 | ~~~~~~~~~~~~~~~~~~~~~~
115 |
116 | .. autoclass:: SelectMenu
117 | :members:
118 | :inherited-members:
119 | :exclude-members: to_dict
120 |
121 |
122 | SelectOption
123 | ~~~~~~~~~~~~~~~~~~~~~~
124 |
125 | .. autoclass:: SelectOption
126 | :members:
127 | :exclude-members: to_dict
128 |
129 |
130 | Interactions
131 | =================
132 |
133 |
134 | Message
135 | ~~~~~~~~~~~~~~~~~~~~~~
136 |
137 | .. autoclass:: Message()
138 | :members:
139 |
140 | ButtonInteraction
141 | ~~~~~~~~~~~~~~~~~~~~~~
142 |
143 | .. autoclass:: ButtonInteraction()
144 | :members:
145 | :inherited-members:
146 | :exclude-members: to_dict
147 |
148 |
149 | SelectInteraction
150 | ~~~~~~~~~~~~~~~~~~~~~~
151 |
152 | .. autoclass:: SelectInteraction()
153 | :members:
154 | :inherited-members:
155 | :exclude-members: to_dict
156 |
157 | Tools
158 | =========
159 |
160 | .. autofunction:: components_to_dict
--------------------------------------------------------------------------------
/docs/source/_static/js/override_page.js:
--------------------------------------------------------------------------------
1 | /*
2 |
3 | This override module is needed to change some values I Could not change in a custom css
4 | (I know this is a shit idea but it kinda works)
5 |
6 | */
7 |
8 | function change_color(class_name, color) {
9 | result = document.getElementsByClassName(class_name)
10 | for (var i = 0; i < result.length; i++) {
11 | result[i].style.color = color
12 | result[i].style["font-weight"] = 100
13 | }
14 | }
15 |
16 | window.onload = function() {
17 | //#region colors
18 | let string_green = "#afff7d"
19 | let self_red = "#ff525d"
20 | let int_orange = "#ffed85"
21 |
22 | // numbers
23 | change_color("mi", int_orange)
24 | // '' string
25 | change_color("s1", string_green)
26 | // "" string
27 | change_color("s2", string_green)
28 | // """""" docstring
29 | change_color("sd", string_green)
30 | // bools
31 | change_color("kc", self_red)
32 |
33 | // import statements
34 | change_color("kn", "#d962fc")
35 | // import package
36 | change_color("nn", "orange")
37 | // comments
38 | change_color("c1", "grey")
39 |
40 |
41 | // class name
42 | change_color("nc", "orange")
43 | // __init__
44 | change_color("fm", "#5865F2")
45 | // self
46 | change_color("bp", self_red)
47 |
48 | // async, def, await
49 | change_color("k", "#eb52ff")
50 | // in keyword
51 | change_color("ow", "#eb52ff")
52 | // function name
53 | change_color("nf", "#5c9aff")
54 | // Decorators
55 | change_color("nd", "#FE0")
56 |
57 | // built-in function calls
58 | change_color("nb", "#ecfc5b")
59 | // +, -, *, /, =
60 | // change_color("o", "#69f5f2")
61 | //#endregion
62 |
63 | // fix code
64 | result = document.getElementsByTagName("code");
65 | for (let i = 0; i < result.length; i++)
66 | result[i].style["font-weight"] = 100
67 |
68 | // mobile only
69 | change_color("wy-nav-top", "#5865F2")
70 |
71 |
72 | // attribute einzeln in eine Zeile packen
73 | result = document.getElementsByClassName("py property")
74 | for (let i = 0; i < result.length; i++)
75 | result[i].style["display"] = "block";
76 |
77 |
78 | // Makes page full size
79 | result = document.getElementsByClassName("wy-nav-content")
80 | if (result.length > 0)
81 | result[0].style["max-width"] = "100%";
82 |
83 | // displays the menu icon
84 | result = document.getElementsByClassName("wy-nav-top")
85 | if (result.length > 0)
86 | result[0].style["color"] = "white";
87 |
88 | // remove the discord_ui. ... prefix
89 | result = document.getElementsByClassName("reference internal")
90 | for(let i = 0; i < result.length; i++) {
91 | if (result[i].text != null && result[i].text.match("discord_ui\.[\w]*\."))
92 | result[i].text = (result[i].text.split(".").at(-1))
93 | }
94 | // result = document.getElementsByClassName("sig sig-object py")
95 | // for (let i = 0; i < result.length; i++) {
96 | // if (result[i].innerText.startsWith("class"))
97 | // result[i].innerText = result[i].innerText.replace("discord_ui.", " ").replace("", "")
98 | // }
99 |
100 | // Add author
101 | document.getElementsByClassName("rst-content")[0].innerHTML += "\nModified by 404kuso
"
102 |
103 | }
--------------------------------------------------------------------------------
/examples/role_picker.py:
--------------------------------------------------------------------------------
1 | # Example by 404kuso
2 | # https://github.com/discord-py-ui/discord-ui/tree/main/examples/role_picker.py
3 | #
4 | # This example will use a slash subcommand and will send a select menu only visible to the user,
5 | # where the user can choose between roles to get
6 | #
7 | # Note: For this example to work, the bot has to have the MANAGE_ROLES permission and the bot role
8 | # has to be higher than the roles.
9 | # Also, if you want to test this, replace '785567635802816595' in guild_ids=[] with a guild id of
10 | # your choice, because guild slash commands are way faster than globals
11 | #
12 | # Replace '867715564155568158', '867715628504186911', '867715582903582743' and '867715674386071602'
13 | # with roles you want to give the user
14 |
15 | import asyncio
16 | from discord.ext import commands
17 | from discord_ui import UI, SlashInteraction, SelectMenu, SelectOption
18 |
19 | # The main bot client
20 | client = commands.Bot(" ")
21 | # initialize the extension
22 | ui = UI(client, slash_options={"wait_sync": 2})
23 |
24 | # Create a slash command
25 | @ui.slash.command(name="role-picker", description="let's you pick roles", guild_ids=[785567635802816595])
26 | async def command(ctx: SlashInteraction):
27 |
28 | # The role picker component
29 | role_picker = SelectMenu(options=[
30 | SelectOption("867715564155568158", "javascript", "I'm a javascript programmer"),
31 | SelectOption("867715628504186911", "java", "I'm a java programmer"),
32 | SelectOption("867715582903582743", "python", "I'm a python programmer"),
33 | SelectOption("867715674386071602", "ruby", "I'm a ruby programmer")
34 | ], custom_id="role_picker", placeholder="Select your programming language", max_values=4)
35 |
36 | # Send the select menu, only visble to the user
37 | msg = await ctx.send("pick your roles", components=[role_picker], hidden=True)
38 | try:
39 | # Wait for a selection on a select menu with the custom_id
40 | # 'role_picker' by the user who used the slash command,
41 | # with a timeout of 20 seconds
42 | menu = await msg.wait_for("select", client, timeout=20)
43 | # Get all roles in the current guild
44 | roles = await ctx.channel.guild.fetch_roles()
45 | given_roles = []
46 | # Defer the interaction, in case we need more than 3 seconds
47 | await menu.defer(hidden=True)
48 | # For every value of the selectmenu selection
49 | for role in menu.selected_values:
50 | # For every role in the guild's roles
51 | for _r in roles:
52 | # If the id of the current guild role is the same as the value of the current selected value
53 | # (in our case the value is the id of the role the user should get)
54 | if str(_r.id) == str(role):
55 | # Add the role to the user
56 | await menu.author.add_roles(_r)
57 | # Add the current role name to an array where all the given roles are
58 | given_roles.append(_r.name)
59 |
60 | # Send which roles the user got, only visible to the user
61 | await menu.respond("I gave you following roles: `" + ', `'.join(given_roles) + "`", hidden=True)
62 | # When 20 seconds without input have passed (we set the timeout to 20 seconds)
63 | except asyncio.TimeoutError:
64 | # Send a hidden message saying that the user took to long to choose
65 | await ctx.send(content="you took too long to choose", hidden=True)
66 |
67 | # Run the bot (replace 'token_here' with your bot token)
68 | client.run("token_here")
69 |
--------------------------------------------------------------------------------
/discord_ui/override.py:
--------------------------------------------------------------------------------
1 | """https://github.com/discord-py-ui/discord-ui/blob/main/discord_ui/override.py
2 |
3 | This module overrides some methods of the discord's functions.
4 | This overrides the Messageable.send, Webhook.send, the Message.__new__ method (whenever a new Message is created, it will use our own Message type)
5 | The same goes for the WebhookMessage, it will be overriden by our own Webhook type.
6 | And last but not least, if you're using dpy 2, the discord.ext.commands.Bot will be overriden with our
7 | own class, which enables `enable_debug_events` in order for our lib to work
8 | """
9 | from .tools import MISSING
10 | from .receive import Message
11 | from .http import get_message_payload, BetterRoute, send_files
12 |
13 | import discord
14 |
15 | import sys
16 |
17 | def override_dpy():
18 | """This function overrides default dpy objects.
19 | You shouldn't need to use this method by your own, the lib overrides everything that needs to be
20 | overriden by default"""
21 | # override for dpy forks
22 | module = sys.modules[discord.__name__]
23 |
24 | #region message override
25 | async def send(self: discord.TextChannel, content=None, **kwargs) -> Message:
26 | channel = await self._get_channel()
27 | route = BetterRoute("POST", f"/channels/{channel.id}/messages")
28 |
29 | listener = None
30 | if kwargs.get("listener") is not None:
31 | listener = kwargs.pop("listener")
32 | if kwargs.get("components") is None and listener is not None:
33 | kwargs["components"] = listener.to_components()
34 | r = None
35 | if kwargs.get("file") is None and kwargs.get("files") is None:
36 | payload = get_message_payload(content=content, **kwargs)
37 | r = await self._state.http.request(route, json=payload)
38 | else:
39 | if kwargs.get("file") is not None:
40 | files = [kwargs.pop("file")]
41 | elif kwargs.get("files") is not None:
42 | files = kwargs.pop("files")
43 |
44 | payload = get_message_payload(content=content, **kwargs)
45 | r = await send_files(route, files=files, payload=payload, http=self._state.http)
46 |
47 | msg = Message(state=self._state, channel=channel, data=r)
48 | if kwargs.get("delete_after") is not None:
49 | await msg.delete(delay=kwargs.pop("delete_after"))
50 |
51 | if listener is not None:
52 | listener._start(msg)
53 |
54 | return msg
55 | def message_override(cls, *args, **kwargs):
56 | if cls is discord.message.Message:
57 | return object.__new__(Message)
58 | else:
59 | return object.__new__(cls)
60 |
61 |
62 | module.abc.Messageable.send = send
63 | module.message.Message.__new__ = message_override
64 | #endregion
65 |
66 | #region webhook override
67 | def send_webhook(self: discord.Webhook, content=MISSING, *, wait=False, username=MISSING, avatar_url=MISSING, tts=False, files=None, embed=MISSING, embeds=MISSING, allowed_mentions=MISSING, components=MISSING):
68 | payload = get_message_payload(content, tts=tts, embed=embed, embeds=embeds, allowed_mentions=allowed_mentions, components=components)
69 |
70 | if username is not None:
71 | payload["username"] = username
72 | if avatar_url is not None:
73 | payload["avatar_url"] = str(avatar_url)
74 |
75 | return self._adapter.execute_webhook(payload=payload, wait=wait, files=files)
76 |
77 | module.webhook.Webhook.send = send_webhook
78 | #endregion
79 |
80 | # override for dpy forks
81 | sys.modules[discord.__name__] = module
--------------------------------------------------------------------------------
/examples/calculator.py:
--------------------------------------------------------------------------------
1 | # Example by 404kuso
2 | # https://github.com/discord-py-ui/discord-ui/tree/main/examples/calculator.py
3 | #
4 | # This example will send a working calculator to the text channel with buttons
5 | #
6 | # Note:
7 | # If you want to test this, replace '785567635802816595' in guild_ids=[] with a guild id of
8 | # your choice, because guild slash commands are way faster than globals
9 |
10 | import asyncio
11 | from discord.ext import commands
12 | from discord_ui import SlashInteraction, UI, Button, LinkButton
13 |
14 | # The main discord bot client
15 | client = commands.Bot(" ")
16 | # Initialize the extension
17 | ui = UI(client)
18 |
19 |
20 | # A component list for the calculator
21 | calculator = [
22 | [Button("7", color="blurple"), Button("8", color="blurple"), Button("9", color="blurple"), Button("+", color="green"), Button(")", color="green")],
23 | [Button("4", color="blurple"), Button("5", color="blurple"), Button("6", color="blurple"), Button("-", color="green"), Button("(", color="green")],
24 | [Button("1", color="blurple"), Button("2", color="blurple"), Button("3", color="blurple"), Button("*", color="green"), Button("⌫", "backs", "red")],
25 | [Button(".", color="green"), Button("0", color="blurple"), Button("=", color="gray"), Button("/", color="green"), Button("C", "cls", "red")],
26 | LinkButton("https://github.com/discord-py-ui/discord-ui/tree/main/examples/calculator.py", "ヾ(≧▽≦*) click here for source code ヾ(≧▽≦*)")
27 | ]
28 |
29 |
30 |
31 | # Create a slash command
32 | @ui.slash.command(name="calculator", description="opens a calculator, that will automatically close when no input was provided after 20 seconds", guild_ids=[785567635802816595])
33 | async def test(ctx: SlashInteraction):
34 | # The current query for the calculator
35 | query = ""
36 | # Send the calculato, \u200b is an 'empty' char
37 | msg = await ctx.send("```\n\u200b```", components=calculator)
38 |
39 | # Infinite loop
40 | while True:
41 | try:
42 | # Wait for a button press with a timeout of 20 seconds
43 | btn = await msg.wait_for("button", client, timeout=20)
44 | # Respond to the button
45 | await btn.respond()
46 | # If the button was the equal button
47 | if btn.custom_id == "equ":
48 | try:
49 | # Execute the current calculation query
50 | # Note: Eval is not a safe function, just to say
51 | query += "\n= " + str(eval(query))
52 | # When trying to divide by zero
53 | except ZeroDivisionError:
54 | # Indicate that an error appeared
55 | query = "Cannot divide by zero"
56 | # If the "C" buttont was pressed
57 | elif btn.custom_id == "cls":
58 | # Clear the query
59 | query = ""
60 | # If the button was the delete button
61 | elif btn.custom_id == "backs":
62 | # Remove one character
63 | query = query[:-1]
64 | # else
65 | else:
66 | # Add the label on the button to the query
67 | query += btn.label
68 |
69 | # Show the current query, if the query is empty, send the 'empty' character
70 | await msg.edit(content="```python\n" + (query if query != "" else "\u200b") + "```")
71 | # If the equal button was pessed
72 | if btn.custom_id == "equ":
73 | # clear the query
74 | query = ""
75 | # When 20 seconds passed without input (we set the timeout to 20 seconds)
76 | except asyncio.TimeoutError:
77 | # Delete the calculator
78 | # if you don't want to delete the calculator, just comment the next line out
79 | await msg.delete()
80 | # Break out of the inifite loop
81 | break
82 |
83 | # Start the bot, replace 'bot_token_here' with your bot token
84 | client.run("bot_token_here")
85 |
--------------------------------------------------------------------------------
/docs/source/_static/css/main.css:
--------------------------------------------------------------------------------
1 |
2 | .wy-menu-vertical header, .wy-menu-vertical p.caption {
3 | color: #5865F2;
4 | }
5 |
6 | /* Dark-Mode */
7 | /* .wy-nav-content, .wy-nav-content-wrap {
8 | background-color: #23272A;
9 | color: white;
10 | } */
11 |
12 | .wy-menu-vertical li.toctree-l2 span.toctree-expand {
13 | color: black;
14 | }
15 |
16 | .wy-side-nav-search input[type="text"] {
17 | border-color: #5865F2;
18 | }
19 |
20 | /* Class */
21 | html.writer-html5 .rst-content dl[class]:not(.option-list):not(.field-list):not(.footnote):not(.glossary):not(.simple)>dt:first-child {
22 | color: black;
23 | background-color: #F6F6F6;
24 | border: 3px solid #F6F6F6;
25 | width: 90%;
26 | height: 110%;
27 | border-radius: 4px;
28 | border: 3px solid black;
29 | font-size: 100%;
30 | }
31 |
32 | /* Attribute */
33 | html.writer-html5 .rst-content dl[class]:not(.option-list):not(.field-list):not(.footnote):not(.glossary):not(.simple) dl:not(.field-list):not(.simple)>dt {
34 | width: 90%;
35 | height: 110%;
36 | font-size: 95%;
37 | color: black;
38 | border: 4.5px solid #F6F6F6;
39 | border-radius: 6px;
40 | background-color: #F6F6F6;
41 | font-weight: 700;
42 | font-size: 16px;
43 | }
44 |
45 | html.writer-html5 .rst-content dl[class]:not(.option-list):not(.field-list):not(.footnote):not(.glossary):not(.simple) dd>dl.simple:not(.field-list)>dt {
46 | font-weight: 500;
47 | color: #404040;
48 | }
49 |
50 | html.writer-html5 .rst-content dl[class]:not(.option-list):not(.field-list):not(.footnote):not(.glossary):not(.simple) dl:not(.field-list)>dt {
51 | border: none;
52 | border-left: none;
53 | font-size: 16px;
54 | font-weight: 600;
55 | color: #404040;
56 | background-color: #fcfcfc;
57 | }
58 |
59 | .wy-nav-content a:hover {
60 | color: rgba(88,101,242,.5);
61 | }
62 | .wy-nav-content a, .wy-nav-content a:visited {
63 | color: rgb(88,101,242);
64 | }
65 |
66 | .py.property {
67 | display: block;
68 | }
69 |
70 | .wy-nav-content {
71 | max-width: 100%;
72 | }
73 |
74 | /* Buttons */
75 | .btn.btn-neutral {
76 | border: 1px solid #2C2F33;
77 | border-radius: 5px;
78 | color: white
79 | }
80 |
81 | /* Note box background */
82 | .rst-content .note .admonition-title, .note .admonition-title {
83 | background-color: rgba(88,101,242,.5);
84 | }
85 |
86 | /* Warning box title */
87 | .rst-content .warning .admonition-title, .warning .admonition-title {
88 | background-color: #fee65cad;
89 | }
90 | /* Warning box background */
91 | .rst-content .warning, .warning {
92 | background-color: rgba(254,231,92,.5);
93 | }
94 |
95 |
96 | .admonition-title {
97 | border-top: 1px solid white;
98 | border-radius: 5px;
99 | }
100 |
101 | .admonition {
102 | border: 1px solid white;
103 | border-radius: 10px;
104 | color: black;
105 | }
106 |
107 | code.docutils.literal.notranslate, .highlight, div.highlight-default.notranslate {
108 | background-color: #2C2F33;
109 | border: 2px solid #2C2F33;
110 | border-radius: 5px;
111 | line-height: 1em;
112 | font-weight: 80;
113 | color: white;
114 | }
115 |
116 |
117 | html.writer-html5 .rst-content dl[class]:not(.option-list):not(.field-list):not(.footnote):not(.glossary):not(.simple) .descclassname {
118 | color: white;
119 | }
120 |
121 | html.writer-html5 .rst-content dl[class]:not(.option-list):not(.field-list):not(.footnote):not(.glossary):not(.simple)>dt:first-child {
122 | font-family: SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,Courier,monospace;
123 | color: white;
124 | background-color: #2C2F33;
125 | }
126 |
127 | .wy-nav-shift {
128 | background-color: #2C2F33;
129 | }
130 |
131 | .wy-menu-vertical a:active {
132 | background-color: #5865F2;
133 | }
134 |
135 | /* Navigationsdings */
136 | .wy-side-nav-search, .wy-nav-top {
137 | /* background-color: #5865F2; */
138 | background: #5865F2;
139 | border: 4px solid #5865F2;
140 | border-bottom-left-radius: 6px;
141 | border-bottom-right-radius: 6px;
142 | }
143 |
144 | .toctree-l1.current {
145 | /* border: 0.5px solid white; */
146 | border-radius: 2.5px;
147 | }
148 |
149 |
150 |
151 | /* #region Scrollbar */
152 | /* Breite */
153 | ::-webkit-scrollbar {
154 | width: 8px;
155 | height: 6px;
156 | }
157 |
158 | /* Hintergrund von dem außer dem Scrollding */
159 | ::-webkit-scrollbar-track {
160 | background: #1b1b1b ;
161 | border: 1px solid #1b1b1b;
162 | /* border-radius: 15px; */
163 | }
164 |
165 | /* Farbe von dem Scrollding */
166 | ::-webkit-scrollbar-thumb {
167 | background: #5865F2;
168 | border: 1px solid #5865F2;
169 | border-radius: 15px;
170 | }
171 |
172 | /* Hover Farbe */
173 | ::-webkit-scrollbar-thumb:hover {
174 | background: #5865F2;
175 | }
176 | /* #endregion */
--------------------------------------------------------------------------------
/discord_ui/http.py:
--------------------------------------------------------------------------------
1 | from .errors import WrongType
2 | from .tools import MISSING, components_to_dict, setup_logger
3 |
4 | import discord
5 | from discord.http import Route
6 |
7 | import json
8 | import asyncio
9 | from typing import List
10 |
11 | logging = setup_logger(__name__)
12 |
13 | class BetterRoute(Route):
14 | BASE = "https://discord.com/api/v9"
15 |
16 | async def send_files(route, files, payload, http):
17 | """Sends files"""
18 |
19 | form = []
20 | form.append({'name': 'payload_json', 'value': json.dumps(payload, separators=(',', ':'), ensure_ascii=True)})
21 |
22 | if len(files) == 1:
23 | file = files[0]
24 | form.append({
25 | 'name': 'file',
26 | 'value': file.fp,
27 | 'filename': file.filename,
28 | 'content_type': 'application/octet-stream'
29 | })
30 | else:
31 | for index, file in enumerate(files):
32 | form.append({
33 | 'name': 'file%s' % index,
34 | 'value': file.fp,
35 | 'filename': file.filename,
36 | 'content_type': 'application/octet-stream'
37 | })
38 |
39 | return await http.request(route, form=form, files=files)
40 |
41 | def get_message_payload(content=MISSING, tts=False, embed: discord.Embed=MISSING, embeds: List[discord.Embed]=MISSING, attachments: List[discord.Attachment]=MISSING, nonce: int=MISSING,
42 | allowed_mentions: discord.AllowedMentions=MISSING, reference: discord.MessageReference=MISSING, mention_author: bool=MISSING, components: list=MISSING, stickers: List[discord.Sticker]=MISSING, suppress: bool=MISSING, flags=MISSING):
43 | """Turns parameters from send functions into a payload for requests"""
44 |
45 | payload = {"tts": tts}
46 |
47 | if content is not MISSING:
48 | if content is None:
49 | payload["content"] = ""
50 | else:
51 | payload["content"] = str(content)
52 |
53 | if suppress not in [MISSING, None]:
54 | flags = discord.MessageFlags._from_value(flags or discord.MessageFlags.DEFAULT_VALUE)
55 | flags.suppress_embeds = suppress
56 | payload['flags'] = flags.value
57 |
58 | if nonce not in [MISSING, None]:
59 | payload["nonce"] = nonce
60 |
61 | if embed is not MISSING or embeds is not MISSING:
62 | if embeds is None and embed is None:
63 | embeds = []
64 | # if embed exsists and embeds doesn't exsist
65 | elif embed not in [MISSING, None] and embeds in [MISSING, None]:
66 | embeds = [embed]
67 | # if embed doesn't exsist and embeds does
68 | elif embed in [MISSING, None] and embeds not in [MISSING, None]:
69 | embeds = embed
70 | # check type things
71 | elif embeds and not all(isinstance(x, discord.Embed) for x in embeds):
72 | raise WrongType("embeds", embeds, 'list[discord.Embed]')
73 | payload["embeds"] = [em.to_dict() for em in embeds or []]
74 |
75 | if attachments is not MISSING:
76 | if attachments is None:
77 | payload["attachments"] = []
78 | else:
79 | if not all(isinstance(x, discord.Attachment) for x in attachments):
80 | raise WrongType("attachments", attachments, "List[discord.attachment]")
81 | payload["attachments"] = [x.to_dict() for x in attachments]
82 |
83 | if reference is not MISSING and reference is not None:
84 | if not isinstance(reference, (discord.MessageReference, discord.Message)):
85 | raise WrongType("reference", reference, ['discord.MessageReference', 'discord.Message'])
86 | if isinstance(reference, discord.MessageReference):
87 | payload["message_reference"] = reference.to_dict()
88 | elif isinstance(reference, discord.Message):
89 | payload["message_reference"] = discord.MessageReference.from_message(reference).to_dict()
90 |
91 | if allowed_mentions is not MISSING:
92 | if allowed_mentions is None:
93 | allowed_mentions = discord.AllowedMentions()
94 | else:
95 | if not isinstance(allowed_mentions, discord.AllowedMentions):
96 | raise WrongType("allowed_mentions", allowed_mentions, "discord.AllowedMentions")
97 | payload["allowed_mentions"] = allowed_mentions.to_dict()
98 | if mention_author is not MISSING and mention_author is not None:
99 | allowed_mentions = payload["allowed_mentions"] if "allowed_mentions" in payload else discord.AllowedMentions().to_dict()
100 | allowed_mentions['replied_user'] = mention_author
101 | payload["allowed_mentions"] = allowed_mentions
102 |
103 | if components is not MISSING:
104 | if components is None:
105 | payload["components"] = []
106 | else:
107 | payload["components"] = components_to_dict(components)
108 |
109 | if stickers is not MISSING:
110 | if stickers is None:
111 | payload["stickers"] = []
112 | else:
113 | payload["stickers"] = [s.id for s in stickers]
114 |
115 | return payload
116 |
117 | def handle_rate_limit(data):
118 | logging.warning("You are being rate limited. Retrying after " + str(data["retry_after"]) + " seconds")
119 | return asyncio.sleep(data["retry_after"])
--------------------------------------------------------------------------------
/discord_ui/enums.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | import discord
4 |
5 | import inspect
6 | from enum import IntEnum
7 | from typing import Union
8 |
9 | Channel = Union[
10 | discord.TextChannel,
11 | discord.VoiceChannel,
12 | discord.StageChannel,
13 | discord.StoreChannel,
14 | discord.CategoryChannel,
15 | discord.StageChannel,
16 | ]
17 | """Typing object for all possible channel types, only for type hinting"""
18 |
19 | _Mentionable = Union[
20 | Channel,
21 | discord.Member,
22 | discord.Role
23 | ]
24 | """Typing object for possible returned classes in :class:`~OptionType.Mentionable`, only for type hinting"""
25 | class __Mentionable:
26 | """Empty class for comparing a class to `Mentionable`"""
27 | pass
28 |
29 | Mentionable: _Mentionable = __Mentionable
30 | """Type for a SlashOption with type of :class:`Mentionable`
31 |
32 | Usage:
33 | ~~~~~~
34 |
35 | .. code-block::
36 |
37 | @ui.slash.command(options=[SlashOption(Mentionable, "an_option")])
38 | async def test(ctx, an_option):
39 | ...
40 |
41 | or
42 |
43 | .. code-block::
44 |
45 | @ui.slash.command()
46 | async def test(ctx, an_option: Mentionable): # OptionType of 'an_option' is now Mentionable
47 | ...
48 | """
49 |
50 | class BaseIntEnum(IntEnum):
51 | def __str__(self) -> str:
52 | return self.name
53 |
54 |
55 | class ButtonStyle(BaseIntEnum):
56 | Blurple = Primary = 1
57 | Grey = Secondary = 2
58 | Green = Succes = 3
59 | Red = Destructive = 4
60 | URL = Link = 5
61 |
62 | @classmethod
63 | def getColor(cls, s):
64 | if isinstance(s, int):
65 | return cls(s)
66 | if isinstance(s, cls):
67 | return s
68 | s = s.lower()
69 | if s in ("blurple", "primary"):
70 | return cls.Blurple
71 | if s in ("grey", "gray", "secondary"):
72 | return cls.Grey
73 | if s in ("green", "succes"):
74 | return cls.Green
75 | if s in ("red", "danger"):
76 | return cls.Red
77 |
78 | class CommandType(BaseIntEnum):
79 | Slash = 1
80 | """Chat-input command"""
81 | User = 2
82 | """User-context command"""
83 | Message = 3
84 | """Message-context command"""
85 |
86 | @staticmethod
87 | def from_string(typ):
88 | if isinstance(typ, str):
89 | if typ.lower() == "slash":
90 | return CommandType.Slash
91 | elif typ.lower() == "user":
92 | return CommandType.User
93 | elif typ.lower() == "message":
94 | return CommandType.Message
95 | elif isinstance(typ, CommandType):
96 | return typ
97 | else:
98 | return CommandType(typ)
99 | def __str__(self):
100 | return self.name
101 | class ComponentType(BaseIntEnum):
102 | Action_row = 1
103 | Button = 2
104 | Select = 3
105 | class OptionType(BaseIntEnum):
106 | Subcommand = SUB_COMMAND = 1
107 | Subcommand_group = SUB_COMMAND_GROUP = 2
108 | String = STRING = 3
109 | Integer = INTEGER = 4
110 | """Any integer between -2^53 and 2^53"""
111 | Boolean = bool = BOOLEAN = BOOL = 5
112 | Member = user = MEMBER = USER = 6
113 | Channel = CHANNEL = 7
114 | """Includes all channel types + categories"""
115 | Role = ROLE = 8
116 | Mentionable = MENTIONABLE = 9
117 | """Includes users and roles"""
118 | Float = Number = FLOAT = NUMBER = 10
119 | """Any double between -2^53 and 2^53"""
120 |
121 | @classmethod
122 | def any_to_type(cls, whatever) -> OptionType:
123 | """Converts something to a option type if possible"""
124 | if isinstance(whatever, int) and whatever in range(1, 11):
125 | return whatever
126 | if inspect.isclass(whatever):
127 | if whatever is str:
128 | return cls.String
129 | if whatever is int:
130 | return cls.Integer
131 | if whatever is bool:
132 | return cls.Boolean
133 | if whatever in [discord.User, discord.Member]:
134 | return cls.Member
135 | if whatever in [discord.TextChannel, discord.VoiceChannel, discord.StageChannel, discord.CategoryChannel]:
136 | return cls.Channel
137 | if whatever is discord.Role:
138 | return cls.Role
139 | if whatever is Mentionable:
140 | return cls.Mentionable
141 | if whatever is float:
142 | return cls.Float
143 | if isinstance(whatever, str):
144 | whatever = whatever.lower()
145 | if whatever in ["str", "string", "text", "char[]"]:
146 | return cls.String
147 | if whatever in ["int", "integer", "number"]:
148 | return cls.Integer
149 | if whatever in ["bool", "boolean"]:
150 | return cls.Boolean
151 | if whatever in ["user", "discord.user", "member", "discord.member", "usr", "mbr"]:
152 | return cls.Member
153 | if whatever in ["channel"]:
154 | return cls.Channel
155 | if whatever in ["role", "discord.role"]:
156 | return cls.Role
157 | if whatever in ["mentionable", "mention"]:
158 | return cls.Mentionable
159 | if whatever in ["float", "floating", "floating number", "f"]:
160 | return cls.Float
161 | if isinstance(whatever, list):
162 | ret = cls.Channel
163 | ret.__channel_types__ = whatever
164 | return ret
165 | if isinstance(whatever, range):
166 | if float in [cls.any_to_type(whatever.start), cls.any_to_type(whatever.stop)]:
167 | _type = cls.Float
168 | else:
169 | _type = cls.Integer
170 | _type.__min__, _type.__max__ = whatever.start, whatever.stop
171 | return _type
172 | class Limits:
173 | """Limits for OptionType Parameters"""
174 | class Numeric:
175 | min, max = -2**53, 2**53
176 |
177 | class InteractionResponseType(BaseIntEnum):
178 | Pong = 1
179 | """respond to ping"""
180 | # Ack = 2 # deprecated
181 | # """``deprecated`` acknowledge that message was received"""
182 | # Channel_message = 3 # deprecated
183 | # """``deprecated`` respond with message"""
184 | Channel_message = 4
185 | """
186 | respond with message
187 | `command` | `component`"""
188 | Deferred_channel_message = 5
189 | """
190 | defer interaction
191 | `command | component`"""
192 | Deferred_message_update = 6
193 | """
194 | update message later
195 | `component`"""
196 | Message_update = 7
197 | """
198 | update message for component
199 | `component`"""
200 | Autocomplete_result = 8
201 | """
202 | respond with auto-complete choices
203 | `command`"""
204 |
--------------------------------------------------------------------------------
/discord_ui/tools.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | import functools
4 | import logging
5 | import warnings
6 | from typing import TYPE_CHECKING, Any, Callable, List, TypeVar
7 |
8 | __all__ = (
9 | 'components_to_dict',
10 | )
11 |
12 |
13 | class _All():
14 | def __init__(self) -> None:
15 | pass
16 | def __contains__(self, _):
17 | return True
18 | def __iter__(self):
19 | return iter([True])
20 |
21 | All = _All()
22 |
23 |
24 | class _MISSING:
25 | def __repr__(self) -> str:
26 | return "..."
27 | def __bool__(self) -> bool:
28 | return False
29 | def __eq__(self, o: object) -> bool:
30 | return isinstance(o, _MISSING)
31 | def __ne__(self, o: object) -> bool:
32 | return not self.__eq__(o)
33 | def __str__(self) -> str:
34 | return self.__repr__()
35 | def __sizeof__(self) -> int:
36 | return 0
37 | def __len__(self) -> int:
38 | return 0
39 | def __contains__(self, value):
40 | return False
41 | def get(self, *args):
42 | return self
43 | class _EMPTY_CHECK():
44 | """An empty check that will always return True"""
45 | def __call__(self, *args: Any, **kwds: Any) -> Any:
46 | return True
47 | def __repr__(self) -> str:
48 | return "empty_check"
49 |
50 | MISSING = _MISSING()
51 | EMPTY_CHECK = _EMPTY_CHECK()
52 |
53 | R = TypeVar("R")
54 |
55 | if TYPE_CHECKING:
56 | from typing_extensions import ParamSpec
57 | P = ParamSpec('P')
58 |
59 |
60 | def deprecated(instead=None) -> Callable[[Callable[P, R]], Callable[P, R]]:
61 | def wrapper(callback: Callable[P, R]) -> Callable[P, R]:
62 | @functools.wraps(callback)
63 | def wrapped(*args: P.args, **kwargs: P.kwargs):
64 | if instead is not None:
65 | msg = f"{callback.__name__} is deprecated, use {instead} instead!"
66 | else:
67 | msg = f"{callback.__name__} is deprecated!"
68 | # region warning
69 | # turn filter off
70 | warnings.simplefilter('always', DeprecationWarning)
71 | # warn the user, use stacklevel 2
72 | warnings.warn(msg, stacklevel=2, category=DeprecationWarning)
73 | # turn filter on again
74 | warnings.simplefilter('default', DeprecationWarning)
75 | # endregion
76 |
77 | return callback(*args, **kwargs)
78 | return wrapped
79 | return wrapper
80 |
81 |
82 | def _raise(ex):
83 | """Method to raise an exception in a context where the normal `raise` context cant be used"""
84 | raise ex
85 | def _none(*args, empty_array=False):
86 | return all(x in [None, MISSING] + [[], [[]]][empty_array is True] for x in args)
87 | def _or(*args, default=None):
88 | for i, _ in enumerate(args):
89 | if not _none(args[i]):
90 | return args[i]
91 | return default
92 | def _default(default, *args, empty_array=True):
93 | if _none(*args, empty_array=empty_array):
94 | return default
95 | if len(args) == 1:
96 | return args[0]
97 | return args
98 | def try_get(value, index, default):
99 | try:
100 | return value[index]
101 | except (IndexError, TypeError):
102 | return default
103 |
104 | def setattribute(object, attribute, value):
105 | setattr(object, attribute, value)
106 | return object
107 |
108 | def get_index(l: list, elem: Any, mapping = lambda x: x, default: int = -1) -> int:
109 | """Returns the index of an element in the list
110 |
111 | Parameters
112 | ----------
113 | l: :class:`List`
114 | The list to get from
115 | elem: :class:`Any`
116 | The element that has to be found
117 | mapping: :class:`function`
118 | A function that will be applied to the current element that is checked, before comparing it to the target
119 | default: :class:`Any`
120 | The element that will be returned if nothing is found; default -1
121 |
122 | Returns
123 | -------
124 | :class:`Any`
125 | The found element
126 |
127 |
128 | Example:
129 | ```py
130 | any_list = [(1, 2), (2, 3), (3, 4)]
131 | get(any_list, 4, lambda x: x[1])
132 | ```
133 | """
134 | i = 0
135 | for x in l:
136 | if mapping(x) == elem:
137 | return i
138 | i += 1
139 | return default
140 |
141 | def get(l: list, elem: Any = True, mapping = lambda x: True, default: Any = None, check=lambda x: True):
142 | """Gets a element from a list
143 |
144 | Parameters
145 | ----------
146 | l: :class:`List`
147 | The list to get from
148 | elem: :class:`Any`
149 | The element that has to be found
150 | mapping: :class:`function`
151 | A function that will be applied to the current element that is checked, before comparing it to the target
152 | default: :class:`Any`
153 | The element that will be returned if nothing is found; default None
154 |
155 | Returns
156 | -------
157 | :class:`Any`
158 | The found element
159 |
160 |
161 | Example:
162 | ```py
163 | any_list = [(1, 2), (2, 3), (3, 4)]
164 | get(any_list, 4, lambda x: x[1])
165 | ```
166 | """
167 | for x in l:
168 | if mapping(x) == elem and check(x) is True:
169 | return x
170 | return default
171 |
172 | def iterable(o):
173 | try:
174 | iter(o)
175 | return True
176 | except TypeError:
177 | return False
178 |
179 | def components_to_dict(components) -> List[dict]:
180 | """Converts a list of components to a dict that can be used for other extensions
181 |
182 | Parameters
183 | ----------
184 | components: :class:`list`
185 | A list of components that should be converted.
186 |
187 | Example
188 |
189 | .. code-block::
190 |
191 | components_to_dict([Button(...), [LinkButton(...), Button(...)]])
192 |
193 | Raises
194 | ------
195 | :class:`Exception`
196 | Invalid data was passed
197 |
198 | Returns
199 | -------
200 | List[:class:`dict`]
201 | The converted data
202 |
203 |
204 | """
205 | wrappers: List[List[Any]] = []
206 | component_list = []
207 |
208 | if len(components) > 1:
209 | curWrapper = []
210 | i = 0 #
211 | for component in components:
212 | # if its a subarray
213 | if hasattr(component, "items") or isinstance(component, (list, tuple)):
214 | # if this isnt the first line
215 | if i > 0 and len(curWrapper) > 0:
216 | wrappers.append(curWrapper)
217 | curWrapper = []
218 |
219 | # ActionRow was used
220 | if hasattr(component, "items"):
221 | wrappers.append(component.items)
222 | # ComponentStore was used
223 | elif hasattr(component, "_components"):
224 | wrappers.append(component._components)
225 | else:
226 | # just comepletely append the components to all rappers
227 | wrappers.append(component)
228 | continue
229 |
230 | # i > 0 => Preventing empty component field when first button wants to newLine
231 | if component.component_type == 3:
232 | if i > 0:
233 | wrappers.append(curWrapper)
234 | curWrapper = []
235 | wrappers.append(component)
236 | elif component.component_type == 2:
237 | if component.new_line and i > 0:
238 | wrappers.append(curWrapper)
239 | curWrapper = [component]
240 | else:
241 | curWrapper.append(component)
242 | i += 1
243 | if len(curWrapper) > 0:
244 | wrappers.append(curWrapper)
245 | else:
246 | wrappers = [components]
247 |
248 | for wrap in wrappers:
249 | if isinstance(wrap, list) and not all(hasattr(x, "to_dict") for x in wrap):
250 | raise Exception("Components with types [" + ', '.join([str(type(x)) for x in wrap]) + "] are missing to_dict() method")
251 | component_list.append({"type": 1, "components": [x.to_dict() for x in wrap] if iterable(wrap) else [wrap.to_dict()]})
252 | return component_list
253 |
254 | def setup_logger(name):
255 | """
256 | Thx redstone ;)
257 | https://github.com/RedstoneZockt/rotstein-dc-py/blob/main/rotstein_py/logging.py
258 | """
259 | level = logging.ERROR
260 |
261 | logger = logging.getLogger(name)
262 | logger.setLevel(level)
263 |
264 | stream_handler = logging.StreamHandler()
265 | formatter = logging.Formatter(f"%(levelname)s: %(message)s")
266 | stream_handler.setFormatter(formatter)
267 | stream_handler.setLevel(level)
268 |
269 | logger.addHandler(stream_handler)
270 | return logger
--------------------------------------------------------------------------------
/discord_ui/slash/ext/command_decorators.py:
--------------------------------------------------------------------------------
1 | """
2 | discord_ui.ext.command_decorator
3 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
4 |
5 | Important: Every decorator should be placed before the actual slashcommand decorator,
6 | except check auto-response decorators, they have to be placed after the target check decorator.
7 |
8 | Note: **Theoretically**, these decorators could be used together with normal cog commands and
9 | not only the slash cog commands, but if you use them for the normal commands, the ``hidden`` param
10 | doesn't work
11 | """
12 |
13 | import inspect
14 | import functools
15 | from typing import TypeVar, Any, List
16 |
17 | from discord.ext import commands
18 |
19 |
20 | __all__ = (
21 | 'auto_guild',
22 | 'check_failed',
23 | 'any_failure_response',
24 | 'alias',
25 | 'no_sync',
26 | 'auto_defer',
27 | )
28 |
29 | f = TypeVar("f")
30 |
31 | class _auto_guild_sentinel():
32 | def __init__(self):
33 | self.guild_ids: List[int] = []
34 | """The guild_ids that should be used when decorating a command with this class"""
35 | def __call__(self, m):
36 | if inspect.isfunction(m):
37 | m.__guild_ids__ = self.guild_ids
38 | else:
39 | m.guild_ids = self.guild_ids
40 | return m
41 |
42 | auto_guild: _auto_guild_sentinel = _auto_guild_sentinel()
43 | """Decorator for setting guild_ids to application-commands.
44 | This decorator has to be placed before the actual command decorator
45 |
46 | Usage
47 | -----
48 |
49 | .. code-block::
50 |
51 | @ui.slash.command(...)
52 | @ext.auto_guild
53 | async def my_command(ctx, ...):
54 | ...
55 | """
56 |
57 | def check_failed(content=None, hidden=False, **fields):
58 | """A decorator for autoresponding to a cog check that failed.
59 |
60 | The decorator has to be placed after the check deceorator
61 |
62 | Parameters
63 | ----------
64 | content: :class:`str`
65 | The raw message content
66 | tts: :class:`bool`
67 | Whether the message should be send with text-to-speech
68 | embed: :class:`discord.Embed`
69 | Embed rich content
70 | embeds: List[:class:`discord.Embed`]
71 | A list of embeds for the message
72 | file: :class:`discord.File`
73 | The file which will be attached to the message
74 | files: List[:class:`discord.File`]
75 | A list of files which will be attached to the message
76 | nonce: :class:`int`
77 | The nonce to use for sending this message
78 | allowed_mentions: :class:`discord.AllowedMentions`
79 | Controls the mentions being processed in this message
80 | mention_author: :class:`bool`
81 | Whether the author should be mentioned
82 | components: List[:class:`~Button` | :class:`~LinkButton` | :class:`~SelectMenu`]
83 | A list of message components to be included
84 | delete_after: :class:`float`
85 | After how many seconds the message should be deleted, only works for non-hiddend messages
86 | hidden: :class:`bool`
87 | Whether the response should be visible only to the user
88 |
89 |
90 | Example
91 | -------
92 |
93 | .. code-block::
94 |
95 | # first check
96 | @ext.check_failed("command is guild only", hidden=True)
97 | @commands.guild_only()
98 | # second check
99 | @ext.check_failed("command can only used in nsfw channels", hidden=True)
100 | @commands.is_nsfw()
101 | @cogs.slash_command(guild_ids=[867699578034716683])
102 | async def callback(self, ctx):
103 | ...
104 | """
105 | def wrapper(cog):
106 | # get last check
107 | check = cog.checks[-1]
108 | _invoke = cog.invoke
109 |
110 | async def invoke(ctx, *args, **kwargs):
111 | try:
112 | if not check(ctx):
113 | await ctx.send(content, **fields, hidden=hidden)
114 | return
115 | except commands.CheckFailure:
116 | await ctx.send(content, **fields, hidden=hidden)
117 | raise
118 | await _invoke(ctx, *args, **kwargs)
119 |
120 | cog.invoke = invoke
121 | return cog
122 | return wrapper
123 |
124 | def any_failure_response(content, hidden=False, **fields):
125 | """Decorator for autoresponding to all checks of a cog command that failed.
126 |
127 | Note if you use the `check_failure_response` for a check, its response will be sent
128 |
129 | Parameters
130 | ----------
131 | content: :class:`str`
132 | The raw message content
133 | tts: :class:`bool`
134 | Whether the message should be send with text-to-speech
135 | embed: :class:`discord.Embed`
136 | Embed rich content
137 | embeds: List[:class:`discord.Embed`]
138 | A list of embeds for the message
139 | file: :class:`discord.File`
140 | The file which will be attached to the message
141 | files: List[:class:`discord.File`]
142 | A list of files which will be attached to the message
143 | nonce: :class:`int`
144 | The nonce to use for sending this message
145 | allowed_mentions: :class:`discord.AllowedMentions`
146 | Controls the mentions being processed in this message
147 | mention_author: :class:`bool`
148 | Whether the author should be mentioned
149 | components: List[:class:`~Button` | :class:`~LinkButton` | :class:`~SelectMenu`]
150 | A list of message components to be included
151 | delete_after: :class:`float`
152 | After how many seconds the message should be deleted, only works for non-hiddend messages
153 | hidden: :class:`bool`
154 | Whether the response should be visible only to the user
155 |
156 |
157 | """
158 | def wrapper(cog):
159 | _invoke = cog.invoke
160 |
161 | async def invoke(ctx, *args, **kwargs):
162 | try:
163 | if not await cog.can_run(ctx):
164 | await ctx.send(content, hidden=hidden, **fields)
165 | return
166 | except commands.CheckFailure:
167 | await ctx.send(content, hidden=hidden, **fields)
168 | raise
169 | await _invoke(ctx, *args, **kwargs)
170 |
171 | cog.invoke = invoke
172 | return cog
173 | return wrapper
174 |
175 | def alias(aliases):
176 | """Decorator for slashcommand aliases that will add the same command but with different names.
177 |
178 | Parameters
179 | ----------
180 | aliases: List[:class:`str`] | :class:`str`
181 | The alias(es) for the command with wich the command can be invoked with
182 |
183 | Usage:
184 |
185 | .. code-block::
186 |
187 | @ui.slash.command(name="cats", ...)
188 | @ui.slash.alias(["catz", "cute_things"])
189 |
190 | """
191 | def wrapper(command):
192 | if not hasattr(command, "__aliases__") or command.__aliases__ is None:
193 | command.__aliases__ = []
194 | # Allow multiple alias decorators
195 | command.__aliases__.extend(aliases if not isinstance(aliases, str) else [aliases])
196 | return command
197 | return wrapper
198 |
199 | def no_sync():
200 | """Decorator that will prevent an application-command to be synced with the api.
201 |
202 | Example
203 | -------
204 |
205 | .. code-block::
206 |
207 | from discord_ui import ext
208 |
209 | @ui.slash.command()
210 | @ext.no_sync()
211 | async def no_sync(ctx):
212 | \"\"\"This command will never be synced with the api\"\"\"
213 | ...
214 |
215 | """
216 | def wrapper(callback):
217 | callback.__sync__ = False
218 | return callback
219 | return wrapper
220 |
221 | def auto_defer(hidden=False):
222 | """A decorator for auto deferring a interaction. This decorator has to be placed before the main decorator
223 |
224 | Parameters
225 | ----------
226 | hidden: :class:`bool`, optional
227 | Whether the interaction should be deferred hidden; default ``False``
228 |
229 | Example
230 | --------
231 |
232 | .. code-block::
233 |
234 | from discord_ui import ext
235 |
236 | @ui.slash.command()
237 | @ext.auto_defer()
238 | async def my_command(ctx):
239 | \"\"\"This command will be deferred automatically\"\"\"
240 | ...
241 |
242 | """
243 | # https://stackoverflow.com/questions/69076152/how-to-inject-a-line-of-code-into-an-existing-function#answers-header
244 | def decorator(func):
245 | func.__auto_defer__ = True
246 | @functools.wraps(func)
247 | async def wrapper(*args, **kwargs):
248 | # if there is self param use the next one
249 | ctx = args[1 if list(inspect.signature(func).parameters.keys())[0] == "self" else 0]
250 | # use defer for "auto_defering"
251 | await ctx.defer(hidden=hidden)
252 | return await func(*args, **kwargs)
253 | return wrapper
254 | return decorator
--------------------------------------------------------------------------------
/discord_ui/slash/ext/builder.py:
--------------------------------------------------------------------------------
1 | from ..http import SlashHTTP
2 | from ...enums import CommandType, OptionType
3 | from ...tools import try_get
4 | from ..types import (
5 | SlashCommand, SlashOption, SlashOptionCollection,
6 | SlashPermission, SlashSubcommand, format_name
7 | )
8 |
9 | import inspect
10 | from typing import List, Dict
11 |
12 | __all__ = (
13 | 'SlashBuilder',
14 | )
15 |
16 | class Subcommand(SlashSubcommand):
17 | __slots__ = SlashSubcommand.__slots__ + ("__group__", "build",)
18 | def __init__(self, callback, name=None, description=None, options=None) -> None:
19 | super().__init__(callback, base_names=[], name=name, description=description, options=options)
20 | self.__group__ = getattr(callback, "__group__", None)
21 | self.build: SlashBuilder = None
22 | @property
23 | def group_name(self) -> str:
24 | """The name of the parent group"""
25 | return try_get(self.__group__, 0, None)
26 | @property
27 | def group_description(self) -> str:
28 | """The description of the parent group"""
29 | return try_get(self.__group__, 1, "\u200b")
30 | @property
31 | def has_group(self) -> bool:
32 | """Whether this command has a parent group"""
33 | return self.__group__ != None
34 | def to_super_dict(self):
35 | base = super().to_dict()
36 | if self.has_group:
37 | return SlashOption(OptionType.SUB_COMMAND_GROUP, self.group_name, self.group_description, options=[base]).to_dict()
38 | return base
39 | async def invoke(self, ctx, *args, **kwargs):
40 | if self.build != None:
41 | return await self.callback(self.build, ctx, *args, **kwargs)
42 | return await self.callback(ctx, *args, **kwargs)
43 | async def update_id(self):
44 | id = await super().update_id()
45 | if self.build != None:
46 | self.build._id = id
47 | return id
48 |
49 | class SlashBuilder():
50 | """A Superclass for building custom Slashcommands
51 |
52 |
53 | Usage
54 | ------
55 |
56 | .. code-block::
57 |
58 | class MyCommand(SlashBuilder):
59 | def __init__(self):
60 | super().__init__()
61 | self.name = "base_name"
62 | self.description = "This is the base"
63 |
64 | @SlashBuilder.subcommand("sub")
65 | async def my_sub(self, ctx):
66 | ...
67 | """
68 | def __init__(self, name=None, description=None, guild_ids=None, guild_permissions=None, default_permission=True) -> None:
69 | self.__sync__ = True
70 | self.__aliases__ = []
71 | self.__auto_defer__ = None
72 | self.__choice_generators__ = {}
73 | self.command_type = CommandType.Slash
74 |
75 | self._id = None # set later
76 | self._name = None
77 | self._http: SlashHTTP = None # set later
78 | self.name = name
79 |
80 |
81 | self.description: str = description
82 | """The description of the base slashcommand"""
83 | self.guild_ids: List[int] = guild_ids
84 | """The guild ids in which the command is available.
85 | If no guild ids are provided, this command will be a global slashcommand"""
86 | self.guild_permissions: Dict[int, SlashPermission] = guild_permissions
87 | """The permisions for different guilds"""
88 | self.default_permission: bool = default_permission
89 | """Whether this command can be used by default or not"""
90 | self.permissions = SlashPermission()
91 | """The current permissions"""
92 | self._options = SlashOptionCollection()
93 |
94 | def __init_subclass__(cls) -> None:
95 | cls.command_type = CommandType.Slash
96 |
97 | async def invoke(self, *args, **kwargs):
98 | await self.callback(*args, **kwargs)
99 |
100 | @property
101 | def options(self) -> SlashOptionCollection:
102 | """The options for this slashcommand"""
103 | return self._options
104 | @options.setter
105 | def options(self, value):
106 | self._options = SlashOptionCollection(value)
107 | async def _fetch_id(self):
108 | return await SlashCommand._fetch_id(self)
109 | async def update_id(self):
110 | return await SlashCommand.update_id(self)
111 |
112 | @property
113 | def id(self) -> int:
114 | """The ID of thte slashcommand"""
115 | return self._id
116 | @property
117 | def name(self):
118 | """The name of the base slashcommamnd"""
119 | return self._name
120 | @name.setter
121 | def name(self, value):
122 | self._name = format_name(value)
123 |
124 | def get_subcommands(self) -> List[Subcommand]:
125 | return [x[1] for x in inspect.getmembers(self, predicate=lambda x: isinstance(x, Subcommand))]
126 | def has_groups(self):
127 | return all(x.has_group for x in self.get_subcommands())
128 | def has_subs(self):
129 | return len(self.get_subcommands()) > 0
130 | def _subs_to_dict(self):
131 | _commands: List[Subcommand] = [
132 | (x.group_name, x) for x in
133 | [x[1] for x in inspect.getmembers(self, predicate=lambda x: isinstance(x, Subcommand))]
134 | ]
135 | if not all(x[1].has_group for x in _commands):
136 | return [x[1].to_super_dict() for x in _commands]
137 | commands = {}
138 | for x in _commands:
139 | if x[0] in commands:
140 | commands[x[0]]["options"] += x[1].to_supper_dict()["options"]
141 | continue
142 | commands[x[0]] = x[1].to_super_dict()
143 | return list(commands.values())
144 |
145 | def to_dict(self):
146 | return {
147 | "name": self.name,
148 | "description": self.description,
149 | "default_permission": self.default_permission,
150 | "options": self.options.to_dict() if not self.has_subs() else self._subs_to_dict()
151 | }
152 |
153 | def register(self, slash: SlashCommand):
154 | if self.has_subs():
155 | if self.has_groups():
156 | for x in self.get_subcommands():
157 | setattr(getattr(self, f"{x.callback.__name__}"), "build", self)
158 | x.base_names = [self.name] + [x.group_name or ()]
159 | if slash.subcommands.get(self.name) == None:
160 | slash.subcommands[self.name] = {}
161 | if slash.subcommands[self.name].get(x.group_name) == None:
162 | slash.subcommands[self.name][x.group_name] = {}
163 | slash.subcommands[self.name][x.group_name][x.name] = x
164 | else:
165 | for x in self.get_subcommands():
166 | setattr(getattr(self, f"{x.callback.__name__}"), "build", self)
167 | x.base_names = [self.name]
168 | if slash.subcommands.get(self.name) == None:
169 | slash.subcommands[self.name] = {}
170 | slash.subcommands[self.name][x.name] = x
171 | else:
172 | slash.commands[self.name] = self
173 | @property
174 | def guild_only(self) -> bool:
175 | """Whether this command is only available in some guilds or is a global command"""
176 | return SlashCommand.guild_only.getter(self)
177 |
178 | @staticmethod
179 | def subcommand(name=None, description=None, options=None):
180 | """Decorator to a callback for a subcommand
181 |
182 | Parameters
183 | ----------
184 | name: :class:`str`, optional
185 | The name for the subcommand; default None
186 | description: :class:`str`, optional
187 | The description for the subcommand; default None
188 | options: List[:class:`SlashOption`], optional
189 | Options for the subcoommand; default None
190 |
191 | Usage
192 | ------
193 |
194 | .. code-block::
195 |
196 | @SlashBuilder.subcommand("subcommand", "This is a subcoommand", [SlashOption(...)])
197 | async def sub_callback(self, ctx, ...):
198 | ...
199 | """
200 | def wrapper(callback) -> Subcommand:
201 | return Subcommand(callback, name, description, options)
202 | return wrapper
203 | @staticmethod
204 | def group(name, description=None):
205 | """Decorator for a subcommand group
206 |
207 | Parameters
208 | ----------
209 | name: :class:`str`
210 | The name of the group
211 | description: :class:`str`, optional
212 | The description of the group; default None
213 |
214 |
215 | Usage
216 | -----
217 |
218 | .. code-block::
219 |
220 | @SlashBuilder.group("group_name")
221 | @SlashBuilder.subcommand(...)
222 | async def sub_callback(self, ctx, ...):
223 | ...
224 |
225 | """
226 | def wrapper(callback):
227 | callback.__group__ = (format_name(name), description,)
228 | return callback
229 | return wrapper
--------------------------------------------------------------------------------
/discord_ui/slash/ext.py:
--------------------------------------------------------------------------------
1 | """
2 | discord_ui.ext
3 | ~~~~~~~~~~~~~~~
4 |
5 | An extension module to the libary that has some usefull decorators and functions
6 | for application-commands.
7 |
8 | .. code-block::
9 |
10 | from discord_ui import ext
11 |
12 | - - -
13 |
14 | Important: Every decorator should be placed before the actual slashcommand decorator,
15 | except check auto-response decorators, they have to be placed after the target check decorator.
16 |
17 | Note: **Theoretically**, these decorators could be used together with normal cog commands and
18 | not only the slash cog commands, but if you use them for the normal commands, the ``hidden`` param
19 | doesn't work
20 | """
21 |
22 | import functools
23 | import inspect
24 |
25 | from discord.ext.commands import errors
26 |
27 |
28 | def check_failure_response(content=None, hidden=False, **fields):
29 | """A decorator for autoresponding to a cog check that failed.
30 |
31 | The decorator has to be placed after the check deceorator
32 |
33 | Parameters
34 | ----------
35 | content: :class:`str`
36 | The raw message content
37 | tts: :class:`bool`
38 | Whether the message should be send with text-to-speech
39 | embed: :class:`discord.Embed`
40 | Embed rich content
41 | embeds: List[:class:`discord.Embed`]
42 | A list of embeds for the message
43 | file: :class:`discord.File`
44 | The file which will be attached to the message
45 | files: List[:class:`discord.File`]
46 | A list of files which will be attached to the message
47 | nonce: :class:`int`
48 | The nonce to use for sending this message
49 | allowed_mentions: :class:`discord.AllowedMentions`
50 | Controls the mentions being processed in this message
51 | mention_author: :class:`bool`
52 | Whether the author should be mentioned
53 | components: List[:class:`~Button` | :class:`~LinkButton` | :class:`~SelectMenu`]
54 | A list of message components to be included
55 | delete_after: :class:`float`
56 | After how many seconds the message should be deleted, only works for non-hiddend messages
57 | hidden: :class:`bool`
58 | Whether the response should be visible only to the user
59 |
60 |
61 | Example
62 | -------
63 |
64 | .. code-block::
65 |
66 | # first check
67 | @ext.check_failure_response("command is guild only", hidden=True)
68 | @commands.guild_only()
69 | # second check
70 | @ext.check_failure_response("command can only used in nsfw channels", hidden=True)
71 | @commands.is_nsfw()
72 | @cogs.slash_cog(guild_ids=[867699578034716683])
73 | async def callback(self, ctx):
74 | ...
75 | """
76 | def wraper(cog):
77 | # get last check
78 | check = cog.checks[-1]
79 | _invoke = cog.invoke
80 |
81 | async def invoke(ctx, *args, **kwargs):
82 | try:
83 | if not check(ctx):
84 | await ctx.send(content, **fields, hidden=hidden)
85 | return
86 | except errors.CheckFailure:
87 | await ctx.send(content, **fields, hidden=hidden)
88 | raise
89 | await _invoke(ctx, *args, **kwargs)
90 |
91 | cog.invoke = invoke
92 | return cog
93 | return wraper
94 |
95 | def any_failure_response(content, hidden=False, **fields):
96 | """Decorator for autoresponding to all checks of a cog command that failed.
97 |
98 | Note if you use the `check_failure_response` for a check, its response will be sent
99 |
100 | Parameters
101 | ----------
102 | content: :class:`str`
103 | The raw message content
104 | tts: :class:`bool`
105 | Whether the message should be send with text-to-speech
106 | embed: :class:`discord.Embed`
107 | Embed rich content
108 | embeds: List[:class:`discord.Embed`]
109 | A list of embeds for the message
110 | file: :class:`discord.File`
111 | The file which will be attached to the message
112 | files: List[:class:`discord.File`]
113 | A list of files which will be attached to the message
114 | nonce: :class:`int`
115 | The nonce to use for sending this message
116 | allowed_mentions: :class:`discord.AllowedMentions`
117 | Controls the mentions being processed in this message
118 | mention_author: :class:`bool`
119 | Whether the author should be mentioned
120 | components: List[:class:`~Button` | :class:`~LinkButton` | :class:`~SelectMenu`]
121 | A list of message components to be included
122 | delete_after: :class:`float`
123 | After how many seconds the message should be deleted, only works for non-hiddend messages
124 | hidden: :class:`bool`
125 | Whether the response should be visible only to the user
126 |
127 |
128 | """
129 | def wraper(cog):
130 | _invoke = cog.invoke
131 |
132 | async def invoke(ctx, *args, **kwargs):
133 | try:
134 | if not await cog.can_run(ctx):
135 | await ctx.send(content, hidden=hidden, **fields)
136 | return
137 | except errors.CheckFailure:
138 | await ctx.send(content, hidden=hidden, **fields)
139 | raise
140 | await _invoke(ctx, *args, **kwargs)
141 |
142 | cog.invoke = invoke
143 | return cog
144 | return wraper
145 |
146 | def guild_change(guild_id, *, name=None, description=None, default_permission=True):
147 | """A decorator for slashcommands that will apply changes to a specific guild
148 |
149 | Note that this decorator should mainly be used for guild commands, because if used with
150 | a global command, both commands will show up, the changed one and the global one.
151 |
152 | Parameters
153 | ----------
154 | guild_id: :class:`int` | :class:`str`
155 | The guild_id where the changes should be applied to
156 | name: :class:`str`, optional
157 | The new name; default None
158 | description: :class:`str`, optional
159 | The new description; default None
160 | default_permission: :class:`bool` | :class:`discord.Permissions`, optional
161 | Permissions that a user needs to have in order to execute the command, default ``True``.
162 | If a bool was passed, it will indicate whether all users can use the command (``True``) or not (``False``)
163 |
164 | """
165 | def wraper(callback):
166 | if not hasattr(callback, "__guild_changes__"):
167 | callback.__guild_changes__ = {}
168 | callback.__guild_changes__[str(guild_id)] = (name, description, default_permission)
169 | return callback
170 | return wraper
171 |
172 | def alias(aliases):
173 | """Decorator for slashcommand aliases that will add the same command but with different names.
174 |
175 | Parameters
176 | ----------
177 | aliases: List[:class:`str`] | :class:`str`
178 | The alias(es) for the command with wich the command can be invoked with
179 |
180 | Usage:
181 |
182 | .. code-block::
183 |
184 | @ui.slash.command(name="cats", ...)
185 | @ui.slash.alias(["catz", "cute_things"])
186 |
187 | """
188 | def wraper(command):
189 | if not hasattr(command, "__aliases__"):
190 | command.__aliases__ = []
191 | # Allow multiple alias decorators
192 | command.__aliases__.extend(aliases if not isinstance(aliases, str) else [aliases])
193 | return command
194 | return wraper
195 |
196 | def no_sync():
197 | """Decorator that will prevent an application-command to be synced with the api.
198 |
199 | Example
200 | -------
201 |
202 | .. code-block::
203 |
204 | from discord_ui import ext
205 |
206 | @ui.slash.command()
207 | @ext.no_sync()
208 | async def no_sync(ctx):
209 | \"\"\"This command will never be synced with the api\"\"\"
210 | ...
211 |
212 | """
213 | def wraper(callback):
214 | callback.__sync__ = False
215 | return callback
216 | return wraper
217 |
218 | def auto_defer(hidden=False):
219 | """A decorator for auto deferring a interaction. This decorator has to be placed before the main decorator
220 |
221 | Parameters
222 | ----------
223 | hidden: :class:`bool`, optional
224 | Whether the interaction should be deferred hidden; default ``False``
225 |
226 | Example
227 | --------
228 |
229 | .. code-block::
230 |
231 | from discord_ui import ext
232 |
233 | @ui.slash.command()
234 | @ext.auto_defer()
235 | async def my_command(ctx):
236 | \"\"\"This command will be deferred automatically\"\"\"
237 | ...
238 |
239 | """
240 | # https://stackoverflow.com/questions/69076152/how-to-inject-a-line-of-code-into-an-existing-function#answers-header
241 | def decorator(func):
242 | func.__auto_defer__ = True
243 | @functools.wraps(func)
244 | async def wraper(*args, **kwargs):
245 | # if there is self param use the next one
246 | ctx = args[1 if list(inspect.signature(func).parameters.keys())[0] == "self" else 0]
247 | # use defer for "auto_defering"
248 | await ctx.defer(hidden=hidden)
249 | return await func(*args, **kwargs)
250 | return wraper
251 | return decorator
--------------------------------------------------------------------------------
/discord_ui/slash/http.py:
--------------------------------------------------------------------------------
1 | from typing import List
2 | from .errors import NoCommandFound
3 | from ..tools import get, setup_logger
4 | from ..http import BetterRoute, handle_rate_limit, send_files
5 |
6 | from discord.http import HTTPClient
7 | from discord.state import ConnectionState
8 | from discord import NotFound, HTTPException, Forbidden
9 |
10 |
11 | import aiohttp
12 |
13 | logging = setup_logger(__name__)
14 |
15 | class SlashHTTP():
16 | def __init__(self, client) -> None:
17 | self._http: HTTPClient = client.http
18 | self.token: str = client._connection.http.token
19 | self.application_id: int = client.user.id
20 | async def respond_to(self, interaction_id, interaction_token, response_type, data=None, files=None):
21 | route = BetterRoute("POST", f'/interactions/{interaction_id}/{interaction_token}/callback')
22 | payload = {
23 | "type": getattr(response_type, "value", response_type)
24 | }
25 | if data:
26 | payload["data"] = data
27 | if files is not None:
28 | return await send_files(route, files, payload, self._http)
29 | return await self._http.request(route, json=payload)
30 | async def bulk_overwrite_global_commands(self, data: list) -> List[dict]:
31 | try:
32 | return await self._http.request(BetterRoute('PUT', f'/applications/{self.application_id}/commands'), json=data)
33 | except HTTPException as ex:
34 | if ex.code == 429:
35 | await handle_rate_limit(await ex.response.json())
36 | return await self.bulk_overwrite_global_commands(data)
37 | async def bulk_overwrite_guild_commands(self, guild_id, data: list):
38 | try:
39 | return await self._http.request(BetterRoute('PUT', f'/applications/{self.application_id}/guilds/{guild_id}/commands'), json=data)
40 | except HTTPException as ex:
41 | if ex.code == 429:
42 | await handle_rate_limit(await ex.response.json())
43 | return await self.bulk_overwrite_guild_commands(guild_id, data)
44 |
45 |
46 | async def fetch_command(self, id, guild_id=None):
47 | try:
48 | if guild_id:
49 | return await self._http.request(BetterRoute("GET",f"/applications/{self.application_id}/guilds/{guild_id}/commands/{id}"))
50 | return await self._http.request(BetterRoute("GET", f"/applications/{self.application_id}/commands/{id}"))
51 | except HTTPException as ex:
52 | if ex.status == 429:
53 | await handle_rate_limit(await ex.response.json())
54 | return await self.fetch_command(id, guild_id)
55 | raise ex
56 | async def get_command(self, command_name, guild_id=None, type=None):
57 | return get(
58 | (
59 | await self.get_global_commands()
60 | ) if guild_id is None else (
61 | await self.get_guild_commands(guild_id)
62 | ), check=lambda x: x.get("name") == command_name and (type or x.get("type")) == x.get("type")
63 | )
64 | async def get_id(self, command_name, guild_id=None, type=None):
65 | found = await self.get_command(command_name, guild_id, getattr(type, "value", type))
66 | if found is None:
67 | raise NoCommandFound("No command found with name '" + command_name + "'")
68 | return found.get('id')
69 |
70 | async def delete_global_commands(self):
71 | try:
72 | await self._http.request(
73 | BetterRoute('PUT', f'/applications/{self.application_id}/commands'), json=[]
74 | )
75 | except HTTPException as ex:
76 | if ex.status == 429:
77 | await handle_rate_limit(await ex.response.json())
78 | return await self.delete_global_commands()
79 | async def delete_guild_commands(self, guild_id):
80 | try:
81 | await self._http.request(
82 | BetterRoute('PUT', f'/applications/{self.application_id}/guilds/{guild_id}/commands'), json=[]
83 | )
84 | except HTTPException as ex:
85 | if ex.status == 429:
86 | await handle_rate_limit(await ex.response.json())
87 | return await self.delete_guild_commands(guild_id)
88 |
89 | async def delete_global_command(self, command_id):
90 | try:
91 | return await self._http.request(BetterRoute("DELETE", f"/applications/{self.application_id}/commands/{command_id}"))
92 | except HTTPException as ex:
93 | if ex.status == 429:
94 | await handle_rate_limit(await ex.response.json())
95 | return await self.delete_global_command(command_id)
96 | raise ex
97 | async def delete_guild_command(self, command_id, guild_id):
98 | try:
99 | return await self._http.request(BetterRoute("DELETE", f"/applications/{self.application_id}/guilds/{guild_id}/commands/{command_id}"))
100 | except HTTPException as ex:
101 | if ex.status == 429:
102 | await handle_rate_limit(await ex.response.json())
103 | return await self.delete_guild_command(command_id, guild_id)
104 | raise ex
105 |
106 | async def get_command_permissions(self, command_id, guild_id):
107 | try:
108 | return await self._http.request(BetterRoute("GET", f"/applications/{self.application_id}/guilds/{guild_id}/commands/{command_id}/permissions"))
109 | except NotFound:
110 | return {"id": command_id, "application_id": self.application_id, "permissions": []}
111 | except HTTPException as ex:
112 | if ex.status == 429:
113 | await handle_rate_limit(await ex.response.json())
114 | return await self.get_command_permissions(command_id, guild_id)
115 | else:
116 | raise ex
117 | async def update_command_permissions(self, guild_id, command_id, permissions):
118 | async with aiohttp.ClientSession() as client:
119 | async with client.put(f"https://discord.com/api/v9/applications/{self.application_id}/guilds/{guild_id}/commands/{command_id}/permissions",
120 | headers={"Authorization": "Bot " + self.token}, json={"permissions": permissions}) as response:
121 | if response.status == 200:
122 | return await response.json()
123 | elif response.status == 429:
124 | data = await handle_rate_limit(await response.json())
125 | await self.update_command_permissions(guild_id, command_id, permissions)
126 | return data
127 | raise HTTPException(response, response.content)
128 |
129 | async def create_global_command(self, command: dict):
130 | try:
131 | return await self._http.request(BetterRoute("POST", f"/applications/{self.application_id}/commands"), json=command)
132 | except HTTPException as ex:
133 | if ex.status == 429:
134 | await handle_rate_limit(await ex.response.json())
135 | return await self.create_global_command(command)
136 | raise ex
137 | async def create_guild_command(self, command, guild_id, permissions = []):
138 | try:
139 | data = await self._http.request(BetterRoute("POST", f"/applications/{self.application_id}/guilds/{guild_id}/commands"), json=command)
140 | await self.update_command_permissions(guild_id, data["id"], permissions)
141 | return data
142 | except HTTPException as ex:
143 | if ex.status == 429:
144 | await handle_rate_limit(await ex.response.json())
145 | return await self.create_guild_command(command, guild_id, permissions)
146 | raise ex
147 |
148 |
149 | async def edit_global_command(self, command_id: str, new_command: dict):
150 | try:
151 | return await self._http.request(BetterRoute("PATCH", f"/applications/{self.application_id}/commands/{command_id}"), json=new_command)
152 | except HTTPException as ex:
153 | if ex.status == 429:
154 | await handle_rate_limit(await ex.response.json())
155 | return await self.edit_global_command(command_id, new_command)
156 | raise ex
157 | async def edit_guild_command(self, command_id, guild_id: str, new_command: dict, permissions: dict=None):
158 | try:
159 | data = await self._http.request(BetterRoute("PATCH", f"/applications/{self.application_id}/guilds/{guild_id}/commands/{command_id}"), json=new_command)
160 | if permissions is not None:
161 | return await self.update_command_permissions(guild_id, data["id"], permissions)
162 | except HTTPException as ex:
163 | if ex.status == 429:
164 | await handle_rate_limit(await ex.response.json())
165 | return await self.edit_guild_command(command_id, guild_id, new_command, permissions)
166 | raise ex
167 |
168 | async def get_global_commands(self):
169 | try:
170 | return await self._http.request(BetterRoute("GET", f"/applications/{self.application_id}/commands"))
171 | except HTTPException as ex:
172 | if ex.status == 429:
173 | await handle_rate_limit(await ex.response.json())
174 | return await self.get_global_commands()
175 | raise ex
176 | async def get_guild_commands(self, guild_id):
177 | try:
178 | return await self._http.request(BetterRoute("GET", f"/applications/{self.application_id}/guilds/{guild_id}/commands"))
179 | except HTTPException as ex:
180 | if ex.status == 429:
181 | await handle_rate_limit(await ex.response.json())
182 | return await self.get_guild_commands(guild_id)
183 | if ex.status == 403:
184 | logging.warning("got forbidden in " + str(guild_id))
185 | return []
186 | raise ex
187 |
188 | # just for typing
189 | class ModifiedSlashState(ConnectionState):
190 | slash_http: SlashHTTP = None
191 | http: HTTPClient
192 |
--------------------------------------------------------------------------------
/discord_ui/slash/tools.py:
--------------------------------------------------------------------------------
1 | from .types import OptionType
2 | from ..tools import get, setup_logger
3 | from ..errors import CouldNotParse
4 | from ..enums import Channel, Mentionable
5 |
6 | import discord
7 |
8 | import typing
9 |
10 | logging = setup_logger(__name__)
11 |
12 | __all__ = (
13 | 'ParseMethod',
14 | 'create_choice',
15 | )
16 |
17 | class AdditionalType:
18 | MESSAGE = 44
19 | GUILD = 45
20 |
21 | class ParseMethod:
22 | """Methods of how the interaction argument data should be treated
23 |
24 | - ``RAW`` [0]
25 | Returns the raw value which was received
26 | - ``RESOLVE`` [1]
27 | Uses the resolved data which will be delivered together with the received interaction
28 | - ``FETCH`` [2]
29 | Fetches all the ids of the received data with an api call
30 | - ``CACHE`` [3]
31 | Uses the internal bot cache to get the data
32 |
33 | .. warning::
34 |
35 | The cache method uses the `.get_guild`, `.get_channel`... methods, which needs to have some intents enabled
36 | (`more information `__)
37 |
38 | - ``AUTO`` [4]
39 | This will try all methods beginning (RESOLVE, FETCH, CACHE, RAW) and changes to the next method whenever an exception occurs
40 | """
41 |
42 | RAW = Raw = 0
43 | RESOLVE = Resolve = 1
44 | FETCH = Fetch = 2
45 | CACHE = Cache = 3
46 | AUTO = Auto = 4
47 |
48 | def format_name(value):
49 | return str(value).lower().replace(" ", "-")
50 |
51 | def resolve(data, _state):
52 | resolved = {}
53 | for x in data["data"]["resolved"]:
54 | if x == "members":
55 | resolved["members"] = {}
56 | for m_id in data["data"]["resolved"]["members"]:
57 | member_data = data["data"]["resolved"]["members"][m_id]
58 | member_data["user"] = data["data"]["resolved"]["users"][m_id]
59 | resolved["members"][m_id] = discord.Member(data=member_data, guild=_state._get_guild(int(data["guild_id"])), state=_state)
60 | elif x == "messages":
61 | resolved["messages"] = {}
62 | for message_id in data["data"]["resolved"]["messages"]:
63 | message_data = data["data"]["resolved"]["messages"][message_id]
64 | resolved["messages"][message_id] = discord.Message(data=message_data, channel=_state.get_channel(data["channel_id"]), state=_state)
65 | elif x == "channels":
66 | resolved["channels"] = {}
67 | for channel_id in data["data"]["resolved"]["channels"]:
68 | channel_data = data["data"]["resolved"]["channels"][channel_id]
69 |
70 | guild = _state._get_guild(data["guild_id"])
71 | channel = None
72 | if discord.enums.ChannelType(channel_data["type"]) is discord.enums.ChannelType.text:
73 | channel = discord.TextChannel(data=channel_data, guild=guild, state=_state)
74 | elif discord.enums.ChannelType(channel_data["type"]) is discord.enums.ChannelType.voice:
75 | channel = discord.VoiceChannel(data=channel_data, guild=guild, state=_state)
76 | elif discord.enums.ChannelType(channel_data["type"]) is discord.enums.ChannelType.category:
77 | channel = discord.CategoryChannel(data=channel_data, guild=guild, state=_state)
78 | elif discord.enums.ChannelType(channel_data["type"]) is discord.enums.ChannelType.group:
79 | channel = discord.GroupChannel(data=channel_data, guild=guild, state=_state)
80 | elif discord.enums.ChannelType(channel_data["type"]) is discord.enums.ChannelType.news:
81 | channel = discord.NewsChannel(data=channel_data, guild=guild, state=_state)
82 | elif discord.enums.ChannelType(channel_data["type"]) is discord.enums.ChannelType.private:
83 | channel = discord.DMChannel(data=channel_data, guild=guild, state=_state)
84 | elif discord.enums.ChannelType(channel_data["type"]) is discord.enums.ChannelType.store:
85 | channel = discord.StoreChannel(data=channel_data, guild=guild, state=_state)
86 | elif discord.enums.ChannelType(channel_data["type"]) is discord.enums.ChannelType.stage_voice:
87 | channel = discord.StageChannel(data=channel_data, guild=guild, state=_state)
88 | resolved["channels"][channel_id] = channel
89 | elif x == "roles":
90 | resolved["roles"] = {}
91 | for role_id in data["data"]["resolved"]["roles"]:
92 | role_data = data["data"]["resolved"]["roles"][role_id]
93 | resolved["roles"][role_id] = discord.Role(data=role_data, guild=_state._get_guild(data["guild_id"]), state=_state)
94 | elif x == "users":
95 | pass
96 | else:
97 | logging.warning("Could not resolve data of type '" + str(x) + "'")
98 |
99 | return resolved
100 |
101 | async def fetch_data(value, typ, data, _discord):
102 | logging.debug("fetching something with type " + str(typ) + " value " + str(value))
103 | if typ == OptionType.MEMBER:
104 | return await (await _discord.fetch_guild(int(data["guild_id"]))).fetch_member(int(value))
105 | elif typ == OptionType.CHANNEL:
106 | return await _discord.fetch_channel(int(value))
107 | elif typ == OptionType.ROLE:
108 | return get(await (await _discord.fetch_guild(int(data["guild_id"]))).fetch_roles(), check=lambda x: x.id == int(value))
109 | elif typ == AdditionalType.MESSAGE:
110 | return await (await _discord.fetch_channel(int(data["channel_id"]))).fetch_message(int(value))
111 | else:
112 | return value
113 |
114 | def resolve_data(value, typ, data, state):
115 | resolved = resolve(data, state)
116 | logging.debug("resolving something with type " + str(typ) + " value " + str(value))
117 | if typ == OptionType.MEMBER:
118 | return resolved["members"].get(value)
119 | elif typ == OptionType.ROLE:
120 | return resolved["roles"].get(value)
121 | elif typ == OptionType.CHANNEL:
122 | return resolved["channels"].get(value)
123 | elif typ == OptionType.MENTIONABLE:
124 | return list(resolved.values())[0].get(value)
125 | elif typ == AdditionalType.MESSAGE:
126 | return resolved["messages"].get(value)
127 | else:
128 | return value
129 |
130 | def cache_data(value, typ, data, _state):
131 | logging.debug("getting something out of the cache with type " + str(typ) + " value " + str(value))
132 | if typ in [OptionType.STRING, OptionType.INTEGER, OptionType.BOOLEAN, OptionType.FLOAT]:
133 | return value
134 | elif typ == OptionType.MEMBER:
135 | return _state._get_guild(int(data["guild_id"])).get_member(int(value))
136 | elif typ == OptionType.CHANNEL:
137 | return _state.get_channel(int(value))
138 | elif typ == OptionType.ROLE:
139 | return _state._get_guild(int(data["guild_id"])).get_role(int(value))
140 | elif typ == AdditionalType.MESSAGE:
141 | return _state._get_guild(int(data["guild_id"])).get_partial_message(int(value))
142 | elif typ == AdditionalType.GUILD:
143 | return _state._get_guild(int(value))
144 | else:
145 | return value
146 |
147 | async def handle_options(data, options, method, _discord: discord.Client):
148 | _options = {}
149 | for op in options:
150 | if op["type"] not in [OptionType.SUB_COMMAND, OptionType.SUB_COMMAND_GROUP]:
151 | parsed = await handle_thing(op["value"], op["type"], data, method, _discord)
152 | logging.debug("value in handle_options is " + str(op["value"]) + " with type " + str(op["type"]) + " and name is " + str(op["name"]) + " parsed " + str(parsed))
153 |
154 | if parsed is None:
155 | raise CouldNotParse(op["value"], op["type"], method)
156 | _options[op["name"]] = parsed
157 | return _options
158 |
159 | async def handle_thing(value, typ, data, method, _discord, auto=False) -> typing.Union[str, int, bool, discord.Member, Channel, discord.Role, float, Mentionable, discord.Message, discord.Guild]:
160 | logging.debug("Trying to handle val " + str(value) + " type " + str(typ) + " with method " + str(method) + " auto is" + str(auto))
161 | typ = int(typ)
162 | if method is ParseMethod.RESOLVE or method is ParseMethod.AUTO:
163 | try:
164 | return resolve_data(value, typ, data, _discord._connection)
165 | except Exception as ex:
166 | logging.warning("Got exepction while resolving data" +
167 | f"\n{type(ex).__name__}: {ex}\n" +
168 | f"{__file__}:{ex.__traceback__.tb_lineno}" +
169 | ("\nTrying next method" if method is ParseMethod.AUTO else "")
170 | )
171 | if method is ParseMethod.AUTO:
172 | return await handle_thing(value, typ, data, ParseMethod.FETCH, _discord, True)
173 | elif method is ParseMethod.FETCH:
174 | try:
175 | return await fetch_data(value, typ, data, _discord)
176 | except Exception as ex:
177 | logging.warning("Got exepction while getting data from cache" +
178 | f"\n{type(ex).__name__}: {ex}\n" +
179 | f"{__file__}:{ex.__traceback__.tb_lineno}" +
180 | ("\nTrying next method" if method is ParseMethod.AUTO else "")
181 | )
182 | if auto is True:
183 | return await handle_thing(value, typ, data, ParseMethod.CACHE, _discord, auto)
184 | elif method is ParseMethod.CACHE:
185 | try:
186 | return cache_data(value, typ, data, _discord._connection)
187 | except Exception as ex:
188 | logging.warning("Got exepction while resolving data" +
189 | f"\n{type(ex).__name__}: {ex}\n" +
190 | f"{__file__}:{ex.__traceback__.tb_lineno}" +
191 | ("\nTrying next method" if method is ParseMethod.AUTO else "")
192 | )
193 | if auto:
194 | return await handle_thing(value, typ, data, ParseMethod.RAW, _discord, auto)
195 | elif method is ParseMethod.RAW:
196 | return value
197 | else:
198 | logging.warning("Unkonw parsemethod: " + str(method) + "\nReturning raw value")
199 | return value
200 |
201 | def create_choice(name, value) -> dict:
202 | """A function that will create a choice for a :class:`~SlashOption`
203 |
204 | Parameters
205 | ----------
206 | name: :class:`str`
207 | The name of the choice
208 | value: :class:`Any`
209 | The value that will be received when the user selected this choice
210 |
211 | Returns
212 | -------
213 | :class:`dict`
214 | The created choice
215 |
216 | """
217 | return {"name": name, "value": value}
--------------------------------------------------------------------------------
/discord_ui/listener.py:
--------------------------------------------------------------------------------
1 | """
2 | discord_ui.listeners
3 | =====================
4 |
5 | A module for listeners that are going to be attached to a message.
6 | You could think of it like a cog but for message components.
7 |
8 |
9 | .. code-block::
10 |
11 | from discord_ui import Listener
12 |
13 |
14 | - - -
15 |
16 | Usage
17 | ======
18 |
19 | To use the ``Listener`` class, you need to create a subclass of it
20 |
21 | Example
22 |
23 | .. code-block::
24 |
25 | class MyListener(Listener):
26 | ...
27 |
28 | Listeners
29 | ---------
30 |
31 | To add a button listener, you need to use the ``Listener.button`` deorator
32 |
33 | .. code-block::
34 |
35 | class MyListener(Listener):
36 | ...
37 |
38 | @Listener.button("custom_id here")
39 | async def somebutton(self, ctx):
40 | ...
41 |
42 | This will add a button listener that will wait for a button with the custom id "custom_id here"
43 |
44 |
45 | To add a select listener, you need to use the ``Listener.select`` decoratorr
46 |
47 | .. code-block::
48 |
49 | class MyListener(Listener):
50 | ...
51 |
52 | @Listener.select("my_id")
53 | async def someselect(self, ctx):
54 | ...
55 |
56 | This will add a select menu listener that will wait for a select menu with the custom id "my_id"
57 |
58 | You can filter the select callback with the ``values`` parameter
59 |
60 | .. code-block::
61 |
62 | class MyListener(Listener):
63 | ...
64 |
65 | @Listener.select("my_id", values=["2"])
66 | async def someselect(self, ctx):
67 | ...
68 |
69 | The callback will now be only called if the selected values of a select menu equal to ``values``
70 |
71 | There are some more useful things you can use
72 |
73 |
74 | Class
75 | ------
76 |
77 | You can add a timeout to the listener after which the listener will be removed from the message
78 |
79 | .. code-block::
80 |
81 | class MyListener(Listener):
82 | def __init__(self):
83 | self.timeout = 20 # 20 seconds timeout
84 |
85 | If you **set** timeout to ``None``, the listener will never timeout
86 |
87 | You can also add a list of target users to the listener
88 |
89 | .. code-block::
90 |
91 | class MyListener(Listener):
92 | def __init__(self):
93 | self.target_users = [a, list, of, users]
94 |
95 | And last but not least, you can supress the `discord_ui.listener.NoListenerFound` errors when no
96 | listener could be found
97 |
98 | .. code-block::
99 |
100 | class MyListener(Listener):
101 | def __init__(self):
102 | self.supress_no_listener_found = True
103 |
104 |
105 | Sending
106 | --------
107 |
108 | To send components and add the listener, you can use five different ways
109 |
110 | First method:
111 |
112 | .. code-block::
113 |
114 | @bot.listen()
115 | async def on_message(message)
116 | class MyListener(Listener):
117 | def __init__(self):
118 | pass
119 | @Listener.button("test")
120 | async def test(self, ctx):
121 | ...
122 |
123 | await message.channel.send("showcase", components=[Button("this is a showcase", "test")], listener=MyListener())
124 |
125 | Second method:
126 |
127 | .. code-block::
128 |
129 | @bot.listen()
130 | async def on_message(message)
131 | class MyListener(Listener):
132 | def __init__(self):
133 | self.components = [Button("this is a showcase", "test")]
134 | @Listener.button("test")
135 | async def test(self, ctx):
136 | ...
137 |
138 | # MyListener.components will be used for the components in the message
139 | await message.channel.send("showcase", listener=MyListener())
140 |
141 | Third method:
142 |
143 | .. code-block::
144 |
145 | @bot.listen()
146 | async def on_message(message)
147 | class MyListener(Listener):
148 | def __init__(self):
149 | ...
150 |
151 | @Listener.button("test")
152 | async def test(self, ctx):
153 | ...
154 |
155 | msg = await message.channel.send("showcase", components=[Button("this is a showcase", "test")])
156 | msg.attach_listener(MyListener())
157 |
158 | Fourth method:
159 |
160 | .. code-block::
161 |
162 | ui = discord_ui.UI(bot)
163 |
164 | @bot.listen()
165 | async def on_message(message)
166 | class MyListener(Listener):
167 | def __init__(self):
168 | pass
169 | @Listener.button("test")
170 | async def test(self, ctx):
171 | ...
172 |
173 | msg = await message.channel.send("showcase", components=[Button("this is a showcase", "test")])
174 | ui.components.attach_listener_to(msg, MyListener())
175 |
176 | And the last method:
177 |
178 | .. code-block::
179 |
180 | ui = discord_ui.UI(bot)
181 |
182 | @bot.listen()
183 | async def on_message(message)
184 | class MyListener(Listener):
185 | def __init__(self):
186 | pass
187 | @Listener.button("test")
188 | async def test(self, ctx):
189 | ...
190 |
191 | msg = await message.channel.send("showcase", components=[Button("this is a showcase", "test")])
192 | MyListener().attach_me_to(msg)
193 | """
194 |
195 | from .tools import setup_logger
196 | from .receive import ComponentContext, Message, ButtonInteraction, SelectInteraction
197 | from .components import Button, ComponentType, LinkButton, SelectMenu
198 |
199 | import discord
200 | from discord.ext.commands import CheckFailure
201 |
202 | import asyncio
203 | from inspect import getmembers
204 | from typing import Dict, List, Union, Callable, Coroutine
205 |
206 | __all__ = (
207 | 'Listener',
208 | )
209 |
210 | logging = setup_logger(__name__)
211 |
212 | class AnyID:
213 | pass
214 |
215 | class _Listener():
216 | def __init__(self, callback, custom_id, component_type, values=None) -> None:
217 | self.callback = callback
218 | self.custom_id = custom_id or AnyID
219 | self.type = component_type
220 | self.target_values = [str(v) for v in values] if values is not None else None
221 |
222 | self.__commands_checks__ = []
223 | if hasattr(self.callback, "__command_checks__"):
224 | self.__commands_checks__ = self.callback.__commands_checks__
225 |
226 | async def __call__(self, *args, **kwargs):
227 | return await self.invoke(*args, **kwargs)
228 | def add_check(self, check):
229 | self.__commands_checks__.append(check)
230 | def remove_check(self, check):
231 | self.__commands_checks__.remove(check)
232 | @property
233 | def checks(self):
234 | return self.__commands_checks__
235 | async def can_run(self, ctx):
236 | """Whether the command can be run"""
237 | predicates = self.checks
238 | if not predicates:
239 | # since we have no checks, then we just return True.
240 | return True
241 | return await discord.utils.async_all(predicate(ctx) for predicate in predicates)
242 |
243 | async def invoke(self, ctx, listener):
244 | if not await self.can_run(ctx):
245 | raise CheckFailure()
246 | await self.callback(listener, ctx)
247 |
248 | class NoListenerFound(Exception):
249 | """Exception that will be thrown when no matching listener was found"""
250 | def __init__(self, msg=None, *args: object) -> None:
251 | super().__init__(msg or "Could not find a matching listener", *args)
252 | class WrongUser(Exception):
253 | def __init__(self, msg=None, *args: object) -> None:
254 | super().__init__(msg or "Wrong user used component", *args)
255 |
256 |
257 | class Listener():
258 | """A class for a listener attached to a message that will receive components of it.
259 | To use this class you have to create a subclass inhering this class
260 |
261 |
262 | Example
263 | -------
264 |
265 | .. code-block::
266 |
267 | class MyListener(Listener)
268 | ...
269 |
270 | Parameters
271 | -----------
272 | timeout: :class:`float`, optional
273 | A timeout after how many seconds the listener shouold be deleted.
274 | If ``None``, the listener will never timeout
275 | target_users: List[:class:`discord.Member` | :class:`discord.User` | :class:`int` | :class:`str`]
276 | A list of users or user ids from which the interactions has to be be received.
277 | Every interaction by other users will be ignored
278 | """
279 | def __init__(self, timeout=180.0, target_users=None) -> None:
280 | self._target_users = []
281 | self.timeout: float = timeout
282 | """Timeout after how many seconds the listener should timeout and be deleted"""
283 | self.target_users = target_users
284 | self.components: List[Union[List[Button, LinkButton, SelectMenu], Button, LinkButton, SelectMenu]] = []
285 | """The components that are going to be send together with the listener"""
286 | self.message: Message = None
287 | """The target message"""
288 | self.supress_no_listener_found: bool = False
289 | """Whether `discord_ui.listener.NoListenerFound` should be supressed and not get thrown
290 | when no target component listener could be found"""
291 |
292 | def __init_subclass__(cls) -> None:
293 | cls.__listeners__ = []
294 | cls.timeout = 180.0
295 | cls._target_users = None
296 | cls.supress_no_listener_found = False
297 | cls._on_error = {x[1].__exception_cls__: x[1] for x in getmembers(cls, predicate=lambda x: getattr(x, "__on_error__", False))}
298 | cls._wrong_user = next(iter([x[1] for x in getmembers(cls, predicate=lambda x: getattr(x, "__wrong_user__", False))]), None)
299 |
300 |
301 | @property
302 | def target_users(self) -> List[int]:
303 | """A list of user ids from which the interaction has to come"""
304 | return self._target_users
305 | @target_users.setter
306 | def target_users(self, value):
307 | if value != None:
308 | self._target_users = [int(getattr(x, 'id', x)) for x in value]
309 | else:
310 | self._target_users = None
311 |
312 | @staticmethod
313 | def button(custom_id=None):
314 | """A decorator that will setup a callback for a button
315 |
316 | Parameters
317 | ----------
318 | custom_id: :class:`str`, optional
319 | The custom id of the target button. If no custom_id is passed, the name of the callback will be used for the custom id.
320 | Note that you can't have two callbacks with the same function name
321 |
322 | Example
323 | --------
324 |
325 | .. code-block::
326 |
327 | class MyListener(Listener):
328 | def __init__(self, ...):
329 | ...
330 |
331 | @Listener.button("my_id")
332 | async def callback(self, ctx):
333 | ...
334 |
335 | """
336 | def wrapper(callback):
337 | return _Listener(callback, custom_id, ComponentType.Button)
338 | return wrapper
339 | @staticmethod
340 | def select(custom_id=None, values=None):
341 | """A decorator that will set a callback up for a select menu
342 |
343 | Parameters
344 | ----------
345 | custom_id: :class:`str`, optional
346 | The custom id of the target menu. If no id specified, the name of the callback will be used.
347 | Note that you can't have two callbacks with the same function name
348 | values: List[:class:`str`], optional
349 | What values must be selected in order to invoke the callback; default None
350 |
351 | Example
352 | --------
353 |
354 | .. code-block::
355 |
356 | class MyListener(Listener):
357 | def __init__(self, ...)
358 | ...
359 |
360 | @Listener.select("my_id")
361 | async def callback(self, ctx):
362 | ...
363 |
364 | # This callback will be only called if the selected values of the menu are '2'
365 | @Listener.select("my_id", values=['2'])
366 | async def othere_callback(self, ctx):
367 | ...
368 | """
369 | def wrapper(callback):
370 | return _Listener(callback, custom_id or callback.__name__, ComponentType.Select, values)
371 | return wrapper
372 |
373 | @staticmethod
374 | def on_error(exception_cls=BaseException):
375 | """Decorator for a function that will handle exceptions
376 |
377 | Parameters
378 | ----------
379 | exception_cls: :class:`class`, optional
380 | The type of the exception that should be handled; default Exception
381 |
382 | Example
383 | -------
384 |
385 | .. code-block::
386 |
387 | from discord.ext.commands import CheckFailure
388 |
389 | class MyListener(Listener):
390 | ...
391 | # exception handler for checkfailures
392 | @Listener.on_error(CheckFailure)
393 | async def check_failure(self, ctx, exception):
394 | await ctx.send("check failed: " + str(exception), hidden=True)
395 | @Listener.on_error(Exception)
396 | async def exception(self, ctx, exception):
397 | await ctx.send("base exception occured " + str(exception))
398 |
399 | """
400 | def wrapper(callback: Callable[[Listener, Union[ButtonInteraction, SelectInteraction], Exception], Coroutine[None, None, None]]):
401 | callback.__on_error__ = True
402 | callback.__exception_cls__ = exception_cls
403 | return callback
404 | return wrapper
405 | @staticmethod
406 | def wrong_user():
407 | """Decorator for a function that will be called when a user that is not in `.target_users` tried
408 | to use a component
409 |
410 | Example
411 | -------
412 |
413 | .. code-block::
414 |
415 | class MyListener(Listener):
416 | def __init__(self):
417 | self.components = [Button()]
418 | self.target_users = [785567635802816595]
419 | @Listener.button()
420 | async def a_button(self, ctx):
421 | await ctx.send("you are allowed")
422 | @Listener.wrong_user()
423 | async def you_wrong(self, ctx):
424 | await ctx.send("this component is not for you")
425 |
426 |
427 | """
428 | def wrapper(callback):
429 | callback.__wrong_user__ = True
430 | return callback
431 | return wrapper
432 |
433 | async def _call_listeners(self, interaction_component):
434 | listeners = self._get_listeners_for(interaction_component)
435 | if len(listeners) > 0:
436 | for listener in listeners:
437 | if self._target_users is not None and not interaction_component.author.id in self._target_users:
438 | if self._wrong_user is not None:
439 | await self._wrong_user(interaction_component)
440 | raise WrongUser()
441 | try:
442 | await listener.invoke(interaction_component, self)
443 | except tuple([x for x in self._on_error]) as ex:
444 | handler = self._on_error.get(next(iter([x for x in self._on_error if isinstance(ex, x)]), None))
445 | if handler is not None:
446 | await handler(self, interaction_component, ex)
447 | else:
448 | raise ex
449 | elif not self.supress_no_listener_found:
450 | raise NoListenerFound()
451 | def _get_listeners(self) -> Dict[str, List[_Listener]]:
452 | all_listeners = [x[1] for x in getmembers(self, predicate=lambda x: isinstance(x, _Listener))]
453 | listeners = {}
454 | for lister in all_listeners:
455 | # prevent NoneType has no attribute 'append'
456 | if not listeners.get(lister.custom_id):
457 | listeners[lister.custom_id] = []
458 | listeners[lister.custom_id].append(lister)
459 | return listeners
460 | def _get_listeners_for(self, interaction_component: ButtonInteraction) -> List[_Listener]:
461 | listeners = self._get_listeners()
462 | listers = listeners.get(AnyID, []) # fill list with any_id listeners directly
463 | for listener in listeners.get(interaction_component.custom_id, []):
464 | if listener.type == interaction_component.component.component_type:
465 | if listener.target_values is not None:
466 | if sorted(interaction_component.data["values"]) == sorted(listener.target_values):
467 | # if all(v in interaction_component.data["values"] for v in listener.target_values):
468 | listers.append(listener)
469 | else:
470 | listers.append(listener)
471 | return listers
472 |
473 | def to_components(self):
474 | return self.components
475 |
476 | def _stop(self):
477 | del self._state._component_listeners[self._target_message_id]
478 | logging.debug("deleted listener")
479 | def _start(self, message):
480 | self.message = message
481 | self._state: discord.state.ConnectionState = message._state
482 | self._target_message_id = str(self.message.id)
483 | self._state._component_listeners[self._target_message_id] = self
484 |
485 | loop = asyncio.get_event_loop()
486 | # call deletion function later
487 | if getattr(self, 'timeout', None) is not None:
488 | loop.call_later(self.timeout, self._stop)
489 |
490 | def attach_me_to(self, message):
491 | """Attaches this listener to a message after it was sent
492 |
493 | Parameters
494 | ----------
495 | message: :class:`~Message`
496 | The target message
497 |
498 | """
499 | self._start(message)
500 | async def put_me_to(self, message):
501 | """Attaches this listener to a message and edits it if the message is missing components
502 |
503 | Parameters
504 | ----------
505 | message: :class:`~Message`
506 | The target message
507 |
508 | """
509 | if len(message.components) == 0:
510 | await message.edit(components=self.to_components())
511 | self.attach_me_to(message)
--------------------------------------------------------------------------------
/docs/source/usage.rst:
--------------------------------------------------------------------------------
1 | =================
2 | Usage
3 | =================
4 |
5 |
6 |
7 | Get started
8 | =====================
9 |
10 | At first, you need to import the discord.py package and this package
11 |
12 | .. code-block::
13 |
14 | import discord
15 | from discord.ext import commands
16 | from discord_ui import Components
17 |
18 |
19 | Create a new discord client
20 |
21 | .. code-block::
22 |
23 | client = commands.Bot(" ")
24 |
25 | .. warning::
26 |
27 | Note that the discord client has to be of type :class:`discord.ext.commands.Bot`, or else it won't work properly
28 |
29 | Then you need to create a new :class:`~UI` instance, with which you can use message components and slash commands
30 |
31 | .. code-block::
32 |
33 | ui = UI(client)
34 |
35 | .. important::
36 |
37 | If you initalize the UI instance, you can choose if you want to override some of discord.py's default functions or not.
38 | In this tutorial, we will show both methods, one with the overriden methods and one with the not overriden methots.
39 | The only difference will be that if you want to send components in a webhook, etc., you can use the ``.send`` method,
40 | because it is replaced with our own custom method. If you choose not to override dpy, you need to use ``ui.components.send``
41 | or ``ui.components.send_webhook`` instead.
42 |
43 |
44 | Message-components
45 | =====================
46 |
47 | Sending
48 | ~~~~~~~~~~~~~~~~~~~~~~
49 |
50 | To send a component, we need to acces our :class:`~Components` class with ``ui.components`` and use the ``.send()`` function of it
51 |
52 | In this example, we'll wait for a message with the content "!test"
53 |
54 | .. code-block::
55 |
56 | @client.listen()
57 | async def on_message(message):
58 | if message.content == "!test":
59 | ...
60 |
61 | Now we will send a component to the text channel where the *"!test"* message came from
62 |
63 | Let's say we want to send two buttons and a select menu
64 |
65 | We need to import them at first. For that, we need to go back to the beginning, where we imported the module
66 |
67 | .. code-block::
68 |
69 | import discord
70 | from discord.ext import commands
71 | from discord_ui import Components, Button, SelectMenu, SelectOption
72 |
73 | And to send them, we use
74 |
75 |
76 | **Default**
77 |
78 | .. code-block::
79 |
80 | ...
81 | await ui.components.send(message.channel, "Hello World", components=[
82 | Button("press me", "my_custom_id", "green"),
83 | Button("or press me!", "my_other_custom_id", emoji="😁", new_line=True),
84 | SelectMenu(options=[
85 | SelectOption("choose me", 1),
86 | SelectOption("or me", 2),
87 | SelectOption("or me", 3)
88 | ], "another_custom_id", placeholder="Select something")
89 | ])
90 |
91 | **Overriden**
92 |
93 | .. code-block::
94 |
95 | ...
96 | await message.channel.send(message.channel, "Hello World", components=[
97 | Button("press me", "my_custom_id", "green"),
98 | Button("or press me!", "my_other_custom_id", emoji="😁", new_line=True),
99 | SelectMenu(options=[
100 | SelectOption("choose me", 1),
101 | SelectOption("or me", 2),
102 | SelectOption("or me", 3)
103 | ], "another_custom_id", placeholder="Select something")
104 | ])
105 |
106 | The message
107 |
108 | .. image:: images/components/hello_world_all_components.png
109 | :width: 1000
110 |
111 | The select menu
112 |
113 | .. image:: images/components/hello_world_all_components_select_menu.png
114 | :width: 1000
115 |
116 | .. note::
117 |
118 | Instead of using `new_line=True`, you can either put all components you want to have in one line into a list
119 |
120 | .. code-block::
121 |
122 | components=[[Button(...), Button(...)], LinkButton(...)]
123 |
124 |
125 | or put them into an :class:`~ActionRow`
126 |
127 | .. code-block::
128 |
129 | components=[ActionRow(Button(...), Button(...)), LinkButton(...)]
130 |
131 |
132 | Now that we sent some components, how do we receive them?
133 |
134 | Receiving
135 | ~~~~~~~~~~~~~~~
136 |
137 | To receive a button press or a selection, we can listen to the ``button`` and the ``select`` events
138 |
139 |
140 | **Button**
141 |
142 | .. code-block::
143 |
144 | @client.listen('on_button')
145 | async def on_button(btn):
146 | # respond
147 | await btn.respond("you clicked on " + btn.component.content)
148 |
149 | .. image:: images/components/press_button_example.gif
150 | :width: 600
151 |
152 |
153 | To get the user who pressed the button, you use ``btn.author``.
154 | If you want to acces the message on which the button is, you use ``btn.messsage``.
155 |
156 | **Select menu**
157 |
158 | .. code-block::
159 |
160 | @client.listen('on_select')
161 | async def on_menu(menu):
162 | # respond
163 | await menu.respond("you selected " + ', '.join([value.content for value in menu.selected_options]))
164 |
165 | .. image:: images/components/select_menu_example.gif
166 | :width: 600
167 |
168 | To get the user who selected a value, you use ``menu.author``.
169 | To get the value(s) selected by the user, you need to acces ``menu.selected_values``
170 |
171 |
172 | .. code-block::
173 |
174 | async def component_callback(component):
175 | await component.respond("yo")
176 |
177 | where the ``component`` parameter the pressed button or the selected menu
178 |
179 |
180 | Easier ways
181 | ~~~~~~~~~~~~
182 |
183 | But there are some more ways to receive and respond to them
184 |
185 | You can send a message and directly wait for a button press and respond to it
186 |
187 |
188 | **Default**
189 |
190 | .. code-block::
191 |
192 | @client.listen()
193 | async def on_message(message):
194 | if message.content == "!test":
195 | btn = await (
196 | await ui.components.send(message.channel, "hello", components=[
197 | Button("there", "custom_id")
198 | ])
199 | ).wait_for("button", client)
200 | await btn.respond("you pressed a button")
201 |
202 | **Overriden**
203 |
204 | .. code-block::
205 |
206 | @client.listen()
207 | async def on_message(message):
208 | if message.content == "!test":
209 | btn = await (
210 | await message.channel.send(message.channel, "hello", components=[
211 | Button("there", "custom_id")
212 | ])
213 | ).wait_for("button", client)
214 | await btn.respond("you pressed a button")
215 |
216 |
217 |
218 | And we got listening components with a function that will always be executed if a component with a special custom_id was pressed
219 |
220 | .. code-block::
221 |
222 | @ui.components.listening_component(custom_id="listening")
223 | async def listening_component(component):
224 | await component.respond("we got a component in the message " + str(component.message.id))
225 |
226 |
227 | Sending the components
228 |
229 | **Default**
230 |
231 | .. code-block::
232 |
233 | @client.listen()
234 | async def on_message(message):
235 | if message.content == "!test":
236 | await message.channel.send(message.channel, "listening", components=[
237 | Button("hi there", "listening"),
238 | SelectMenu(
239 | options=[
240 | SelectOption(label="This is a option", value="my_value", description="This is the description of the option")
241 | ], custom_id="listening"
242 | )
243 | ]
244 | )
245 |
246 | **Overriden**
247 |
248 | .. code-block::
249 |
250 | @client.listen()
251 | async def on_message(message):
252 | if message.content == "!test":
253 | await message.channel.send(message.channel, "listening", components=[
254 | Button("hi there", "listening"),
255 | SelectMenu(options=[SelectOption(label="This is a option", value="my_value", description="This is the description of the option")], "listening")
256 | ]
257 | )
258 |
259 |
260 | Slash-commands
261 | ====================
262 |
263 |
264 | .. important::
265 |
266 | If you want to use slash commands, in the oauth2 invite link generation,
267 | you have to check both ``bot`` and ``application.commands`` fields
268 |
269 | .. image:: images/slash/invite_scope.png
270 | :width: 900
271 |
272 |
273 |
274 | To create a new slash command, we need to acces the ``slash`` attribute from the initialized ``ui``
275 |
276 |
277 | Basic command
278 | ~~~~~~~~~~~~~~
279 |
280 | .. warning::
281 |
282 | If you want to test slash commands, use ``guild_ids=[guild ids to test here]``, because if you use global commands,
283 | it will take some titme to create/update the slash command (`discord api docs reference `__)
284 |
285 | In this example, we will create a simple slash command
286 |
287 | .. code-block::
288 |
289 | @ui.slash.command(name="test", description="this is a test command", guild_ids=[785567635802816595])
290 | async def command(ctx):
291 | ...
292 |
293 | The command in discord would be
294 |
295 | .. image:: images/slash/test_default.png
296 | :width: 1000
297 |
298 | .. note::
299 |
300 | Replace ``785567635802816595`` with your guild id
301 |
302 |
303 | Parameters
304 | ~~~~~~~~~~~~~~
305 |
306 | To add parameters to the command, we change the code and use the ``options`` parameter
307 |
308 | It acceps a list of :class:`~SlashOption`
309 |
310 | .. code-block::
311 |
312 | @ui.slash.command(name="test", description="this is a test command", options=[
313 | SlashOption(int, name="parameter1", description="this is a parameter")
314 | ], guild_ids=[785567635802816595])
315 | async def command(ctx, parameter1="nothing"):
316 | await ctx.respond("I got `" + str(parameter1) + "` for `parameter1`")
317 |
318 |
319 | This will add a parameter that accepts a number to the slash command
320 |
321 | .. image:: images/slash/test_param_optional.png
322 | :width: 1000
323 |
324 | As you can see ``parameter1`` says "optional", which means you can use the command without to specify it
325 |
326 | Because the parameter is optional, in the callback defenition, we have to set a default value for ``parameter``, which in this case is "nothing"
327 |
328 | .. important::
329 |
330 | The name of the arguments the function accepts have to be the same as the argument name you specify in the discord slash command
331 |
332 | Without the parameter
333 |
334 | .. image:: images/slash/test_param_optional_usage_none.gif
335 | :width: 550
336 |
337 | And with
338 |
339 | .. image:: images/slash/test_param_optional_usage_1.gif
340 | :width: 550
341 |
342 | As you can see, we said that the parameter only accepts integers (numbers), and when you try to use a string, it will say *Input a valid integer.*
343 |
344 |
345 | If you want the parameter to be required, in the option, you have to set ``required`` to ``True``
346 |
347 | .. code-block::
348 |
349 | @ui.slash.command(name="test", description="this is a test command", options=[
350 | SlashOption(int, name="parameter1", description="this is a parameter", required=True)
351 | ], guild_ids=[785567635802816595])
352 | async def command(ctx, parameter1):
353 | await ctx.respond("I got `" + str(parameter1) + "` for `parameter1`")
354 |
355 | .. image:: images/slash/test_param_options_required.gif
356 | :width: 550
357 |
358 |
359 | Choices
360 | ~~~~~~~~~~
361 |
362 | You can add choices for youur options, where the user can choose between a defined list of choices
363 |
364 | Too add them, where we add the options with the :class:`~SlashOption` class, we use the ``choices`` parameter and change our code to
365 |
366 |
367 | .. code-block::
368 |
369 | @ui.slash.command(name="test", description="this is a test command", options=[
370 | SlashOption(int, name="parameter1", description="this is a parameter", choices=[
371 | {"name": "first choice", "value": 1}, {"name": "second choice", "value": 2}
372 | ])
373 | ], guild_ids=[785567635802816595])
374 | async def command(ctx, parameter1="nothing"):
375 | await ctx.respond("I got `" + str(parameter1) + "` for `parameter1`")
376 |
377 | Choices are a list of dict, where ``"name":`` is the displayed choice name and ``"value":`` is the real value,
378 | which will be received when the choice is selected
379 |
380 | You can also use the ``create_choice`` function to make it easier
381 |
382 | .. code-block::
383 |
384 | from discord_ui import create_choice
385 | ...
386 |
387 | @ui.slash.command(name="test", description="this is a test command", options=[
388 | SlashOption(int, name="parameter1", description="this is a parameter", choices=[
389 | create_choice("first choice", 1), create_choice("second choice", 2)
390 | ])
391 | ], guild_ids=[785567635802816595])
392 | async def command(ctx, parameter1="nothing"):
393 | await ctx.respond("I got `" + str(parameter1) + "` for `parameter1`")
394 |
395 | .. image:: images/slash/test_param_choices.gif
396 | :width: 550
397 |
398 | .. note::
399 |
400 | The value of the choice has to be of the same type then the option argument type, which in our case is ``int``, a number
401 |
402 | Permissions
403 | ~~~~~~~~~~~~
404 |
405 | You can set permissions for your commands
406 | There are two ways to set permissions
407 |
408 | default permission
409 | --------------------
410 |
411 | Default permissions apply to all servers, you can set them either to ``True`` or ``False``
412 |
413 | If the default permission to ``False``, no one can use the command, if it's ``True``, everyone can use it
414 |
415 |
416 | .. code-block::
417 |
418 | @ui.slash.command(name="test", description="this is a test command", options=[
419 | SlashOption(int, name="parameter1", description="this is a parameter")
420 | ], guild_ids=[785567635802816595], default_permission=False)
421 | async def command(ctx, parameter1="nothing"):
422 | ...
423 |
424 | In this example, no one can use the command
425 |
426 |
427 | guild permissions
428 | ------------------
429 |
430 | Additionallly, you can use guild permissions, which apply to guilds specified by guild ids
431 |
432 | You can add role ids or/and user ids
433 |
434 | .. code-block::
435 |
436 | @ui.slash.command(name="test", description="this is a test command", options=[
437 | SlashOption(int, name="parameter1", description="this is a parameter")
438 | ], guild_ids=[785567635802816595], guild_permissions={
439 | 785567635802816595: SlashPermission(
440 | allowed={
441 | "539459006847254542": SlashPermission.USER,
442 | "849035012476895232": SlashPermission.ROLE
443 | },
444 | forbidden={
445 | "785567792899948577": SlashPermission.ROLE
446 | }
447 | )})
448 | async def command(ctx, parameter1="nothing"):
449 | ...
450 |
451 | Allowed command
452 |
453 | .. image:: images/slash/allowed_command.png
454 | :width: 1000
455 |
456 | Forbidden command
457 |
458 | .. image:: images/slash/forbidden_command.png
459 | :width: 1000
460 |
461 |
462 | You can later update the command permissions with the :meth:`~Slash.update_permissions` function.
463 |
464 |
465 | guild ids
466 | ~~~~~~~~~~~
467 |
468 | You can decide if you want your commmand only be usable in some guilds you specify or globaly
469 |
470 | To set the guilds where the command is useable, you need to set the ``guild_id`` parameter in the slash command to your list of guild ids
471 |
472 | .. code-block::
473 |
474 | @ui.slash.command(name="test", description="this is a test command", guild_ids=[785567635802816595])
475 | async def command(ctx, parameter1="nothing"):
476 | ...
477 |
478 |
479 | autocompletion
480 | ~~~~~~~~~~~~~~~
481 | You are now able to generate choices for an option based on input, author, channel and more things.
482 |
483 | This feature is currently limited to desktop only, mobile clients will treat the option like a normal option.
484 |
485 | To use that feature, you need to change two things with :class:`~SlashOption`
486 |
487 | .. code-block::
488 |
489 | async def my_generator(ctx: AutocompleteInteraction):
490 | ...
491 | return [choices here]
492 |
493 | @ui.slash.command(options=[SlashOption(str, "name", autocomplete=True, generator=my_generator, required=True)])
494 | async def my_command(ctx, name):
495 | ...
496 |
497 | The callback function needs to return a list of a dict or a tuple that are going to be the choices.
498 |
499 | For example
500 |
501 | .. code-block::
502 |
503 | async def my_generator(ctx: AutocompleteInteraction):
504 | ...
505 | return [{"name": "a choice name", "value": "yeah"}, ("other choice", "other value")]
506 |
507 | You can change the options based on the "query" the user has already typed
508 |
509 | .. code-block::
510 |
511 | async def my_generator(ctx: AutocompleteInteraction):
512 | available_choices = ["hello", "hellow", "world", "warudo", "this", "is", "a", "test"]
513 | return [(x, x) for x in available_choices if x.startswith(ctx.value_query)]
514 |
515 |
516 | You can also generate choices based on other options that were already selected.
517 | This example filters user that have the role passed in the "staff" option
518 | .. code-block::
519 |
520 | async def my_generator(ctx: AutocompleteInteraction):
521 | role: discord.Role = ctx.selected_options["staff"]
522 | members = role.guild.fetch_members().filter(predicate=lambda x: x.get_role(role.id))
523 | return [(x.name, str(x.id)) async for x in members]
524 |
525 | .. image:: images/slash/autogenerate.gif
526 | :width: 550
527 |
528 | Subcommands, Subcommandgroups and Contextcommands
529 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
530 |
531 | You can also use subcommands and subcommand groups, they work almost the same as the normal slash command
532 |
533 | subcommand
534 | -----------
535 |
536 | A subcommand is a slash command with the same base name that can have multiple subcommands
537 |
538 | .. code-block::
539 |
540 | base
541 | |-- subcommand1
542 | |-- subcommand2
543 |
544 | The only difference between ``subcommand`` and ``slashcommand`` is that you got a new ``base_names`` parameter.
545 | This is the name/names of the parent command
546 |
547 | For example
548 |
549 | .. code-block::
550 |
551 | @ui.slash.subcommand(base_names="hello", name="world", description="this is a subcommand")
552 | async def command(ctx):
553 | ...
554 |
555 | would look like this
556 |
557 | .. image:: images/slash/hello_world_subcommand.png
558 | :width: 1000
559 |
560 | subcommand group
561 | ------------------
562 | A subcommand group is a group of subcommands, you could see it like a subcommand of a subcommand
563 |
564 |
565 | .. code-block::
566 |
567 | base
568 | |---subcommand
569 | | |---subcommand
570 | | |---subcommand
571 | |---subcommand
572 | |---subcommand
573 |
574 | For example
575 |
576 | .. code-block::
577 |
578 | @ui.slash.subcommand(base_names=["hello", "beautiful"], name="world", description="this is a subcommand group")
579 | async def command(ctx):
580 | ...
581 |
582 | Would look like this
583 |
584 | .. image:: images/slash/hello_beautiful_world_subcommandgroup.png
585 | :width: 1000
586 |
587 |
588 | context-commands
589 | -----------------
590 | context-commands are basically slash commands, but focusing on messages and users
591 |
592 | To create a message command, which can be used when right-clicking a message, we use
593 |
594 | .. code-block::
595 |
596 | @ui.slash.message_command(name="quote")
597 | async def callback(ctx, message):
598 | ...
599 |
600 | .. image:: images/context/message_command.gif
601 | :width: 1000
602 |
603 | And for a user command, we use
604 |
605 | .. code-block::
606 |
607 | @ui.slash.user_command(name="avatar"):
608 | async def callback(ctx, user):
609 | ...
610 |
611 | .. image:: images/context/user_command.gif
612 | :width: 1000
613 |
614 | They both work in the same way as slash commands, so responding to them will still be the same, the only differnce are the parameters
615 |
616 | .. note::
617 |
618 | ``message`` and ``user`` are just example names for the parameters, you can use whatever you want for them
619 |
620 |
621 |
622 | easier ways
623 | ~~~~~~~~~~~~
624 |
625 | There are some few things that can be done in easier ways
626 |
627 |
628 | application-command names and descriptions
629 | --------------------------------------------
630 |
631 | If you want to register a slash command, you can leave out the name and description parameter, they will be replaced with the callback function name and the docstring of the callback
632 |
633 |
634 | .. code-block::
635 |
636 | @ui.slash.command(guild_ids=[785567635802816595])
637 | async def test(ctx): # The name of the slash command will be 'test', because the function's name is test
638 | """this is a test command""" # the description of the command will be the docstring
639 | ...
640 |
641 | Same goes for subcommands and context-commands
642 |
643 | .. code-block::
644 |
645 | # subcommand
646 | @ui.slash.subcommand(base_names=["hello"], guild_ids=[785567635802816595])
647 | async def world(ctx):
648 | # Note: If you don't pass description and don't use a docstring, the empty description will be replaced with the commands name
649 | ...
650 |
651 | # context command
652 | @ui.slash.message_command(guild_ids=[785567635802816595])
653 | async def quote(ctx, message):
654 | ...
655 |
656 |
657 | You can also use the callback's function parameters to specify the slashcommand options
658 |
659 |
660 | .. code-block::
661 |
662 | @ui.slash.command()
663 | async def a_command(ctx, some_int = 0): # slashcommand will take an optional option with the name "some_int" of type int
664 | """this is a command
665 |
666 | It will only use the first line for the command description
667 | """
668 | # command with the name "a_command", description is "this is a command"
669 | ...
670 |
671 |
672 |
673 | @ui.slash.command()
674 | async def other_command(ctx, user): # slashcommand will take a required option with the name "user" of type user
675 | """to show a new feature"""
676 | ...
677 | # command with the name "other_command", description is "to show a new feature"
678 |
679 | @ui.slash.command()
680 | async def another_command(ctx, smth: "channel"): # slashcommand will take a required option with the name "smth" of type channel
681 | """in this libary"""
682 | ...
683 | # command with the name "another_command", description is "in this libary"
684 |
685 | SlashOption types
686 | -----------------
687 |
688 | You can set the type of an SlashOption in various ways
689 |
690 | .. code-block::
691 |
692 | SlashOption("int", ...) # Option takes an integer
693 | SlashOption(int, ...) # Option takes an integer
694 | SlashOption(4, ...) # Option takes an integer
695 | SlashOption(OptionType.Integer, ...) # Option takes an integer
696 | SlashOption(OptionType.INTEGER, ...) # Option takes an integer
697 |
698 | # same goes for other types
699 | SlashOption("user", ...) # Option takes a member
700 | SlashOption(discord.User, ...) # Option takes a member
701 | SlashOption(discord.Member, ...) # Option takes a member
702 | SlashOption(6, ...) # Option takes a member
703 | SlashOption(OptionType.User, ...) # Option takes a member
704 | SlashOption(OptionType.USER, ...) # Option takes a member
705 | SlashOption(OptionType.Member, ...) # Option takes a member
706 | SlashOption(OptionType.MEMBER, ...) # Option takes a member
707 |
708 | button colors
709 | --------------
710 |
711 | You can set the color of a button with many ways
712 |
713 | .. code-block::
714 |
715 | Button(..., color="red") # red button
716 | Button(..., color="rEd") # red button
717 | Button(..., color="danger") # red button
718 | Button(..., color="DANger") # red button
719 | Button(..., color=ButtonStyle.Red) # red button
720 | Button(..., color=ButtonStyle.Destructive) # red button
721 |
722 | autocompletion
723 | ---------------
724 |
725 | For autocompletion you dont have to pass the ``autocomplete`` parameter to the option,
726 | if you pass ``generator``, ``autocomplete`` will be automatically set to ``True``
727 |
728 | .. code-block::
729 |
730 | async def my_generator(ctx: AutocompleteInteraction):
731 | ...
732 | return [choices here]
733 |
734 | @ui.slash.command(options=[SlashOption(str, "name", generator=my_generator, required=True)])
735 | async def my_command(ctx, name):
736 | ...
737 |
738 | You can set set the generator for the autocompletion with a decorator
739 |
740 |
741 | .. code-block::
742 |
743 | @ui.slash.command(options=[SlashOption(str, "name", required=True)])
744 | async def my_command(ctx, name):
745 | ...
746 |
747 | # set the generator
748 | @my_command.options[0].autocomplete_function # you could also use my_command.options["name"]
749 | async def my_generator(ctx: AutocompleteInteraction):
750 | return [...]
751 |
--------------------------------------------------------------------------------
/discord_ui/components.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | from discord.ext.commands import BadArgument
4 |
5 | from .tools import All
6 | from .enums import ButtonStyle, ComponentType
7 | from .errors import InvalidLength, OutOfValidRange, WrongType
8 |
9 | import discord
10 | from discord import InvalidArgument
11 |
12 | import inspect
13 | import string
14 | from random import choice
15 | from typing import List, Union
16 |
17 | __all__ = (
18 | 'SelectMenu',
19 | 'SelectOption',
20 | 'Button',
21 | 'LinkButton',
22 | 'ActionRow'
23 | )
24 |
25 | class ComponentStore():
26 | """A class for storing message components together with some useful methods"""
27 | def __init__(self, components=[]):
28 | """Creates a new `ComponentStore`
29 |
30 | Parameters
31 | ----------
32 | components: List[:class:`Button` | :class:`LinkButton` | :class:`SelectMenu`]
33 | The components that should be stored
34 |
35 | """
36 | self._components: List[Union[Button, LinkButton, SelectMenu]] = []
37 | # for checks
38 | [self.append(x) for x in components]
39 | def _get_index_for(self, key):
40 | if isinstance(key, int):
41 | return key
42 | if isinstance(key, str):
43 | s = [i for i, x in enumerate(self._components) if x.custom_id == key]
44 | if len(s) == 0:
45 | raise KeyError(key)
46 | return s[0]
47 | raise WrongType(key, "index", ["str", "int"])
48 | def __getitem__(self, key):
49 | return self._components[self._get_index_for(key)]
50 | def __setitem__(self, key, value):
51 | self._components[self._get_index_for(key)] = value
52 | def __delitem__(self, key):
53 | self._components.pop(self._get_index_for(key))
54 | def __iter__(self):
55 | return iter(self._components)
56 | def __len__(self):
57 | return len(self._components)
58 | def __repr__(self):
59 | return f"<{self.__class__.__name__}{str(self._components)}>"
60 |
61 | def to_list(self):
62 | """Returns this class as an list"""
63 | return self._components
64 | def copy(self):
65 | return self.__class__()
66 | def append(self, item):
67 | if hasattr(item, "custom_id") and item.custom_id in [x.custom_id if hasattr(x, "custom_id") else None for x in self._components]:
68 | raise BadArgument(f"A component with the custom_id '{item.custom_id} already exists! CustomIds have to be unique'")
69 | self._components.append(item)
70 | def clear(self):
71 | self._components = []
72 |
73 | def disable(self, index=All, disable=True):
74 | """
75 | Disables or enables component(s)
76 |
77 | index: :class:`int` | :class:`str` | :class:`range` | List[:class:`int` | :class:`str`], optional
78 | Index(es) or custom_id(s) for the components that should be disabled or enabled; default all components
79 | disable: :class:`bool`, optional
80 | Whether to disable (``True``) or enable (``False``) components; default True
81 |
82 | """
83 | if index is All:
84 | index = range(len(self._components))
85 | if isinstance(index, (range, list, tuple)):
86 | for i in index:
87 | self._components[i].disabled = disable
88 | elif isinstance(index, (int, str)):
89 | self._components[index].disabled = disable
90 | return self
91 | @property
92 | def buttons(self) -> List[Union[Button, LinkButton]]:
93 | """All components with the type `Button`"""
94 | return [self._components[i] for i, x in enumerate(self._components) if x.component_type == ComponentType.Button]
95 | @property
96 | def selects(self) -> List[SelectMenu]:
97 | """All components with the type `Select`"""
98 | return [self._components[i] for i, x in enumerate(self._components) if x.component_type == ComponentType.Select]
99 | def get_rows(self) -> List[ComponentStore]:
100 | """
101 | Returns the component rows as componentstores
102 |
103 | Example
104 | --------
105 |
106 | If the components are
107 |
108 | ```
109 | Button1, Button2
110 | Button3
111 | SelectMenu
112 | ```
113 |
114 | The `.get_rows` method would then return
115 |
116 | ```py
117 | >>> message.get_rows()
118 | [
119 | ComponentStore[Button1, Button2],
120 | ComponentStore[Button3],
121 | ComponentStore[SelectMenu]
122 | ]
123 | ```
124 |
125 | """
126 | rows = []
127 | current_row = []
128 | for i, x in enumerate(self._components):
129 | if getattr(x, 'new_line', True) == True and i > 0:
130 | rows.append(ComponentStore(current_row))
131 | current_row = []
132 | current_row.append(self._components[i])
133 | if len(current_row) > 0:
134 | rows.append(ComponentStore(current_row))
135 | return rows
136 |
137 | class SelectOption():
138 | """
139 | An option for a select menu
140 |
141 | Parameters
142 | ----------
143 | value: :class:`str`
144 | The dev-define value of the option, max 100 characters
145 | label: :class:`str`
146 | The user-facing name of the option, max 25 characters; default \u200b ("empty" char)
147 | description: :class:`str`, optional
148 | An additional description of the option, max 50 characters
149 | emoji: :class:`discord.Emoji` | :class:`str`, optional
150 | Emoji appearing before the label; default MISSING
151 | default: :class:`bool`
152 | Whether this option should be selected by default in the select menu; default False
153 |
154 | Raises
155 | -------
156 | :class:`WrongType`
157 | A value you want to set is not an instance of a valid type
158 | :class:`InvalidLenght`
159 | The lenght of a value is not valid
160 | :class:`OutOfValidRange`
161 | A value is out of its valid range
162 | """
163 | def __init__(self, value, label="\u200b", description=None, emoji=None, default=False) -> None:
164 | """
165 | Creates a new SelectOption
166 |
167 | Example:
168 | ```py
169 | SelectOption(label="This is a option", value="my_value", description="This is the description of the option")
170 | ```
171 | """
172 | self._label = None
173 | self._value = None
174 | self._description = None
175 | self._emoji = None
176 |
177 | self.default: bool = default
178 | """Whether this option is selected by default in the menu or not"""
179 | self.label = label
180 | self.value = value
181 | self.description = description
182 | self.emoji = emoji
183 | def __repr__(self) -> str:
184 | return f""
185 |
186 | @property
187 | def content(self) -> str:
188 | """The complete option content, consisting of the emoji and label"""
189 | return (self.emoji + " ") if self.emoji is not None else "" + (self.label or '')
190 |
191 | @property
192 | def label(self) -> str:
193 | """The main text appearing on the option """
194 | return self._label
195 | @label.setter
196 | def label(self, value: str):
197 | if value is None:
198 | value = ""
199 | elif value is not None and not isinstance(value, str):
200 | raise WrongType("label", value, "str")
201 | elif value is not None and len(value) > 100 and value > 0:
202 | raise InvalidLength("label", value, 100, 0)
203 | self._label = value
204 |
205 | @property
206 | def value(self) -> str:
207 | """A unique value for the option, which will be usedd to identify the selected value"""
208 | return self._value
209 | @value.setter
210 | def value(self, value):
211 | if inspect.isclass(value):
212 | raise WrongType("value", value, ["int", "str", "bool", "float"])
213 | if isinstance(value, str):
214 | if len(value) > 100 or len(value) < 1:
215 | raise InvalidLength("value", _min=1, _max=100)
216 | self._value = value
217 |
218 | @property
219 | def description(self) -> str:
220 | """A short description for the option"""
221 | return self._description
222 | @description.setter
223 | def description(self, value):
224 | if value is not None and not isinstance(value, str):
225 | raise WrongType("description", "str")
226 | if value is not None and len(value) > 100:
227 | raise InvalidLength("description", 100, 0)
228 | self._description = value
229 |
230 | @property
231 | def emoji(self) -> str:
232 | """
233 | The mention of the emoji before the text
234 |
235 | .. note::
236 | For setting the emoji, you can use a :class:`str` or a :class:`discord.Emoji`
237 | """
238 | if self._emoji is None:
239 | return None
240 | if "id" not in self._emoji:
241 | return self._emoji["name"]
242 | return f'<{"a" if "animated" in self._emoji else ""}:{self._emoji["name"]}:{self._emoji["id"]}>'
243 | @emoji.setter
244 | def emoji(self, val: Union[discord.Emoji, str, dict]):
245 | """The emoji appearing before the label"""
246 | if val is None:
247 | self._emoji = None
248 | elif isinstance(val, str):
249 | self._emoji = {
250 | "id": None,
251 | "name": val
252 | }
253 | elif isinstance(val, discord.Emoji):
254 | self._emoji = {
255 | "id": val.id,
256 | "name": val.name,
257 | "animated": val.animated
258 | }
259 | elif isinstance(val, dict):
260 | self._emoji = val
261 | elif val is None:
262 | self._emoji = None
263 | else:
264 | raise WrongType("emoji", val, ["str", "discord.Emoji", "dict"])
265 |
266 |
267 | def to_dict(self) -> dict:
268 | payload = {
269 | "label": self._label,
270 | "value": self._value,
271 | "default": self.default
272 | }
273 | if self._description is not None:
274 | payload["description"] = self._description
275 | if self._emoji is not None:
276 | payload["emoji"] = self._emoji
277 | return payload
278 |
279 | @classmethod
280 | def _from_data(cls, data) -> SelectOption:
281 | """
282 | Initializes a new SelectOption from a dict
283 |
284 | Parameters
285 | ----------
286 | data: :class:`dict`
287 | The data to initialize from
288 | Returns
289 | -------
290 | :class:`~SelectOption`
291 | The new Option generated from the dict
292 |
293 | """
294 | x = SelectOption(data["value"], data.get("label"), data.get("description"), data.get("emoji"))
295 | x.default = data.get("default", False)
296 | return x
297 |
298 | class Component():
299 | def __init__(self, component_type) -> None:
300 | self._component_type = getattr(component_type, "value", component_type)
301 | @property
302 | def component_type(self) -> ComponentType:
303 | """The component type"""
304 | return ComponentType(self._component_type)
305 |
306 | class UseableComponent(Component):
307 | def __init__(self, component_type) -> None:
308 | Component.__init__(self, component_type)
309 | @property
310 | def custom_id(self) -> str:
311 | """A custom identifier for this component"""
312 | return self._custom_id
313 | @custom_id.setter
314 | def custom_id(self, value: str):
315 | if len(value) > 100 or len(value) < 1:
316 | raise InvalidLength("custom_id", 0, 100)
317 | if not isinstance(value, str):
318 | raise WrongType("custom_id", value, "str")
319 | self._custom_id = value
320 |
321 | class SelectMenu(UseableComponent):
322 | """
323 | A ui-dropdown selectmenu
324 |
325 | Parameters
326 | ----------
327 | options: List[:class:`~SelectOption`]
328 | A list of options to select from
329 | custom_id: :class:`str`, optional
330 | The custom_id for identifying the menu, max 100 characters
331 | min_values: :class:`int`, optional
332 | The minimum number of items that must be chosen; default ``1``, min 0, max 25
333 | max_values: :class:`int`, optional
334 | The maximum number of items that can be chosen; default ``1``, max 25
335 | placeholder: :class:`str`, optional
336 | A custom placeholder text if nothing is selected, max 100 characters; default MISSING
337 | default: :class:`int` | :class:`range`, optional
338 | The position of the option that should be selected by default; default MISSING
339 | disabled: :class:`bool`, optional
340 | Whether the select menu should be disabled or not; default ``False``
341 | """
342 | def __init__(self, options, custom_id=None, min_values=1, max_values=1, placeholder=None, default=None, disabled=False) -> None:
343 | """
344 | Creates a new ui select menu
345 |
346 | Example:
347 | ```py
348 | SelectMenu(options=[SelectOption(...)], custom_id="my_id", min_values=2, placeholder="select something", default=0)
349 | ```
350 | """
351 | UseableComponent.__init__(self, ComponentType.Select)
352 | self.options: List[SelectOption] = options or None
353 |
354 | self.max_values: int = 0
355 | """The maximum number of items that can be chosen; default 1, max 25"""
356 | self.min_values: int = 0
357 | """
358 | The minimum number of items that must be chosen; default 1, min 0, max 25
359 | """
360 | self.disabled = disabled
361 | """
362 | Whether the selectmenu is disabled or not"""
363 | self.placeholder: str = placeholder
364 | """
365 | Custom placeholder text if nothing is selected"""
366 | self.custom_id = custom_id or ''.join([choice(string.ascii_letters) for _ in range(100)])
367 | self.disabled = disabled
368 |
369 | if min_values is not None and max_values is None:
370 | if min_values < 0 or min_values > 25:
371 | raise OutOfValidRange("min_values", 0, 25)
372 | self.min_values = min_values
373 | self.max_values = min_values
374 | elif min_values is None and max_values is not None:
375 | if max_values < 0 or min_values > 25:
376 | raise OutOfValidRange("min_values", 0, 25)
377 | self.min_values = 1
378 | self.max_values = max_values
379 | elif min_values is not None and max_values is not None:
380 | if max_values < 0 or min_values > 25:
381 | raise OutOfValidRange("min_values", 0, 25)
382 | if min_values < 0 or min_values > 25:
383 | raise OutOfValidRange("min_values", 0, 25)
384 | self.min_values = min_values
385 | self.max_values = max_values
386 |
387 | if default is not None:
388 | self.set_default_option(default)
389 | def __str__(self) -> str:
390 | return self.custom_id
391 | def __repr__(self) -> str:
392 | return f""
393 |
394 | @staticmethod
395 | def _from_data(data) -> SelectMenu:
396 | return SelectMenu([
397 | SelectOption._from_data(d) for d in data["options"]
398 | ], data["custom_id"], data.get("min_values"), data.get("max_values"), data.get("placeholder"), disabled=data.get("disabled", False)
399 | )
400 | # region props
401 |
402 | @property
403 | def default_options(self) -> List[SelectOption]:
404 | """The option selected by default"""
405 | return [x for x in self.options if x.default]
406 | def set_default_option(self, position) -> SelectMenu:
407 | """
408 | Selects the default selected option
409 |
410 | Parameters
411 | ----------
412 | position: :class:`int` | :class:`range`
413 | The position of the option that should be default.
414 | If ``position`` is of type :class:`range`, it will iterate through it and disable all components with the index of the indexes.
415 | """
416 | if not isinstance(position, (int, range)):
417 | raise WrongType("position", position, "int")
418 | if isinstance(position, int):
419 | if position < 0 or position >= len(self.options):
420 | raise OutOfValidRange("default option position", 0, str(len(self.options) - 1))
421 | self.options[position].default = True
422 | return self
423 | for pos in position:
424 | self.options[pos].default = True
425 | # endregion
426 |
427 | def to_dict(self) -> dict:
428 | payload = {
429 | "type": self._component_type,
430 | "custom_id": self._custom_id,
431 | "options": [x.to_dict() for x in self.options],
432 | "disabled": self.disabled,
433 | "min_values": self.min_values,
434 | "max_values": self.max_values
435 | }
436 | if self.placeholder is not None:
437 | payload["placeholder"] = self.placeholder
438 | return payload
439 |
440 | class BaseButton(Component):
441 | def __init__(self, label, color, emoji, new_line, disabled) -> None:
442 | Component.__init__(self, ComponentType.Button)
443 | if label is None and emoji is None:
444 | raise InvalidArgument("You need to pass a label or an emoji")
445 | self._label = None
446 | self._style = None
447 | self._emoji = None
448 | self._url = None
449 |
450 | self.new_line = new_line
451 | self.label = label
452 | self.color = color
453 | self.disabled = disabled
454 | self.emoji = emoji
455 |
456 | def __repr__(self):
457 | return f"<{self.__class__.__name__}(custom_id={self.custom_id}, color={self.color})>"
458 | def __str__(self) -> str:
459 | return self.content
460 | def to_dict(self):
461 | payload = {"type": self._component_type, "style": self._style, "disabled": self.disabled, "emoji": self._emoji}
462 | if self._style == ButtonStyle.URL:
463 | payload["url"] = self._url
464 | else:
465 | payload["custom_id"] = self._custom_id
466 | if self._emoji is not None:
467 | payload["emoji"] = self._emoji
468 | if self._label is not None:
469 | payload["label"] = self._label
470 | return payload
471 |
472 | @property
473 | def content(self) -> str:
474 | """The complete content in the button ("{emoji} {label}")"""
475 | return (self.emoji + " " if self.emoji is not None else "") + (self.label or '')
476 |
477 | @property
478 | def label(self) -> str:
479 | """The label displayed on the button"""
480 | return self._label
481 | @label.setter
482 | def label(self, val: str):
483 | if val is None:
484 | val = ""
485 | elif val is not None and not isinstance(val, str):
486 | raise WrongType("label", val, "str")
487 | elif val is not None and len(val) > 100:
488 | raise InvalidLength("label", _max=100)
489 | elif val is not None and len(val) < 1:
490 | raise InvalidLength("label", _min=0)
491 |
492 | self._label = str(val)
493 |
494 | @property
495 | def color(self) -> int:
496 | """The color for the button"""
497 | return self._style
498 | @color.setter
499 | def color(self, val):
500 | if ButtonStyle.getColor(val) is None:
501 | raise InvalidArgument(str(val) + " is not a valid color")
502 | self._style = ButtonStyle.getColor(val).value
503 |
504 | @property
505 | def emoji(self) -> str:
506 | """
507 | The mention of the emoji before the text
508 |
509 | .. note::
510 | For setting the emoji, you can use a str or discord.Emoji
511 | """
512 | if self._emoji is None:
513 | return None
514 | if "id" not in self._emoji:
515 | return self._emoji["name"]
516 | return f'<{"a" if "animated" in self._emoji else ""}:{self._emoji["name"]}:{self._emoji["id"]}>'
517 | @emoji.setter
518 | def emoji(self, val: Union[discord.Emoji, str, dict]):
519 | if val is None:
520 | self._emoji = None
521 | elif isinstance(val, str):
522 | self._emoji = {
523 | "name": val
524 | }
525 | elif isinstance(val, discord.Emoji):
526 | self._emoji = {
527 | "id": val.id,
528 | "name": val.name,
529 | "animated": val.animated
530 | }
531 | elif isinstance(val, dict):
532 | self._emoji = val
533 | else:
534 | raise WrongType("emoji", val, ["str", "discord.Emoji", "dict"])
535 |
536 | class Button(BaseButton, UseableComponent):
537 | """
538 | A ui-button
539 |
540 | Parameters
541 | ----------
542 | custom_id: :class:`str`, optional
543 | A identifier for the button, max 100 characters
544 | If no custom_id was passed, a random 100 character string will be generated
545 | label: :class:`str`, optional
546 | Text that appears on the button, max 80 characters; default \u200b ("empty" char)
547 | color: :class:`str` | :class:`int`, optional
548 | The color of the button; default "blurple"
549 |
550 | .. tip:
551 |
552 | You can either use a string for a color or an int. Color strings are:
553 | (`primary`, `blurple`), (`secondary`, `grey`), (`succes`, `green`) and (`danger`, `Red`)
554 |
555 | If you want to use integers, take a lot at the :class:`~ButtonStyle` class
556 |
557 | emoji: :class:`discord.Emoji` | :class:`str`, optional
558 | The emoji displayed before the text; default MISSING
559 | new_line: :class:`bool`, optional
560 | Whether a new line should be added before the button; default False
561 | disabled: :class:`bool`, optional
562 | Whether the button is disabled; default False
563 |
564 | Raises
565 | -------
566 | :class:`WrongType`
567 | A value you want to set is not an instance of a valid type
568 | :class:`InvalidLenght`
569 | The lenght of a value is not valid
570 | :class:`OutOfValidRange`
571 | A value is out of its valid range
572 | :class:`InvalidArgument`
573 | The color you provided is not a valid color alias
574 | """
575 | def __init__(self, label="\u200b", custom_id=None, color="blurple", emoji=None, new_line=False, disabled=False) -> None:
576 | """
577 | Creates a new ui-button
578 |
579 | Example:
580 | ```py
581 | Button("This is a cool button", "my_custom_id", "green")
582 | ```
583 | """
584 | BaseButton.__init__(self, label, color, emoji, new_line, disabled)
585 | UseableComponent.__init__(self, self.component_type)
586 | self.custom_id = custom_id or ''.join([choice(string.ascii_letters) for _ in range(100)])
587 | def copy(self) -> Button:
588 | return self.__class__(
589 | label=self.label,
590 | custom_id=self.custom_id,
591 | color=self.color,
592 | emoji=self.emoji,
593 | new_line=self.new_line,
594 | disabled=self.disabled
595 | )
596 |
597 | @classmethod
598 | def _from_data(cls, data, new_line=False) -> Button:
599 | """
600 | Returns a new button initialized from api response data
601 |
602 | Returns
603 | -------
604 | Button
605 | The initialized button
606 | """
607 | return Button(
608 | label=data.get("label"),
609 | custom_id=data["custom_id"],
610 | color=data["style"],
611 | emoji=data.get("emoji"),
612 | new_line=new_line,
613 | disabled=data.get("disabled", False)
614 | )
615 |
616 | class LinkButton(BaseButton):
617 | """
618 | A ui-button that will open a link when it's pressed
619 |
620 | Parameters
621 | ----------
622 | url: :class:`str`
623 | A url which will be opened when pressing the button
624 | label: :class:`str`, optional
625 | Text that appears on the button, max 80 characters; default \u200b ("empty" char)
626 | emoji: :class:`discord.Emoji` | :class:`str`, optional
627 | Emoji that appears before the label; default MISSING
628 | new_line: :class:`bool`, optional
629 | Whether a new line should be added before the button; default False
630 | disabled: :class:`bool`, optional
631 | Whether the button is disabled; default False
632 |
633 | Raises
634 | -------
635 | :class:`WrongType`
636 | A value you want to set is not an instance of a valid type
637 | :class:`InvalidLenght`
638 | The lenght of a value is not valid
639 | :class:`OutOfValidRange`
640 | A value is out of its valid range
641 | """
642 | def __init__(self, url, label="\u200b", emoji=None, new_line=False, disabled=False) -> None:
643 | """
644 | Creates a new LinkButton object
645 |
646 | Example:
647 | ```py
648 | LinkButton("https://discord.com/", "press me (if you can)!", emoji="😀", disabled=True)
649 | ```
650 | """
651 | BaseButton.__init__(self, label, ButtonStyle.URL, emoji, new_line, disabled)
652 | self._url = None
653 | self.url = url
654 |
655 | def __repr__(self) -> str:
656 | return f"<{self.__class__.__name__}(url={self.url}, content={self.content}, custom_id={self.custom_id})>"
657 | def copy(self) -> LinkButton:
658 | return self.__class__(
659 | url=self.url,
660 | label=self.label,
661 | emoji=self.emoji,
662 | new_line=self.new_line,
663 | disabled=self.disabled
664 | )
665 |
666 | @property
667 | def url(self) -> str:
668 | """The link which will be opened when the button was pressed"""
669 | return self._url
670 | @url.setter
671 | def url(self, val: str):
672 | if not isinstance(val, str):
673 | raise WrongType("url", val, "str")
674 | self._url = str(val)
675 |
676 | @classmethod
677 | def _from_data(cls, data, new_line=False) -> LinkButton:
678 | return LinkButton(
679 | url=data["url"],
680 | label=data.get("label"),
681 | emoji=data.get("emoji"),
682 | new_line=new_line,
683 | disabled=data.get("disabled", False)
684 | )
685 |
686 |
687 | class ActionRow():
688 | """
689 | Alternative to setting ``new_line`` in a full component list or putting the components in a list
690 |
691 | Only works for :class:`~Button` and :class:`~LinkButton`, because :class:`~SelectMenu` is always in a new line
692 | """
693 | def __init__(self, *items):
694 | """
695 | Creates a new component list
696 |
697 | Examples
698 | ```py
699 | ActionRow(Button(...), Button(...))
700 |
701 | ActionRow([Button(...), Button(...)])
702 | ```
703 | """
704 | self.items: List[Union[Button, LinkButton, SelectMenu]] = items[0] if all(isinstance(i, list) for i in items) else items
705 | """The componetns in the action row"""
706 | self.component_type = 1
707 |
708 | def disable(self, disable=True) -> ActionRow:
709 | for i, _ in enumerate(self.items):
710 | if isinstance(self.items[i], list):
711 | for j, _ in enumerate(self.items[i]):
712 | self.items[i][j].disabled = disable
713 | continue
714 | self.items[i].disabled = disable
715 | return self
716 | def filter(self, check = lambda x: ...):
717 | """
718 | Filters all components
719 |
720 | Parameters
721 | ----------
722 | check: :class:`lambda`
723 | What condition has to be True that the component will pass the filter
724 |
725 | Returns
726 | -------
727 | List[:class:`~Button` | :class:`~LinkButton`]
728 | The filtered components
729 |
730 | """
731 | return [x for x in self.items if check(x)]
732 |
733 |
734 | def make_component(data, new_line = False):
735 | if ComponentType(data["type"]) == ComponentType.Button:
736 | if data["style"] == ButtonStyle.URL:
737 | return LinkButton._from_data(data, new_line)
738 | return Button._from_data(data, new_line)
739 | if ComponentType(data["type"]) is ComponentType.Select:
740 | return SelectMenu._from_data(data)
741 | # if data["type"] == ComponentType.ACTION_ROW:
742 | # return ActionRow._from_data(data)
--------------------------------------------------------------------------------