├── 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 += "\n

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