├── .coveragerc ├── .flake8 ├── .gitattributes ├── .github ├── ISSUE_TEMPLATE │ ├── config.yml │ └── issue.yml ├── PULL_REQUEST_TEMPLATE.md ├── labeler │ └── subpackages.yml └── workflows │ ├── lint_test.yml │ └── triaging.yml ├── .gitignore ├── .isort.cfg ├── CONTRIBUTING.md ├── LICENSE ├── LICENSE-APACHE ├── LICENSE-MIT ├── README.md ├── docs ├── mkdocs.yml ├── requirements.txt ├── source │ ├── css │ │ └── _extra.css │ ├── index.md │ ├── reference │ │ ├── interactions │ │ │ └── commands.md │ │ └── utils.md │ ├── setup │ │ ├── authenticating.md │ │ ├── creating-the-bot.md │ │ ├── images │ │ │ ├── authenticating │ │ │ │ ├── bot-overview.png │ │ │ │ ├── general-information.png │ │ │ │ └── oauth2-page.png │ │ │ ├── creating-the-bot │ │ │ │ ├── bot-confirmation.png │ │ │ │ ├── bot-invite-flow.png │ │ │ │ ├── bot-joined.png │ │ │ │ ├── bot-overview.png │ │ │ │ ├── build-a-bot.png │ │ │ │ ├── developer-portal.png │ │ │ │ ├── general-information.png │ │ │ │ ├── name-prompt.png │ │ │ │ └── oauth2-tab.png │ │ │ └── running-the-bot │ │ │ │ └── interaction-url.png │ │ ├── index.md │ │ └── running-the-bot.md │ └── tutorial │ │ └── extensions │ │ ├── advanced-usage.md │ │ ├── extensions-faq.md │ │ └── simple-example.md └── templates │ └── python │ └── material │ ├── method.html │ └── parameters.html ├── library ├── wumpy-bot │ ├── README.md │ ├── pyproject.toml │ └── wumpy │ │ └── bot │ │ ├── __init__.py │ │ ├── _bot.py │ │ ├── _dispatch.py │ │ ├── _errors.py │ │ ├── _extension.py │ │ ├── _utils.py │ │ ├── events │ │ ├── __init__.py │ │ ├── _channel.py │ │ ├── _gateway.py │ │ ├── _guild.py │ │ └── _message.py │ │ └── py.typed ├── wumpy-cache │ ├── README.md │ ├── pyproject.toml │ └── wumpy │ │ └── cache │ │ ├── __init__.py │ │ ├── _protocol.py │ │ ├── in_memory │ │ ├── __init__.py │ │ ├── _base.py │ │ ├── _channel.py │ │ ├── _guild.py │ │ └── _user.py │ │ └── py.typed ├── wumpy-gateway │ ├── README.md │ ├── pyproject.toml │ └── wumpy │ │ └── gateway │ │ ├── __init__.py │ │ ├── _errors.py │ │ ├── _shard.py │ │ ├── _utils.py │ │ └── py.typed ├── wumpy-interactions │ ├── README.md │ ├── pyproject.toml │ └── wumpy │ │ └── interactions │ │ ├── __init__.py │ │ ├── _app.py │ │ ├── _compat.py │ │ ├── _errors.py │ │ ├── _middleware.py │ │ ├── _models.py │ │ ├── _utils.py │ │ ├── commands │ │ ├── __init__.py │ │ ├── _base.py │ │ ├── _checks.py │ │ ├── _context.py │ │ ├── _option.py │ │ ├── _registrar.py │ │ └── _slash.py │ │ ├── components │ │ ├── __init__.py │ │ └── _handler.py │ │ └── py.typed ├── wumpy-models │ ├── README.md │ ├── pyproject.toml │ └── wumpy │ │ └── models │ │ ├── __init__.py │ │ ├── _raw │ │ ├── __init__.py │ │ ├── _channels.py │ │ ├── _components.py │ │ ├── _embed.py │ │ ├── _emoji.py │ │ ├── _flags.py │ │ ├── _guild.py │ │ ├── _integrations.py │ │ ├── _interactions.py │ │ ├── _invite.py │ │ ├── _member.py │ │ ├── _message.py │ │ ├── _permissions.py │ │ ├── _role.py │ │ ├── _sticker.py │ │ └── _user.py │ │ ├── _stateful │ │ ├── __init__.py │ │ ├── _asset.py │ │ ├── _channels.py │ │ ├── _emoji.py │ │ ├── _guild.py │ │ ├── _integrations.py │ │ ├── _interactions.py │ │ ├── _invite.py │ │ ├── _member.py │ │ ├── _message.py │ │ ├── _role.py │ │ ├── _sticker.py │ │ └── _user.py │ │ ├── _utils.py │ │ └── py.typed └── wumpy-rest │ ├── README.md │ ├── pyproject.toml │ └── wumpy │ └── rest │ ├── __init__.py │ ├── _config.py │ ├── _errors.py │ ├── _impl.py │ ├── _ratelimiter.py │ ├── _requester.py │ ├── _route.py │ ├── _utils.py │ ├── endpoints │ ├── __init__.py │ ├── _channel.py │ ├── _commands.py │ ├── _gateway.py │ ├── _guild.py │ ├── _guild_template.py │ ├── _interactions.py │ ├── _sticker.py │ ├── _user.py │ └── _webhook.py │ └── py.typed ├── readthedocs.yml ├── setup.py └── tests ├── README.md ├── wumpy-bot ├── extensions │ ├── data_passed.py │ ├── empty.py │ ├── non_callable.py │ ├── raises.py │ ├── raises_load.py │ └── raises_unload.py ├── test_dispatcher.py └── test_extension.py ├── wumpy-gateway └── test_limiter.py ├── wumpy-interactions └── commands │ ├── test_context.py │ ├── test_decorators.py │ ├── test_option.py │ └── test_slash.py ├── wumpy-models ├── test_base.py ├── test_flags.py ├── test_message.py └── test_permissions.py └── wumpy-rest └── test_ratelimiter.py /.coveragerc: -------------------------------------------------------------------------------- 1 | [report] 2 | exclude_lines = 3 | pragma: not covered 4 | @overload 5 | if TYPE_CHECKING: 6 | raise NotImplementedError 7 | -------------------------------------------------------------------------------- /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | # Lines should be between 79 to 95, but the hard-stop is 105 3 | max-line-length = 105 4 | 5 | per-file-ignores = 6 | # Allow any __init__.py file to have unused imports 7 | **/__init__.py: F401, F403, F405 8 | 9 | ignore = 10 | # Line break before binary operator (Flake8 has one for before- and after 11 | # linebreaks. From my understanding you're meant to disable one of them). 12 | W503 13 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # Auto detect text files and perform LF normalization 2 | * text=auto 3 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false 2 | contact_links: 3 | - name: Read the Documentation 4 | url: https://wumpy.readthedocs.io/ 5 | about: Refer to the documentation before asking for help 6 | 7 | - name: Questions or Support 8 | url: https://github.com/wumpyproject/wumpy/discussions/new?category=questions-and-support 9 | about: Ask the community for help with a question or report a problem with the library 10 | 11 | - name: Discord API Feature Request 12 | url: https://github.com/discord/discord-api-docs/discussions/new 13 | about: Suggest a new feature to the Discord API 14 | 15 | - name: Library Feature Request 16 | url: https://github.com/wumpyproject/wumpy/discussions/new?category=feature-requests 17 | about: Discuss a new feature to be implemented into Wumpy 18 | 19 | - name: Development Discussion 20 | url: https://github.com/wumpyproject/wumpy/discussions/new?category=development 21 | about: Open a discussion about development of the library 22 | 23 | - name: Join the Discord server 24 | url: https://discord.gg/ZWpjYdKKTF 25 | about: There is a development server for IRC-style discussion 26 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/issue.yml: -------------------------------------------------------------------------------- 1 | name: Open an Issue 2 | description: Open a new issue with the library. Consider picking the other options first 3 | 4 | body: 5 | - type: markdown 6 | # '>' means that single newlines are turned into spaces - like with Markdown 7 | attributes: 8 | # We use double-newlines here so that they appear as empty newlines in 9 | # the final markdown that GitHub renders. 10 | value: > 11 | **Use [GitHub Discussions][discussions] for miscellaneous support and development.** 12 | 13 | 14 | Thanks for taking the time to open an issue. Issues are used to track verified bugs, 15 | feature requests that should be worked on, and to track API support. 16 | 17 | 18 | All issues and subsequent pull requests get tracked in the 19 | [organization project][org-project] so that they are triaged and one can follow its 20 | progress. This means that support queries should not go into the issue tracker - for 21 | that [GitHub Discussions][discussions] are used! 22 | 23 | 24 | If you're opening an issue to discuss and scope out a change or addition use 25 | [GitHub Discussions][discussions] or join the [development Discord server][discord]. 26 | 27 | 28 | This text is not included in the final issue, please fill out the form below: 29 | 30 | [discussions]: https://github.com/wumpyproject/wumpy/discussions 31 | [org-project]: https://github.com/orgs/wumpyproject/projects/1 32 | [discord]: https://discord.gg/ZWpjYdKKTF 33 | 34 | --- 35 | 36 | - type: textarea 37 | attributes: 38 | label: Summary 39 | description: > 40 | A Tl;Dr-style summary of why the issue is opened. Please keep this short and use one of 41 | the other text areas below to elaborate. 42 | validations: 43 | required: true 44 | 45 | - type: textarea 46 | attributes: 47 | label: Description 48 | description: > 49 | Longer description and full explanation of the issue. If this is a feature request 50 | explain the underlying problem - or if it is an API feature explain it more in detail 51 | and how it could be implemented into the library. 52 | 53 | - type: textarea 54 | attributes: 55 | label: Reproduction steps 56 | description: Instructions for how to locally run into the issue. How did you run into it? 57 | 58 | - type: textarea 59 | attributes: 60 | label: Tasks 61 | description: > 62 | A list of things to do before closing this issue. Please use checkboxes as shown in the 63 | placeholder below, these can be marked as completed individually. 64 | placeholder: '- [ ] ...' 65 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ## Summary 2 | 3 | 4 | 5 | 6 | ## Semantic Versioning 7 | 8 | 9 | -------------------------------------------------------------------------------- /.github/labeler/subpackages.yml: -------------------------------------------------------------------------------- 1 | wumpy-bot: 2 | - 'library/wumpy-bot/**/*' 3 | 4 | wumpy-cache: 5 | - 'library/wumpy-cache/**/*' 6 | 7 | wumpy-gateway: 8 | - 'library/wumpy-gateway/**/*' 9 | 10 | wumpy-interactions: 11 | - 'library/wumpy-interactions/**/*' 12 | 13 | wumpy-models: 14 | - 'library/wumpy-models/**/*' 15 | 16 | wumpy-rest: 17 | - 'library/wumpy-rest/**/*' 18 | -------------------------------------------------------------------------------- /.github/workflows/triaging.yml: -------------------------------------------------------------------------------- 1 | name: Triage issues and PRs 2 | 3 | on: 4 | issues: 5 | types: 6 | - opened 7 | pull_request: 8 | types: 9 | - opened 10 | 11 | jobs: 12 | greet: 13 | name: Greet first-time contributors 14 | 15 | runs-on: ubuntu-latest 16 | steps: 17 | - uses: actions/first-interaction@v1 18 | with: 19 | repo-token: ${{ secrets.GITHUB_TOKEN }} 20 | 21 | issue-message: > 22 | ### Thank you for opening your first issue! 🎉 23 | 24 | 25 | Issues are used to track verified bugs, feature requests that should be worked on, 26 | and keep tabs on API support. This issue will be added to the 27 | [organization project][org-project] that contains all issues and PRs in various 28 | stages. 29 | 30 | 31 | You do not need to create an issue for each code-change you want to make, but it 32 | can be good to ensure that you don't work on the same thing as another person or 33 | put effort into something that ultimately gets rejected. 34 | 35 | 36 | --- 37 | 38 | 39 | The next step is opening a pull request, if you want to. For more help, take a look 40 | at the [Contribution guide][contrib-guide] and join the 41 | [development Discord server][discord] for steps and useful tips. 42 | 43 | [org-project]: https://github.com/orgs/wumpyproject/projects/1 44 | [contrib-guide]: https://github.com/wumpyproject/wumpy/blob/main/CONTRIBUTING.md 45 | [discord]: https://discord.gg/ZWpjYdKKTF 46 | 47 | pr-message: > 48 | ### Congratulations on your first PR 49 | 50 | 51 | Contributions - like yours here - is what makes open source projects work. All 52 | changes are greatly appreciated: whether that is smaller cosmetic changes or bigger 53 | features - thank you for putting the time to contribute. ❤️ 54 | 55 | 56 | This PR will be added to the [organization project][org-project] to be triaged. You 57 | should receive a review within at least a few days depending on when you opened the 58 | pull request. The review may ask for some more changes before merging, which you 59 | should push to the same branch in your fork. 60 | 61 | 62 | Eventually your PR will be approved and merged into the repository. Your changes 63 | will then be include in the next release of the subpackage it was apart of! 64 | 65 | 66 | --- 67 | 68 | 69 | Consider joining the [development Discord server][discord] to ask for help with 70 | finishing your PR or! 71 | 72 | [org-project]: https://github.com/orgs/wumpyproject/projects/1 73 | [discord]: https://discord.gg/ZWpjYdKKTF 74 | 75 | add-labels: 76 | name: Label subpackages for PRs 77 | 78 | runs-on: ubuntu-latest 79 | steps: 80 | - if: github.event_name == 'pull_request' 81 | uses: actions/labeler@v4 82 | with: 83 | repo-token: ${{ secrets.GITHUB_TOKEN }} 84 | configuration-path: '.github/labeler/subpackages.yml' 85 | sync-labels: true 86 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | wumpy/*.html 8 | *.c 9 | *.so 10 | 11 | # Distribution / packaging 12 | .Python 13 | build/ 14 | develop-eggs/ 15 | dist/ 16 | downloads/ 17 | eggs/ 18 | .eggs/ 19 | lib/ 20 | lib64/ 21 | parts/ 22 | sdist/ 23 | var/ 24 | wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # Installer logs 31 | pip-log.txt 32 | pip-delete-this-directory.txt 33 | 34 | # Unit test / coverage reports 35 | htmlcov/ 36 | .tox/ 37 | .nox/ 38 | .coverage 39 | .coverage.* 40 | .cache 41 | nosetests.xml 42 | coverage.xml 43 | *.cover 44 | .hypothesis/ 45 | .pytest_cache/ 46 | 47 | # Sphinx documentation 48 | docs/_build/ 49 | 50 | # PyBuilder 51 | target/ 52 | 53 | # Jupyter Notebook 54 | .ipynb_checkpoints 55 | 56 | # IPython 57 | profile_default/ 58 | ipython_config.py 59 | 60 | # pyenv 61 | .python-version 62 | 63 | # Environments 64 | .env 65 | .venv 66 | env/ 67 | venv/ 68 | ENV/ 69 | env.bak/ 70 | venv.bak/ 71 | 72 | # Spyder project settings 73 | .spyderproject 74 | .spyproject 75 | 76 | # Rope project settings 77 | .ropeproject 78 | 79 | # Visual Studio code settings 80 | .vscode 81 | 82 | # IntelliJ project settings 83 | .idea 84 | 85 | # mkdocs documentation 86 | /site 87 | 88 | # mypy 89 | .mypy_cache/ 90 | .dmypy.json 91 | dmypy.json 92 | 93 | # Pyre type checker 94 | .pyre/ 95 | -------------------------------------------------------------------------------- /.isort.cfg: -------------------------------------------------------------------------------- 1 | [settings] 2 | multi_line_output=5 3 | combine_as_imports=true 4 | 5 | known_third_party=wumpy 6 | 7 | skip_glob=**/__init__.py 8 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | Thank you for your interest in contributing to this project. 4 | 5 | ## Setting up tools 6 | 7 | Static type checkers are encouraged to be used. There are plans to set up a 8 | CI that run PyRight or MyPy on the project to ensure it passes type checking. 9 | 10 | This project uses [flake8](https://github.com/PyCQA/flake8) for linting as a 11 | laid-back linter, mainly it enforces line length. Preferably lines should be 12 | between 79 and 95 characters long, but flake8 will not complain until a line 13 | reaches 105 characters which is the hard limit. 14 | 15 | Imports should be ordered according to the rules of 16 | [isort](https://github.com/PyCQA/isort). 17 | 18 | ### Building the documentation 19 | 20 | Because of how [Read the Docs](https://readthedocs.org/) builds our documentation from 21 | the root of our project that means that files and folders have to be configured 22 | from that origin. 23 | 24 | This means that building and serving the documentation needs to be done from 25 | *the root of the project* as well with the following commands: 26 | 27 | ```bash 28 | # Locally serve the documentation 29 | mkdocs serve --config-file docs/mkdocs.yml 30 | 31 | # For building use the equivalent: 32 | mkdocs build --config-file docs/mkdocs.yml 33 | ``` 34 | 35 | ## Making commits 36 | 37 | Once you have made your changes it is time to commit it to Git. These commits 38 | should be imperative and clearly explain what was changed. The commit body 39 | can be used to explain why it was changed. 40 | 41 | If you are unsure what this means, 42 | [read this blog post](https://chris.beams.io/posts/git-commit/). The blog post 43 | can be summarized to having your commit messages fit into this sentence: 44 | 45 | > If applied, this commit will ... 46 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | This software is made available under the terms of *either* of the licenses 2 | found in LICENSE-MIT or LICENSE-APACHE. Contributions to this software are 3 | made under the terms of *both* these licenses. 4 | -------------------------------------------------------------------------------- /LICENSE-MIT: -------------------------------------------------------------------------------- 1 | Permission is hereby granted, free of charge, to any person obtaining a copy 2 | of this software and associated documentation files (the "Software"), to deal 3 | in the Software without restriction, including without limitation the rights 4 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 5 | copies of the Software, and to permit persons to whom the Software is 6 | furnished to do so, subject to the following conditions: 7 | 8 | The above copyright notice and this permission notice shall be included in all 9 | copies or substantial portions of the Software. 10 | 11 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 12 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 13 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 14 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 15 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 16 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 17 | SOFTWARE. 18 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Wumpy 2 | 3 | A Discord API wrapper for Python. Wumpy aims to be easy enough for Wumpus, and 4 | extensible enough for Clyde! 5 | 6 | ## Community 7 | 8 | There is a [development Discord server](https://discord.gg/ZWpjYdKKTF) you can 9 | join. GitHub Discussions is used for support. 10 | 11 | ## Usage 12 | 13 | If you are new to Wumpy consider [reading the documentation](https://wumpy.rtfd.io). 14 | 15 | A lot of the functionality of Wumpy is separated into *multiple subpackages*, 16 | for example [`wumpy-gateway`](library/wumpy-gateway/README.md) (imported as 17 | `wumpy.gateway`) is a subpackage that only contains the code for interacting 18 | with the gateway. 19 | 20 | If you are building another library, consider using some of these subpackages 21 | instead of re-implementing the same functionality - more times than not it is 22 | only the highest level (the one that the end-user interacts with) that differs. 23 | For Wumpy the highest level of abstraction is 24 | [`wumpy-client`](library/wumpy-client/README.md) which is built on top of all 25 | other subpackages. 26 | 27 | ## Support 28 | 29 | If you have a problem please open a discussion through GitHub. This is the best 30 | place to ask for help as only bug reports or detailed feature requests should 31 | go in the issue tracker. 32 | 33 | ⭐ Please consider starring the repository, it really helps! ⭐ 34 | 35 | ## Future plans 36 | 37 | The highest priority right now is to get full API coverage, while keeping the 38 | standards of quality that Wumpy stands for. 39 | 40 | Development is triaged through 41 | [this GitHub project](https://github.com/orgs/wumpyproject/projects/1) if you 42 | get curious about the current state of the project, this brings together all 43 | issues and todos from all subpackage. 44 | 45 | ## Contributing 46 | 47 | The library is only first now starting to get a good structure. Take a look 48 | at [CONTRIBUTING.md](CONTRIBUTING.md) and 49 | [the Wiki](https://github.com/wumpyproject/wumpy/wiki) for developer notes and 50 | different design decision write-ups. 51 | 52 | The library is licensed under one of MIT or Apache 2.0 (up to you). Please 53 | see [LICENSE](./LICENSE) for more information. 54 | 55 | ## Version guarantees 56 | 57 | The Wumpy project's official subpackages all follow 58 | [Semantic Versioning 2.0](https://semver.org/): 59 | 60 | - Major version bumps hold no guarantees at all. 61 | 62 | - Minor version bumps are backwards compatible but not forwards compatible. 63 | 64 | - Patch versions are *both* backwards compatible and forwards compatible. 65 | 66 | To simplify the above, this is how it will affect you (these are imaginary 67 | example versions): 68 | 69 | - You can upgrade from `v1.0.6` to `v1.0.8`, or downgrade to `v1.0.3` without 70 | any code changes. 71 | 72 | - You can upgrade from `v1.5.0` to `v1.6.0` without any code changes, but you 73 | can't downgrade to `v1.4.0`. 74 | 75 | - You cannot upgrade from `v4.0.0` to `v5.0.0` or downgrade to `v3.0.0`. Major 76 | versions have no guarantee of compatability. 77 | 78 | ## Public API 79 | 80 | The public API is documented in the API reference hosted on Read the docs, 81 | which can be found [here](https://wumpy.rtfd.io/). 82 | -------------------------------------------------------------------------------- /docs/mkdocs.yml: -------------------------------------------------------------------------------- 1 | site_name: Wumpy 2 | site_description: Documentation for the Discord API wrapper Wumpy 3 | site_author: Bluenix 4 | 5 | repo_url: https://github.com/wumpyproject/wumpy 6 | repo_name: GitHub 7 | edit_uri: tree/main/docs 8 | 9 | docs_dir: 'source' 10 | 11 | theme: 12 | name: material 13 | palette: 14 | scheme: discord # Custom scheme 15 | primary: blurple 16 | accent: blurple 17 | features: 18 | - content.code.annotate 19 | - navigation.indexes 20 | - navigation.tabs 21 | - navigation.top 22 | extra_css: 23 | - css/_extra.css 24 | markdown_extensions: 25 | - admonition 26 | - pymdownx.highlight 27 | - pymdownx.superfences 28 | - pymdownx.tabbed 29 | 30 | nav: 31 | - Start page: 32 | - index.md 33 | 34 | - Setting up the bot: 35 | - setup/index.md 36 | - Creating the Bot: setup/creating-the-bot.md 37 | - Authenticatin with Discord: setup/authenticating.md 38 | - Running the Bot: setup/running-the-bot.md 39 | 40 | - Basic Tutorial: 41 | - Extensions: 42 | - Extensions FAQ: tutorial/extensions/extensions-faq.md 43 | - Getting started: tutorial/extensions/simple-example.md 44 | - Advanced usage: tutorial/extensions/advanced-usage.md 45 | 46 | - Reference: 47 | - Application commands: reference/interactions/commands.md 48 | - Utils: reference/utils.md 49 | 50 | plugins: 51 | - search: {} 52 | - mkdocstrings: 53 | custom_templates: docs/templates/ 54 | enable_inventory: true 55 | handlers: 56 | python: 57 | selection: 58 | new_path_syntax: true 59 | inherited_members: true 60 | rendering: 61 | show_source: false 62 | show_root_heading: true 63 | show_root_full_path: false 64 | show_root_members_full_path: false 65 | members_order: source 66 | -------------------------------------------------------------------------------- /docs/requirements.txt: -------------------------------------------------------------------------------- 1 | mkdocs==1.2.2 2 | mkdocs-material==7.3.0 3 | mkdocstrings==0.16.1 4 | -------------------------------------------------------------------------------- /docs/source/css/_extra.css: -------------------------------------------------------------------------------- 1 | /* This is the custom Discord color scheme */ 2 | [data-md-color-scheme=discord] { 3 | 4 | /* Default color shades */ 5 | --md-default-fg-color: #dcddde; 6 | --md-default-fg-color--light: #e3e5e8; 7 | --md-default-fg-color--lighter: #f5f6f7; 8 | --md-default-fg-color--lightest: #ffffff; 9 | --md-default-bg-color: #36393f; 10 | --md-default-bg-color--light: #4F545C; 11 | --md-default-bg-color--lighter: #72767D; 12 | --md-default-bg-color--lightest: #72767D; 13 | 14 | /* Primary color shades */ 15 | --md-primary-fg-color: #5865f2; 16 | --md-primary-fg-color--light: #5865f2; 17 | --md-primary-fg-color--dark: #5865f2; 18 | --md-primary-bg-color: hsla(0, 0%, 100%, 1); 19 | --md-primary-bg-color--light: hsla(0, 0%, 100%, 0.7); 20 | 21 | /* Accent color shades */ 22 | --md-accent-fg-color: #5865f2; 23 | --md-accent-fg-color--transparent: hsla(#5865f2, 0.1); 24 | --md-accent-bg-color: hsla(0, 0%, 100%, 1); 25 | --md-accent-bg-color--light: hsla(0, 0%, 100%, 0.7); 26 | 27 | /* Code color shades */ 28 | --md-code-fg-color: hsla(var(--md-hue),18%,86%,1); 29 | --md-code-bg-color: #18191c; 30 | --md-code-bg-border: #23272a; 31 | 32 | /* Code highlighting color shades */ 33 | --md-code-hl-color: rgba(66,135,255,0.15); 34 | --md-code-hl-number-color: #e6695b; 35 | --md-code-hl-special-color: #f06090; 36 | --md-code-hl-function-color: #c973d9; 37 | --md-code-hl-constant-color: #9383e2; 38 | --md-code-hl-keyword-color: #6791e0; 39 | --md-code-hl-comment-color: #4f545c; 40 | --md-code-hl-string-color: #2fb170; 41 | --md-code-hl-docstring-color: var(--md-code-hl-string-color); 42 | --md-code-hl-name-color: var(--md-code-fg-color); 43 | --md-code-hl-operator-color: var(--md-default-fg-color--light); 44 | --md-code-hl-punctuation-color: var(--md-default-fg-color--light); 45 | --md-code-hl-generic-color: var(--md-default-fg-color--light); 46 | --md-code-hl-variable-color: var(--md-default-fg-color--light); 47 | 48 | /* Typeset color shades */ 49 | --md-typeset-color: #b9bbbe; 50 | 51 | /* Typeset `a` color shades */ 52 | --md-typeset-a-color: var(--md-default-fg-color--lightest); 53 | 54 | /* Typeset `mark` color shades */ 55 | --md-typeset-mark-color: rgba(255,255,0,0.5); 56 | 57 | /* Typeset `del` and `ins` color shades */ 58 | --md-typeset-del-color: hsla(6, 90%, 60%, 0.15); 59 | --md-typeset-ins-color: hsla(150, 90%, 44%, 0.15); 60 | 61 | /* Typeset `kbd` color shades */ 62 | --md-typeset-kbd-color: hsla(0, 0%, 98%, 1); 63 | --md-typeset-kbd-accent-color: hsla(0, 100%, 100%, 1); 64 | --md-typeset-kbd-border-color: hsla(0, 0%, 72%, 1); 65 | 66 | /* Typeset `table` color shades */ 67 | --md-typeset-table-color: hsla(0, 0%, 0%, 0.12); 68 | 69 | /* Admonition color shades */ 70 | --md-admonition-fg-color: var(--md-default-fg-color); 71 | --md-admonition-bg-color: var(--md-default-bg-color); 72 | 73 | /* Footer color shades */ 74 | --md-footer-fg-color: hsla(0, 0%, 100%, 1); 75 | --md-footer-fg-color--light: hsla(0, 0%, 100%, 0.7); 76 | --md-footer-fg-color--lighter: hsla(0, 0%, 100%, 0.3); 77 | --md-footer-bg-color: hsla(0, 0%, 0%, 0.87); 78 | --md-footer-bg-color--dark: hsla(0, 0%, 0%, 0.32); 79 | 80 | /* Fonts similar to Discord's */ 81 | --md-text-font-family: Whitney,Helvetica Neue,Helvetica,Arial,sans-serif; 82 | --md-code-font-family: monospace; 83 | } 84 | 85 | /* Make headers clearer */ 86 | .md-typeset h1, .md-typeset h2, .md-typeset h3 { 87 | color: var(--md-default-fg-color--lightest); 88 | } 89 | 90 | /* Give codeblocks borders */ 91 | .md-typeset code { 92 | outline-width: 1px; 93 | border: 1px solid var(--md-code-bg-border); 94 | border-radius: 4px; 95 | } 96 | 97 | /* Center images */ 98 | .md-typeset img, .md-typeset svg { 99 | position: relative; 100 | left: 50%; 101 | transform: translate(-50%); 102 | } 103 | 104 | /* Make docstrings have a separate color */ 105 | .highlight .sd { 106 | color: var(--md-code-hl-docstring-color); 107 | } 108 | 109 | /* 110 | * The navigation links use the lighter colors by default but in the discord 111 | * theme the lighter colors mean more white and accentuated 112 | */ 113 | .md-nav__link[data-md-state=blur] { 114 | color: var(--md-typeset-color); 115 | } 116 | 117 | .md-nav__link:focus, .md-nav__link:hover { 118 | color: var(--md-primary-fg-color); 119 | } 120 | 121 | .md-nav__item .md-nav__link--active { 122 | color: var(--md-default-fg-color--lightest); 123 | } 124 | 125 | .doc-heading code { 126 | /*display: flex;*/ 127 | border: none; /* Remove the border from earlier */ 128 | } 129 | 130 | /* Indentation. */ 131 | div.doc-contents:not(.first) { 132 | padding-left: 25px; 133 | } 134 | 135 | /* Avoid breaking parameters name, etc. in table cells. */ 136 | td code { 137 | word-break: normal !important; 138 | } 139 | -------------------------------------------------------------------------------- /docs/source/index.md: -------------------------------------------------------------------------------- 1 | # Wumpy 2 | 3 | Imagine an efficient and extensible Discord API wrapper. 4 | -------------------------------------------------------------------------------- /docs/source/reference/interactions/commands.md: -------------------------------------------------------------------------------- 1 | --- 2 | hide: 3 | - toc 4 | --- 5 | 6 | # Application commands API Reference 7 | 8 | This file contains the API reference for the application commands part of the 9 | `wumpy.interactions` package. 10 | 11 | ::: wumpy.interactions.commands:CommandRegistrar 12 | 13 | ::: wumpy.interactions.commands:Option 14 | 15 | ::: wumpy.interactions.commands.option:OptionClass 16 | 17 | ::: wumpy.interactions.commands:option 18 | 19 | ::: wumpy.interactions.commands:SlashCommand 20 | 21 | ::: wumpy.interactions.commands:SubcommandGroup 22 | 23 | ::: wumpy.interactions.commands:Subcommand 24 | 25 | ::: wumpy.interactions.commands:MessageCommand 26 | 27 | ::: wumpy.interactions.commands:UserCommand 28 | -------------------------------------------------------------------------------- /docs/source/reference/utils.md: -------------------------------------------------------------------------------- 1 | --- 2 | hide: 3 | - toc 4 | --- 5 | 6 | # Wumpy utils API Reference 7 | 8 | ::: wumpy.utils:Event 9 | 10 | ::: wumpy.utils:EventDispatcher 11 | -------------------------------------------------------------------------------- /docs/source/setup/creating-the-bot.md: -------------------------------------------------------------------------------- 1 | # Creating the bot 2 | 3 | The first step of all Discord tutorials is to create the bot account, this is how you 4 | authenticate and talk to the Discord API. 5 | 6 | ## Creating an application 7 | 8 | Discord's API consist of applications. These applications are used for games, "Log in with 9 | Discord", and of course - bots! 10 | 11 | Start by navigating to the [Discord Developer Portal](https://discord.com/developers/). The 12 | page should look like this: 13 | 14 | ![Overview of the Discord Developer Portal](images/creating-the-bot/developer-portal.png) 15 | 16 | Now click the "New Application" button at the top right. This will prompt you for a name for 17 | your application, like this: 18 | 19 | ![Prompt asking for a name](images/creating-the-bot/name-prompt.png) 20 | 21 | Wumpus decided to call its bot "Wumpbot", but you are free to name yours as you wish. 22 | 23 | Now click "Create", you should see the following page: 24 | 25 | ![General information about the application](images/creating-the-bot/general-information.png) 26 | 27 | This step is completely optional, but you can now add a description and picture to your 28 | application if you want to. 29 | 30 | ## Adding a bot to the mix 31 | 32 | It is now time to add a bot to the application, click the "Bot" tab in the sidebar. It should 33 | look like this: 34 | 35 | ![Overview of empty Bot tab](images/creating-the-bot/build-a-bot.png) 36 | 37 | Click "Add Bot" in the top right and then "Yes, do it!" in the following dialog box to confirm: 38 | 39 | ![Confirmation box](images/creating-the-bot/bot-confirmation.png) 40 | 41 | There is now a bot attached to your application. You should see the following page: 42 | 43 | ![Overview of Bot tab with information](images/creating-the-bot/bot-overview.png) 44 | 45 | ## Inviting the bot to your server 46 | 47 | The very last part of creating a Discord bot is inviting it to your server. 48 | 49 | In the left sidebar go to "OAuth2" and pick "bot" as well as "application.commands" in the 50 | OAuth2 URL Generator at the bottom of the page: 51 | 52 | ![Overview of OAuth2 tab](images/creating-the-bot/oauth2-tab.png) 53 | 54 | Now copy the link generated and open it in your browser. It should look like this: 55 | 56 | ![OAuth2 flow page](images/creating-the-bot/bot-invite-flow.png) 57 | 58 | Select the server you want to add the bot to and click "Authorize". The bot will now appear 59 | in your server with the power of technology: 60 | 61 | ![Bot appearing in the Discord server](images/creating-the-bot/bot-joined.png) 62 | -------------------------------------------------------------------------------- /docs/source/setup/images/authenticating/bot-overview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wumpyproject/wumpy/08b1628a8aa4bddf6bd25b4e9418247783fa6663/docs/source/setup/images/authenticating/bot-overview.png -------------------------------------------------------------------------------- /docs/source/setup/images/authenticating/general-information.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wumpyproject/wumpy/08b1628a8aa4bddf6bd25b4e9418247783fa6663/docs/source/setup/images/authenticating/general-information.png -------------------------------------------------------------------------------- /docs/source/setup/images/authenticating/oauth2-page.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wumpyproject/wumpy/08b1628a8aa4bddf6bd25b4e9418247783fa6663/docs/source/setup/images/authenticating/oauth2-page.png -------------------------------------------------------------------------------- /docs/source/setup/images/creating-the-bot/bot-confirmation.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wumpyproject/wumpy/08b1628a8aa4bddf6bd25b4e9418247783fa6663/docs/source/setup/images/creating-the-bot/bot-confirmation.png -------------------------------------------------------------------------------- /docs/source/setup/images/creating-the-bot/bot-invite-flow.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wumpyproject/wumpy/08b1628a8aa4bddf6bd25b4e9418247783fa6663/docs/source/setup/images/creating-the-bot/bot-invite-flow.png -------------------------------------------------------------------------------- /docs/source/setup/images/creating-the-bot/bot-joined.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wumpyproject/wumpy/08b1628a8aa4bddf6bd25b4e9418247783fa6663/docs/source/setup/images/creating-the-bot/bot-joined.png -------------------------------------------------------------------------------- /docs/source/setup/images/creating-the-bot/bot-overview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wumpyproject/wumpy/08b1628a8aa4bddf6bd25b4e9418247783fa6663/docs/source/setup/images/creating-the-bot/bot-overview.png -------------------------------------------------------------------------------- /docs/source/setup/images/creating-the-bot/build-a-bot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wumpyproject/wumpy/08b1628a8aa4bddf6bd25b4e9418247783fa6663/docs/source/setup/images/creating-the-bot/build-a-bot.png -------------------------------------------------------------------------------- /docs/source/setup/images/creating-the-bot/developer-portal.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wumpyproject/wumpy/08b1628a8aa4bddf6bd25b4e9418247783fa6663/docs/source/setup/images/creating-the-bot/developer-portal.png -------------------------------------------------------------------------------- /docs/source/setup/images/creating-the-bot/general-information.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wumpyproject/wumpy/08b1628a8aa4bddf6bd25b4e9418247783fa6663/docs/source/setup/images/creating-the-bot/general-information.png -------------------------------------------------------------------------------- /docs/source/setup/images/creating-the-bot/name-prompt.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wumpyproject/wumpy/08b1628a8aa4bddf6bd25b4e9418247783fa6663/docs/source/setup/images/creating-the-bot/name-prompt.png -------------------------------------------------------------------------------- /docs/source/setup/images/creating-the-bot/oauth2-tab.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wumpyproject/wumpy/08b1628a8aa4bddf6bd25b4e9418247783fa6663/docs/source/setup/images/creating-the-bot/oauth2-tab.png -------------------------------------------------------------------------------- /docs/source/setup/images/running-the-bot/interaction-url.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wumpyproject/wumpy/08b1628a8aa4bddf6bd25b4e9418247783fa6663/docs/source/setup/images/running-the-bot/interaction-url.png -------------------------------------------------------------------------------- /docs/source/setup/index.md: -------------------------------------------------------------------------------- 1 | # Introduction 2 | 3 | The purpose of this guide is to walk you through setting it up a bot and getting it to run. 4 | 5 | These steps will not be repeated anywhere else in the documentation, and all documentation is 6 | based around the fact that you have read this. 7 | -------------------------------------------------------------------------------- /docs/source/setup/running-the-bot.md: -------------------------------------------------------------------------------- 1 | # Running the bot 2 | 3 | Discord will not host bots for you, it is your responsibility to be running the bot. 4 | This page will go through tips for running the bot and testing locally. 5 | 6 | ## Interaction server 7 | 8 | The interaction server is just an HTTP server, like a website! 9 | 10 | Wumpy's `InteractionApp` implements the ASGI specification, meaning that you can run 11 | your bot with any ASGI server. The following is a non-exhaustive list of ASGI servers: 12 | 13 | - [Uvicorn](https://github.com/encode/uvicorn) 14 | - [Hypercorn](https://gitlab.com/pgjones/hypercorn) 15 | 16 | Follow the respective guide for the ASGI server you pick. 17 | 18 | To use an interaction server, make sure the the server is running then log into the 19 | [Discord Developer Portal](https://discord.com/developers) and configure the interaction 20 | server URL to tell Discord where to send the interactions: 21 | 22 | ![Application overview](images/running-the-bot/interaction-url.png) 23 | 24 | ### Running it locally 25 | 26 | The big drawback with using an interaction server is that Discord requires the usage of HTTPS 27 | which can have a very complicated setup. 28 | 29 | This means that you need to port-forward the server and configure certificates. 30 | 31 | There are tools for this though, because you are not alone in this inconvenience. 32 | 33 | [Ngrok](https://ngrok.com) will open a tunnel to your computer, and allow you to 34 | test the interaction server locally over HTTPS. 35 | 36 | !!! info 37 | Ngrok will not give you the same URL everytime you run it unless you are using a Pro plan. 38 | This means that you need to update the interaction URL everytime you restart Ngrok. 39 | 40 | ## Gateway 41 | 42 | The Discord gateway uses WebSockets and requires no further setup. 43 | 44 | Start the bot by importing `anyio` and use `anyio.run()` with the backend you want to use: 45 | 46 | === "Asyncio" 47 | 48 | ```python 49 | import anyio 50 | import wumpy 51 | 52 | bot = wumpy.Client(...) 53 | 54 | if __name__ == '__main__': 55 | anyio.run(bot.start, backend='asyncio') 56 | ``` 57 | 58 | !!! note 59 | `if __name__ == '__main__':` is a way to ensure that code only runs when the file is 60 | ran directly, otherwise you might start the bot when attempting to import it. 61 | 62 | === "Trio" 63 | 64 | ```python 65 | import anyio 66 | import wumpy 67 | 68 | bot = wumpy.Client(...) 69 | 70 | if __name__ == '__main__': 71 | anyio.run(bot.start, backend='trio') 72 | ``` 73 | 74 | !!! note 75 | `if __name__ == '__main__':` is a way to ensure that code only runs when the file is 76 | ran directly, otherwise you might start the bot when attempting to import it. 77 | 78 | !!! warning 79 | The downside with a long-living WebSocket is that many free hosters like 80 | [Replit](https://replit.com) or [Heroku](https://heroku.com) might kill the bot. 81 | -------------------------------------------------------------------------------- /docs/source/tutorial/extensions/advanced-usage.md: -------------------------------------------------------------------------------- 1 | # More advanced extensions usages 2 | 3 | There are times when you want to run some code upon loading. This is fully 4 | supported in Wumpy's extension system. 5 | 6 | ## Partial example 7 | 8 | Most of the time you just want to some code when loading but still load 9 | commands and listeners. This can be accomplished as follows: 10 | 11 | ```python 12 | from wumpy import Extension 13 | 14 | 15 | ext = Extension() 16 | 17 | 18 | ... # Here is some code that adds commands and listeners 19 | 20 | 21 | def load(target, data): 22 | print('Loading extension...') 23 | 24 | # Let the extension take over 25 | return ext.load(target, data) 26 | ``` 27 | 28 | ## Underlying extension API 29 | 30 | The underlying extension API is very ambiguous and only consists of two 31 | callables. 32 | 33 | The first callable should take two positional arguments - the target that 34 | the items (like commands and listeners) should be added to and a dictionary of 35 | values passed. 36 | 37 | The second argument allows passing values into an extension as it is loaded, 38 | which can be useful for configuration. 39 | 40 | At last the first callable should return another callable which only takes one 41 | argument - the target from before. The point of this callable is to undo what 42 | the first callable did (unload). 43 | 44 | ## Full usage 45 | 46 | A fully managed extension loading where you set some attribute can look like 47 | this as an example: 48 | 49 | ```python 50 | def unloader(target): 51 | del target.loaded 52 | 53 | def load(target, data): 54 | target.loaded = data.get('value', True) 55 | # Return the unloader that Wumpy will call when this extension is 56 | # unloaded for cleanup. 57 | return unloader 58 | ``` 59 | -------------------------------------------------------------------------------- /docs/source/tutorial/extensions/extensions-faq.md: -------------------------------------------------------------------------------- 1 | # Why use extensions? 2 | 3 | As you follow this tutorial you will eventually feel the need to split your 4 | Discord bot into multiple files to make your code mode readable and easier to 5 | manage. This is exactly what extensions are for. 6 | 7 | ## What are extensions? 8 | 9 | Extensions allow you to define some commands and listeners elsewhere, then load 10 | them onto your main bot or application. 11 | 12 | This way you can put some commands having to do with a certain feature in one 13 | specific file reducing the length of your main file. 14 | 15 | ## Why are extensions loaded using strings? 16 | 17 | There's another big benefit to extensions; they are dynamically loaded! 18 | 19 | For you as a user this means that you can reload an extension to apply changes 20 | without restarting your bot. If the extensions are imported directly using 21 | `from ... import ...` that complicates things greatly. 22 | 23 | ## What are the downsides of extensions? 24 | 25 | When extensions are reloaded they are also fully executed again so that new 26 | changes can be applied. This means that existing code could be holding outdated 27 | references. 28 | 29 | For example, if you define a class inside an extension and have other code 30 | that imports this and uses it with `isinstance(...)` you will get unexpected 31 | behavior when reloading an extension. The code that has the `isinstance(...)` 32 | *could* be holding a reference to the old class (from Python's point of view 33 | that is a different class) and `isinstance(...)` will return False. 34 | 35 | This is very uncommon if you structure your code correctly though, but could be 36 | worth noting when designing your bot. 37 | -------------------------------------------------------------------------------- /docs/source/tutorial/extensions/simple-example.md: -------------------------------------------------------------------------------- 1 | # Simple extension example 2 | 3 | For most code you can easily change `InteractionApp` or `GatewayBot` to 4 | `Extension` and it will work as expected. 5 | 6 | Here's the code-block from [the Hello World](#hello-world.md) changed to be an 7 | extension: 8 | 9 | ```python 10 | from wumpy import Extension, interactions 11 | 12 | 13 | ext = Extension() 14 | 15 | 16 | @ext.command() 17 | async def hello(interaction: interactions.CommandInteraction) -> None: 18 | """Greet the bot and say hello.""" 19 | await interaction.respond('Hello') 20 | ``` 21 | 22 | Inside of your main file you can now load this extension like this: 23 | 24 | ```python 25 | from wumpy import interactions 26 | 27 | 28 | app = interactions.InteractionApp(...) 29 | 30 | 31 | # Load the extension, which is inside `hello_world.py`. Then after the ':' we 32 | # tell Wumpy that the actual extension variable is called `ext`. 33 | app.load_extension('hello_world:ext') 34 | ``` 35 | -------------------------------------------------------------------------------- /docs/templates/python/material/method.html: -------------------------------------------------------------------------------- 1 | {{ log.debug() }} 2 | {% if config.show_if_no_docstring or method.has_contents %} 3 | 4 |
5 | {% with html_id = method.path %} 6 | 7 | {% if not root or config.show_root_heading %} 8 | 9 | {% if root %} 10 | {% set show_full_path = config.show_root_full_path %} 11 | {% set root_members = True %} 12 | {% elif root_members %} 13 | {% set show_full_path = config.show_root_members_full_path or config.show_object_full_path %} 14 | {% set root_members = False %} 15 | {% else %} 16 | {% set show_full_path = config.show_object_full_path %} 17 | {% endif %} 18 | 19 | {% filter heading(heading_level, 20 | role="method", 21 | id=html_id, 22 | class="doc doc-heading", 23 | toc_label=method.name ~ "()") %} 24 | 25 | 26 | {% with properties = method.properties %} 27 | {% include "properties.html" with context %} 28 | {% endwith %} 29 | 30 | {% filter highlight(language="python", inline=True) %} 31 | {% if show_full_path %}{{ method.path }}{% else %}{{ method.name }}{% endif %} 32 | {% with signature = method.signature %}{% include "signature.html" with context %}{% endwith %} 33 | {% endfilter %} 34 | 35 | {% endfilter %} 36 | 37 | {% else %} 38 | {% if config.show_root_toc_entry %} 39 | {% filter heading(heading_level, 40 | role="method", 41 | id=html_id, 42 | toc_label=method.path, 43 | hidden=True) %} 44 | {% endfilter %} 45 | {% endif %} 46 | {% set heading_level = heading_level - 1 %} 47 | {% endif %} 48 | 49 |
50 | {% with docstring_sections = method.docstring_sections %} 51 | {% include "docstring.html" with context %} 52 | {% endwith %} 53 | 54 | {% if config.show_source and method.source %} 55 |
56 | Source code in {{ method.relative_file_path }} 57 | {{ method.source.code|highlight(language="python", linestart=method.source.line_start, linenums=False) }} 58 |
59 | {% endif %} 60 |
61 | 62 | {% endwith %} 63 |
64 | 65 | {% endif %} -------------------------------------------------------------------------------- /docs/templates/python/material/parameters.html: -------------------------------------------------------------------------------- 1 | {{ log.debug() }} 2 |

Parameters:

3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | {% for parameter in parameters %} 14 | 15 | 16 | 17 | 18 | 19 | 30 | 31 | {% endfor %} 32 | 33 |
NameTypeDescriptionDefault
{{ parameter.name }}{% if parameter.annotation %}{{ parameter.annotation }}{% endif %}{{ parameter.description|convert_markdown(heading_level, html_id) }} 20 | {% if parameter.default %} 21 | {% if parameter.default == '' %} 22 | optional 23 | {% else %} 24 | {{ parameter.default }} 25 | {% endif %} 26 | {% else %} 27 | required 28 | {% endif %} 29 |
-------------------------------------------------------------------------------- /library/wumpy-bot/README.md: -------------------------------------------------------------------------------- 1 | # Wumpy-bot 2 | 3 | Easy to use high abstraction over other Wumpy subpackages. 4 | 5 | This is the most beginner-friendly way to use Wumpy, and provides all features 6 | of the Discord API. This is the primary package installed from PyPI when you 7 | only specify [`wumpy`](https://pypi.org/project/wumpy/). 8 | 9 | ## Getting started 10 | 11 | All official Wumpy projects prioritise both asyncio and Trio support so you can 12 | run the bot under either: 13 | 14 | ```python 15 | import anyio 16 | from wumpy.bot import Bot 17 | 18 | 19 | bot = Bot('ABC123.XYZ789') # Replace with your token and keep it safe! 20 | 21 | # This runs the bot with Trio as the event loop (recommended), 22 | # use backend='asyncio' to run it under asyncio. 23 | anyio.run(bot.run, backend='trio') 24 | ``` 25 | 26 | ### Registering listeners 27 | 28 | Continuing from the previous code, you can register listeners for Discord 29 | events using Wumpy's rich event listeners: 30 | 31 | ```python 32 | import anyio 33 | from wumpy.bot import Bot 34 | from wumpy.bot.events import MessageDeleteEvent 35 | 36 | 37 | bot = Bot('ABC123.XYZ789') 38 | 39 | 40 | @bot.listener() 41 | async def log_deleted_messages(event: MessageDeleteEvent): 42 | print(f'Message {event.message_id} in {event.channel_id} was deleted') 43 | 44 | 45 | anyio.run(bot.run, backend='trio') 46 | ``` 47 | 48 | The listener is registered with the `@bot.listener()` decorator, which tells 49 | Wumpy to read the annotation of the first parameter (name does not matter, but 50 | here it is called `event`) and register that function for the type of event 51 | that it is typehinted as. 52 | -------------------------------------------------------------------------------- /library/wumpy-bot/pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "wumpy-bot" 3 | version = "0.1.0" 4 | description = "Highly abstracted and easy to use wrapper over the Wumpy project" 5 | readme = {file = "README.md", content-type="text/markdown"} 6 | 7 | requires-python = ">=3.7" 8 | 9 | license = {text = "Dual-licensed under the Apache License 2.0 and the MIT License"} 10 | authors = [{name = "Bluenix", email = "bluenixdev@gmail.com"}] 11 | 12 | keywords = [ 13 | "wumpy", "wumpus", "wrapper", 14 | "discord", "discord-api", "discord-bot", "discord-api-wrapper", 15 | "python-3" 16 | ] 17 | classifiers = [ 18 | "Development Status :: 2 - Pre-Alpha", 19 | "Framework :: AnyIO", 20 | "Intended Audience :: Developers", 21 | "License :: OSI Approved :: MIT License", 22 | "License :: OSI Approved :: Apache Software License", 23 | "Natural Language :: English", 24 | "Operating System :: OS Independent", 25 | "Programming Language :: Python", 26 | "Programming Language :: Python :: 3", 27 | "Programming Language :: Python :: 3 :: Only", 28 | "Programming Language :: Python :: 3.7", 29 | "Programming Language :: Python :: 3.8", 30 | "Programming Language :: Python :: 3.9", 31 | "Programming Language :: Python :: 3.10", 32 | "Topic :: Internet", 33 | "Topic :: Internet :: WWW/HTTP", 34 | "Topic :: Software Development :: Libraries", 35 | "Topic :: Software Development :: Libraries :: Python Modules", 36 | "Typing :: Typed", 37 | ] 38 | 39 | dependencies = [ 40 | "anyio >= 3.3.4, <4", 41 | "typing_extensions >= 4.3, <5", 42 | "attrs >= 21.3, <= 24", 43 | "wumpy-gateway >= 0.3, <1", 44 | "wumpy-rest >= 0.3, <1", 45 | "wumpy-models >= 0.1, <1", 46 | "wumpy-interactions >= 0.1, <1", 47 | "wumpy-cache >= 0.1, <1", 48 | ] 49 | 50 | [project.urls] 51 | Homepage = "https://github.com/wumpyproject/wumpy" 52 | Repository = "https://github.com/wumpyproject/wumpy/tree/main/library/wumpy-bot" 53 | Documentation = "https://wumpy.rtfd.io" 54 | 55 | [build-system] 56 | requires = ["flit_core >=3.5, <4"] 57 | build-backend = "flit_core.buildapi" 58 | 59 | [tool.flit.module] 60 | # This is a subpackage under the wumpy namespace package, 61 | # we need to tell flit this otherwise it tries to make the 62 | # import wumpy-client rather than wumpy.client 63 | name = "wumpy.bot" 64 | -------------------------------------------------------------------------------- /library/wumpy-bot/wumpy/bot/__init__.py: -------------------------------------------------------------------------------- 1 | from ._bot import ( 2 | Bot, 3 | get_bot, 4 | ) 5 | from ._dispatch import ( 6 | ErrorContext, 7 | Event, 8 | EventDispatcher, 9 | ErrorHandlerMixin, 10 | ) 11 | from ._errors import ( 12 | WumpyException, 13 | ConnectionClosed, 14 | CommandException, 15 | ExtensionFailure, 16 | ) 17 | from ._extension import ( 18 | Extension, 19 | ExtensionLoader, 20 | ) 21 | 22 | __all__ = ( 23 | 'Bot', 24 | 'get_bot', 25 | 'ErrorContext', 26 | 'Event', 27 | 'EventDispatcher', 28 | 'ErrorHandlerMixin', 29 | 'WumpyException', 30 | 'ConnectionClosed', 31 | 'CommandException', 32 | 'ExtensionFailure', 33 | ) 34 | -------------------------------------------------------------------------------- /library/wumpy-bot/wumpy/bot/_utils.py: -------------------------------------------------------------------------------- 1 | import functools 2 | from typing import Any, Callable, Dict, Generic, Optional, Type, TypeVar 3 | 4 | # Internal, but it would be pointless to copy it over. 5 | from wumpy.models._utils import \ 6 | _get_as_snowflake as _get_as_snowflake # noqa: F401 7 | 8 | __all__ = ( 9 | 10 | ) 11 | 12 | 13 | T = TypeVar('T') 14 | 15 | 16 | def _eval_annotations(obj: 'Callable[..., object]') -> Dict[str, Any]: 17 | """Eval a callable's annotations. 18 | 19 | This is primarily a backport of Python 3.10's `get_annotations()` 20 | method implemented by Larry Hastings: 21 | https://github.com/python/cpython/commit/74613a46fc79cacc88d3eae4105b12691cd4ba20 22 | 23 | Parameters: 24 | obj: The received callable to evaluate 25 | 26 | Returns: 27 | A dictionary of parameter name to its annotation. 28 | """ 29 | unwrapped = obj 30 | while True: 31 | if hasattr(unwrapped, '__wrapped__'): 32 | unwrapped = unwrapped.__wrapped__ 33 | continue 34 | if isinstance(unwrapped, functools.partial): 35 | unwrapped = unwrapped.func 36 | continue 37 | break 38 | 39 | annotations = getattr(unwrapped, '__annotations__', None) 40 | eval_globals = getattr(unwrapped, '__globals__', None) 41 | 42 | if annotations is None or not annotations: 43 | return {} 44 | 45 | if not isinstance(annotations, dict): 46 | raise ValueError(f'{unwrapped!r}.__annotations__ is neither a dict nor None') 47 | 48 | try: 49 | return { 50 | key: value if not isinstance(value, str) else eval(value, eval_globals) 51 | for key, value in annotations.items() 52 | } 53 | except (NameError, SyntaxError) as e: 54 | raise ValueError(f'Could not evaluate the annotations of {unwrapped!r}') from e 55 | 56 | 57 | class RuntimeVar(Generic[T]): 58 | """Descriptor for attributes that are set during runtime. 59 | 60 | This descriptor raises a `RuntimeError` if the attribute is accessed before 61 | it has been set - the benefit of which is that you can have attributes that 62 | should usualy use `Optional[T]` but without the unnecessary runtime checks 63 | to pass type hinting. 64 | 65 | This is a generic for the type of the underlying value. 66 | """ 67 | 68 | name: Optional[str] 69 | value: T 70 | 71 | def __init__(self, name: Optional[str] = None) -> None: 72 | self.name = name 73 | 74 | def __set_name__(self, owner: Type[object], name: str) -> None: 75 | if self.name is not None: 76 | self.name = name 77 | 78 | def __get__(self, instance: Optional[object], cls: Type[object]) -> T: 79 | if instance is None: 80 | raise AttributeError(f'{cls.__name__!r} object has no attribute {self.name!r}') 81 | 82 | if not hasattr(self, 'value'): 83 | raise RuntimeError( 84 | f'Cannot access runtime variable {self.name!r} before bot is running' 85 | ) 86 | 87 | return self.value 88 | 89 | def __set__(self, instance: object, value: T) -> None: 90 | self.value = value 91 | -------------------------------------------------------------------------------- /library/wumpy-bot/wumpy/bot/events/__init__.py: -------------------------------------------------------------------------------- 1 | from ._channel import ( 2 | ChannelCreateEvent, 3 | ChannelUpdateEvent, 4 | ChannelDeleteEvent, 5 | TypingEvent, 6 | ThreadCreateEvent, 7 | ThreadUpdateEvent, 8 | ThreadDeleteEvent, 9 | ThreadListSyncEvent, 10 | ChannelPinsUpdateEvent, 11 | ) 12 | from ._gateway import ( 13 | HelloEvent, 14 | ResumedEvent, 15 | ReadyEvent, 16 | ) 17 | from ._guild import ( 18 | GuildDeleteEvent, 19 | BanAddEvent, 20 | BanRemoveEvent, 21 | GuildEmojisUpdateEvent, 22 | GuildStickersUpdateEvent, 23 | MemberJoinEvent, 24 | MemberRemoveEvent, 25 | MemberUpdateEvent, 26 | RoleCreateEvent, 27 | RoleUpdateEvent, 28 | RoleDeleteEvent, 29 | ) 30 | from ._message import ( 31 | MessageCreateEvent, 32 | MessageUpdateEvent, 33 | MessageDeleteEvent, 34 | BulkMessageDeleteEvent, 35 | ReactionAddEvent, 36 | ReactionRemoveEvent, 37 | ReactionClearEvent, 38 | ReactionEmojiClearEvent, 39 | ) 40 | 41 | __all__ = ( 42 | 'ChannelCreateEvent', 43 | 'ChannelUpdateEvent', 44 | 'ChannelDeleteEvent', 45 | 'TypingEvent', 46 | 'ThreadCreateEvent', 47 | 'ThreadUpdateEvent', 48 | 'ThreadDeleteEvent', 49 | 'ThreadListSyncEvent', 50 | 'ChannelPinsUpdateEvent', 51 | 'HelloEvent', 52 | 'ReadyEvent', 53 | 'ResumedEvent', 54 | 'GuildDeleteEvent', 55 | 'BanAddEvent', 56 | 'BanRemoveEvent', 57 | 'GuildEmojisUpdateEvent', 58 | 'GuildStickersUpdateEvent', 59 | 'MemberJoinEvent', 60 | 'MemberRemoveEvent', 61 | 'MemberUpdateEvent', 62 | 'RoleCreateEvent', 63 | 'RoleUpdateEvent', 64 | 'RoleDeleteEvent', 65 | 'MessageCreateEvent', 66 | 'MessageUpdateEvent', 67 | 'MessageDeleteEvent', 68 | 'BulkMessageDeleteEvent', 69 | 'ReactionAddEvent', 70 | 'ReactionRemoveEvent', 71 | 'ReactionClearEvent', 72 | 'ReactionEmojiClearEvent', 73 | ) 74 | -------------------------------------------------------------------------------- /library/wumpy-bot/wumpy/bot/events/_gateway.py: -------------------------------------------------------------------------------- 1 | from typing import Any, ClassVar, FrozenSet, Mapping, Optional, Tuple 2 | 3 | import attrs 4 | from discord_typings import ReadyData 5 | from typing_extensions import Self 6 | from wumpy.models import ApplicationFlags, Snowflake, User 7 | 8 | from .._dispatch import Event 9 | 10 | __all__ = ( 11 | 'HelloEvent', 12 | 'ResumedEvent', 13 | 'ReadyEvent', 14 | ) 15 | 16 | 17 | @attrs.define() 18 | class HelloEvent(Event): 19 | 20 | NAME: ClassVar[str] = 'HELLO' 21 | 22 | @classmethod 23 | def from_payload(cls, payload: Mapping[str, Any], cached: None = None) -> Self: 24 | return cls() 25 | 26 | 27 | @attrs.define() 28 | class ResumedEvent(Event): 29 | 30 | NAME: ClassVar[str] = 'RESUMED' 31 | 32 | @classmethod 33 | def from_payload(cls, payload: Mapping[str, Any], cached: None = None) -> Optional[Self]: 34 | return cls() 35 | 36 | 37 | @attrs.define(kw_only=True) 38 | class ReadyEvent(Event): 39 | user: User 40 | guilds: FrozenSet[Snowflake] 41 | shard: Optional[Tuple[int, int]] 42 | 43 | flags: ApplicationFlags 44 | 45 | NAME: ClassVar[str] = 'READY' 46 | 47 | @classmethod 48 | def from_payload(cls, payload: ReadyData, cached: None = None) -> Self: 49 | return cls( 50 | user=User.from_data(payload['user']), 51 | guilds=frozenset([Snowflake(int(guild['id'])) for guild in payload['guilds']]), 52 | shard=tuple(payload['shard']) if 'shard' in payload else None, 53 | flags=ApplicationFlags(int(payload['application']['flags'])) 54 | ) 55 | -------------------------------------------------------------------------------- /library/wumpy-bot/wumpy/bot/py.typed: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wumpyproject/wumpy/08b1628a8aa4bddf6bd25b4e9418247783fa6663/library/wumpy-bot/wumpy/bot/py.typed -------------------------------------------------------------------------------- /library/wumpy-cache/README.md: -------------------------------------------------------------------------------- 1 | # Wumpy-cache 2 | 3 | Simple memory cache for the gateway. 4 | 5 | The default implementation in `wumpy-cache` is rather simple dictionaries, but 6 | it can be overriden by other implementations as long as they follow the typing 7 | `Cache` Protocol. 8 | 9 | This means that you can use alternate implementations of data structures for 10 | more efficient retrieval for your use-case or offload caching to something like 11 | Redis or even store it in Postgres. 12 | -------------------------------------------------------------------------------- /library/wumpy-cache/pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "wumpy-cache" 3 | version = "0.1.0" 4 | description = "Simple and plain caching for Discord API libraries." 5 | readme = {file = "README.md", content-type="text/markdown"} 6 | 7 | requires-python = ">=3.7" 8 | 9 | license = {text = "Dual-licensed under the Apache License 2.0 and the MIT License"} 10 | authors = [{name = "Bluenix", email = "bluenixdev@gmail.com"}] 11 | 12 | keywords = [ 13 | "wumpy", "wumpus", "wrapper", 14 | "discord", "discord-api", "discord-bot", "discord-api-wrapper", 15 | "python-3" 16 | ] 17 | classifiers = [ 18 | "Development Status :: 1 - Planning", 19 | "Framework :: AnyIO", 20 | "Intended Audience :: Developers", 21 | "License :: OSI Approved :: MIT License", 22 | "License :: OSI Approved :: Apache Software License", 23 | "Natural Language :: English", 24 | "Operating System :: OS Independent", 25 | "Programming Language :: Python", 26 | "Programming Language :: Python :: 3", 27 | "Programming Language :: Python :: 3 :: Only", 28 | "Programming Language :: Python :: 3.7", 29 | "Programming Language :: Python :: 3.8", 30 | "Programming Language :: Python :: 3.9", 31 | "Programming Language :: Python :: 3.10", 32 | "Topic :: Internet", 33 | "Topic :: Internet :: WWW/HTTP", 34 | "Topic :: Software Development :: Libraries", 35 | "Topic :: Software Development :: Libraries :: Python Modules", 36 | "Typing :: Typed", 37 | ] 38 | 39 | dependencies = [ 40 | "anyio >= 3.3.4, <4", 41 | "typing_extensions >= 4, <5", 42 | "discord-typings >= 0.4.0, <1", 43 | "wumpy-models >= 0.1.0, <1" 44 | ] 45 | 46 | [project.urls] 47 | Homepage = "https://github.com/wumpyproject/wumpy" 48 | Repository = "https://github.com/wumpyproject/wumpy/tree/main/library/wumpy-cache" 49 | documentation = "https://wumpy.rtfd.io" 50 | 51 | [build-system] 52 | requires = ["flit_core >=3.5, <4"] 53 | build-backend = "flit_core.buildapi" 54 | 55 | [tool.flit.module] 56 | # This is a subpackage under the wumpy namespace package, 57 | # we need to tell flit this otherwise it tries to make the 58 | # import wumpy-cache rather than wumpy.cache 59 | name = "wumpy.cache" 60 | -------------------------------------------------------------------------------- /library/wumpy-cache/wumpy/cache/__init__.py: -------------------------------------------------------------------------------- 1 | from ._protocol import ( 2 | Cache, 3 | ) 4 | 5 | __all__ = ( 6 | 'Cache', 7 | ) 8 | -------------------------------------------------------------------------------- /library/wumpy-cache/wumpy/cache/in_memory/__init__.py: -------------------------------------------------------------------------------- 1 | from ._channel import ( 2 | ChannelMemoryCache, 3 | MessageMemoryCache, 4 | ) 5 | from ._guild import ( 6 | EmojiMemoryCache, 7 | GuildMemoryCache, 8 | RoleMemoryCache, 9 | ) 10 | from ._user import ( 11 | MemberMemoryCache, 12 | UserMemoryCache, 13 | ) 14 | 15 | 16 | __all__ = ( 17 | 'ChannelMemoryCache', 18 | 'MessageMemoryCache', 19 | 'EmojiMemoryCache', 20 | 'GuildMemoryCache', 21 | 'RoleMemoryCache', 22 | 'MemberMemoryCache', 23 | 'UserMemoryCache', 24 | 'InMemoryCache', 25 | ) 26 | 27 | 28 | class InMemoryCache(ChannelMemoryCache, EmojiMemoryCache, GuildMemoryCache, MemberMemoryCache, 29 | MessageMemoryCache, RoleMemoryCache, UserMemoryCache): 30 | """Cache implementation storing data in local memory.""" 31 | 32 | __slots__ = () 33 | -------------------------------------------------------------------------------- /library/wumpy-cache/wumpy/cache/in_memory/_base.py: -------------------------------------------------------------------------------- 1 | from types import TracebackType 2 | from typing import Any, Callable, Mapping, Optional, SupportsInt, Tuple, Type 3 | 4 | import anyio.lowlevel 5 | from discord_typings import UserData 6 | from typing_extensions import Self 7 | from wumpy.models import ( 8 | Category, Emoji, Guild, Invite, Member, Message, Role, Sticker, Thread, 9 | User 10 | ) 11 | 12 | from .._protocol import Cache, Channel 13 | 14 | __all__ = ( 15 | 16 | ) 17 | 18 | 19 | EventProcessor = Callable[[Mapping[str, Any]], Tuple[Optional[Any], Any]] 20 | 21 | 22 | class BaseMemoryCache(Cache): 23 | """The base for all in-memory caches. 24 | 25 | The point of this is to house the base implementation of the required 26 | `update()` method, which propogates to the matching processor, allowing for 27 | one O(1) dictionary lookup on the attributes. 28 | """ 29 | 30 | __slots__ = () 31 | 32 | async def __aenter__(self) -> Self: 33 | return self 34 | 35 | async def __aexit__( 36 | self, 37 | exc_type: Optional[Type[BaseException]], 38 | exc_val: Optional[BaseException], 39 | traceback: Optional[TracebackType] 40 | ) -> None: 41 | pass 42 | 43 | async def update(self, payload: Mapping[str, Any], *, return_old: bool = True) -> Any: 44 | """Propogate the `update()` to a processor. 45 | 46 | Processors needs to follow: `_process_discord_event` naming; starting 47 | with `_process_` and then the event from Discord but lowercased. 48 | 49 | This method will simply lookup the naming above in the attributes and 50 | call it. 51 | 52 | Parameters: 53 | payload: The payload to propogate. 54 | 55 | Returns: 56 | Whatever the processor returned; if no processor is found, None. 57 | """ 58 | if 't' not in payload: 59 | # If the type key is missing, that means that this is not a 60 | # dispatch event, so there is nothing for us to handle. 61 | await anyio.lowlevel.checkpoint() 62 | return None 63 | 64 | # Because this is a coroutine-function we should await somewhere, but 65 | # it's best to run the in-memory cache stuff as soon as possible 66 | # (so that other tasks can't modify the cache). This can't happen after 67 | # we have successfully updated the cache though as we don't want to get 68 | # cancelled causing us to both succeed and fail at the same time. 69 | # Therefore the yielding is split into multiple steps. 70 | await anyio.lowlevel.checkpoint_if_cancelled() 71 | 72 | try: 73 | processor: EventProcessor = getattr(self, f"_process_{payload['t'].lower()}") 74 | except AttributeError: 75 | return None 76 | else: 77 | return processor(payload['d']) 78 | finally: 79 | await anyio.lowlevel.cancel_shielded_checkpoint() 80 | 81 | # If we don't define these methods the type checker will complain because 82 | # we don't implement the protocol 83 | 84 | async def get_guild(self, guild: SupportsInt) -> Optional[Guild]: 85 | ... 86 | 87 | async def get_role(self, role: SupportsInt) -> Optional[Role]: 88 | ... 89 | 90 | async def get_channel(self, channel: SupportsInt) -> Optional[Channel]: 91 | ... 92 | 93 | async def get_thread(self, thread: SupportsInt) -> Optional[Thread]: 94 | ... 95 | 96 | async def get_category(self, category: SupportsInt) -> Optional[Category]: 97 | ... 98 | 99 | async def get_message( 100 | self, 101 | channel: Optional[SupportsInt], 102 | message: SupportsInt 103 | ) -> Optional[Message]: 104 | ... 105 | 106 | async def get_emoji(self, emoji: SupportsInt) -> Optional[Emoji]: 107 | ... 108 | 109 | async def get_sticker(self, sticker: SupportsInt) -> Optional[Sticker]: 110 | ... 111 | 112 | async def get_invite(self, invite: SupportsInt) -> Optional[Invite]: 113 | ... 114 | 115 | async def get_member(self, guild: SupportsInt, user: SupportsInt) -> Optional[Member]: 116 | ... 117 | 118 | async def get_user(self, user: SupportsInt) -> Optional[User]: 119 | ... 120 | 121 | # Certain caches depend on others to store things for them, we need to 122 | # define these fallbacks so that they can be safely called without worrying 123 | # about AttributeErrors trying to access the method. 124 | 125 | def _store_user(self, data: UserData) -> User: 126 | # This method is overriden by UserMemoryCache and adds the user to the 127 | # cache - still, in the fallback we can create a user without actually 128 | # storing it for later. 129 | return User.from_data(data) 130 | -------------------------------------------------------------------------------- /library/wumpy-cache/wumpy/cache/in_memory/_channel.py: -------------------------------------------------------------------------------- 1 | import collections 2 | from typing import ( 3 | Any, Deque, Dict, FrozenSet, Mapping, Optional, SupportsInt, Type, Union 4 | ) 5 | 6 | from discord_typings import ( 7 | ChannelData, MessageCreateData, MessageDeleteBulkData, MessageDeleteData, 8 | MessageUpdateData 9 | ) 10 | from wumpy.models import Category, Message, TextChannel, Thread, VoiceChannel 11 | 12 | from ._base import BaseMemoryCache, Channel 13 | 14 | __all__ = ( 15 | 'ChannelMemoryCache', 16 | 'MessageMemoryCache', 17 | ) 18 | 19 | 20 | AllChannels = Union[Channel, Category, Thread] 21 | 22 | 23 | class ChannelMemoryCache(BaseMemoryCache): 24 | _channels: Dict[int, AllChannels] 25 | 26 | CHANNELS: Mapping[int, Type[AllChannels]] = { 27 | 0: TextChannel, 28 | 2: VoiceChannel, 29 | 4: Category, 30 | 5: TextChannel, 31 | 10: Thread, 32 | 11: Thread, 33 | 12: Thread, 34 | 13: VoiceChannel, 35 | } 36 | 37 | def __init__(self, *args: Any, **kwargs: Any) -> None: 38 | super().__init__(*args, **kwargs) 39 | 40 | self._channels = {} 41 | 42 | def _process_create_channel(self, data: ChannelData, *, return_old: bool = True) -> None: 43 | cls = self.CHANNELS[data['type']] 44 | # The type checker doesn't understand the relation between data['type'] 45 | # and the class returned so it thinks that any channel data may be 46 | # passed to any channel class. 47 | channel = cls.from_data(data) # type: ignore 48 | self._channels[channel.id] = channel 49 | return 50 | 51 | def _process_channel_update( 52 | self, 53 | data: ChannelData, 54 | *, 55 | return_old: bool = True 56 | ) -> Optional[AllChannels]: 57 | old = self._process_channel_delete(data, return_old=return_old) 58 | self._process_create_channel(data, return_old=return_old) 59 | return old 60 | 61 | def _process_channel_delete( 62 | self, 63 | data: ChannelData, 64 | *, 65 | return_old: bool = True 66 | ) -> Optional[AllChannels]: 67 | return self._channels.pop(int(data['id']), None) 68 | 69 | async def get_channel(self, channel: SupportsInt) -> Optional[AllChannels]: 70 | return self._channels.get(int(channel)) 71 | 72 | async def get_thread(self, thread: SupportsInt) -> Optional[Thread]: 73 | channel = self.get_channel(thread) 74 | if isinstance(channel, Thread): 75 | return channel 76 | 77 | return None 78 | 79 | async def get_category(self, category: SupportsInt) -> Optional[Category]: 80 | channel = await self.get_channel(category) 81 | if isinstance(channel, Category): 82 | return channel 83 | 84 | return None 85 | 86 | 87 | class MessageMemoryCache(BaseMemoryCache): 88 | _messages: Deque[Message] 89 | 90 | def __init__(self, *args: Any, max_messages: int, **kwargs: Any) -> None: 91 | super().__init__(*args, **kwargs) 92 | 93 | self._messages = collections.deque(maxlen=max_messages) 94 | 95 | def _process_message_create( 96 | self, 97 | data: MessageCreateData, 98 | *, 99 | return_old: bool = True 100 | ) -> None: 101 | m = Message.from_data(data) 102 | self._messages.append(m) 103 | 104 | def _process_message_update( 105 | self, 106 | data: MessageUpdateData, 107 | *, 108 | return_old: bool = True 109 | ) -> Optional[Message]: 110 | old = self._process_message_delete(data, return_old=return_old) 111 | self._process_message_create(data, return_old=return_old) 112 | return old 113 | 114 | def _process_message_delete( 115 | self, 116 | data: MessageDeleteData, 117 | *, 118 | return_old: bool = True 119 | ) -> Optional[Message]: 120 | if return_old: 121 | id_ = int(data['id']) 122 | 123 | found = [msg for msg in self._messages if msg == id_] 124 | if found: 125 | return found[0] 126 | 127 | return None 128 | 129 | def _process_message_delete_bulk( 130 | self, 131 | data: MessageDeleteBulkData, 132 | *, 133 | return_old: bool = True 134 | ) -> FrozenSet[Message]: 135 | if return_old: 136 | ids = set([int(id_) for id_ in data['ids']]) 137 | 138 | found = [msg for msg in self._messages if msg.id in ids] 139 | return frozenset(found) 140 | 141 | return frozenset() 142 | 143 | async def get_message( 144 | self, 145 | channel: Optional[SupportsInt], 146 | message: SupportsInt 147 | ) -> Optional[Message]: 148 | id_ = int(message) 149 | found = [msg for msg in self._messages if msg == id_] 150 | if found: 151 | return found[0] 152 | 153 | return None 154 | -------------------------------------------------------------------------------- /library/wumpy-cache/wumpy/cache/in_memory/_user.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Dict, Optional, SupportsInt, Tuple 2 | from weakref import WeakValueDictionary 3 | 4 | from discord_typings import ( 5 | GuildMemberAddData, GuildMemberRemoveData, GuildMemberUpdateData, UserData 6 | ) 7 | from wumpy.models import Member, User 8 | 9 | from ._base import BaseMemoryCache 10 | 11 | __all__ = ( 12 | 'UserMemoryCache', 13 | 'MemberMemoryCache', 14 | ) 15 | 16 | 17 | class UserMemoryCache(BaseMemoryCache): 18 | _users: 'WeakValueDictionary[int, User]' 19 | 20 | def __init__(self, *args: Any, **kwargs: Any) -> None: 21 | super().__init__(*args, **kwargs) 22 | 23 | self._users = WeakValueDictionary() 24 | 25 | def _store_user(self, data: UserData) -> User: 26 | # This is slower than simply constructing another User model, but for 27 | # memory purposes we want to share it between objects like Members as 28 | # much as possible. 29 | user = self._users.get(int(data['id'])) 30 | if ( 31 | user and user.id == int(data['id']) 32 | and user.name == data['username'] 33 | and user.discriminator == data['discriminator'] 34 | and user.public_flags == data.get('public_flags') 35 | ): 36 | return user # The existing user is up-to-date 37 | 38 | user = User.from_data(data) 39 | self._users[int(data['id'])] = user 40 | 41 | return user 42 | 43 | 44 | class MemberMemoryCache(BaseMemoryCache): 45 | _members: Dict[Tuple[int, int], Member] 46 | 47 | def __init__(self, *args: Any, **kwargs: Any) -> None: 48 | super().__init__(*args, **kwargs) 49 | 50 | self._members = {} 51 | 52 | def _process_guild_member_add( 53 | self, 54 | data: GuildMemberAddData, 55 | *, 56 | return_old: bool = True 57 | ) -> None: 58 | guild_id = int(data['guild_id']) 59 | user = self._store_user(data['user']) 60 | 61 | member = Member.from_user(user, data) 62 | self._members[(guild_id, member.id)] = member 63 | 64 | def _process_guild_member_update( 65 | self, 66 | data: GuildMemberUpdateData, 67 | *, 68 | return_old: bool = True 69 | ) -> Optional[Member]: 70 | old = self._process_guild_member_remove(data, return_old=return_old) 71 | self._process_guild_member_add(data, return_old=return_old) 72 | return old 73 | 74 | def _process_guild_member_remove( 75 | self, 76 | data: GuildMemberRemoveData, 77 | *, 78 | return_old: bool = True 79 | ) -> Optional[Member]: 80 | return self._members.pop((int(data['guild_id']), int(data['user']['id'])), None) 81 | 82 | async def get_member(self, guild: SupportsInt, user: SupportsInt) -> Optional[Member]: 83 | return self._members.get((int(guild), int(user))) 84 | -------------------------------------------------------------------------------- /library/wumpy-cache/wumpy/cache/py.typed: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wumpyproject/wumpy/08b1628a8aa4bddf6bd25b4e9418247783fa6663/library/wumpy-cache/wumpy/cache/py.typed -------------------------------------------------------------------------------- /library/wumpy-gateway/README.md: -------------------------------------------------------------------------------- 1 | # Wumpy-gateway 2 | 3 | Gateway implementation for the Wumpy project. 4 | 5 | ## Installation 6 | 7 | Wumpy-gateway is available as a package on PyPI. Just use your favourite 8 | package manager such as pip or Poetry: 9 | 10 | ```bash 11 | # Installing from PyPI using pip: 12 | pip install wumpy-gateway 13 | ``` 14 | 15 | ```bash 16 | # Alternatively, using Poetry: 17 | poetry add wumpy-gateway 18 | ``` 19 | 20 | ## Quickstart 21 | 22 | The API of wumpy-gateway is very simple: 23 | 24 | ```python 25 | from wumpy.gateway import Shard 26 | 27 | 28 | INTENTS = 65535 29 | TOKEN = 'ABC123.XYZ789' 30 | 31 | 32 | async def main(): 33 | # Connect to the URI wss://gateway.discord.gg/ with the token 34 | # ABC123.XYZ8789 and all intents. 35 | async with Shard('wss://gateway.discord.gg/', TOKEN, INTENTS) as ws: 36 | async for event in ws: 37 | print(event) # The deserialized JSON event payload 38 | ``` 39 | -------------------------------------------------------------------------------- /library/wumpy-gateway/pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "wumpy-gateway" 3 | version = "0.3.0" 4 | description = "Lowlevel but easy-to-use wrapper over the Discord gateway" 5 | readme = {file = "README.md", content-type="text/markdown"} 6 | 7 | # Both discord-gateway and anyio have 3.6.2 as the minimum requirement 8 | # but @contextlib.asynccontextmanager was added in Python 3.7 9 | requires-python = ">=3.7" 10 | 11 | license = {text = "Dual-licensed under the Apache License 2.0 and the MIT License"} 12 | authors = [{name = "Bluenix", email = "bluenixdev@gmail.com"}] 13 | 14 | keywords = [ 15 | "wumpy", "wumpus", "wrapper", 16 | "discord", "discord-api", "discord-bot", "discord-api-wrapper", 17 | "python-3" 18 | ] 19 | classifiers = [ 20 | "Development Status :: 2 - Pre-Alpha", 21 | "Framework :: AnyIO", 22 | "Intended Audience :: Developers", 23 | "License :: OSI Approved :: MIT License", 24 | "License :: OSI Approved :: Apache Software License", 25 | "Natural Language :: English", 26 | "Operating System :: OS Independent", 27 | "Programming Language :: Python", 28 | "Programming Language :: Python :: 3", 29 | "Programming Language :: Python :: 3 :: Only", 30 | "Programming Language :: Python :: 3.7", 31 | "Programming Language :: Python :: 3.8", 32 | "Programming Language :: Python :: 3.9", 33 | "Programming Language :: Python :: 3.10", 34 | "Topic :: Internet", 35 | "Topic :: Internet :: WWW/HTTP", 36 | "Topic :: Software Development :: Libraries", 37 | "Topic :: Software Development :: Libraries :: Python Modules", 38 | "Typing :: Typed", 39 | ] 40 | 41 | dependencies = ["discord-gateway >=0.4.0, <1", "anyio >= 3.3.4, <4"] 42 | 43 | [project.urls] 44 | Homepage = "https://github.com/wumpyproject/wumpy" 45 | Repository = "https://github.com/wumpyproject/wumpy/tree/main/library/wumpy-gateway" 46 | Documentation = "https://wumpy.rtfd.io" 47 | 48 | [build-system] 49 | requires = ["flit_core >=3.5, <4"] 50 | build-backend = "flit_core.buildapi" 51 | 52 | [tool.flit.module] 53 | # This is a subpackage under the wumpy namespace package, 54 | # we need to tell flit this otherwise it tries to make the 55 | # import wumpy-gateway rather than wumpy.gateway 56 | name = "wumpy.gateway" 57 | -------------------------------------------------------------------------------- /library/wumpy-gateway/wumpy/gateway/__init__.py: -------------------------------------------------------------------------------- 1 | """Gateway implementation for the Wumpy project. 2 | 3 | This module contains a main `Shard` class, to get started import it and use the 4 | `connect()` classmethod as an asynchronous context manager: 5 | 6 | ```python 7 | from wumpy.gateway import Shard 8 | 9 | 10 | TOKEN = 'ABC123.XYZ789' 11 | INTENTS = 65535 12 | 13 | 14 | async def main(): 15 | # Connect to the URI wss://gateway.discord.gg/ with the token 16 | # ABC123.XYZ8789 and all intents. 17 | async with Shard.connect('wss://gateway.discord.gg/', TOKEN, INTENTS) as ws: 18 | async for event in ws: 19 | print(event) # The deserialized JSON event payload 20 | ``` 21 | """ 22 | 23 | from ._errors import ( 24 | ConnectionClosed, 25 | ) 26 | from ._shard import ( 27 | Shard, 28 | ) 29 | from ._utils import ( 30 | GatewayLimiter, 31 | DefaultGatewayLimiter, 32 | ) 33 | 34 | __all__ = ( 35 | 'ConnectionClosed', 36 | 'Shard', 37 | 'GatewayLimiter', 38 | 'DefaultGatewayLimiter', 39 | ) 40 | -------------------------------------------------------------------------------- /library/wumpy-gateway/wumpy/gateway/_errors.py: -------------------------------------------------------------------------------- 1 | __all__ = ( 2 | 'ConnectionClosed', 3 | ) 4 | 5 | 6 | class ConnectionClosed(Exception): 7 | """Exception indicating that the connection to Discord fully closed. 8 | 9 | This is raised when the connection cannot naturally reconnect and the 10 | program should exit - which happens if Discord unexpectedly closes the 11 | socket during the crucial bootstrapping process. 12 | 13 | Additionally, it may also be raised if Discord responded with a special 14 | error code in the 4000-range - which signals that the connection absolutely 15 | cannot reconnect such as sending an improper token or intents. 16 | """ 17 | pass 18 | -------------------------------------------------------------------------------- /library/wumpy-gateway/wumpy/gateway/_utils.py: -------------------------------------------------------------------------------- 1 | import time 2 | from contextlib import asynccontextmanager 3 | from functools import partial 4 | from types import TracebackType 5 | from typing import ( 6 | Any, AsyncContextManager, AsyncGenerator, Awaitable, Callable, Optional, 7 | Type 8 | ) 9 | 10 | import anyio 11 | from discord_gateway import Opcode 12 | from typing_extensions import Protocol, Self 13 | 14 | __all__ = ( 15 | 'GatewayLimiter', 16 | 'DefaultGatewayLimiter', 17 | ) 18 | 19 | 20 | class GatewayLimiter(Protocol): 21 | """Interface for a gateway ratelimiter. 22 | 23 | This protocol is almost the same as the following: 24 | 25 | ```python 26 | AsynContextManager[Callable[ 27 | [int], AsyncContextManager[object] 28 | ]] 29 | ``` 30 | 31 | The difference is the fact that the async context manager must also be 32 | callable (and therefore it should return `self`). 33 | """ 34 | async def __aenter__(self) -> object: 35 | ... 36 | 37 | async def __aexit__( 38 | self, 39 | exc_type: Optional[Type[BaseException]], 40 | exc_val: Optional[BaseException], 41 | exc_tb: Optional[TracebackType] 42 | ) -> Optional[bool]: 43 | ... 44 | 45 | def __call__(self, __opcode: Opcode) -> AsyncContextManager[object]: 46 | ... 47 | 48 | 49 | class DefaultGatewayLimiter: 50 | _lock: anyio.Lock 51 | _reset: Optional[float] 52 | _value: int 53 | 54 | RATE = 120 - 3 # We leave a margin of 3 heartbeats per minute 55 | PER = 60 56 | 57 | __slots__ = ('_lock', '_reset', '_value') 58 | 59 | def __init__(self) -> None: 60 | self._reset = None 61 | self._value = self.RATE 62 | 63 | # self._lock is set in __aenter__() 64 | 65 | @asynccontextmanager 66 | async def __call__(self, opcode: Opcode) -> AsyncGenerator[None, None]: 67 | # Heartbeats are not ratelimited and we have left a margin for them. 68 | if opcode is not Opcode.HEARTBEAT: 69 | async with self._lock: 70 | if self._reset is None or self._reset < time.perf_counter(): 71 | self._reset = time.perf_counter() + self.PER 72 | self._value = self.RATE - 1 73 | 74 | elif self._value <= 0: 75 | await anyio.sleep(self._reset - time.perf_counter()) 76 | self._reset = time.perf_counter() + self.PER 77 | self._value = self.RATE - 1 78 | else: 79 | self._value -= 1 80 | 81 | # Yield once we have released the lock, it is only held for the 82 | # case that we're sleeping, because if one tasks sleep then all 83 | # following tasks will also need to sleep. 84 | yield 85 | 86 | async def __aenter__(self) -> Self: 87 | self._lock = anyio.Lock() 88 | 89 | return self 90 | 91 | async def __aexit__( 92 | self, 93 | exc_type: Optional[Type[BaseException]], 94 | exc_val: Optional[BaseException], 95 | exc_tb: Optional[TracebackType] 96 | ) -> None: 97 | pass 98 | -------------------------------------------------------------------------------- /library/wumpy-gateway/wumpy/gateway/py.typed: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wumpyproject/wumpy/08b1628a8aa4bddf6bd25b4e9418247783fa6663/library/wumpy-gateway/wumpy/gateway/py.typed -------------------------------------------------------------------------------- /library/wumpy-interactions/README.md: -------------------------------------------------------------------------------- 1 | # Wumpy-interactions 2 | 3 | Support for HTTP interactions with the Discord API 4 | 5 | Refer to the [documentation](https://wumpy.rtfd.io) for more. 6 | -------------------------------------------------------------------------------- /library/wumpy-interactions/pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "wumpy-interactions" 3 | version = "0.1.0" 4 | description = "Support for HTTP interactions with the Discord API" 5 | readme = {file = "README.md", content-type="text/markdown"} 6 | 7 | requires-python = ">=3.7" 8 | 9 | license = {text = "Dual-licensed under the Apache License 2.0 and the MIT License"} 10 | authors = [{name = "Bluenix", email = "bluenixdev@gmail.com"}] 11 | 12 | keywords = [ 13 | "wumpy", "wumpus", "wrapper", 14 | "discord", "discord-api", "discord-bot", "discord-api-wrapper", "discord-interaction", 15 | "python-3" 16 | ] 17 | classifiers = [ 18 | "Development Status :: 2 - Pre-Alpha", 19 | "Framework :: AnyIO", 20 | "Intended Audience :: Developers", 21 | "License :: OSI Approved :: MIT License", 22 | "License :: OSI Approved :: Apache Software License", 23 | "Natural Language :: English", 24 | "Operating System :: OS Independent", 25 | "Programming Language :: Python", 26 | "Programming Language :: Python :: 3", 27 | "Programming Language :: Python :: 3 :: Only", 28 | "Programming Language :: Python :: 3.7", 29 | "Programming Language :: Python :: 3.8", 30 | "Programming Language :: Python :: 3.9", 31 | "Programming Language :: Python :: 3.10", 32 | "Topic :: Internet", 33 | "Topic :: Internet :: WWW/HTTP", 34 | "Topic :: Software Development :: Libraries", 35 | "Topic :: Software Development :: Libraries :: Python Modules", 36 | "Typing :: Typed", 37 | ] 38 | 39 | dependencies = [ 40 | "typing_extensions >= 4, <5", 41 | "anyio >= 3.3.4, <4", 42 | "discord-typings >= 0.4.0, <1", 43 | "pynacl >= 1, <2", 44 | "wumpy-models >= 0.1.0, <1" 45 | ] 46 | 47 | [project.optional-dependencies] 48 | sanic = ["sanic >= 21.3, <22.3"] 49 | 50 | [project.urls] 51 | Homepage = "https://github.com/wumpyproject/wumpy" 52 | Repository = "https://github.com/wumpyproject/wumpy/tree/main/library/wumpy-interactions" 53 | Documentation = "https://wumpy.rtfd.io" 54 | 55 | [build-system] 56 | requires = ["flit_core >=3.5, <4"] 57 | build-backend = "flit_core.buildapi" 58 | 59 | [tool.flit.module] 60 | # This is a subpackage under the wumpy namespace package, 61 | # we need to tell flit this otherwise it tries to make the 62 | # import wumpy-interactions rather than wumpy.interactions 63 | name = "wumpy.interactions" 64 | -------------------------------------------------------------------------------- /library/wumpy-interactions/wumpy/interactions/__init__.py: -------------------------------------------------------------------------------- 1 | from ._app import ( 2 | InteractionAppRequester, 3 | InteractionApp, 4 | get_app, 5 | ) 6 | from ._compat import ( 7 | Request, 8 | ASGIRequest, 9 | SanicRequest, 10 | ) 11 | from ._errors import ( 12 | CommandSetupError, 13 | ) 14 | from ._middleware import ( 15 | ASGIMiddleware, 16 | SanicMiddleware, 17 | ) 18 | from ._models import ( 19 | AutocompleteInteraction, 20 | Interaction, 21 | CommandInteraction, 22 | ComponentInteraction, 23 | ) 24 | from ._utils import ( 25 | DiscordRequestVerifier, 26 | ) 27 | from .components import ( 28 | ComponentHandler, 29 | ) 30 | from .commands import ( 31 | MiddlewareDecorator, 32 | CheckFailure, 33 | check, 34 | BucketType, 35 | max_concurrency, 36 | cooldown, 37 | ContextMenuCommand, 38 | MessageCommand, 39 | UserCommand, 40 | MiddlewareCallback, 41 | CommandMiddlewareMixin, 42 | CommandType, 43 | Option, 44 | option, 45 | OptionClass, 46 | group, 47 | command, 48 | CommandRegistrar, 49 | Command, 50 | SubcommandGroup, 51 | command_payload, 52 | ) 53 | 54 | __all__ = ( 55 | 'InteractionAppRequester', 56 | 'InteractionApp', 57 | 'get_app', 58 | 'Request', 59 | 'ASGIRequest', 60 | 'SanicRequest', 61 | 'CommandSetupError', 62 | 'ASGIMiddleware', 63 | 'SanicMiddleware', 64 | 'AutocompleteInteraction', 65 | 'Interaction', 66 | 'CommandInteraction', 67 | 'ComponentInteraction', 68 | 'DiscordRequestVerifier', 69 | 'ComponentHandler', 70 | 'MiddlewareDecorator', 71 | 'CheckFailure', 72 | 'check', 73 | 'BucketType', 74 | 'max_concurrency', 75 | 'cooldown', 76 | 'ContextMenuCommand', 77 | 'MessageCommand', 78 | 'UserCommand', 79 | 'MiddlewareCallback', 80 | 'CommandMiddlewareMixin', 81 | 'CommandType', 82 | 'Option', 83 | 'option', 84 | 'OptionClass', 85 | 'group', 86 | 'command', 87 | 'CommandRegistrar', 88 | 'Command', 89 | 'SubcommandGroup', 90 | 'command_payload', 91 | ) 92 | -------------------------------------------------------------------------------- /library/wumpy-interactions/wumpy/interactions/_compat.py: -------------------------------------------------------------------------------- 1 | import json 2 | from typing import ( 3 | TYPE_CHECKING, Any, Awaitable, Callable, Dict, Mapping, Optional, overload 4 | ) 5 | 6 | from typing_extensions import Protocol 7 | 8 | try: 9 | from sanic import HTTPResponse # type: ignore 10 | SANIC_AVAILABLE = True 11 | except ImportError: 12 | SANIC_AVAILABLE = False 13 | 14 | if TYPE_CHECKING: 15 | from sanic import Request as OriginalSanicRequest 16 | 17 | __all__ = ( 18 | 'Request', 19 | 'ASGIRequest', 20 | 'SanicRequest', 21 | ) 22 | 23 | 24 | class Request(Protocol): 25 | 26 | @overload 27 | async def respond( 28 | self, 29 | data: Optional[Mapping[str, Any]] = None, 30 | *, 31 | status: int = 200, 32 | ) -> None: 33 | ... 34 | 35 | @overload 36 | async def respond( 37 | self, 38 | *, 39 | status: int = 200, 40 | body: Optional[bytes] = None, 41 | content_type: Optional[str] = None, 42 | ) -> None: 43 | ... 44 | 45 | 46 | class ASGIRequest(Request): 47 | def __init__( 48 | self, 49 | scope: Dict[str, Any], 50 | receive: Callable[[], Awaitable[Dict[str, Any]]], 51 | send: Callable[[Dict[str, Any]], Awaitable[None]] 52 | ) -> None: 53 | self._responded = False 54 | 55 | self.scope = scope 56 | self.receive = receive 57 | self.send = send 58 | 59 | async def respond( 60 | self, 61 | data: Optional[Mapping[str, Any]] = None, 62 | *, 63 | status: int = 200, 64 | body: Optional[bytes] = None, 65 | content_type: Optional[str] = None, 66 | ) -> None: 67 | if ( 68 | data is None and body is None 69 | or data is not None and body is not None 70 | ): 71 | raise TypeError("'respond()' has to be called with one of 'data' and 'body'") 72 | 73 | if self._responded: 74 | raise RuntimeError('Response has already been sent') 75 | 76 | if data is not None: 77 | body = json.dumps(data).encode('utf-8') 78 | content_type = 'application/json' 79 | 80 | if content_type is None: 81 | content_type = 'text/plain' 82 | 83 | await self.send({ 84 | 'type': 'http.response.start', 'status': status, 85 | 'headers': [(b'content-type', content_type.encode('utf-8'))] 86 | }) 87 | await self.send({'type': 'http.response.body', 'body': body}) 88 | 89 | 90 | class SanicRequest(Request): 91 | def __init__(self, request: 'OriginalSanicRequest') -> None: 92 | self._responded = False 93 | 94 | self.request = request 95 | 96 | async def respond( 97 | self, 98 | data: Optional[Mapping[str, Any]] = None, 99 | *, 100 | status: int = 200, 101 | body: Optional[bytes] = None, 102 | content_type: Optional[str] = None, 103 | ) -> None: 104 | if ( 105 | data is None and body is None 106 | or data is not None and body is not None 107 | ): 108 | raise TypeError("'respond()' has to be called with one of 'data' and 'body'") 109 | 110 | if self._responded: 111 | raise RuntimeError('Response has already been sent') 112 | 113 | if data is not None: 114 | body = json.dumps(data).encode('utf-8') 115 | content_type = 'application/json' 116 | 117 | if content_type is None: 118 | content_type = 'text/plain' 119 | 120 | await self.request.respond(HTTPResponse( 121 | body, status=status, content_type=content_type 122 | )) 123 | -------------------------------------------------------------------------------- /library/wumpy-interactions/wumpy/interactions/_errors.py: -------------------------------------------------------------------------------- 1 | __all__ = ( 2 | 'CommandSetupError', 3 | ) 4 | 5 | 6 | class CommandSetupError(Exception): 7 | """Raised when local command setup does not align with interactions. 8 | 9 | Examples of this is incorrect types of local options compared to what 10 | Discord sends, or subcommand-groups not receiving subcommands. 11 | """ 12 | pass 13 | -------------------------------------------------------------------------------- /library/wumpy-interactions/wumpy/interactions/_utils.py: -------------------------------------------------------------------------------- 1 | import functools 2 | from typing import Any, Callable, Dict, Optional, Union 3 | 4 | from nacl.exceptions import BadSignatureError 5 | from nacl.signing import VerifyKey 6 | 7 | __all__ = ( 8 | 'DiscordRequestVerifier', 9 | ) 10 | 11 | 12 | class DiscordRequestVerifier: 13 | """Thin wrapper over PyNaCl's Ed25519 verifier. 14 | 15 | This is designed to verify Discord interactions requests. See the 16 | documentation for middlewares for specific libraries wrapping this class. 17 | """ 18 | 19 | def __init__(self, public_key: str) -> None: 20 | self._verifier = VerifyKey(bytes.fromhex(public_key)) 21 | 22 | def verify( 23 | self, 24 | signature: str, 25 | timestamp: Union[str, bytes], 26 | body: Union[str, bytes, bytearray] 27 | ) -> bool: 28 | """Verify the signature of a request. 29 | 30 | Parameters: 31 | signature: The `X-Signature-Ed25519` header signature. 32 | timestamp: The `X-Signature-Timestamp` header value. 33 | body: 34 | The request body data. This can be either a string, bytes or 35 | bytearray (although they are all converted into `bytes`). 36 | 37 | Returns: 38 | Whether the signature is valid. If this returns `False` you should 39 | respond with a 401 Unauthorized. 40 | """ 41 | if isinstance(timestamp, str): 42 | timestamp = timestamp.encode('utf-8') 43 | 44 | if isinstance(body, str): 45 | body = body.encode('utf-8') 46 | 47 | message = bytes(timestamp + body) 48 | 49 | try: 50 | self._verifier.verify(message, signature=bytes.fromhex(signature)) 51 | return True 52 | except BadSignatureError: 53 | return False 54 | 55 | 56 | def _eval_annotations(obj: Callable) -> Dict[str, Any]: 57 | """Eval a callable's annotations. 58 | 59 | This is primarily a backport of Python 3.10's `get_annotations()` 60 | method implemented by Larry Hastings: 61 | https://github.com/python/cpython/commit/74613a46fc79cacc88d3eae4105b12691cd4ba20 62 | 63 | Parameters: 64 | obj: The received callable to evaluate 65 | 66 | Returns: 67 | A dictionary of parameter name to its annotation. 68 | """ 69 | unwrapped = obj 70 | while True: 71 | if hasattr(unwrapped, '__wrapped__'): 72 | unwrapped = unwrapped.__wrapped__ 73 | continue 74 | if isinstance(unwrapped, functools.partial): 75 | unwrapped = unwrapped.func 76 | continue 77 | break 78 | 79 | annotations = getattr(unwrapped, '__annotations__', None) 80 | eval_globals = getattr(unwrapped, '__globals__', None) 81 | 82 | if annotations is None or not annotations: 83 | return {} 84 | 85 | if not isinstance(annotations, dict): 86 | raise ValueError(f'{unwrapped!r}.__annotations__ is neither a dict nor None') 87 | 88 | try: 89 | return { 90 | key: value if not isinstance(value, str) else eval(value, eval_globals) 91 | for key, value in annotations.items() 92 | } 93 | except (NameError, SyntaxError) as e: 94 | raise ValueError(f'Could not evaluate the annotations of {unwrapped!r}') from e 95 | 96 | 97 | class State: 98 | """An object which allows setting arbitrary attributes.""" 99 | 100 | __state: Dict[str, Any] 101 | 102 | __slots__ = ('__state',) 103 | 104 | def __init__(self, state: Optional[Dict[str, Any]] = None) -> None: 105 | super().__setattr__('__state', state.copy() if state is not None else {}) 106 | 107 | def __setattr__(self, key: str, value: Any) -> None: 108 | self.__state[key] = value 109 | 110 | def __getattr__(self, key: str) -> Any: 111 | try: 112 | return self.__state[key] 113 | except KeyError: 114 | raise AttributeError( 115 | f'{self.__class__.__name__!r} object has no attribute {key!r}' 116 | ) from None 117 | 118 | def __delattr__(self, key: str) -> None: 119 | try: 120 | del self.__state[key] 121 | except KeyError: 122 | raise AttributeError(key) from None 123 | -------------------------------------------------------------------------------- /library/wumpy-interactions/wumpy/interactions/commands/__init__.py: -------------------------------------------------------------------------------- 1 | from ._checks import ( 2 | MiddlewareDecorator, 3 | CheckFailure, 4 | check, 5 | BucketType, 6 | max_concurrency, 7 | cooldown, 8 | ) 9 | from ._context import ( 10 | ContextMenuCommand, 11 | MessageCommand, 12 | UserCommand, 13 | ) 14 | from ._middleware import ( 15 | MiddlewareCallback, 16 | CommandMiddlewareMixin, 17 | ) 18 | from ._option import ( 19 | CommandType, 20 | Option, 21 | option, 22 | OptionClass, 23 | ) 24 | from ._registrar import ( 25 | group, 26 | command, 27 | CommandRegistrar, 28 | ) 29 | from ._slash import ( 30 | Command, 31 | SubcommandGroup, 32 | command_payload, 33 | ) 34 | 35 | __all__ = ( 36 | 'MiddlewareDecorator', 37 | 'CheckFailure', 38 | 'check', 39 | 'BucketType', 40 | 'max_concurrency', 41 | 'cooldown', 42 | 'ContextMenuCommand', 43 | 'MessageCommand', 44 | 'UserCommand', 45 | 'MiddlewareCallback', 46 | 'CommandMiddlewareMixin', 47 | 'CommandType', 48 | 'Option', 49 | 'option', 50 | 'OptionClass', 51 | 'group', 52 | 'command', 53 | 'CommandRegistrar', 54 | 'Command', 55 | 'SubcommandGroup', 56 | 'command_payload', 57 | ) 58 | -------------------------------------------------------------------------------- /library/wumpy-interactions/wumpy/interactions/commands/_base.py: -------------------------------------------------------------------------------- 1 | import inspect 2 | from functools import update_wrapper 3 | from typing import Any, Awaitable, Callable, Generic, TypeVar 4 | 5 | from typing_extensions import ParamSpec 6 | 7 | from .._utils import _eval_annotations 8 | 9 | __all__ = ( 10 | 11 | ) 12 | 13 | 14 | P = ParamSpec('P') 15 | RT = TypeVar('RT', covariant=True) 16 | 17 | 18 | Callback = Callable[P, Awaitable[RT]] 19 | 20 | 21 | class CommandCallback(Generic[P, RT]): 22 | """Asynchronous callback wrapped with processing. 23 | 24 | This class is used to wrap, store, and process a callback. 25 | 26 | To use it, subclass and override the methods below. They are marked 27 | internal as to not leak out to the user since they are only meant to be 28 | called by this class. 29 | """ 30 | 31 | _callback: Callback[P, RT] 32 | 33 | def __init__(self, callback: Callback[P, RT]) -> None: 34 | super().__init__() 35 | 36 | self.callback = callback 37 | 38 | async def __call__(self, *args: P.args, **kwargs: P.kwargs) -> RT: 39 | return await self.callback(*args, **kwargs) 40 | 41 | @property 42 | def callback(self) -> Callback[P, RT]: 43 | """The callback this object wraps.""" 44 | return self._callback 45 | 46 | @callback.setter 47 | def callback(self, function: Callback[P, RT]) -> None: 48 | self._callback = function 49 | self._process_callback(function) 50 | 51 | def _process_callback(self, callback: Callback[P, RT]) -> None: 52 | """Process a callback being set. 53 | 54 | This is called first out of all available methods and is used to call 55 | the other methods by default. If this method is overriden it is 56 | important to call the super method. 57 | 58 | Parameters: 59 | callback: The callback being set. 60 | """ 61 | update_wrapper(self, callback) 62 | 63 | signature = inspect.signature(callback) 64 | annotations = _eval_annotations(callback) 65 | 66 | if not signature.parameters: 67 | self._process_no_params(signature) 68 | else: 69 | for i, param in enumerate(signature.parameters.values()): 70 | annotation = annotations.get(param.name, param.empty) 71 | 72 | self._process_param(i, param.replace(annotation=annotation)) 73 | 74 | # We piggyback on inspect's Parameter.empty sentinel value 75 | return_type = annotations.get('return', inspect.Parameter.empty) 76 | 77 | if return_type is not inspect.Parameter.empty: 78 | self._process_return_type(return_type) 79 | 80 | def _process_param(self, index: int, param: inspect.Parameter) -> None: 81 | """Process a parameter of the set callback. 82 | 83 | This method is called for each parameter of the callback when being 84 | set, allowing for subclasses to hook into the process. 85 | 86 | Parameters: 87 | index: The index of the parameter. 88 | param: 89 | The parameter of the callback. Annotations have been resolved 90 | and replaced with the actual type. 91 | """ 92 | ... 93 | 94 | def _process_no_params(self, signature: inspect.Signature) -> None: 95 | """Process a callback having no signature. 96 | 97 | This method is only called if `_process_param()` won't be called. 98 | 99 | Parameters: 100 | signature: The signature of the callback. 101 | """ 102 | ... 103 | 104 | def _process_return_type(self, annotation: Any) -> None: 105 | """Process the extracted return type of the callback. 106 | 107 | This is only called if the callback has a return type. Consider 108 | overriding `_process_callback()` and processing the callback 109 | *after the `super()` call* to do more processing at the end. 110 | 111 | Parameters: 112 | annotation: The annotation of the function's return type. 113 | """ 114 | ... 115 | -------------------------------------------------------------------------------- /library/wumpy-interactions/wumpy/interactions/commands/_checks.py: -------------------------------------------------------------------------------- 1 | from functools import wraps 2 | from typing import Awaitable, Callable, TypeVar, cast 3 | from typing_extensions import ParamSpec 4 | 5 | from .._models import CommandInteraction 6 | 7 | __all__ = ( 8 | 'middleware', 9 | 'CheckFailure', 10 | 'check', 11 | ) 12 | 13 | 14 | P = ParamSpec('P') 15 | RT = TypeVar('RT') 16 | 17 | 18 | def middleware( 19 | func: Callable[[CommandInteraction], Awaitable[object]], 20 | ) -> Callable[[Callable[P, Awaitable[RT]]], Callable[P, Awaitable[RT]]]: 21 | """Helper-decorator to apply a function to wrap a callback. 22 | 23 | The benefit of using this over a custom decorator is that it does not 24 | interfere with the command decorator. 25 | 26 | Parameters: 27 | func: 28 | Function to call before the final callback, which takes the 29 | interaction. The return type is ignored. 30 | 31 | Returns: 32 | A decorator which wraps the command callback. 33 | """ 34 | def middleware_decorator( 35 | callback: Callable[P, Awaitable[RT]] 36 | ) -> Callable[P, Awaitable[RT]]: 37 | @wraps(callback) 38 | async def callback_wrapper(*args: P.args, **kwargs: P.kwargs) -> RT: 39 | await func(cast(CommandInteraction, args[0])) 40 | return await callback(*args, **kwargs) 41 | return callback_wrapper 42 | return middleware_decorator 43 | 44 | 45 | class CheckFailure(Exception): 46 | """Raised when a check for an application command fails. 47 | 48 | Checks are encouraged to raise their own subclass of this exception, but it 49 | will automatically be raised if the check only returned a falsely value. 50 | """ 51 | 52 | interaction: CommandInteraction 53 | predicate: Callable[[CommandInteraction], Awaitable[bool]] 54 | 55 | def __init__( 56 | self, 57 | interaction: CommandInteraction, 58 | predicate: Callable[[CommandInteraction], Awaitable[bool]] 59 | ) -> None: 60 | super().__init__(f'Check {self.predicate} failed for interaction {interaction}') 61 | 62 | self.interaction = interaction 63 | self.predicate = predicate 64 | 65 | 66 | def check( 67 | predicate: Callable[[CommandInteraction], Awaitable[bool]], 68 | ) -> Callable[[Callable[P, Awaitable[RT]]], Callable[P, Awaitable[RT]]]: 69 | """Create a check for an application command. 70 | 71 | This is a helper decorator which only executes the command if the passed 72 | predicate returns a truthy value. The benefit of using this over a custom 73 | decorator is that it does not interfere with the command decorator. 74 | 75 | Examples: 76 | 77 | ```python 78 | from typing import Callable 79 | 80 | from wumpy import interactions 81 | from wumpy.interactions import InteractionApp, CommandInteraction 82 | 83 | 84 | async def on_version(v: int) -> Callable[[CommandInteraction], bool]: 85 | async def predicate(interaction: CommandInteraction) -> bool: 86 | return interaction.version == v 87 | return predicate 88 | 89 | 90 | app = InteractionApp(...) 91 | 92 | 93 | @app.command() 94 | @interactions.check(on_version(1)) 95 | async def ping(interaction: CommandInteraction) -> None: 96 | \"\"\"Pong!\"\"\" 97 | await interaction.respond('Pong!') 98 | ``` 99 | 100 | Parameters: 101 | predicate: A callable that takes the interaction and returns a boolean. 102 | 103 | Returns: 104 | A decorator to apply to the application command. 105 | """ 106 | # While these functions are usually named 'decorator' and 'inner' or 107 | # 'wrapper', by using more descriptive names their repr:s (which show up 108 | # when printed) become more useful when debugging 109 | def check_decorator(callback: Callable[P, Awaitable[RT]]) -> Callable[P, Awaitable[RT]]: 110 | @wraps(callback) 111 | async def check_command_wrapper(*args: P.args, **kwargs: P.kwargs) -> RT: 112 | inter = cast(CommandInteraction, args[0]) 113 | 114 | if await predicate(inter) is False: 115 | raise CheckFailure(inter, predicate) 116 | 117 | return await callback(*args, **kwargs) 118 | return check_command_wrapper 119 | return check_decorator 120 | -------------------------------------------------------------------------------- /library/wumpy-interactions/wumpy/interactions/components/__init__.py: -------------------------------------------------------------------------------- 1 | from ._handler import ( 2 | ComponentHandler, 3 | ) 4 | 5 | __all__ = ( 6 | 'ComponentHandler', 7 | ) 8 | -------------------------------------------------------------------------------- /library/wumpy-interactions/wumpy/interactions/components/_handler.py: -------------------------------------------------------------------------------- 1 | import re 2 | from typing import Any, Callable, Coroutine, List, Tuple, Union 3 | 4 | import anyio 5 | 6 | from .._models import ComponentInteraction 7 | 8 | __all__ = ( 9 | 'ComponentHandler', 10 | ) 11 | 12 | 13 | ComponentCallback = Callable[ 14 | [ComponentInteraction, 're.Match[str]'], 15 | Coroutine[Any, Any, object] 16 | ] 17 | 18 | 19 | class ComponentHandler: 20 | """Handler for components, dispatching waiting components. 21 | 22 | This is a mixin class keeping track of handlers for components setup. 23 | """ 24 | 25 | _regex_components: List[Tuple['re.Pattern[str]', ComponentCallback]] 26 | 27 | def __init__(self, *args: Any, **kwargs: Any) -> None: 28 | super().__init__(*args, **kwargs) 29 | 30 | self._regex_components = [] 31 | 32 | async def invoke_component(self, interaction: ComponentInteraction) -> None: 33 | for pattern, callback in self._regex_components: 34 | match = pattern.match(interaction.custom_id) 35 | if match: 36 | await callback(interaction, match) 37 | return 38 | 39 | def add_component(self, pattern: 're.Pattern[str]', func: ComponentCallback) -> None: 40 | """Add a callback to be dispatched when the pattern is matched. 41 | 42 | Parameters: 43 | pattern: The compiled regex pattern to match custom IDs with. 44 | func: The callback to be called when the pattern is matched. 45 | """ 46 | self._regex_components.append((pattern, func)) 47 | 48 | def remove_component(self, pattern: 're.Pattern[str]', func: ComponentCallback) -> None: 49 | """Remove a callback from the list of components. 50 | 51 | Parameters: 52 | pattern: The regex pattern used to add the callback. 53 | func: The callback to be removed. 54 | 55 | Raises: 56 | ValueError: The callback was not found. 57 | """ 58 | try: 59 | self._regex_components.remove((pattern, func)) 60 | except ValueError: 61 | raise ValueError(f'Callback {func} with pattern {pattern} not found.') from None 62 | 63 | def component( 64 | self, 65 | pattern: Union[str, 're.Pattern[str]'] 66 | ) -> Callable[[ComponentCallback], ComponentCallback]: 67 | """Add a callback to be dispatched when the custom ID is matched. 68 | 69 | Examples: 70 | 71 | ```python 72 | @app.component(r'upvote-(?P)') 73 | async def process_upvote(interaction: ComponentInteraction, match: re.Match): 74 | message_id = match.group('message_id') 75 | await interaction.reply( 76 | f'You upvoted message {message_id}', 77 | ephemeral=True 78 | ) 79 | ``` 80 | 81 | Parameters: 82 | pattern: The regex pattern to match custom IDs with. 83 | 84 | Returns: 85 | A decorator that adds the callback to the list of components. 86 | """ 87 | def decorator(func: ComponentCallback) -> ComponentCallback: 88 | nonlocal pattern 89 | 90 | if isinstance(pattern, str): 91 | pattern = re.compile(pattern) 92 | 93 | self.add_component(pattern, func) 94 | return func 95 | return decorator 96 | -------------------------------------------------------------------------------- /library/wumpy-interactions/wumpy/interactions/py.typed: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wumpyproject/wumpy/08b1628a8aa4bddf6bd25b4e9418247783fa6663/library/wumpy-interactions/wumpy/interactions/py.typed -------------------------------------------------------------------------------- /library/wumpy-models/README.md: -------------------------------------------------------------------------------- 1 | # Wumpy-models 2 | 3 | Fully typed, memory efficient Discord object representations. 4 | 5 | ```python 6 | from wumpy.models import User 7 | 8 | 9 | user = User.from_data({ ... }) 10 | 11 | print(f'{user.name}#{user.discriminator}') 12 | ``` 13 | -------------------------------------------------------------------------------- /library/wumpy-models/pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "wumpy-models" 3 | version = "0.1.0" 4 | description = "Fully typed, memory efficient Discord object representations" 5 | readme = {file = "README.md", content-type="text/markdown"} 6 | 7 | requires-python = ">=3.7" 8 | 9 | license = {text = "Dual-licensed under the Apache License 2.0 and the MIT License"} 10 | authors = [{name = "Bluenix", email = "bluenixdev@gmail.com"}] 11 | 12 | keywords = [ 13 | "wumpy", "wumpus", "wrapper", 14 | "discord", "discord-api", "discord-bot", "discord-api-wrapper", 15 | "python-3" 16 | ] 17 | classifiers = [ 18 | "Development Status :: 2 - Pre-Alpha", 19 | "Framework :: AnyIO", 20 | "Intended Audience :: Developers", 21 | "License :: OSI Approved :: MIT License", 22 | "License :: OSI Approved :: Apache Software License", 23 | "Natural Language :: English", 24 | "Operating System :: OS Independent", 25 | "Programming Language :: Python", 26 | "Programming Language :: Python :: 3", 27 | "Programming Language :: Python :: 3 :: Only", 28 | "Programming Language :: Python :: 3.7", 29 | "Programming Language :: Python :: 3.8", 30 | "Programming Language :: Python :: 3.9", 31 | "Programming Language :: Python :: 3.10", 32 | "Topic :: Internet", 33 | "Topic :: Internet :: WWW/HTTP", 34 | "Topic :: Software Development :: Libraries", 35 | "Topic :: Software Development :: Libraries :: Python Modules", 36 | "Typing :: Typed", 37 | ] 38 | 39 | dependencies = [ 40 | "typing_extensions >= 4, <5", 41 | "discord-typings >= 0.4.0, <1", 42 | "attrs >= 21.3, <= 24", 43 | ] 44 | 45 | [project.urls] 46 | Homepage = "https://github.com/wumpyproject/wumpy" 47 | Repository = "https://github.com/wumpyproject/wumpy/tree/main/library/wumpy-models" 48 | Documentation = "https://wumpy.rtfd.io" 49 | 50 | [build-system] 51 | requires = ["flit_core >=3.5, <4"] 52 | build-backend = "flit_core.buildapi" 53 | 54 | [tool.flit.module] 55 | # This is a subpackage under the wumpy namespace package, 56 | # we need to tell flit this otherwise it tries to make the 57 | # import wumpy-models rather than wumpy.models 58 | name = "wumpy.models" 59 | -------------------------------------------------------------------------------- /library/wumpy-models/wumpy/models/__init__.py: -------------------------------------------------------------------------------- 1 | from ._raw import ( 2 | PartialChannel, 3 | ChannelMention, 4 | InteractionChannel, 5 | RawDMChannel, 6 | RawTextChannel, 7 | RawThreadMember, 8 | RawThread, 9 | RawVoiceChannel, 10 | RawCategory, 11 | ActionRow, 12 | Button, 13 | LinkButton, 14 | SelectMenu, 15 | SelectMenuOption, 16 | TextInput, 17 | component_data, 18 | EmbedThumbnail, 19 | EmbedImage, 20 | EmbedFooter, 21 | EmbedField, 22 | EmbedAuthor, 23 | Embed, 24 | EmbedBuilder, 25 | embed_data, 26 | RawEmoji, 27 | RawMessageReaction, 28 | ApplicationFlags, 29 | Intents, 30 | MessageFlags, 31 | UserFlags, 32 | RawGuild, 33 | IntegrationExpire, 34 | IntegrationType, 35 | IntegrationAccount, 36 | RawIntegrationApplication, 37 | RawBotIntegration, 38 | RawStreamIntegration, 39 | InteractionType, 40 | ComponentType, 41 | ApplicationCommandOption, 42 | RawResolvedInteractionData, 43 | CommandInteractionOption, 44 | RawInteraction, 45 | RawAutocompleteInteraction, 46 | RawCommandInteraction, 47 | RawComponentInteraction, 48 | SelectInteractionValue, 49 | RawInvite, 50 | RawMember, 51 | RawInteractionMember, 52 | AllowedMentions, 53 | RawAttachment, 54 | RawMessageMentions, 55 | MessageType, 56 | RawMessage, 57 | Permissions, 58 | PermissionTarget, 59 | PermissionOverwrite, 60 | RoleTags, 61 | RawRole, 62 | StickerType, 63 | StickerFormatType, 64 | RawStickerItem, 65 | RawSticker, 66 | RawUser, 67 | RawBotUser, 68 | ) 69 | from ._stateful import ( 70 | Asset, 71 | DMChannel, 72 | TextChannel, 73 | ThreadMember, 74 | Thread, 75 | VoiceChannel, 76 | Category, 77 | Emoji, 78 | MessageReaction, 79 | Guild, 80 | IntegrationApplication, 81 | BotIntegration, 82 | StreamIntegration, 83 | ResolvedInteractionData, 84 | Interaction, 85 | AutocompleteInteraction, 86 | CommandInteraction, 87 | ComponentInteraction, 88 | Invite, 89 | Member, 90 | InteractionMember, 91 | Attachment, 92 | MessageMentions, 93 | Message, 94 | Role, 95 | StickerItem, 96 | Sticker, 97 | User, 98 | BotUser, 99 | ) 100 | from ._utils import ( 101 | Model, 102 | Snowflake, 103 | ) 104 | 105 | 106 | __all__ = ( 107 | 'PartialChannel', 108 | 'ChannelMention', 109 | 'InteractionChannel', 110 | 'RawDMChannel', 111 | 'RawTextChannel', 112 | 'RawThreadMember', 113 | 'RawThread', 114 | 'RawVoiceChannel', 115 | 'RawCategory', 116 | 'ActionRow', 117 | 'Button', 118 | 'LinkButton', 119 | 'SelectMenu', 120 | 'SelectMenuOption', 121 | 'TextInput', 122 | 'component_data', 123 | 'EmbedThumbnail', 124 | 'EmbedImage', 125 | 'EmbedFooter', 126 | 'EmbedField', 127 | 'EmbedAuthor', 128 | 'Embed', 129 | 'EmbedBuilder', 130 | 'embed_data', 131 | 'RawEmoji', 132 | 'RawMessageReaction', 133 | 'ApplicationFlags', 134 | 'Intents', 135 | 'MessageFlags', 136 | 'UserFlags', 137 | 'RawGuild', 138 | 'IntegrationExpire', 139 | 'IntegrationType', 140 | 'IntegrationAccount', 141 | 'RawIntegrationApplication', 142 | 'RawBotIntegration', 143 | 'RawStreamIntegration', 144 | 'InteractionType', 145 | 'ComponentType', 146 | 'ApplicationCommandOption', 147 | 'RawResolvedInteractionData', 148 | 'CommandInteractionOption', 149 | 'RawInteraction', 150 | 'RawAutocompleteInteraction', 151 | 'RawCommandInteraction', 152 | 'RawComponentInteraction', 153 | 'SelectInteractionValue', 154 | 'RawInvite', 155 | 'RawMember', 156 | 'RawInteractionMember', 157 | 'AllowedMentions', 158 | 'RawAttachment', 159 | 'RawMessageMentions', 160 | 'MessageType', 161 | 'RawMessage', 162 | 'Permissions', 163 | 'PermissionTarget', 164 | 'PermissionOverwrite', 165 | 'RoleTags', 166 | 'RawRole', 167 | 'StickerType', 168 | 'StickerFormatType', 169 | 'RawStickerItem', 170 | 'RawSticker', 171 | 'RawUser', 172 | 'RawBotUser', 173 | 'Asset', 174 | 'DMChannel', 175 | 'TextChannel', 176 | 'ThreadMember', 177 | 'Thread', 178 | 'VoiceChannel', 179 | 'Category', 180 | 'Emoji', 181 | 'MessageReaction', 182 | 'Guild', 183 | 'IntegrationApplication', 184 | 'BotIntegration', 185 | 'StreamIntegration', 186 | 'ResolvedInteractionData', 187 | 'Interaction', 188 | 'AutocompleteInteraction', 189 | 'CommandInteraction', 190 | 'ComponentInteraction', 191 | 'Invite', 192 | 'Member', 193 | 'InteractionMember', 194 | 'Attachment', 195 | 'MessageMentions', 196 | 'Message', 197 | 'Role', 198 | 'StickerItem', 199 | 'Sticker', 200 | 'User', 201 | 'BotUser', 202 | 'Model', 203 | 'Snowflake', 204 | ) 205 | -------------------------------------------------------------------------------- /library/wumpy-models/wumpy/models/_raw/__init__.py: -------------------------------------------------------------------------------- 1 | from ._channels import ( 2 | PartialChannel, 3 | ChannelMention, 4 | InteractionChannel, 5 | RawDMChannel, 6 | RawTextChannel, 7 | RawThreadMember, 8 | RawThread, 9 | RawVoiceChannel, 10 | RawCategory, 11 | ) 12 | from ._components import ( 13 | ActionRow, 14 | Button, 15 | LinkButton, 16 | SelectMenu, 17 | SelectMenuOption, 18 | TextInput, 19 | component_data, 20 | ) 21 | from ._embed import ( 22 | EmbedThumbnail, 23 | EmbedImage, 24 | EmbedFooter, 25 | EmbedField, 26 | EmbedAuthor, 27 | Embed, 28 | EmbedBuilder, 29 | embed_data, 30 | ) 31 | from ._emoji import ( 32 | RawEmoji, 33 | RawMessageReaction, 34 | ) 35 | from ._flags import ( 36 | ApplicationFlags, 37 | Intents, 38 | MessageFlags, 39 | UserFlags, 40 | ) 41 | from ._guild import ( 42 | RawGuild, 43 | ) 44 | from ._integrations import ( 45 | IntegrationAccount, 46 | RawIntegrationApplication, 47 | RawBotIntegration, 48 | RawStreamIntegration, 49 | ) 50 | from ._interactions import ( 51 | RawResolvedInteractionData, 52 | CommandInteractionOption, 53 | RawInteraction, 54 | RawAutocompleteInteraction, 55 | RawCommandInteraction, 56 | RawComponentInteraction, 57 | SelectInteractionValue, 58 | ) 59 | from ._invite import ( 60 | RawInvite, 61 | ) 62 | from ._member import ( 63 | RawMember, 64 | RawInteractionMember, 65 | ) 66 | from ._message import ( 67 | AllowedMentions, 68 | RawAttachment, 69 | RawMessageMentions, 70 | RawMessage, 71 | ) 72 | from ._permissions import ( 73 | Permissions, 74 | PermissionOverwrite, 75 | ) 76 | from ._role import ( 77 | RoleTags, 78 | RawRole, 79 | ) 80 | from ._sticker import ( 81 | RawStickerItem, 82 | RawSticker, 83 | ) 84 | from ._user import ( 85 | RawUser, 86 | RawBotUser, 87 | ) 88 | 89 | __all__ = ( 90 | 'PartialChannel', 91 | 'ChannelMention', 92 | 'InteractionChannel', 93 | 'RawDMChannel', 94 | 'RawTextChannel', 95 | 'RawThreadMember', 96 | 'RawThread', 97 | 'RawVoiceChannel', 98 | 'RawCategory', 99 | 'ActionRow', 100 | 'Button', 101 | 'LinkButton', 102 | 'SelectMenu', 103 | 'SelectMenuOption', 104 | 'TextInput', 105 | 'component_data', 106 | 'EmbedThumbnail', 107 | 'EmbedImage', 108 | 'EmbedFooter', 109 | 'EmbedField', 110 | 'EmbedAuthor', 111 | 'Embed', 112 | 'EmbedBuilder', 113 | 'embed_data', 114 | 'RawEmoji', 115 | 'RawMessageReaction', 116 | 'ApplicationFlags', 117 | 'Intents', 118 | 'MessageFlags', 119 | 'UserFlags', 120 | 'RawGuild', 121 | 'IntegrationAccount', 122 | 'RawIntegrationApplication', 123 | 'RawBotIntegration', 124 | 'RawStreamIntegration', 125 | 'RawResolvedInteractionData', 126 | 'CommandInteractionOption', 127 | 'RawInteraction', 128 | 'RawAutocompleteInteraction', 129 | 'RawCommandInteraction', 130 | 'RawComponentInteraction', 131 | 'SelectInteractionValue', 132 | 'RawInvite', 133 | 'RawMember', 134 | 'RawInteractionMember', 135 | 'AllowedMentions', 136 | 'RawAttachment', 137 | 'RawMessageMentions', 138 | 'RawMessage', 139 | 'Permissions', 140 | 'PermissionOverwrite', 141 | 'RoleTags', 142 | 'RawRole', 143 | 'RawStickerItem', 144 | 'RawSticker', 145 | 'RawUser', 146 | 'RawBotUser', 147 | ) 148 | -------------------------------------------------------------------------------- /library/wumpy-models/wumpy/models/_raw/_emoji.py: -------------------------------------------------------------------------------- 1 | import re 2 | from typing import ClassVar, Optional, Tuple 3 | 4 | import attrs 5 | from discord_typings import EmojiData, MessageReactionData 6 | from typing_extensions import Self 7 | 8 | from .._utils import DISCORD_EPOCH, Model, Snowflake 9 | from ._user import RawUser 10 | 11 | __all__ = ( 12 | 'RawEmoji', 13 | 'RawMessageReaction', 14 | ) 15 | 16 | 17 | @attrs.define(eq=False, frozen=True, kw_only=True) 18 | class RawEmoji(Model): 19 | 20 | name: str 21 | 22 | roles: Tuple[Snowflake, ...] = () 23 | user: Optional[RawUser] = None 24 | 25 | require_colons: bool = True 26 | managed: bool = False 27 | animated: bool = False 28 | available: bool = True 29 | 30 | REGEX: ClassVar['re.Pattern[str]'] = re.compile( 31 | r'a)?:?(?P[A-Za-z0-9\_]+):(?P[0-9]{13,20})>?' 32 | ) 33 | 34 | @classmethod 35 | def from_data(cls, data: EmojiData) -> Self: 36 | user = data.get('user') 37 | if user is not None: 38 | user = RawUser.from_data(user) 39 | 40 | return cls( 41 | id=int(data['id'] or DISCORD_EPOCH << 22), 42 | name=data.get('name') or '_', 43 | roles=tuple(Snowflake(int(s)) for s in data.get('roles', [])), 44 | user=user, 45 | require_colons=data.get('require_colons', True), 46 | managed=data.get('managed', False), 47 | animated=data.get('animated', False), 48 | available=data.get('available', True), 49 | ) 50 | 51 | @classmethod 52 | def from_string(cls, value: str) -> Self: 53 | match = cls.REGEX.match(value) 54 | if match: 55 | return cls( 56 | id=int(match.group('id')), 57 | name=match.group('name'), 58 | roles=(), 59 | user=None, 60 | 61 | require_colons=True, 62 | managed=False, 63 | animated=bool(match.group('animated')), 64 | available=True 65 | ) 66 | 67 | # The regex didn't match, we'll just have to assume the user passed a 68 | # built-in unicode emoji 69 | return cls( 70 | id=DISCORD_EPOCH << 22, 71 | name=value, 72 | ) 73 | 74 | 75 | @attrs.define(frozen=True) 76 | class RawMessageReaction: 77 | count: int 78 | emoji: RawEmoji 79 | 80 | me: bool = attrs.field(default=False, kw_only=True) 81 | 82 | @classmethod 83 | def from_data(cls, data: MessageReactionData) -> Self: 84 | return cls( 85 | count=data['count'], 86 | emoji=RawEmoji.from_data(data['emoji']), 87 | 88 | me=data['me'], 89 | ) 90 | -------------------------------------------------------------------------------- /library/wumpy-models/wumpy/models/_raw/_guild.py: -------------------------------------------------------------------------------- 1 | from typing import List, Optional, Set, Union 2 | 3 | import attrs 4 | from discord_typings import GuildCreateData, GuildData, GuildUpdateData 5 | from typing_extensions import Literal, Self 6 | 7 | from .._utils import Model, Snowflake, _get_as_snowflake 8 | 9 | __all__ = ( 10 | 'RawGuild', 11 | ) 12 | 13 | 14 | @attrs.define(eq=False, frozen=True, kw_only=True) 15 | class RawGuild(Model): 16 | name: str 17 | owner_id: Snowflake 18 | 19 | icon: Optional[str] 20 | splash: Optional[str] 21 | discovery_splash: Optional[str] 22 | 23 | features: Set[str] 24 | 25 | afk_timeout: int 26 | afk_channel_id: Optional[Snowflake] 27 | 28 | verification_level: Literal[0, 1, 2, 3, 4] 29 | default_notifications: Literal[0, 1] 30 | explicit_content_filter: Literal[0, 1, 2] 31 | mfa_level: Literal[0, 1] 32 | premium_tier: Literal[0, 1, 2, 3] 33 | nsfw_level: Literal[0, 1, 2, 3] 34 | 35 | # This is an exception to the immutabily rule, as we want to keep the list 36 | # of these IDs up-to-date without needing to somehow re-create the entire 37 | # guild object each time. 38 | 39 | roles: List[Snowflake] 40 | emojis: List[Snowflake] 41 | members: List[Snowflake] 42 | channels: List[Snowflake] 43 | 44 | @classmethod 45 | def from_data(cls, data: Union[GuildData, GuildCreateData, GuildUpdateData]) -> Self: 46 | return cls( 47 | id=int(data['id']), 48 | name=data['name'], 49 | owner_id=Snowflake(int(data['owner_id'])), 50 | 51 | icon=data.get('icon'), 52 | splash=data.get('splash'), 53 | discovery_splash=data.get('discovery_splash'), 54 | 55 | features=set(data['features']), 56 | 57 | afk_timeout=data['afk_timeout'], 58 | afk_channel_id=_get_as_snowflake(data, 'afk_channel_id'), 59 | 60 | verification_level=data['verification_level'], 61 | default_notifications=data['default_message_notifications'], 62 | explicit_content_filter=data['explicit_content_filter'], 63 | mfa_level=data['mfa_level'], 64 | premium_tier=data['premium_tier'], 65 | nsfw_level=data['nsfw_level'], 66 | 67 | roles=[Snowflake(int(item['id'])) for item in data['roles']], 68 | emojis=[ 69 | Snowflake(int(item['id'])) for item in data['emojis'] 70 | if item['id'] is not None 71 | ], 72 | channels=[Snowflake(int(item['id'])) for item in data.get('channels', [])], 73 | members=[] 74 | ) 75 | -------------------------------------------------------------------------------- /library/wumpy-models/wumpy/models/_raw/_integrations.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | from typing import Optional 3 | 4 | import attrs 5 | from discord_typings import ( 6 | DiscordIntegrationData, IntegrationAccountData, IntegrationApplicationData, 7 | StreamingIntegrationData 8 | ) 9 | from typing_extensions import Self 10 | 11 | from .._utils import Model, Snowflake, _get_as_snowflake 12 | from ._user import RawUser 13 | 14 | __all__ = ( 15 | 'IntegrationAccount', 16 | 'RawIntegrationApplication', 17 | 'RawBotIntegration', 18 | 'RawStreamIntegration', 19 | ) 20 | 21 | 22 | @attrs.define(frozen=True) 23 | class IntegrationAccount: 24 | """Information about the account associated with an integration. 25 | 26 | Attributes: 27 | id: ID of the account. 28 | name: The name of the account. 29 | """ 30 | 31 | id: str 32 | name: str 33 | 34 | @classmethod 35 | def from_data(cls, data: IntegrationAccountData) -> Self: 36 | return cls(data['id'], data['name']) 37 | 38 | 39 | @attrs.define(eq=False, frozen=True, kw_only=True) 40 | class RawIntegrationApplication(Model): 41 | """Information about a bot/OAuth2 application. 42 | 43 | Attributes: 44 | name: The name of the application. 45 | icon: Icon hash of the application. 46 | description: Description of the application. 47 | summary: A summary of the application. 48 | user: The user object associated with the application. 49 | """ 50 | 51 | name: str 52 | icon: Optional[str] 53 | description: str 54 | summary: str 55 | user: Optional[RawUser] = None 56 | 57 | @classmethod 58 | def from_data(cls, data: IntegrationApplicationData) -> Self: 59 | user = data.get('bot') 60 | if user is not None: 61 | user = RawUser.from_data(user) 62 | 63 | return cls( 64 | id=int(data['id']), 65 | name=data['name'], 66 | icon=data.get('icon'), 67 | description=data['description'], 68 | summary=data['summary'], 69 | user=user 70 | ) 71 | 72 | 73 | @attrs.define(eq=False, frozen=True, kw_only=True) 74 | class RawBotIntegration(Model): 75 | """Representation of a bot integration in a guild. 76 | 77 | Attributes: 78 | application: The application associated with the integration. 79 | """ 80 | 81 | name: str 82 | type: str 83 | enabled: bool 84 | account: IntegrationAccount 85 | 86 | application: Optional[RawIntegrationApplication] = None 87 | 88 | @property 89 | def user(self) -> Optional[RawUser]: 90 | """The user associated with the integration.""" 91 | return None if self.application is None else self.application.user 92 | 93 | @classmethod 94 | def from_data(cls, data: DiscordIntegrationData) -> Self: 95 | application = data.get('application') 96 | if application is not None: 97 | application = RawIntegrationApplication.from_data(application) 98 | 99 | return cls( 100 | id=int(data['id']), 101 | name=data['name'], 102 | type=data['type'], 103 | enabled=data['enabled'], 104 | account=IntegrationAccount.from_data(data['account']), 105 | application=application 106 | ) 107 | 108 | 109 | @attrs.define(eq=False, frozen=True, kw_only=True) 110 | class RawStreamIntegration(Model): 111 | """Representation of a guild integration for Twitch or YouTube. 112 | 113 | Attributes: 114 | syncing: Whether the integration is synchronizing. 115 | role_id: The ID of the role associated with the integration. 116 | enable_emoticons: 117 | Whether emoticons should be synchronized (currently only for 118 | twitch integrations). 119 | expire_behavior: The behavior when the integration expires. 120 | expire_grace_period: The grace period before the integration expires. 121 | user: The user associated with the integration. 122 | synced_at: When the integration was last synchronized. 123 | subscriber_count: How many subscribers the integration has. 124 | revoked: Whether the integration has been revoked. 125 | """ 126 | 127 | name: str 128 | type: str 129 | enabled: bool 130 | account: IntegrationAccount 131 | 132 | syncing: bool 133 | role_id: Optional[Snowflake] 134 | enable_emoticons: bool 135 | expire_behavior: int 136 | expire_grace_period: int 137 | user: RawUser 138 | synced_at: datetime 139 | subscriber_count: int 140 | revoked: bool 141 | 142 | @classmethod 143 | def from_data(cls, data: StreamingIntegrationData) -> Self: 144 | return cls( 145 | id=int(data['id']), 146 | name=data['name'], 147 | type=data['type'], 148 | enabled=data['enabled'], 149 | account=IntegrationAccount.from_data(data['account']), 150 | syncing=data['syncing'], 151 | role_id=_get_as_snowflake(data, 'role_id'), 152 | enable_emoticons=data['enable_emoticons'], 153 | expire_behavior=data['expire_behavior'], 154 | expire_grace_period=data['expire_grace_period'], 155 | user=RawUser.from_data(data['user']), 156 | synced_at=datetime.fromisoformat(data['synced_at']), 157 | subscriber_count=data['subscriber_count'], 158 | revoked=data['revoked'], 159 | ) 160 | -------------------------------------------------------------------------------- /library/wumpy-models/wumpy/models/_raw/_invite.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime, timezone 2 | from typing import Optional, Union 3 | 4 | import attrs 5 | from discord_typings import InviteCreateData, InviteData 6 | from typing_extensions import Self 7 | 8 | from ._channels import PartialChannel 9 | from ._user import RawUser 10 | 11 | __all__ = ( 12 | 'RawInvite', 13 | ) 14 | 15 | 16 | @attrs.define(frozen=True) 17 | class RawInvite: 18 | """Representation of a Discord invite.""" 19 | 20 | code: str 21 | expires_at: Optional[datetime] = attrs.field(default=None, kw_only=True) 22 | 23 | inviter: Optional[RawUser] = attrs.field(default=None, kw_only=True) 24 | channel: Optional[PartialChannel] = attrs.field(default=None, kw_only=True) 25 | 26 | def __str__(self) -> str: 27 | return self.url 28 | 29 | @property 30 | def url(self) -> str: 31 | """Formatted invite URL for the invite code.""" 32 | return f'https://discord.gg/{self.code}' 33 | 34 | @property 35 | def expired(self) -> bool: 36 | """Whether the invite has expired.""" 37 | if self.expires_at is None: 38 | return False 39 | 40 | return self.expires_at < datetime.now(timezone.utc) 41 | 42 | @classmethod 43 | def from_data(cls, data: Union[InviteData, InviteCreateData]) -> Self: 44 | expires_at = data.get('expires_at') 45 | if expires_at is not None: 46 | expires_at = datetime.fromisoformat(expires_at) 47 | 48 | inviter = data.get('inviter') 49 | if inviter is not None: 50 | inviter = RawUser.from_data(inviter) 51 | 52 | channel = data.get('channel') 53 | if channel is not None: 54 | channel = PartialChannel.from_data(channel) 55 | 56 | return cls( 57 | code=data['code'], 58 | expires_at=expires_at, 59 | inviter=inviter, 60 | channel=channel 61 | ) 62 | -------------------------------------------------------------------------------- /library/wumpy-models/wumpy/models/_raw/_member.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime, timezone 2 | from typing import Optional, Tuple, Union 3 | 4 | import attrs 5 | from discord_typings import GuildMemberAddData, GuildMemberData, UserData 6 | from typing_extensions import Self 7 | 8 | from .._utils import Snowflake 9 | from ._flags import UserFlags 10 | from ._permissions import Permissions 11 | from ._user import RawUser 12 | 13 | __all__ = ( 14 | 'RawMember', 15 | 'RawInteractionMember', 16 | ) 17 | 18 | 19 | @attrs.define(eq=False, frozen=True, kw_only=True) 20 | class RawMember(RawUser): 21 | joined_at: datetime 22 | roles: Tuple[Snowflake, ...] 23 | 24 | nick: Optional[str] = None 25 | pending: bool = False 26 | 27 | premium_since: Optional[datetime] = None 28 | timed_out_until: Optional[datetime] = None 29 | 30 | @property 31 | def timed_out(self) -> bool: 32 | if self.timed_out_until is None: 33 | return False 34 | 35 | return self.timed_out_until > datetime.now(timezone.utc) 36 | 37 | @classmethod 38 | def from_data( 39 | cls, 40 | data: Union[GuildMemberData, GuildMemberAddData], 41 | user: Optional[UserData] = None 42 | ) -> Self: 43 | if user is None: 44 | if 'user' not in data: 45 | raise ValueError('Cannot create a member without a user.') 46 | else: 47 | user = data['user'] 48 | 49 | premium_since = data.get('premium_since') 50 | if premium_since is not None: 51 | premium_since = datetime.fromisoformat(premium_since) 52 | 53 | timed_out_until = data.get('premium_since') 54 | if timed_out_until is not None: 55 | timed_out_until = datetime.fromisoformat(timed_out_until) 56 | 57 | return cls( 58 | id=int(user['id']), 59 | 60 | name=user['username'], 61 | discriminator=int(user['discriminator']), 62 | bot=user.get('bot', False), 63 | system=user.get('system', False), 64 | public_flags=UserFlags(user.get('public_flags', 0)), 65 | 66 | joined_at=datetime.fromisoformat(data['joined_at']), 67 | roles=tuple(Snowflake(int(s)) for s in data['roles']), 68 | 69 | nick=data.get('nick'), 70 | pending=data.get('pending', False), 71 | 72 | premium_since=premium_since, 73 | timed_out_until=timed_out_until, 74 | ) 75 | 76 | 77 | @attrs.define(eq=False, frozen=True, kw_only=True) 78 | class RawInteractionMember(RawMember): 79 | permissions: Permissions = Permissions(0) 80 | 81 | @classmethod 82 | def from_data(cls, data: GuildMemberData, user: Optional[UserData] = None) -> Self: 83 | if user is None: 84 | if 'user' not in data: 85 | raise ValueError('Cannot create a member without a user.') 86 | else: 87 | user = data['user'] 88 | 89 | premium_since = data.get('premium_since') 90 | if premium_since is not None: 91 | premium_since = datetime.fromisoformat(premium_since) 92 | 93 | timed_out_until = data.get('premium_since') 94 | if timed_out_until is not None: 95 | timed_out_until = datetime.fromisoformat(timed_out_until) 96 | 97 | return cls( 98 | id=int(user['id']), 99 | 100 | name=user['username'], 101 | discriminator=int(user['discriminator']), 102 | bot=user.get('bot', False), 103 | system=user.get('system', False), 104 | public_flags=UserFlags(user.get('public_flags', 0)), 105 | 106 | joined_at=datetime.fromisoformat(data['joined_at']), 107 | roles=tuple(Snowflake(int(s)) for s in data['roles']), 108 | 109 | nick=data.get('nick'), 110 | pending=data.get('pending', False), 111 | 112 | premium_since=premium_since, 113 | timed_out_until=timed_out_until, 114 | 115 | permissions=Permissions(int(data.get('permissions', 0))), 116 | ) 117 | -------------------------------------------------------------------------------- /library/wumpy-models/wumpy/models/_raw/_role.py: -------------------------------------------------------------------------------- 1 | from typing import Optional, SupportsInt, Union 2 | 3 | import attrs 4 | from discord_typings import ( 5 | GuildRoleCreateData, GuildRoleUpdateData, RoleData, RoleTagsData 6 | ) 7 | from typing_extensions import Self 8 | 9 | from .._utils import Model, Snowflake, _get_as_snowflake 10 | from ._permissions import Permissions 11 | 12 | __all__ = ( 13 | 'RoleTags', 14 | 'RawRole', 15 | ) 16 | 17 | 18 | @attrs.define(frozen=True, kw_only=True) 19 | class RoleTags: 20 | """Tags on a particular Discord role. 21 | 22 | This contains extra metadata about a role, such as what makes the role 23 | managed or whether it is the role members get when boosting. 24 | """ 25 | 26 | bot_id: Optional[Snowflake] = None 27 | integration_id: Optional[Snowflake] = None 28 | 29 | premium_subscriber: bool = False 30 | 31 | @classmethod 32 | def from_data(cls, data: RoleTagsData) -> Self: 33 | bot_id = data.get('bot_id') 34 | if bot_id is not None: 35 | bot_id = Snowflake(int(bot_id)) 36 | 37 | integration_id = data.get('integration_id') 38 | if integration_id is not None: 39 | integration_id = Snowflake(int(integration_id)) 40 | 41 | return cls( 42 | bot_id=bot_id, 43 | integration_id=integration_id, 44 | premium_subscriber=data.get('premium_subscriber', False) is None 45 | ) 46 | 47 | 48 | @attrs.define(eq=False, frozen=True, kw_only=True) 49 | class RawRole(Model): 50 | """Representation of a Discord role with permissions. 51 | 52 | A role represents a set of permissions attached to a group of members. 53 | `@everyone` is also a role like any other role expect for the fact that its 54 | ID is the same as the guild it is attached to. 55 | 56 | Attributes: 57 | name: The name of the role. 58 | color: The color of the role as an integer. 59 | position: The position of the role in the role list. 60 | permissions: The permissions that having this role gives. 61 | guild_id: Guild the role belongs to, if it was passed. 62 | hoist: Whether this role is pinned in the user list. 63 | managed: Whether this role is managed by an integration or bot. 64 | mentionable: Whether this role can be mentioned by anyone. 65 | tags: 66 | Extra metadata about the role - this attribute contains data about 67 | what is managing this role and whether it is the role that premium 68 | subscribers (also called "boosters") are given. 69 | """ 70 | 71 | name: str 72 | color: int 73 | position: int 74 | permissions: Permissions = Permissions(0) 75 | 76 | guild_id: Optional[Snowflake] = None 77 | 78 | hoist: bool = False 79 | managed: bool = False 80 | mentionable: bool = False 81 | tags: RoleTags = RoleTags() 82 | 83 | @property 84 | def premium_subscriber(self) -> bool: 85 | """Whether this role is the role that premium subscribers get. 86 | 87 | Shortcut for accessing the `premium_subscriber` attribute of the 88 | RoleTags object. 89 | """ 90 | return self.tags.premium_subscriber 91 | 92 | @classmethod 93 | def from_data( 94 | cls, 95 | data: Union[RoleData, GuildRoleCreateData, GuildRoleUpdateData], 96 | *, 97 | guild_id: Optional[SupportsInt] = None, 98 | ) -> Self: 99 | if guild_id is not None: 100 | guild_id = Snowflake(guild_id) 101 | else: 102 | guild_id = _get_as_snowflake(data, 'guild_id') 103 | 104 | if 'role' in data: 105 | data = data['role'] 106 | 107 | return cls( 108 | id=int(data['id']), 109 | name=data['name'], 110 | color=data['color'], 111 | position=data['position'], 112 | permissions=Permissions(int(data['permissions'])), 113 | hoist=data['hoist'], 114 | managed=data['managed'], 115 | mentionable=data['mentionable'], 116 | tags=RoleTags.from_data(data.get('tags', {})) 117 | ) 118 | -------------------------------------------------------------------------------- /library/wumpy-models/wumpy/models/_raw/_sticker.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | from typing import Optional 3 | 4 | import attrs 5 | from discord_typings import StickerData, StickerItemData 6 | from typing_extensions import Self 7 | 8 | from .._utils import Model, Snowflake, _get_as_snowflake 9 | from ._user import RawUser 10 | 11 | __all__ = ( 12 | 'RawStickerItem', 13 | 'RawSticker', 14 | ) 15 | 16 | 17 | @attrs.define(eq=False, frozen=True, kw_only=True) 18 | class RawStickerItem(Model): 19 | name: str 20 | format_type: int 21 | 22 | @classmethod 23 | def from_data(cls, data: StickerItemData) -> Self: 24 | return cls( 25 | id=int(data['id']), 26 | name=data['name'], 27 | format_type=int(data['format_type']) 28 | ) 29 | 30 | 31 | @attrs.define(eq=False, frozen=True, kw_only=True) 32 | class RawSticker(RawStickerItem): 33 | type: int 34 | 35 | tags: str 36 | description: Optional[str] = None 37 | 38 | pack_id: Optional[Snowflake] = None 39 | sort_value: Optional[int] = None 40 | 41 | available: bool = True 42 | guild_id: Optional[Snowflake] = None 43 | 44 | user: Optional[RawUser] = None 45 | 46 | @classmethod 47 | def from_data(cls, data: StickerData) -> Self: 48 | user = data.get('user') 49 | if user is not None: 50 | user = RawUser.from_data(user) 51 | 52 | return cls( 53 | id=int(data['id']), 54 | 55 | name=data['name'], 56 | description=data.get('description'), 57 | 58 | pack_id=_get_as_snowflake(data, 'pack_id'), 59 | sort_value=data.get('sort_value'), 60 | 61 | tags=data['tags'], 62 | type=data['type'], 63 | format_type=data['format_type'], 64 | 65 | available=data.get('available', True), 66 | guild_id=_get_as_snowflake(data, 'guild_id'), 67 | 68 | user=user 69 | ) 70 | -------------------------------------------------------------------------------- /library/wumpy-models/wumpy/models/_raw/_user.py: -------------------------------------------------------------------------------- 1 | import attrs 2 | from discord_typings import UserData 3 | from typing_extensions import Self 4 | 5 | from .._utils import Model 6 | from ._flags import UserFlags 7 | 8 | __all__ = ( 9 | 'RawUser', 10 | 'RawBotUser', 11 | ) 12 | 13 | 14 | @attrs.define(eq=False, frozen=True, kw_only=True) 15 | class RawUser(Model): 16 | name: str 17 | discriminator: int 18 | 19 | bot: bool = False 20 | system: bool = False 21 | public_flags: UserFlags = UserFlags.none() 22 | 23 | def __str__(self) -> str: 24 | return f'{self.name}#{self.discriminator}' 25 | 26 | @property 27 | def mention(self) -> str: 28 | return f'<@{self.id}>' 29 | 30 | @classmethod 31 | def from_data(cls, data: UserData) -> Self: 32 | return cls( 33 | id=int(data['id']), 34 | name=data['username'], 35 | discriminator=int(data['discriminator']), 36 | bot=data.get('bot', False), 37 | system=data.get('system', False), 38 | public_flags=UserFlags(data.get('public_flags', 0)) 39 | ) 40 | 41 | 42 | @attrs.define(eq=False, frozen=True, kw_only=True) 43 | class RawBotUser(RawUser): 44 | bot: bool = True # Update default 45 | 46 | locale: str 47 | mfa_enabled: bool 48 | verified: bool 49 | 50 | @classmethod 51 | def from_data(cls, data: UserData) -> Self: 52 | if ( 53 | 'locale' not in data 54 | or 'mfa_enabled' not in data 55 | or 'verified' not in data 56 | ): 57 | raise ValueError('Cannot create a BotUser from data without extra user fields') 58 | 59 | return cls( 60 | id=int(data['id']), 61 | name=data['username'], 62 | discriminator=int(data['discriminator']), 63 | 64 | locale=data['locale'], 65 | mfa_enabled=data['mfa_enabled'], 66 | verified=data['verified'], 67 | 68 | bot=data.get('bot', True), 69 | system=data.get('system', False), 70 | public_flags=UserFlags(data.get('public_flags', 0)), 71 | ) 72 | -------------------------------------------------------------------------------- /library/wumpy-models/wumpy/models/_stateful/__init__.py: -------------------------------------------------------------------------------- 1 | from ._asset import ( 2 | Asset, 3 | ) 4 | from ._channels import ( 5 | DMChannel, 6 | TextChannel, 7 | ThreadMember, 8 | Thread, 9 | VoiceChannel, 10 | Category, 11 | ) 12 | from ._emoji import ( 13 | Emoji, 14 | MessageReaction, 15 | ) 16 | from ._guild import ( 17 | Guild, 18 | ) 19 | from ._integrations import ( 20 | IntegrationApplication, 21 | BotIntegration, 22 | StreamIntegration, 23 | ) 24 | from ._interactions import ( 25 | ResolvedInteractionData, 26 | Interaction, 27 | AutocompleteInteraction, 28 | CommandInteraction, 29 | ComponentInteraction, 30 | ) 31 | from ._invite import ( 32 | Invite, 33 | ) 34 | from ._member import ( 35 | Member, 36 | InteractionMember, 37 | ) 38 | from ._message import ( 39 | Attachment, 40 | MessageMentions, 41 | Message, 42 | ) 43 | from ._role import ( 44 | Role, 45 | ) 46 | from ._sticker import ( 47 | StickerItem, 48 | Sticker, 49 | ) 50 | from ._user import ( 51 | User, 52 | BotUser, 53 | ) 54 | 55 | __all__ = ( 56 | 'Asset', 57 | 'DMChannel', 58 | 'TextChannel', 59 | 'ThreadMember', 60 | 'Thread', 61 | 'VoiceChannel', 62 | 'Category', 63 | 'Emoji', 64 | 'MessageReaction', 65 | 'Guild', 66 | 'IntegrationApplication', 67 | 'BotIntegration', 68 | 'StreamIntegration', 69 | 'ResolvedInteractionData', 70 | 'Interaction', 71 | 'AutocompleteInteraction', 72 | 'CommandInteraction', 73 | 'ComponentInteraction', 74 | 'Invite', 75 | 'Member', 76 | 'InteractionMember', 77 | 'Attachment', 78 | 'MessageMentions', 79 | 'Message', 80 | 'Role', 81 | 'StickerItem', 82 | 'Sticker', 83 | 'User', 84 | 'BotUser', 85 | ) 86 | -------------------------------------------------------------------------------- /library/wumpy-models/wumpy/models/_stateful/_asset.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | from urllib.parse import parse_qs, urlencode, urlsplit 3 | 4 | import attrs 5 | from typing_extensions import Literal, Self 6 | 7 | from .._utils import get_api 8 | 9 | __all__ = ( 10 | 'Asset', 11 | ) 12 | 13 | 14 | @attrs.define(frozen=True) 15 | class Asset: 16 | url: str 17 | 18 | BASE = 'https://cdn.discordapp.com' 19 | 20 | @classmethod 21 | def from_path(cls, path: str) -> Self: 22 | return cls(cls.BASE + path) 23 | 24 | def replace( 25 | self, 26 | *, 27 | fmt: Optional[Literal['jpeg', 'jpg', 'png', 'webp', 'gif', 'json']] = None, 28 | size: Optional[int] = None 29 | ) -> Self: 30 | url = urlsplit(self.url) 31 | path = url.path 32 | query = url.query 33 | 34 | if size is not None: 35 | if not (4096 >= size >= 16): 36 | raise ValueError('size argument must be between 16 and 4096.') 37 | 38 | elif size & (size - 1) != 0: 39 | # All powers of two only have one bit set: 1000 40 | # if we subtract 1, then (0111) AND it we should get 0 (0000). 41 | raise ValueError('size argument must be a power of two.') 42 | 43 | query = parse_qs(url.query) 44 | query['size'] = [str(size)] 45 | query = urlencode(query, doseq=True) 46 | 47 | if fmt is not None: 48 | if fmt not in {'jpeg', 'jpg', 'png', 'webp', 'gif', 'json'}: 49 | raise ValueError( 50 | "Image format must be one of: 'jpeg', 'jpg', 'png', 'webp', " 51 | "'gif, or 'json' (for Lottie)" 52 | ) 53 | 54 | destination, _, _ = path.rpartition('.') 55 | path = f'{destination}.{fmt}' 56 | 57 | return self.__class__(f'{url.scheme}://{url.netloc}{path}?{query}') 58 | 59 | async def read(self) -> bytes: 60 | """Read the asset's content and return it as bytes.""" 61 | return await get_api().read_asset(self.url) 62 | -------------------------------------------------------------------------------- /library/wumpy-models/wumpy/models/_stateful/_emoji.py: -------------------------------------------------------------------------------- 1 | import re 2 | from typing import ClassVar, Optional, Tuple 3 | 4 | import attrs 5 | from discord_typings import EmojiData, MessageReactionData 6 | from typing_extensions import Self 7 | 8 | from .._raw import RawEmoji, RawMessageReaction 9 | from .._utils import DISCORD_EPOCH, Model, Snowflake 10 | from . import _user 11 | 12 | __all__ = ( 13 | 'Emoji', 14 | 'MessageReaction', 15 | ) 16 | 17 | 18 | @attrs.define(eq=False, frozen=True) 19 | class Emoji(RawEmoji): 20 | 21 | user: Optional[_user.User] 22 | 23 | @classmethod 24 | def from_data(cls, data: EmojiData) -> Self: 25 | user = data.get('user') 26 | if user is not None: 27 | user = _user.User.from_data(user) 28 | 29 | return cls( 30 | id=int(data['id'] or DISCORD_EPOCH << 22), 31 | name=data.get('name') or '_', 32 | roles=tuple(Snowflake(int(s)) for s in data.get('roles', [])), 33 | user=user, 34 | require_colons=data.get('require_colons', True), 35 | managed=data.get('managed', False), 36 | animated=data.get('animated', False), 37 | available=data.get('available', True), 38 | ) 39 | 40 | 41 | @attrs.define(frozen=True) 42 | class MessageReaction(RawMessageReaction): 43 | emoji: Emoji 44 | 45 | @classmethod 46 | def from_data(cls, data: MessageReactionData) -> Self: 47 | return cls( 48 | count=data['count'], 49 | emoji=Emoji.from_data(data['emoji']), 50 | 51 | me=data['me'], 52 | ) 53 | -------------------------------------------------------------------------------- /library/wumpy-models/wumpy/models/_stateful/_guild.py: -------------------------------------------------------------------------------- 1 | import attrs 2 | 3 | from .._raw import RawGuild 4 | 5 | __all__ = ( 6 | 'Guild', 7 | ) 8 | 9 | @attrs.define(eq=False, frozen=True) 10 | class Guild(RawGuild): 11 | ... 12 | -------------------------------------------------------------------------------- /library/wumpy-models/wumpy/models/_stateful/_integrations.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | 3 | import attrs 4 | from discord_typings import ( 5 | DiscordIntegrationData, IntegrationApplicationData 6 | ) 7 | from typing_extensions import Self 8 | 9 | from .._raw import ( 10 | IntegrationAccount, RawBotIntegration, 11 | RawIntegrationApplication, RawStreamIntegration 12 | ) 13 | from . import _user 14 | 15 | __all__ = ( 16 | 'IntegrationApplication', 17 | 'BotIntegration', 18 | 'StreamIntegration', 19 | ) 20 | 21 | 22 | @attrs.define(eq=False, frozen=True, kw_only=True) 23 | class IntegrationApplication(RawIntegrationApplication): 24 | user: Optional['_user.User'] 25 | 26 | @classmethod 27 | def from_data(cls, data: IntegrationApplicationData) -> Self: 28 | user = data.get('bot') 29 | if user is not None: 30 | user = _user.User.from_data(user) 31 | 32 | return cls( 33 | id=int(data['id']), 34 | name=data['name'], 35 | icon=data.get('icon'), 36 | description=data['description'], 37 | summary=data['summary'], 38 | user=user 39 | ) 40 | 41 | 42 | @attrs.define(eq=False, frozen=True, kw_only=True) 43 | class BotIntegration(RawBotIntegration): 44 | application: Optional[IntegrationApplication] = None 45 | 46 | @property 47 | def user(self) -> Optional['_user.User']: 48 | """The user associated with the integration.""" 49 | return None if self.application is None else self.application.user 50 | 51 | @classmethod 52 | def from_data(cls, data: DiscordIntegrationData) -> Self: 53 | application = data.get('application') 54 | if application is not None: 55 | application = IntegrationApplication.from_data(application) 56 | 57 | return cls( 58 | id=int(data['id']), 59 | name=data['name'], 60 | type=data['type'], 61 | enabled=data['enabled'], 62 | account=IntegrationAccount.from_data(data['account']), 63 | application=application 64 | ) 65 | 66 | 67 | @attrs.define(eq=False, frozen=True) 68 | class StreamIntegration(RawStreamIntegration): 69 | ... 70 | -------------------------------------------------------------------------------- /library/wumpy-models/wumpy/models/_stateful/_invite.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime, timezone 2 | from typing import Optional, Union 3 | 4 | import attrs 5 | from discord_typings import InviteCreateData, InviteData 6 | from typing_extensions import Self 7 | 8 | from .._raw import PartialChannel, RawInvite 9 | from .._utils import MISSING, get_api 10 | from . import _user 11 | 12 | __all__ = ( 13 | 'Invite', 14 | ) 15 | 16 | 17 | @attrs.define(frozen=True, kw_only=True) 18 | class Invite(RawInvite): 19 | """Representation of a Discord invite.""" 20 | 21 | inviter: Optional[_user.User] # Override typehint 22 | 23 | @classmethod 24 | def from_data(cls, data: Union[InviteData, InviteCreateData]) -> Self: 25 | expires_at = data.get('expires_at') 26 | if expires_at is not None: 27 | expires_at = datetime.fromisoformat(expires_at) 28 | 29 | inviter = data.get('inviter') 30 | if inviter is not None: 31 | inviter = _user.User.from_data(inviter) 32 | 33 | channel = data.get('channel') 34 | if channel is not None: 35 | channel = PartialChannel.from_data(channel) 36 | 37 | return cls( 38 | code=data['code'], 39 | expires_at=expires_at, 40 | inviter=inviter, 41 | channel=channel 42 | ) 43 | 44 | async def delete(self, *, reason: str = MISSING) -> Self: 45 | """Delete the invite. 46 | 47 | This method requires the `MANAGE_CHANNELS` permission on the channel 48 | that the invite belongs to, or `MANAGE_GUILD` on the guild. 49 | 50 | Parameters: 51 | reason: Audit log reason for deleting the invite. 52 | 53 | Returns: 54 | The most recent data of the now deleted invite. 55 | """ 56 | data = await get_api().delete_invite(self.code, reason=reason) 57 | return self.__class__.from_data(data) 58 | -------------------------------------------------------------------------------- /library/wumpy-models/wumpy/models/_stateful/_member.py: -------------------------------------------------------------------------------- 1 | import attrs 2 | 3 | from .._raw import RawInteractionMember, RawMember 4 | from ._user import User 5 | 6 | __all__ = ( 7 | 'Member', 8 | 'InteractionMember', 9 | ) 10 | 11 | 12 | @attrs.define(eq=False, frozen=True) 13 | class Member(RawMember, User): 14 | ... 15 | 16 | 17 | @attrs.define(eq=False, frozen=True) 18 | class InteractionMember(RawInteractionMember, Member): 19 | ... 20 | -------------------------------------------------------------------------------- /library/wumpy-models/wumpy/models/_stateful/_message.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | from typing import Iterable, Optional, Sequence, SupportsInt, Tuple, Union 3 | 4 | import attrs 5 | from discord_typings import ( 6 | AllowedMentionsData, AttachmentData, MessageCreateData, MessageData, 7 | MessageUpdateData 8 | ) 9 | from typing_extensions import Self 10 | 11 | from .._raw import ( 12 | ChannelMention, Embed, RawAttachment, RawMessage, RawMessageMentions 13 | ) 14 | from .._utils import Snowflake, _get_as_snowflake 15 | from . import _emoji, _member, _user 16 | 17 | __all__ = ( 18 | 'Attachment', 19 | 'MessageMentions', 20 | 'Message', 21 | ) 22 | 23 | 24 | @attrs.define(eq=False) 25 | class Attachment(RawAttachment): 26 | ... 27 | 28 | 29 | @attrs.define(kw_only=True) 30 | class MessageMentions(RawMessageMentions): 31 | users: Union[Tuple['_user.User', ...], Tuple['_member.Member', ...]] 32 | 33 | roles: Tuple[Snowflake, ...] 34 | 35 | @classmethod 36 | def from_message( 37 | cls, 38 | data: Union[MessageData, MessageCreateData, MessageUpdateData] 39 | ) -> Self: 40 | if data['mentions'] and 'member' in data['mentions'][0]: 41 | # Pyright doesn't understand that the type has narrowed down to 42 | # List[UserMentionData] with the 'member' key. 43 | users = tuple(_member.Member.from_data(m['member'], m) for m in data['mentions']) # type: ignore 44 | else: 45 | users = tuple(_user.User.from_data(u) for u in data['mentions']) 46 | 47 | return cls( 48 | users=users, 49 | channels=tuple( 50 | ChannelMention.from_data(c) 51 | for c in data.get('mention_channels', []) 52 | ), 53 | roles=tuple(Snowflake(int(r)) for r in data['mention_roles']), 54 | ) 55 | 56 | 57 | @attrs.define(eq=False, kw_only=True) 58 | class Message(RawMessage): 59 | author: Union[_user.User, _member.Member] 60 | 61 | channel_id: Snowflake 62 | guild_id: Optional[Snowflake] = None 63 | 64 | attachments: Tuple[Attachment, ...] 65 | reactions: Tuple['_emoji.MessageReaction', ...] = () 66 | mentions: MessageMentions = MessageMentions() 67 | 68 | @classmethod 69 | def from_data( 70 | cls, 71 | data: Union[MessageData, MessageCreateData, MessageUpdateData] 72 | ) -> Self: 73 | if 'member' in data: 74 | author = _member.Member.from_data(data['member'], data['author']) 75 | else: 76 | author = _user.User.from_data(data['author']) 77 | 78 | return cls( 79 | id=int(data['id']), 80 | type=data['type'], 81 | author=author, 82 | 83 | channel_id=Snowflake(int(data['channel_id'])), 84 | guild_id=_get_as_snowflake(data, 'guild_id'), 85 | 86 | content=data['content'], 87 | tts=data['tts'], 88 | attachments=tuple(Attachment.from_data(a) for a in data['attachments']), 89 | embeds=tuple(Embed.from_data(e) for e in data['embeds']), 90 | reactions=tuple(_emoji.MessageReaction.from_data(r) for r in data.get('reactions', [])), 91 | mentions=MessageMentions.from_message(data), 92 | 93 | pinned=data['pinned'], 94 | ) 95 | -------------------------------------------------------------------------------- /library/wumpy-models/wumpy/models/_stateful/_role.py: -------------------------------------------------------------------------------- 1 | import attrs 2 | 3 | from .._raw import RawRole 4 | from .._utils import MISSING, get_api 5 | 6 | __all__ = ( 7 | 'Role', 8 | ) 9 | 10 | 11 | @attrs.define(eq=False) 12 | class Role(RawRole): 13 | ... 14 | 15 | async def delete(self, *, reason: str = MISSING) -> None: 16 | """Delete the role. 17 | 18 | This method requires the `MANAGE_ROLES` permission. 19 | 20 | Parameters: 21 | reason: Audit log reason for deleting the role. 22 | """ 23 | if not self.guild_id: 24 | raise ValueError('Cannot delete role without known guild ID') 25 | 26 | await get_api().delete_role(self.guild_id, self.id, reason=reason) 27 | -------------------------------------------------------------------------------- /library/wumpy-models/wumpy/models/_stateful/_sticker.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | 3 | import attrs 4 | from discord_typings import StickerData 5 | from typing_extensions import Self 6 | 7 | from .._raw import RawSticker, RawStickerItem 8 | from .._utils import _get_as_snowflake 9 | from . import _user 10 | 11 | __all__ = ( 12 | 'StickerItem', 13 | 'Sticker', 14 | ) 15 | 16 | 17 | @attrs.define(eq=False, frozen=True, kw_only=True) 18 | class StickerItem(RawStickerItem): 19 | ... 20 | 21 | 22 | @attrs.define(eq=False, frozen=True, kw_only=True) 23 | class Sticker(RawSticker, StickerItem): 24 | 25 | user: Optional['_user.User'] 26 | 27 | @classmethod 28 | def from_data(cls, data: StickerData) -> Self: 29 | user = data.get('user') 30 | if user is not None: 31 | user = _user.User.from_data(user) 32 | 33 | return cls( 34 | id=int(data['id']), 35 | 36 | name=data['name'], 37 | description=data.get('description'), 38 | 39 | pack_id=_get_as_snowflake(data, 'pack_id'), 40 | sort_value=data.get('sort_value'), 41 | 42 | tags=data['tags'], 43 | type=data['type'], 44 | format_type=data['format_type'], 45 | 46 | available=data.get('available', True), 47 | guild_id=_get_as_snowflake(data, 'guild_id'), 48 | 49 | user=user 50 | ) 51 | -------------------------------------------------------------------------------- /library/wumpy-models/wumpy/models/_stateful/_user.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | 3 | import attrs 4 | from typing_extensions import Self 5 | 6 | from .._raw import RawBotUser, RawUser 7 | from .._utils import MISSING, get_api 8 | from . import _channels # Potential circular imports 9 | 10 | __all__ = ( 11 | 'User', 12 | 'BotUser', 13 | ) 14 | 15 | 16 | @attrs.define(eq=False, frozen=True) 17 | class User(RawUser): 18 | ... 19 | 20 | async def create_dm(self) -> '_channels.DMChannel': 21 | """Create a DM with the user to be able to send messages. 22 | 23 | This method (endpoint) is idempotent and will return the same channel 24 | on subsequent calls. 25 | 26 | Returns: 27 | The created or reused DM channel. 28 | """ 29 | data = await get_api().create_dm(self.id) 30 | return _channels.DMChannel.from_data(data) 31 | 32 | 33 | @attrs.define(eq=False, frozen=True) 34 | class BotUser(RawBotUser): 35 | ... 36 | 37 | async def edit( 38 | self, 39 | *, 40 | username: str = MISSING, 41 | avatar: Optional[str] = MISSING, 42 | ) -> Self: 43 | """Edit the current bot user. 44 | 45 | Parameters: 46 | username: New username to set. 47 | avatar: New base64-encoded image data for the avatar. 48 | 49 | Returns: 50 | New instance of the bot user with updated values. 51 | """ 52 | data = await get_api().edit_my_user(username=username, avatar=avatar) 53 | return self.__class__.from_data(data) 54 | -------------------------------------------------------------------------------- /library/wumpy-models/wumpy/models/_utils.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime, timezone 2 | from typing import ( 3 | TYPE_CHECKING, Any, Mapping, NoReturn, Optional, SupportsInt, Union 4 | ) 5 | 6 | import attrs 7 | from typing_extensions import Self 8 | 9 | __all__ = ( 10 | 'DISCORD_EPOCH', 11 | 'Model', 12 | 'Snowflake', 13 | ) 14 | 15 | 16 | DISCORD_EPOCH = 1420070400000 17 | 18 | 19 | @attrs.define(frozen=True) 20 | class Model: 21 | """The root for all Wumpy objects, a Discord object with an ID. 22 | 23 | A Model is a simple wrapper over an integer - the Discord snowflake which 24 | is guaranteed by Discord to be unique. It tries to support as many 25 | operations as possible. This class is later used for all models in 26 | `wumpy-models` that are exposed. 27 | 28 | Attributes: 29 | id: The underlying integer value representing the Discord snowflake. 30 | """ 31 | 32 | id: int 33 | 34 | def __repr__(self) -> str: 35 | return f'wumpy.models.Model(id={self.id})' 36 | 37 | def __hash__(self) -> int: 38 | return self.id >> 22 39 | 40 | def __int__(self) -> int: 41 | return self.id 42 | 43 | def __float__(self) -> float: 44 | return float(self.id) 45 | 46 | def __complex__(self) -> complex: 47 | return complex(self.id) 48 | 49 | def __index__(self) -> int: 50 | return self.id 51 | 52 | def __eq__(self, other: object) -> bool: 53 | if isinstance(other, int): 54 | value = other 55 | elif isinstance(other, self.__class__): 56 | value = other.id 57 | else: 58 | return NotImplemented 59 | 60 | return self.id == value 61 | 62 | def __ne__(self, other: object) -> bool: 63 | # There's a performance hit to not defining __ne__, even though 64 | # Python will automatically call __eq__ and invert it 65 | 66 | if isinstance(other, int): 67 | value = other 68 | elif isinstance(other, self.__class__): 69 | value = other.id 70 | else: 71 | return NotImplemented 72 | 73 | return self.id != value 74 | 75 | @property 76 | def created_at(self) -> datetime: 77 | timestamp = (self.id >> 22) + DISCORD_EPOCH 78 | return datetime.fromtimestamp(timestamp / 1000, tz=timezone.utc) 79 | 80 | 81 | @attrs.define(eq=False, frozen=True) 82 | class Snowflake(Model): 83 | """Standalone Discord snowflake. 84 | 85 | This is seperate from Model as not all methods on this class should be 86 | inherited to subclasses, such as the `from_datetime()` classmethod. Any 87 | standalone ID field will be an instance of this class. 88 | 89 | Attributes: 90 | id: The underlying integer value representing the Discord snowflake. 91 | """ 92 | 93 | def __init__(self, id: Union[SupportsInt, str]) -> None: 94 | super().__init__(int(id)) 95 | 96 | @property 97 | def worker_id(self) -> int: 98 | """Return the ID of the worker that created the snowflake.""" 99 | return (self.id & 0x3E0000) >> 17 100 | 101 | @property 102 | def process_id(self) -> int: 103 | """Return the ID of the process that created the snowflake.""" 104 | return (self.id & 0x1F000) >> 12 105 | 106 | @property 107 | def process_increment(self) -> int: 108 | """Return the increment of the process that created the snowflake.""" 109 | return self.id & 0xFFF 110 | 111 | @classmethod 112 | def from_datetime(cls, dt: datetime) -> Self: 113 | """Craft a snowflake created at the specified time. 114 | 115 | This enables a neat trick for pagination through the Discord API as 116 | Discord only look at the timestamp it represents. 117 | 118 | Parameters: 119 | dt: The datetime of when the snowflake should be created. 120 | 121 | Returns: 122 | The snowflake created at the specified time. 123 | """ 124 | return cls(int(dt.timestamp() * 1000 - DISCORD_EPOCH) << 22) 125 | 126 | 127 | def _get_as_snowflake(data: Optional[Mapping[str, Any]], key: str) -> Optional[Snowflake]: 128 | """Get a key as a snowflake. 129 | 130 | Returns None if `data` is None or does not have the key. 131 | 132 | Parameters: 133 | data: The optional mapping to get the key from. 134 | key: The key to attempt to look up. 135 | 136 | Returns: 137 | The value of the key wrapped in a Snowflake, if there was a mapping 138 | passed and the key could be found. 139 | """ 140 | if data is None: 141 | return None 142 | 143 | value: Union[str, int, None] = data.get(key) 144 | return Snowflake(int(value)) if value is not None else None 145 | 146 | try: 147 | from wumpy.rest import MISSING, get_api 148 | except ImportError: 149 | if not TYPE_CHECKING: 150 | def get_api(subclass: Optional[type] = None, *, verify: bool = False) -> NoReturn: 151 | raise RuntimeError('There is no currently active API') 152 | 153 | MISSING = object() 154 | -------------------------------------------------------------------------------- /library/wumpy-models/wumpy/models/py.typed: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wumpyproject/wumpy/08b1628a8aa4bddf6bd25b4e9418247783fa6663/library/wumpy-models/wumpy/models/py.typed -------------------------------------------------------------------------------- /library/wumpy-rest/README.md: -------------------------------------------------------------------------------- 1 | # Wumpy-rest 2 | 3 | Richly and accurately typed wrapper around the Discord REST API. 4 | 5 | ## Usage 6 | 7 | The best way to use `wumpy-rest` is to import `APIClient`: 8 | 9 | ```python 10 | import anyio 11 | from wumpy.rest import APIClient 12 | 13 | 14 | TOKEN = 'ABC123.XYZ789' 15 | 16 | 17 | async def main(): 18 | async with APIClient(TOKEN) as api: 19 | print(await api.fetch_my_user()) 20 | 21 | 22 | anyio.run(main) 23 | ``` 24 | 25 | `APIClient` is a class that implements all routes of the Discord API. This is 26 | made up of multiple route classes. You can create your own class with the 27 | routes you use: 28 | 29 | ```python 30 | from wumpy.rest import ( 31 | ApplicationCommandRequester, InteractionRequester, 32 | HTTPXRequester 33 | ) 34 | 35 | 36 | class MyAPIClient(ApplicationCommandRequester, InteractionRequester, HTTPXRequester): 37 | 38 | __slots__ = () # Save some memory for this class 39 | ``` 40 | 41 | ### Files 42 | 43 | Some endpoints support uploading files, for these a file-like object is 44 | expected that's been opened in binary-mode (for example `'rb'`). 45 | 46 | For the message/interaction endpoints, remember to include a matching 47 | `attachment` object with `'id'` set to the index of the file. 48 | 49 | ## Ratelimiter 50 | 51 | You can pass a custom ratelimiter to the requester if you want to customize 52 | that behaviour. For more, [read the documentation](https://wumpy.rtfd.io). 53 | Here's an example of a ratelimiter that does no ratelimiting and does not 54 | handle any kind of `429`-responses. 55 | 56 | ```python 57 | from contextlib import asynccontextmanager 58 | from typing import ( 59 | Any, AsyncContextManager, AsyncGenerator, Awaitable, Callable, Coroutine, 60 | Mapping 61 | ) 62 | 63 | import anyio 64 | from wumpy.rest import APIClient 65 | 66 | 67 | class NoOpRatelimiter: 68 | """Ratelimiter implementation that does nothing; a no-op implementation.""" 69 | 70 | async def __aenter__(self): 71 | return self 72 | 73 | async def __aexit__( 74 | self, 75 | exc_type: Optional[Type[BaseException]], 76 | exc_val: Optional[BaseException], 77 | exc_tb: Optional[TracebackType] 78 | ) -> object: 79 | pass 80 | 81 | @asynccontextmanager 82 | async def __call__(self, route: Route) -> AsyncGenerator[ 83 | Callable[[Mapping[str, str]], Coroutine[Any, Any, object]], 84 | None 85 | ]: 86 | # The return type may look a little weird, but this is how 87 | # @asynccontextmanager works. You pass it a function that returns an 88 | # async generator (which yields what the asynchronous context manager 89 | # then returns). 90 | yield self.update 91 | 92 | async def update(self, headers: Mapping[str, str]) -> object: 93 | pass 94 | 95 | 96 | async def main(): 97 | async with APIClient(TOKEN, ratelimiter=NoOpRatelimiter()) as api: 98 | print(await api.fetch_my_user()) 99 | 100 | 101 | anyio.run(main) 102 | ``` 103 | -------------------------------------------------------------------------------- /library/wumpy-rest/pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "wumpy-rest" 3 | version = "0.3.0" 4 | description = "Reusable and richly typed wrapper over the Discord REST API" 5 | readme = {file = "README.md", content-type="text/markdown"} 6 | 7 | # Both httpx and anyio has 3.6.2 as the minimum requirement 8 | # but @contextlib.asynccontextmanager was added in Python 3.7 9 | requires-python = ">=3.7" 10 | 11 | license = {text = "Dual-licensed under the Apache License 2.0 and the MIT License"} 12 | authors = [{name = "Bluenix", email = "bluenixdev@gmail.com"}] 13 | 14 | keywords = [ 15 | "wumpy", "wumpus", "wrapper", 16 | "discord", "discord-api", "discord-bot", "discord-api-wrapper", 17 | "python-3" 18 | ] 19 | classifiers = [ 20 | "Development Status :: 3 - Alpha", 21 | "Framework :: AnyIO", 22 | "Intended Audience :: Developers", 23 | "License :: OSI Approved :: MIT License", 24 | "License :: OSI Approved :: Apache Software License", 25 | "Natural Language :: English", 26 | "Operating System :: OS Independent", 27 | "Programming Language :: Python", 28 | "Programming Language :: Python :: 3", 29 | "Programming Language :: Python :: 3 :: Only", 30 | "Programming Language :: Python :: 3.7", 31 | "Programming Language :: Python :: 3.8", 32 | "Programming Language :: Python :: 3.9", 33 | "Programming Language :: Python :: 3.10", 34 | "Topic :: Internet", 35 | "Topic :: Internet :: WWW/HTTP", 36 | "Topic :: Software Development :: Libraries", 37 | "Topic :: Software Development :: Libraries :: Python Modules", 38 | "Typing :: Typed", 39 | ] 40 | 41 | dependencies = [ 42 | "anyio >= 3.3.4, <4", 43 | "httpx[http2] >= 0.22, < 1", 44 | "discord-typings >= 0.5.0, < 1" 45 | ] 46 | 47 | [project.urls] 48 | Homepage = "https://github.com/wumpyproject/wumpy" 49 | Repository = "https://github.com/wumpyproject/wumpy/tree/main/library/wumpy-rest" 50 | Documentation = "https://wumpy.rtfd.io" 51 | 52 | [build-system] 53 | requires = ["flit_core >=3.5, <4"] 54 | build-backend = "flit_core.buildapi" 55 | 56 | [tool.flit.module] 57 | # This is a subpackage under the wumpy namespace package, 58 | # we need to tell flit this otherwise it tries to make the 59 | # import wumpy-rest rather than wumpy.rest 60 | name = "wumpy.rest" 61 | -------------------------------------------------------------------------------- /library/wumpy-rest/wumpy/rest/__init__.py: -------------------------------------------------------------------------------- 1 | from ._config import ( 2 | RatelimiterContext, 3 | abort_if_ratelimited, 4 | ) 5 | from ._errors import ( 6 | HTTPException, 7 | RateLimited, 8 | Forbidden, 9 | NotFound, 10 | ServerException, 11 | ) 12 | from ._impl import ( 13 | HTTPXRequester, 14 | APIClient, 15 | get_api, 16 | ) 17 | from ._ratelimiter import ( 18 | Ratelimiter, 19 | DictRatelimiter, 20 | ) 21 | from ._requester import ( 22 | Requester, 23 | ) 24 | from ._route import ( 25 | Route, 26 | ) 27 | from ._utils import ( 28 | MISSING, 29 | ) 30 | 31 | __all__ = ( 32 | 'RatelimiterContext', 33 | 'abort_if_ratelimited', 34 | 'HTTPException', 35 | 'RateLimited', 36 | 'Forbidden', 37 | 'NotFound', 38 | 'ServerException', 39 | 'HTTPXRequester', 40 | 'APIClient', 41 | 'get_api', 42 | 'Ratelimiter', 43 | 'DictRatelimiter', 44 | 'Requester', 45 | 'Route', 46 | 'MISSING', 47 | ) 48 | -------------------------------------------------------------------------------- /library/wumpy-rest/wumpy/rest/_config.py: -------------------------------------------------------------------------------- 1 | from contextvars import ContextVar, Token 2 | from types import TracebackType 3 | from typing import Optional, Type 4 | 5 | from typing_extensions import Self 6 | 7 | from ._errors import RequestException 8 | 9 | __all__ = ( 10 | 'RatelimiterContext', 11 | 'abort_if_ratelimited', 12 | ) 13 | 14 | 15 | _abort_if_ratelimited: ContextVar[bool] = ContextVar('_abort_if_ratelimited', default=False) 16 | 17 | 18 | class RatelimiterContext: 19 | """Ratelimiting context variables for the request made. 20 | 21 | This class provides attributes which are gotten from underlying context 22 | variables at instantiation time. These context variables allow 23 | transparently configuring ratelimiting without adding a bunch of parameters 24 | to each request - nor having to change the signature of ratelimiters for 25 | every update. 26 | 27 | It is adviced that all ratelimiters implement all of these behaviours. If 28 | some configuration cannot be implemented the ratelimiter should raise an 29 | error to signal to the developer that this configuration cannot be used. 30 | 31 | An instance of this class can be safely passed to a different context 32 | without having attributes change, thus an instance of this represents the 33 | current state at a certain point in time. 34 | 35 | Examples: 36 | 37 | ```python 38 | ctx = RatelimiterContext() 39 | print(ctx.abort_if_ratelimited) 40 | ``` 41 | 42 | Attributes: 43 | abort_if_ratelimited: 44 | Abort the request if it will hit ratelimits. 45 | 46 | This ratelimiting configuration is what powers the context manager 47 | of the same name (`abort_if_ratelimited()`). It allows the user to 48 | specify that they wish to abort requests if they get ratelimited. 49 | """ 50 | 51 | abort_if_ratelimited: bool 52 | 53 | def __new__(cls) -> Self: 54 | self = super().__new__(cls) 55 | 56 | self.abort_if_ratelimited = _abort_if_ratelimited.get() 57 | 58 | return self 59 | 60 | 61 | class _AbortRatelimitsManager: 62 | 63 | _aborted: Optional[bool] 64 | _previous: Token 65 | 66 | def __init__(self) -> None: 67 | self._aborted = None 68 | 69 | def __enter__(self) -> Self: 70 | self._previous = _abort_if_ratelimited.set(True) 71 | 72 | return self 73 | 74 | def __exit__( 75 | self, 76 | exc_type: Optional[Type[BaseException]] = None, 77 | exc_val: Optional[BaseException] = None, 78 | traceback: Optional[TracebackType] = None 79 | ) -> Optional[bool]: 80 | _abort_if_ratelimited.reset(self._previous) 81 | 82 | if isinstance(exc_val, RequestException) and exc_val.status_code in {408, 429}: 83 | self._aborted = True 84 | return True 85 | 86 | self._aborted = False 87 | 88 | @property 89 | def aborted(self) -> bool: 90 | if self._aborted is None: 91 | raise RuntimeError( 92 | "Cannot access 'aborted' before 'abort_if_ratelimited()' has been exited" 93 | ) 94 | 95 | return self._aborted 96 | 97 | 98 | def abort_if_ratelimited() -> _AbortRatelimitsManager: 99 | """Abort requests if they are ratelimited. 100 | 101 | This function returns a context manager, that when entered, will abort any 102 | requests if they will be ratelimited. Useful for skipping somewhat 103 | pointless requests that you don't want to wait for. 104 | 105 | !!! info 106 | The global ratelimit of all requests is always abided. This function 107 | is for ignoring larger route-specific ratelimits. 108 | 109 | The way this is implemented is by catching the `Ratelimited` exception. 110 | That means that not all code inside of this context manager will run if the 111 | request gets ratelimited. Therefore, if you want to use the result of the 112 | request this code needs to be placed inside of the context manager. 113 | 114 | If you want to know whether the requests were aborted, you can check the 115 | `aborted` attribute on the context manager: 116 | 117 | ```python 118 | with abort_if_ratelimited() as abort_ratelimits: 119 | user = await api.fetch_user(344404945359077377) 120 | print(f"Found user {user['name']}#{user['discriminator]}") 121 | 122 | # Note that 'user' may or may not be defined here, depending on whether the 123 | # request was aborted or not. That's why we print inside of the context 124 | # manager. 125 | 126 | if abort_ratelimits.aborted: 127 | # Since the request was aborted, the above print did not get to run 128 | print('Skipped fetching because of ratelimits') 129 | ``` 130 | 131 | For more complex situations, you can also nest this to only abort subset of 132 | requests at a time. This allows you to compose requests that get aborted on 133 | their own without aborting all requests inside of the parent. 134 | 135 | !!! note 136 | This function does not skip the ratelimiter, rather, it aborts the 137 | request *if* the ratelimiter wants to wait. You cannot use this to 138 | force requests to bypass the ratelimiter. 139 | 140 | Returns: 141 | A context manager which skips requests if they will be ratelimited. 142 | """ 143 | return _AbortRatelimitsManager() 144 | -------------------------------------------------------------------------------- /library/wumpy-rest/wumpy/rest/_errors.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Dict, Mapping, Optional, Union 2 | 3 | from httpx import codes 4 | 5 | __all__ = ( 6 | 'HTTPException', 7 | 'RateLimited', 8 | 'Forbidden', 9 | 'NotFound', 10 | 'ServerException', 11 | ) 12 | 13 | 14 | class HTTPException(Exception): 15 | """Base for all HTTP exceptions. 16 | 17 | The exceptions are smartly inherited in the following schema: 18 | 19 | HTTPException 20 | └── RequestException 21 | ├── Forbidden 22 | ├── NotFound 23 | └── ServerException 24 | """ 25 | 26 | __slots__ = () 27 | 28 | 29 | class RequestException(HTTPException): 30 | """Exception subclassed by exceptions relating to failed requests. 31 | 32 | Attributes: 33 | status_code: The HTTP status code of the response. 34 | status_phrase: The phrase that goes along with the status code. 35 | headers: The headers returned by the response. 36 | data: The body of the response (possibly None). 37 | errors: The errors returned by Discord in the body. 38 | message: The message returned by Discord in the body. 39 | code: The error code returned by Discord in the body. 40 | attempt: 41 | The number of times the request has been attempted, this should be 42 | used to implement exponential backoff in the Ratelimiter. 43 | """ 44 | 45 | status_code: int 46 | status_phrase: str 47 | headers: Mapping[str, str] 48 | 49 | data: Union[str, Dict[str, Any], None] 50 | 51 | errors: Optional[Dict[str, Any]] 52 | message: str 53 | code: int 54 | 55 | attempt: int 56 | 57 | __slots__ = ( 58 | 'status_code', 'status_phrase', 'headers', 'data', 'errors', 59 | 'message', 'code', 'attempt' 60 | ) 61 | 62 | def __init__( 63 | self, 64 | status_code: int, 65 | headers: Mapping[str, str], 66 | data: Union[str, Dict[str, Any], None] = None, 67 | *, 68 | attempt: int = 0 69 | ) -> None: 70 | if isinstance(data, dict): 71 | message = data.get('message', '') 72 | code = data.get('code', 0) 73 | 74 | errors = data.get('errors') 75 | else: 76 | message = '' 77 | code = 0 78 | errors = None 79 | 80 | self.status_code = status_code 81 | self.status_phrase = codes.get_reason_phrase(self.status_code) 82 | self.headers = headers 83 | 84 | self.data = data 85 | 86 | self.errors = errors 87 | self.message = message 88 | self.code = code 89 | 90 | self.attempt = attempt 91 | 92 | super().__init__( 93 | '{0.status_code} {0.status_phrase} (Discord error code: {1}) {2}'.format( 94 | self, code, message 95 | ) 96 | ) 97 | 98 | 99 | class RateLimited(RequestException): 100 | """Exception raised when the client is being rate limited.""" 101 | 102 | __slots__ = () 103 | 104 | 105 | class Forbidden(RequestException): 106 | """Exception raised when the requester hits a 403 response.""" 107 | 108 | __slots__ = () 109 | 110 | 111 | class NotFound(RequestException): 112 | """Exception raised when the requester hits a 404 response.""" 113 | 114 | __slots__ = () 115 | 116 | 117 | class ServerException(RequestException): 118 | """Exception raised when the requester hits a 500 range response.""" 119 | 120 | __slots__ = () 121 | -------------------------------------------------------------------------------- /library/wumpy-rest/wumpy/rest/_route.py: -------------------------------------------------------------------------------- 1 | from typing import Dict, Union 2 | from urllib.parse import quote as urlquote 3 | 4 | __all__ = ( 5 | 'Route', 6 | ) 7 | 8 | 9 | class Route: 10 | """A route that a request should be made to. 11 | 12 | Containing information such as the endpoint, and parameters to use. Mainly 13 | used to figure out ratelimit handling. If the request made should have a 14 | request body this should be passed to the requester. 15 | 16 | Attributes: 17 | method: The HTTP method to use. 18 | path: The path to the endpoint, concatenated to the `BASE` url. 19 | params: 20 | The parameters to format into the path, separated to allow easier 21 | ratelimit handling. 22 | """ 23 | 24 | method: str 25 | path: str 26 | params: Dict[str, Union[str, int]] 27 | 28 | __slots__ = ('method', 'path', 'params') 29 | 30 | def __init__(self, method: str, path: str, **params: Union[str, int]) -> None: 31 | self.method = method 32 | self.path = path 33 | 34 | self.params = params 35 | 36 | def __eq__(self, other: object) -> bool: 37 | return isinstance(other, Route) and self.endpoint == other.endpoint 38 | 39 | def __repr__(self) -> str: 40 | return f'' 41 | 42 | def __str__(self) -> str: 43 | return self.endpoint 44 | 45 | def __hash__(self) -> int: 46 | return hash(self.endpoint) 47 | 48 | @property 49 | def url(self) -> str: 50 | """Return a complete, formatted url that a request should be made to. 51 | 52 | This needs to be appended to the base URL after formatting. 53 | """ 54 | return self.path.format_map( 55 | # Replace special characters with the %xx escapes 56 | {k: urlquote(v) if isinstance(v, str) else v for k, v in self.params.items()} 57 | ) 58 | 59 | @property 60 | def endpoint(self) -> str: 61 | """Return the Discord endpoint this route will request.""" 62 | return f'{self.method} {self.path}' 63 | 64 | @property 65 | def major_params(self) -> str: 66 | """Return a string of the formatted major parameters.""" 67 | param = ( 68 | self.params.get('webhook_id') 69 | or self.params.get('channel_id') 70 | or self.params.get('guild_id') 71 | ) 72 | 73 | if param: 74 | # TODO: Discord handles messages over 14 days differently in terms 75 | # of ratelimiting because it is more costly. 76 | return f':{param}' 77 | 78 | return '' 79 | -------------------------------------------------------------------------------- /library/wumpy-rest/wumpy/rest/_utils.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Callable 2 | 3 | from typing_extensions import Final, final 4 | 5 | __all__ = ( 6 | 'MISSING', 7 | ) 8 | 9 | dump_json: Callable[[Any], str] 10 | load_json: Callable[[str], Any] 11 | 12 | try: 13 | import orjson 14 | 15 | def orjson_compat(obj: Any) -> str: 16 | return orjson.dumps(obj).decode('utf-8') 17 | dump_json = orjson_compat 18 | load_json = orjson.loads 19 | 20 | except ImportError: 21 | import json 22 | 23 | dump_json = json.dumps 24 | load_json = json.loads 25 | 26 | 27 | @final 28 | class MissingType(object): 29 | """Representing an optional default when no value has been passed. 30 | 31 | This is mainly used as a sentinel value for defaults to work nicely 32 | with typehints, so that `Optional[X]` doesn't have to be used. 33 | """ 34 | 35 | def __bool__(self) -> bool: 36 | return False 37 | 38 | def __repr__(self) -> str: 39 | return '' 40 | 41 | 42 | MISSING: Final[Any] = MissingType() 43 | 44 | 45 | # While it would make sense for get_api() to be implemented here, it is placed 46 | # in _impl.py for circular import purposes. 47 | -------------------------------------------------------------------------------- /library/wumpy-rest/wumpy/rest/endpoints/__init__.py: -------------------------------------------------------------------------------- 1 | from ._channel import ( 2 | ChannelEndpoints, 3 | ) 4 | from ._commands import ( 5 | ApplicationCommandEndpoints, 6 | ) 7 | from ._gateway import ( 8 | GatewayEndpoints, 9 | ) 10 | from ._guild_template import ( 11 | GuildTemplateEndpoints, 12 | ) 13 | from ._guild import ( 14 | GuildEndpoints, 15 | ) 16 | from ._interactions import ( 17 | InteractionEndpoints, 18 | ) 19 | from ._sticker import ( 20 | StickerEndpoints, 21 | ) 22 | from ._user import ( 23 | UserEndpoints, 24 | ) 25 | from ._webhook import ( 26 | WebhookEndpoints 27 | ) 28 | 29 | __all__ = ( 30 | 'ChannelEndpoints', 31 | 'ApplicationCommandEndpoints', 32 | 'GatewayEndpoints', 33 | 'GuildTemplateEndpoints', 34 | 'GuildEndpoints', 35 | 'InteractionEndpoints', 36 | 'StickerEndpoints', 37 | 'UserEndpoints', 38 | 'WebhookEndpoints', 39 | ) 40 | -------------------------------------------------------------------------------- /library/wumpy-rest/wumpy/rest/endpoints/_gateway.py: -------------------------------------------------------------------------------- 1 | from discord_typings import GetGatewayBotData 2 | 3 | from .._requester import Requester 4 | from .._route import Route 5 | 6 | __all__ = ( 7 | 'GatewayEndpoints', 8 | ) 9 | 10 | 11 | class GatewayEndpoints(Requester): 12 | """Endpoints for getting Discord gateway information.""" 13 | 14 | __slots__ = () 15 | 16 | async def fetch_gateway(self) -> str: 17 | """Fetch a single valid WSS URL. 18 | 19 | The data this method returns should be cached and will not change. 20 | 21 | Returns: 22 | The URL to connect with a WebSocket to. 23 | """ 24 | d = await self.request(Route('GET', '/gateway')) 25 | return d['url'] 26 | 27 | async def fetch_gateway_bot(self) -> GetGatewayBotData: 28 | """Fetch a valid WSS URL along with helpful metadata. 29 | 30 | Especially useful when running big bots with shards, the data returned 31 | by this call can change per-call as the bot joins/leaves guilds. 32 | 33 | Returns: 34 | Useful metadata about the gateway when connecting. 35 | """ 36 | return await self.request(Route('GET', '/gateway/bot')) 37 | -------------------------------------------------------------------------------- /library/wumpy-rest/wumpy/rest/endpoints/_user.py: -------------------------------------------------------------------------------- 1 | from typing import List, Optional, SupportsInt 2 | 3 | from discord_typings import DMChannelData, PartialGuildData, UserData 4 | 5 | from .._requester import Requester 6 | from .._route import Route 7 | from .._utils import MISSING 8 | 9 | __all__ = ( 10 | 'UserEndpoints', 11 | ) 12 | 13 | 14 | class UserEndpoints(Requester): 15 | """Endpoints for interacting with user data.""" 16 | 17 | __slots__ = () 18 | 19 | async def fetch_my_user(self) -> UserData: 20 | """Fetch the bot user account. 21 | 22 | This is not a shortcut to `fetch_user()`, it has a different ratelimit 23 | and returns a bit more information. 24 | 25 | Returns: 26 | The user object received from Discord. 27 | """ 28 | return await self.request(Route('GET', '/users/@me')) 29 | 30 | async def fetch_user(self, user: SupportsInt) -> UserData: 31 | """Fetch a data about a user by its ID. 32 | 33 | You also do not need to share a guild with the user to fetch their 34 | (limited) information. 35 | 36 | Parameters: 37 | user: The ID of the user to fetch. 38 | 39 | Returns: 40 | The user object received from Discord. 41 | """ 42 | return await self.request(Route('GET', '/users/{user_id}', user_id=int(user))) 43 | 44 | async def edit_my_user( 45 | self, 46 | *, 47 | username: str = MISSING, 48 | avatar: Optional[str] = MISSING, 49 | ) -> UserData: 50 | """Edit the bot user account. 51 | 52 | Parameters: 53 | username: 54 | The new bot user's name (can cause the discriminator to 55 | be randomized if there are already a user with that name). 56 | avatar: 57 | Image data in the Data URI scheme, or None to reset the avatar 58 | to the default Discord version according to the discriminator. 59 | 60 | Returns: 61 | The updated and new user object from Discord. 62 | """ 63 | if username is MISSING and avatar is MISSING: 64 | raise TypeError("at least one of 'username' or 'avatar' is required") 65 | 66 | payload = { 67 | 'username': username, 68 | 'avatar': avatar, 69 | } 70 | 71 | return await self.request(Route('PATCH', '/users/@me'), json=payload) 72 | 73 | async def fetch_my_guilds( 74 | self, 75 | *, 76 | before: SupportsInt = MISSING, 77 | after: SupportsInt = MISSING, 78 | limit: int = 200 79 | ) -> List[PartialGuildData]: 80 | """Fetch all guilds that the bot user is in. 81 | 82 | This endpoint allows pagination for bots who are a member of more than 83 | 200 guilds using the `before` and `after` parameters. 84 | 85 | Parameters: 86 | before: Snowflake to fetch guilds before. 87 | after: Snowflake to fetch guilds after. 88 | limit: How many guilds to maximally return. 89 | 90 | Returns: 91 | A list of partial guilds the bot user is a part of. 92 | """ 93 | params = {'limit': limit} 94 | if before is not MISSING: 95 | params['before'] = int(before) 96 | elif after is not MISSING: 97 | params['after'] = int(after) 98 | 99 | return await self.request(Route('GET', '/users/@me/guilds')) 100 | 101 | async def leave_guild(self, guild: SupportsInt) -> None: 102 | """Make the bot user leave the specified guild. 103 | 104 | Parameters: 105 | guild: The ID of the guild to leave. 106 | """ 107 | await self.request(Route('DELETE', '/users/@me/guilds/{guild_id}', guild_id=int(guild))) 108 | 109 | async def create_dm(self, recipient: SupportsInt) -> DMChannelData: 110 | """Create a DM with the recipient. 111 | 112 | This method is safe to call several times to get the DM channel when 113 | needed. In fact, in other wrappers this is called everytime you send 114 | a message to a user. 115 | 116 | Parameters: 117 | recipient: The user to open a DM with. 118 | 119 | Returns: 120 | The DM channel object returned by Discord. 121 | """ 122 | return await self.request(Route( 123 | 'POST', '/users/@me/channels'), json={'recipient_id': int(recipient)} 124 | ) 125 | -------------------------------------------------------------------------------- /library/wumpy-rest/wumpy/rest/py.typed: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wumpyproject/wumpy/08b1628a8aa4bddf6bd25b4e9418247783fa6663/library/wumpy-rest/wumpy/rest/py.typed -------------------------------------------------------------------------------- /readthedocs.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | mkdocs: 4 | configuration: docs/mkdocs.yml 5 | fail_on_warning: false 6 | 7 | python: 8 | version: '3.8' 9 | install: 10 | - requirements: docs/requirements.txt 11 | - method: pip 12 | path: . 13 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup 2 | 3 | # IMPORTANT: This is only for in-dev installation of the whole Git repository. 4 | # It has no stability guarantees and if possible, it is recommended 5 | # to 'cd' into each subpackage and install them with '--pth-file' 6 | # during development. This setuptools installation is only to allow 7 | # installation from git+https://github.com/wumpyproject/wumpy 8 | 9 | 10 | with open('README.md', 'r', encoding='utf-8') as readme: 11 | long_description = readme.read() 12 | 13 | 14 | setup( 15 | name='wumpy', 16 | 17 | # This version is not representative of Wumpy's real version, rather it is 18 | # set to satisfy and be explicit about versions. 19 | version='0.1.0a0', 20 | 21 | description='Discord API Wrapper - Easy enough for Wumpus, and fast enough for Clyde!', 22 | long_description=long_description, 23 | 24 | author='Bluenix', 25 | author_email='bluenixdev@gmail.com', 26 | 27 | packages=[ 28 | 'wumpy.bot', 'wumpy.cache', 'wumpy.gateway', 'wumpy.interactions', 29 | 'wumpy.interactions.commands', 'wumpy.interactions.components', 30 | 'wumpy.models', 'wumpy.rest.endpoints', 'wumpy.rest', 31 | ], 32 | 33 | package_dir={ 34 | 'wumpy.bot': 'library/wumpy-bot/wumpy/bot', 35 | 'wumpy.cache': 'library/wumpy-cache/wumpy/cache', 36 | 'wumpy.gateway': 'library/wumpy-gateway/wumpy/gateway', 37 | 'wumpy.interactions': 'library/wumpy-interactions/wumpy/interactions', 38 | 'wumpy.models': 'library/wumpy-models/wumpy/models', 39 | 'wumpy.rest': 'library/wumpy-rest/wumpy/rest', 40 | }, 41 | package_data={ 42 | '': ['py.typed'], 43 | }, 44 | 45 | # Sadly we have to duplicate these from the pyproject.toml files 46 | install_requires=[ 47 | 'anyio >= 3.3.4, < 4', 48 | 'httpx[http2] >= 0.22, < 1', 49 | 'discord-typings >= 0.4.0, <1', 50 | 'discord-gateway >=0.3.0, <1', 51 | 'pynacl > 1, < 2', 52 | 'typing_extensions >= 4, <5', 53 | ], 54 | ) 55 | -------------------------------------------------------------------------------- /tests/README.md: -------------------------------------------------------------------------------- 1 | # Testing an API Wrapper.. 2 | 3 | It is quite hard to test an API wrapper. The actual interaction with Discord is 4 | extremely hard to replicate and will never be enough, hence it will never be 5 | correctly tested. 6 | 7 | That said, there are still things to test other than the interaction with 8 | Discord. Namely setting up commands, adding listeners and loading extensions. 9 | 10 | ## Running the test suite 11 | 12 | Firstly, install [pytest](https://docs.pytest.org/en/latest/) and 13 | [coverage.py](https://coverage.readthedocs.io/en/latest). 14 | 15 | Then run the test suit with: 16 | 17 | ```bash 18 | coverage run --branch -m pytest tests/ 19 | ``` 20 | 21 | You can then run the following to generate a coverage report: 22 | 23 | ```bash 24 | coverage.html 25 | ``` 26 | -------------------------------------------------------------------------------- /tests/wumpy-bot/extensions/data_passed.py: -------------------------------------------------------------------------------- 1 | def loader(_, data): 2 | assert data.get('passed') is True 3 | 4 | return lambda _: _ 5 | -------------------------------------------------------------------------------- /tests/wumpy-bot/extensions/empty.py: -------------------------------------------------------------------------------- 1 | from wumpy.bot import Extension 2 | 3 | ext = Extension() 4 | -------------------------------------------------------------------------------- /tests/wumpy-bot/extensions/non_callable.py: -------------------------------------------------------------------------------- 1 | var = 5 2 | -------------------------------------------------------------------------------- /tests/wumpy-bot/extensions/raises.py: -------------------------------------------------------------------------------- 1 | raise NotImplementedError() 2 | -------------------------------------------------------------------------------- /tests/wumpy-bot/extensions/raises_load.py: -------------------------------------------------------------------------------- 1 | def loader(target, data): 2 | raise NotImplementedError 3 | -------------------------------------------------------------------------------- /tests/wumpy-bot/extensions/raises_unload.py: -------------------------------------------------------------------------------- 1 | def unloader(target): 2 | raise NotImplementedError 3 | 4 | 5 | def loader(target, data): 6 | return unloader 7 | -------------------------------------------------------------------------------- /tests/wumpy-bot/test_extension.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from wumpy.bot import ( 3 | EventDispatcher, Extension, ExtensionFailure, ExtensionLoader 4 | ) 5 | from wumpy.bot._extension import _is_submodule 6 | 7 | 8 | def test_is_submodule(): 9 | # At the stage of when this is used all relative imports have been resolved 10 | # so this test only consists of absolute paths 11 | 12 | assert _is_submodule('wumpy', 'wumpy') is True 13 | assert _is_submodule('wumpy.extension', 'wumpy') is True 14 | assert _is_submodule('wumpy.interactions.commands.slash', 'wumpy.interactions') is True 15 | 16 | # Some of these are pretty stupid, but you need some tests that test the 17 | # obvious I guess.. 18 | assert _is_submodule('wumpy', 'discord') is False 19 | assert _is_submodule('wumpy.models', 'discord.ext.commands') is False 20 | assert _is_submodule('wumpy.interactions', 'wumpy.commands') is False 21 | 22 | 23 | def test_data_runtimeerror(): 24 | ext = Extension() 25 | 26 | with pytest.raises(RuntimeError): 27 | # 'data' is not yet accessible because the extensions hasn't been given 28 | # it by being loaded. 29 | _ = ext.data 30 | 31 | 32 | class TestExtensionTransfer: 33 | def test_callable(self): 34 | ext = Extension() 35 | dispatcher = EventDispatcher() 36 | 37 | unload = ext(dispatcher, {}) 38 | unload(dispatcher) 39 | 40 | def test_data(self): 41 | ext = Extension() 42 | dispatcher = EventDispatcher() 43 | 44 | instance = object() 45 | ext.load(dispatcher, {'key': 'value', 'kwarg': 0, 'object': instance}) 46 | 47 | assert ext.data == {'key': 'value', 'kwarg': 0, 'object': instance} 48 | 49 | 50 | class TestExtensionLoader: 51 | def test_relative_no_package(self): 52 | loader = ExtensionLoader() 53 | 54 | with pytest.raises(TypeError): 55 | loader.load_extension('.abc:xyz') 56 | 57 | def test_relative_too_far(self): 58 | loader = ExtensionLoader() 59 | 60 | with pytest.raises(ValueError): 61 | # Note: __package__ in this case is an empty string because of 62 | # where __main__ is in relation to this file. 63 | loader.load_extension('......abc:xyz', 'tests') 64 | 65 | def test_no_spec(self): 66 | loader = ExtensionLoader() 67 | 68 | with pytest.raises(ValueError): 69 | loader.load_extension('README:non_existant') 70 | 71 | def test_exec_exception(self): 72 | loader = ExtensionLoader() 73 | 74 | with pytest.raises(ExtensionFailure): 75 | loader.load_extension('extensions.raises:func') 76 | 77 | def test_bad_attribute(self): 78 | loader = ExtensionLoader() 79 | 80 | with pytest.raises(ValueError): 81 | loader.load_extension('extensions.empty:other') 82 | 83 | def test_non_callable(self): 84 | loader = ExtensionLoader() 85 | 86 | with pytest.raises(ExtensionFailure): 87 | loader.load_extension('extensions.non_callable:var') 88 | 89 | def test_loader_raises(self): 90 | loader = ExtensionLoader() 91 | 92 | with pytest.raises(ExtensionFailure): 93 | loader.load_extension('extensions.raises_load:loader') 94 | 95 | def test_data_passed(self): 96 | loader = ExtensionLoader() 97 | 98 | loader.load_extension('extensions.data_passed:loader', passed=True) 99 | # The test continues in that file 100 | 101 | # Because of how pytest runs the tests __package__ is an empty string, 102 | # causing the test to fail with a TypeError - there's no supported or real 103 | # reason to use relative imports here (unless you're writing tests :p). 104 | 105 | # def test_relative(self): 106 | # loader = ExtensionLoader() 107 | # 108 | # loader.load_extension('.extensions.empty:ext', __package__) 109 | 110 | 111 | class TestUnload: 112 | def test_relative_no_package(self): 113 | loader = ExtensionLoader() 114 | 115 | with pytest.raises(TypeError): 116 | loader.unload_extension('.abc:xyz') 117 | 118 | def test_relative_too_far(self): 119 | loader = ExtensionLoader() 120 | 121 | with pytest.raises(ValueError): 122 | loader.unload_extension('......abc:xyz', 'tests.wumpy') 123 | 124 | def test_raises_unload(self): 125 | loader = ExtensionLoader() 126 | loader.load_extension('extensions.raises_unload:loader') 127 | 128 | with pytest.raises(ExtensionFailure): 129 | loader.unload_extension('extensions.raises_unload') 130 | 131 | def test_unload_not_loaded(self): 132 | loader = ExtensionLoader() 133 | 134 | with pytest.raises(ValueError): 135 | loader.unload_extension('extensions.empty') 136 | -------------------------------------------------------------------------------- /tests/wumpy-gateway/test_limiter.py: -------------------------------------------------------------------------------- 1 | import sys 2 | from time import perf_counter 3 | from typing import NoReturn 4 | from unittest import mock 5 | 6 | import pytest 7 | from discord_gateway import Opcode 8 | from wumpy.gateway import DefaultGatewayLimiter 9 | 10 | 11 | class SimplerGatewayLimiter(DefaultGatewayLimiter): 12 | RATE = 3 13 | PER = 10 14 | 15 | 16 | class TestGatewayLimiter: 17 | @pytest.mark.anyio 18 | async def test_bypass(self) -> None: 19 | async def sleep(duration: float) -> NoReturn: 20 | raise RuntimeError("'sleep()' should not have been called") 21 | 22 | async with SimplerGatewayLimiter() as limiter: 23 | with mock.patch('anyio.sleep', sleep): 24 | for _ in range(SimplerGatewayLimiter.RATE + 1): 25 | async with limiter(Opcode.HEARTBEAT): 26 | pass 27 | 28 | @pytest.mark.anyio 29 | @pytest.mark.skipif(sys.version_info < (3, 8), reason='AsyncMock requires Python 3.8+') 30 | async def test_wait(self) -> None: 31 | slept = mock.AsyncMock() 32 | 33 | async with SimplerGatewayLimiter() as limiter: 34 | with mock.patch('anyio.sleep', slept): 35 | for _ in range(SimplerGatewayLimiter.RATE + 1): 36 | async with limiter(Opcode.PRESENCE_UPDATE): 37 | pass 38 | 39 | assert slept.call_count == 1 40 | 41 | @pytest.mark.anyio 42 | async def test_time_passed(self) -> None: 43 | async def sleep(duration: float) -> NoReturn: 44 | raise RuntimeError("'sleep()' should not have been called") 45 | 46 | time = perf_counter() 47 | 48 | def patched(): 49 | return time + SimplerGatewayLimiter.PER * 1.1 50 | 51 | async with SimplerGatewayLimiter() as limiter: 52 | for _ in range(SimplerGatewayLimiter.RATE): 53 | async with limiter(Opcode.PRESENCE_UPDATE): 54 | pass 55 | 56 | with mock.patch('time.perf_counter', patched), mock.patch('anyio.sleep', sleep): 57 | async with limiter(Opcode.PRESENCE_UPDATE): 58 | pass 59 | -------------------------------------------------------------------------------- /tests/wumpy-interactions/commands/test_context.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from wumpy.interactions import ( 3 | CommandInteraction, CommandRegistrar, CommandType 4 | ) 5 | 6 | 7 | @pytest.mark.parametrize('commandtype', [CommandType.message, CommandType.user]) 8 | class TestContextCommand: 9 | def test_no_annotation(self, commandtype: CommandType): 10 | registrar = CommandRegistrar() 11 | 12 | # For context menu commands there is only one possible annotation so 13 | # we get enough information from the command type. 14 | @registrar.command(commandtype) 15 | async def test(interaction, argument): 16 | ... 17 | 18 | assert registrar.get_command('test') == test 19 | 20 | def test_non_async(self, commandtype: CommandType): 21 | registrar = CommandRegistrar() 22 | 23 | with pytest.raises(TypeError): 24 | @registrar.command(commandtype) # type: ignore 25 | def non_async(interaction, argument): 26 | ... 27 | 28 | def test_wrong_args(self, commandtype: CommandType): 29 | registrar = CommandRegistrar() 30 | 31 | with pytest.raises(TypeError): 32 | @registrar.command(commandtype) # type: ignore 33 | async def no_args(): 34 | ... 35 | 36 | with pytest.raises(TypeError): 37 | @registrar.command(commandtype) # type: ignore 38 | async def too_many_args(interaction, argument, too_much): 39 | ... 40 | 41 | def test_bad_annotation(self, commandtype: CommandType): 42 | registrar = CommandRegistrar() 43 | 44 | with pytest.raises(TypeError): 45 | @registrar.command(commandtype) # type: ignore 46 | async def int_arg(interaction: CommandInteraction, argument: int): 47 | ... 48 | 49 | with pytest.raises(TypeError): 50 | @registrar.command(commandtype) # type: ignore 51 | async def str_arg(interaction: CommandInteraction, argument: str): 52 | ... 53 | 54 | def test_bad_interaction_annotation(self, commandtype: CommandType): 55 | registrar = CommandRegistrar() 56 | 57 | with pytest.raises(TypeError): 58 | @registrar.command(commandtype) # type: ignore 59 | async def bad_annotation(interaction: str, argument): 60 | ... 61 | 62 | def test_argument_name(self, commandtype: CommandType): 63 | # The command should work no matter the names of the arguments 64 | registrar = CommandRegistrar() 65 | 66 | @registrar.command(commandtype) 67 | async def weird_arg_name(ctx: CommandInteraction, target): 68 | ... 69 | 70 | def test_kwarg(self, commandtype: CommandType): 71 | registrar = CommandRegistrar() 72 | 73 | with pytest.raises(TypeError): 74 | @registrar.command(commandtype) 75 | async def kwargs(*, interaction: CommandInteraction, argument): 76 | ... 77 | -------------------------------------------------------------------------------- /tests/wumpy-interactions/commands/test_decorators.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from wumpy.interactions import CommandRegistrar, Option, option 3 | from wumpy.interactions.commands._option import OptionClass 4 | from wumpy.models import ApplicationCommandOption 5 | 6 | 7 | def test_option_func(): 8 | original = OptionClass( 9 | 'default', name='name', description='description', 10 | required=True, choices=['A', 'B', 'C'], 11 | type=ApplicationCommandOption.string 12 | ) 13 | 14 | default = Option( 15 | 'default', name='name', description='description', 16 | required=True, choices=['A', 'B', 'C'], 17 | type=ApplicationCommandOption.string 18 | ) 19 | 20 | # These two should be identical 21 | 22 | assert original.name == default.name 23 | assert original.description == default.description 24 | assert original.required == default.required 25 | assert original.default == default.default 26 | assert original.choices == default.choices 27 | assert original.type == default.type 28 | 29 | 30 | def test_option_func_min_max(): 31 | original = OptionClass( 32 | 'default', name='name', description='description', 33 | min=500, max=1000, 34 | type=ApplicationCommandOption.integer 35 | ) 36 | 37 | default = Option( 38 | 'default', name='name', description='description', 39 | min=500, max=1000, 40 | type=ApplicationCommandOption.integer 41 | ) 42 | 43 | # These two should be identical 44 | 45 | assert original.name == default.name 46 | assert original.description == default.description 47 | assert original.default == default.default 48 | assert original.min == default.min 49 | assert original.max == default.max 50 | assert original.type == default.type 51 | 52 | 53 | def test_option_deco(): 54 | registrar = CommandRegistrar() 55 | 56 | @option( 57 | 'other', name='else', type=str, 58 | choices={'Answer A': 'A', 'Answer B': 'B', 'Answer C': 'C'} 59 | ) 60 | @option('arg', description='The only argument', type=int, min=0, max=10) 61 | @registrar.command() 62 | # The first argument will always be the interaction 63 | async def command(_, arg, other): 64 | """Option testing command.""" 65 | ... 66 | 67 | assert command.options['arg'].description == 'The only argument' 68 | assert command.options['arg'].type == ApplicationCommandOption.integer 69 | assert command.options['arg'].min == 0 70 | assert command.options['arg'].max == 10 71 | 72 | # It should've been renamed now from 'other' 73 | assert command.options['else'].name == 'else' 74 | assert command.options['else'].type == ApplicationCommandOption.string 75 | assert command.options['else'].choices == { 76 | 'Answer A': 'A', 'Answer B': 'B', 'Answer C': 'C' 77 | } 78 | 79 | 80 | def test_option_deco_no_command(): 81 | with pytest.raises(TypeError): 82 | @option('arg', choices=['A', 'B', 'C']) # type: ignore 83 | async def not_command(arg): 84 | ... 85 | 86 | 87 | def test_option_deco_no_param(): 88 | registrar = CommandRegistrar() 89 | 90 | with pytest.raises(ValueError): 91 | @option('bad', description="Doesn't exist") 92 | @registrar.command() 93 | async def command(param): 94 | ... 95 | -------------------------------------------------------------------------------- /tests/wumpy-models/test_base.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime, timezone 2 | 3 | from wumpy.models import Model, Snowflake 4 | 5 | 6 | def test_instantiation() -> None: 7 | model = Model(344404945359077377) 8 | assert model.id == 344404945359077377 9 | 10 | 11 | def test_hashable() -> None: 12 | d = {Model(344404945359077377): True} 13 | assert d[Model(344404945359077377)] 14 | 15 | 16 | def test_conversion() -> None: 17 | id_ = 344404945359077377 18 | m = Model(id_) 19 | 20 | assert int(m) == id_ 21 | assert float(m) == float(id_) 22 | assert complex(m) == complex(id_) 23 | assert bin(m) == bin(id_) # __index__ 24 | 25 | 26 | def test_equal() -> None: 27 | assert Model(344404945359077377) == 344404945359077377 28 | 29 | assert Model(344404945359077377) == Model(344404945359077377) 30 | assert not Model(344404945359077377) == 'Bluenix#7543' 31 | assert not Model(344404945359077377) == Model(841509053422632990) 32 | 33 | 34 | def test_not_equal() -> None: 35 | assert Model(344404945359077377) != 841509053422632990 36 | 37 | assert Model(344404945359077377) != Model(841509053422632990) 38 | assert Model(344404945359077377) != 'Bluenix#7543' 39 | assert not Model(344404945359077377) != Model(344404945359077377) 40 | 41 | 42 | def test_created_at() -> None: 43 | dt = datetime.fromtimestamp(1502182937.708, timezone.utc) 44 | assert Model(344404945359077377).created_at == dt 45 | 46 | 47 | def test_snowflake_info() -> None: 48 | m = Snowflake(175928847299117063) 49 | 50 | assert m.worker_id == 1 51 | assert m.process_id == 0 52 | assert m.process_increment == 7 53 | 54 | 55 | def test_snowflake_from_datetime() -> None: 56 | dt = datetime.fromtimestamp(1462015105796 / 1000) 57 | assert Snowflake.from_datetime(dt) == Snowflake(175928847298985984) 58 | -------------------------------------------------------------------------------- /tests/wumpy-models/test_flags.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from wumpy.models._flags import BitMask, DiscordFlags 3 | from wumpy.models._permissions import TriBitMask 4 | 5 | 6 | class TestMagicMethods: 7 | def test_equality(self): 8 | flag, other = DiscordFlags(0), DiscordFlags(0) 9 | 10 | assert flag == 0 11 | assert flag == other 12 | assert not flag == 'a' 13 | 14 | def test_not_equality(self): 15 | flag, other = DiscordFlags(0), DiscordFlags(1) 16 | 17 | assert flag != 1 18 | assert flag != other 19 | assert flag != 'a' 20 | 21 | def test_convertable(self): 22 | flag = DiscordFlags(123) 23 | 24 | assert int(flag) == 123 25 | assert float(flag) == 123.0 26 | 27 | def test_hashable(self): 28 | assert hash(DiscordFlags(0)) == hash(DiscordFlags(0)) 29 | assert hash(DiscordFlags(123)) != hash(DiscordFlags(321)) 30 | 31 | def test_bitwise_and(self): 32 | a, b = DiscordFlags(0b1010), DiscordFlags(0b0111) 33 | assert a & b == DiscordFlags(0b0010) 34 | 35 | x, y = DiscordFlags(0b0001), 0b1000 36 | assert x & y == DiscordFlags(0b0000) 37 | 38 | with pytest.raises(TypeError): 39 | DiscordFlags(0b1010) & 'a' 40 | 41 | def test_bitwise_xor(self): 42 | a, b = DiscordFlags(0b1100), DiscordFlags(0b0111) 43 | assert a ^ b == DiscordFlags(0b1011) 44 | 45 | x, y = DiscordFlags(0b1111), 0b1111 46 | assert x ^ y == DiscordFlags(0b0000) 47 | 48 | with pytest.raises(TypeError): 49 | DiscordFlags(0b1100) ^ 'a' 50 | 51 | def test_bitwise_or(self): 52 | a, b = DiscordFlags(0b0001), DiscordFlags(0b1000) 53 | assert a | b == DiscordFlags(0b1001) 54 | 55 | x, y = DiscordFlags(0b1000), 0b0010 56 | assert x | y == DiscordFlags(0b1010) 57 | 58 | with pytest.raises(TypeError): 59 | DiscordFlags(0b0000) | 'a' 60 | 61 | 62 | class Flag(DiscordFlags): 63 | red = BitMask(1 << 0) 64 | orange = BitMask(1 << 1) 65 | yellow = BitMask(1 << 2) 66 | green = BitMask(1 << 3) 67 | blue = BitMask(1 << 4) 68 | indigo = BitMask(1 << 5) 69 | violet = BitMask(1 << 6) 70 | 71 | 72 | class TestBitMask: 73 | def test_type_get(self): 74 | assert Flag.yellow == 1 << 2 75 | assert Flag.blue == 1 << 4 76 | 77 | def test_instance_get(self): 78 | instance = Flag(Flag.red | Flag.blue) 79 | 80 | assert instance.red 81 | assert instance.blue 82 | 83 | 84 | class GreenProfile: 85 | # Colour combinations that green can be with in a flag 86 | def __init__(self, allow: int, deny: int) -> None: 87 | self.allow = Flag(allow) 88 | self.deny = Flag(deny) 89 | 90 | red = TriBitMask(1 << 0) 91 | orange = TriBitMask(1 << 1) 92 | yellow = TriBitMask(1 << 2) 93 | green = TriBitMask(1 << 3) 94 | blue = TriBitMask(1 << 4) 95 | indigo = TriBitMask(1 << 5) 96 | violet = TriBitMask(1 << 6) 97 | 98 | 99 | class TestTriBitMask: 100 | def test_type_get(self): 101 | assert GreenProfile.green == 1 << 3 102 | assert GreenProfile.blue == 1 << 4 103 | 104 | def test_instance_get(self): 105 | instance = GreenProfile( 106 | GreenProfile.green | GreenProfile.blue, 107 | GreenProfile.red | GreenProfile.orange 108 | ) 109 | 110 | assert instance.green 111 | assert not instance.orange 112 | 113 | assert instance.violet is None 114 | -------------------------------------------------------------------------------- /tests/wumpy-models/test_message.py: -------------------------------------------------------------------------------- 1 | from wumpy.models import AllowedMentions 2 | 3 | 4 | def test_equal() -> None: 5 | assert AllowedMentions() == AllowedMentions() 6 | assert not AllowedMentions() == 'AllowedMentions' 7 | 8 | 9 | def test_instantion() -> None: 10 | am = AllowedMentions(everyone=False, users=(123,), roles=(456,), replied_user=True) 11 | assert am.everyone is False 12 | assert am.users == (123,) 13 | assert am.roles == (456,) 14 | assert am.replied_user is True 15 | 16 | 17 | def test_none() -> None: 18 | assert AllowedMentions.none() == { 19 | 'parse': [], 20 | 'replied_user': False, 21 | } 22 | 23 | 24 | def test_all() -> None: 25 | assert AllowedMentions.all() == { 26 | 'parse': ['everyone', 'users', 'roles'], 27 | 'replied_user': True, 28 | } 29 | 30 | 31 | def test_bitwise_or() -> None: 32 | a = AllowedMentions(everyone=False, replied_user=True) 33 | b = AllowedMentions(everyone=True, users=[123, 456, 789]) 34 | assert a | b == AllowedMentions(everyone=True, users=[123, 456, 789], replied_user=True) 35 | 36 | 37 | def test_replace() -> None: 38 | replaced = AllowedMentions(everyone=False, roles=[123]).replace(everyone=True) 39 | assert replaced == AllowedMentions(everyone=True, roles=[123]) 40 | -------------------------------------------------------------------------------- /tests/wumpy-models/test_permissions.py: -------------------------------------------------------------------------------- 1 | from wumpy.models import PermissionOverwrite, Permissions, PermissionTarget 2 | 3 | 4 | def test_permission_build() -> None: 5 | perms = Permissions.build( 6 | send_messages=True, embed_links=True, add_reactions=True, connect=True 7 | ) 8 | 9 | assert perms.value == 1067072 10 | 11 | 12 | def test_permission_replace() -> None: 13 | perms = Permissions.build(create_instant_invite=True, ban_members=True) 14 | assert perms.replace(kick_members=True, ban_members=False) == Permissions(3) 15 | 16 | 17 | def test_overwrite_build() -> None: 18 | overwrite = PermissionOverwrite.build( 19 | 344404945359077377, PermissionTarget.member, 20 | send_messages=True, send_messages_in_threads=True, 21 | mute_members=False, move_members=False, priority_speaker=False, stream=False 22 | ) 23 | 24 | assert overwrite.allow == Permissions(274877908992) 25 | assert overwrite.deny == Permissions(20972288) 26 | 27 | 28 | def test_overwrite_equality() -> None: 29 | a = PermissionOverwrite.build( 30 | 344404945359077377, PermissionTarget.member, 31 | add_reactions=True, embed_links=False 32 | ) 33 | b = PermissionOverwrite.build( 34 | 344404945359077377, PermissionTarget.member, 35 | add_reactions=True, embed_links=False 36 | ) 37 | assert a == b 38 | 39 | 40 | def test_overwrite_different_id() -> None: 41 | a = PermissionOverwrite.build( 42 | 344404945359077377, PermissionTarget.member, 43 | connect=True, speak=False 44 | ) 45 | b = PermissionOverwrite.build( 46 | 841509053422632990, PermissionTarget.member, 47 | connect=True, speak=False 48 | ) 49 | assert not a == b 50 | assert a != b 51 | 52 | 53 | def test_overwrite_inequality() -> None: 54 | a = PermissionOverwrite.build( 55 | 344404945359077377, PermissionTarget.member, 56 | manage_roles=True, manage_nicknames=True, manage_channels=False 57 | ) 58 | b = PermissionOverwrite.build( 59 | 344404945359077377, PermissionTarget.member, 60 | manage_nicknames=True, manage_channels=True 61 | ) 62 | assert a != b 63 | 64 | 65 | def test_overwrite_replace() -> None: 66 | overwrite = PermissionOverwrite.build( 67 | 344404945359077377, PermissionTarget.member, 68 | send_messages=True, send_messages_in_threads=True, 69 | add_reactions=False, embed_links=False 70 | ) 71 | replaced = overwrite.replace(speak=True, send_messages_in_threads=False, embed_links=None) 72 | assert replaced.allow == Permissions(2099200) 73 | assert replaced.deny == Permissions(274877907008) 74 | -------------------------------------------------------------------------------- /tests/wumpy-rest/test_ratelimiter.py: -------------------------------------------------------------------------------- 1 | from typing import TYPE_CHECKING 2 | 3 | import pytest 4 | from wumpy.rest import DictRatelimiter 5 | 6 | if TYPE_CHECKING: 7 | from wumpy.testing.suites import RatelimiterSuite 8 | else: 9 | __mod = pytest.importorskip( 10 | 'wumpy.testing.suites', reason='Wumpy-testing is required for the ratelimiter tests' 11 | ) 12 | RatelimiterSuite = __mod.RatelimiterSuite 13 | del __mod 14 | 15 | 16 | class TestDictRatelimiter(RatelimiterSuite): 17 | def get_impl(self) -> DictRatelimiter: 18 | return DictRatelimiter() 19 | --------------------------------------------------------------------------------