├── .editorconfig ├── .gitattributes ├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ ├── feature_request.md │ └── support_inquiry.md └── PULL_REQUEST_TEMPLATE.md ├── .gitignore ├── .pyup.yml ├── .readthedocs.yml ├── .readthedocs └── requirements.txt ├── .travis.yml ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE ├── MANIFEST.in ├── README.rst ├── clamor ├── __init__.py ├── exceptions.py ├── meta.py └── rest │ ├── __init__.py │ ├── endpoints │ ├── __init__.py │ ├── audit_log.py │ ├── base.py │ ├── channel.py │ ├── emoji.py │ ├── gateway.py │ ├── guild.py │ ├── invite.py │ ├── oauth.py │ ├── user.py │ ├── voice.py │ └── webhook.py │ ├── http.py │ ├── rate_limit.py │ └── routes.py ├── docs ├── Makefile ├── make.bat └── source │ ├── _static │ ├── banner.png │ ├── favicon.ico │ ├── logo.png │ └── style.css │ ├── _templates │ └── .gitkeep │ ├── conf.py │ └── index.rst ├── pylama.ini ├── requirements.txt ├── setup.py └── tests ├── __init__.py ├── test_rest_http.py └── test_rest_rate_limit.py /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig configuration file, see: https://editorconfig.org/ 2 | 3 | root = true 4 | 5 | [*] 6 | charset = utf-8 7 | max_line_length = 100 8 | indent_style = space 9 | indent_size = 4 10 | tab_width = 4 11 | end_of_line = lf 12 | trim_trailing_whitespace = true 13 | insert_final_newline = true 14 | 15 | [*.yml] 16 | indent_size = 2 17 | tab_width = 2 18 | 19 | [*.rst] 20 | indent_size = 3 21 | tab_width = 3 22 | 23 | [Makefile] 24 | indent_style = tab 25 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # Handle default line endings 2 | * text=auto eol=lf 3 | *.bat eol=crlf 4 | *.png binary 5 | *.ico binary 6 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: File a bug report to help improve Clamor 4 | 5 | --- 6 | 7 | ## Describe the bug 8 | 9 | A clear and concise description of what the bug is. 10 | 11 | ## To Reproduce 12 | 13 | Steps to reproduce the behavior: 14 | 15 | *1. Go to '...'* 16 | *2. Click on '....'* 17 | *3. Scroll down to '....'* 18 | *4. See error* 19 | 20 | ## Expected behavior 21 | 22 | A clear and concise description of what you expected to happen. 23 | 24 | ## Additional context 25 | 26 | Add any additional context about the problem here. 27 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for Clamor 4 | 5 | --- 6 | 7 | ## Is your feature request related to a problem? Please describe. 8 | 9 | A clear and concise description of what the problem is. 10 | 11 | _Example:_ I'm always frustrated when [...] 12 | 13 | ## Describe the solution you'd like 14 | 15 | A clear and concise description of what you want to happen. 16 | 17 | ## Describe alternatives you've considered 18 | 19 | A clear and concise description of any alternative solutions or features you've considered. 20 | 21 | ## Additional context 22 | 23 | Add any other context or screenshots about the feature request here. 24 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/support_inquiry.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Support inquiry 3 | about: Support with Clamor 4 | 5 | --- 6 | 7 | *Please consider joining our [Discord guild](https://discord.gg/HbKGrVT) for faster and more direct help.* 8 | 9 | ## Is your issue related to a problem with code? Please describe. 10 | 11 | A clear and concise description of what the problem is. 12 | 13 | _Example:_ My problem isn't related to code. I wanted to ask about x. 14 | 15 | ## Describe the expected behavior 16 | 17 | A clear and concise description of what you want to happen. 18 | 19 | ## Describe the actual behavior 20 | 21 | A clear and concise description of what happens instead. 22 | 23 | ## The code you tried (for code-related issues) 24 | 25 | ```python 26 | # Code here 27 | ``` 28 | 29 | ## Errors (if any) 30 | 31 | _The errors you've encountered (if any)._ 32 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | # Pull Request Template 2 | 3 | Please try to complete the below as best as possible. Some of the fields 4 | may not be necessary so feel free to add or edit as you see fit. 5 | 6 | ## Checklist 7 | 8 | _Confirm you have completed the following actions prior to submitting this PR._ 9 | 10 | - [ ] There is an existing issue report for this PR. 11 | - [ ] I have forked this project. 12 | - [ ] I have created a feature branch. 13 | - [ ] My changes have been committed. 14 | - [ ] I have pushed my changes to the branch. 15 | 16 | ## Title 17 | 18 | _Give your PR a short title summarising the patch, bug fix or feature._ 19 | 20 | ## Description 21 | 22 | _Ensure the PR description clearly describes the problem and solution and provide as much relevant information as possible._ 23 | 24 | ## Issue Resolution 25 | 26 | _Tell us which issue this PR fixes._ 27 | 28 | This Pull Request fixes # 29 | 30 | ## Proposed Changes 31 | 32 | _List your proposed changes below._ 33 | 34 | - This PR fixes a bug relating to... 35 | - Adding a patch that enhances the project. 36 | - Various bug fixes. 37 | 38 | ## New or Changed Features 39 | 40 | _Does this PR provide new or changed features or enhancements? If so, what is included in this PR?_ 41 | 42 | * Enhancement to the templates. 43 | * Adds a new feature that does x, y and z. 44 | * Solves a long-standing bug that affects a, b and c. 45 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | MANIFEST 27 | 28 | # PyInstaller 29 | # Usually these files are written by a python script from a template 30 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 31 | *.manifest 32 | *.spec 33 | 34 | # Installer logs 35 | pip-log.txt 36 | pip-delete-this-directory.txt 37 | 38 | # Unit test / coverage reports 39 | htmlcov/ 40 | .tox/ 41 | .coverage 42 | .coverage.* 43 | .cache 44 | nosetests.xml 45 | coverage.xml 46 | *.cover 47 | .hypothesis/ 48 | .pytest_cache/ 49 | 50 | # Translations 51 | *.mo 52 | *.pot 53 | 54 | # Django stuff: 55 | *.log 56 | local_settings.py 57 | db.sqlite3 58 | 59 | # Flask stuff: 60 | instance/ 61 | .webassets-cache 62 | 63 | # Scrapy stuff: 64 | .scrapy 65 | 66 | # Sphinx documentation 67 | docs/_build/ 68 | 69 | # PyBuilder 70 | target/ 71 | 72 | # Jupyter Notebook 73 | .ipynb_checkpoints 74 | 75 | # pyenv 76 | .python-version 77 | 78 | # celery beat schedule file 79 | celerybeat-schedule 80 | 81 | # SageMath parsed files 82 | *.sage.py 83 | 84 | # Environments 85 | .env 86 | .venv 87 | env/ 88 | venv/ 89 | ENV/ 90 | env.bak/ 91 | venv.bak/ 92 | 93 | # Spyder project settings 94 | .spyderproject 95 | .spyproject 96 | 97 | # Rope project settings 98 | .ropeproject 99 | 100 | # mkdocs documentation 101 | /site 102 | 103 | # mypy 104 | .mypy_cache/ 105 | 106 | # Editors 107 | .idea/ 108 | .vscode/ 109 | -------------------------------------------------------------------------------- /.pyup.yml: -------------------------------------------------------------------------------- 1 | # https://pyup.io/ configuration file 2 | 3 | update: all 4 | label_prs: dependency update 5 | -------------------------------------------------------------------------------- /.readthedocs.yml: -------------------------------------------------------------------------------- 1 | formats: 2 | - htmlzip 3 | - epub 4 | 5 | requirements_file: .readthedocs/requirements.txt 6 | 7 | build: 8 | image: latest 9 | 10 | python: 11 | version: 3.6 12 | setup_py_install: true 13 | pip_install: true 14 | -------------------------------------------------------------------------------- /.readthedocs/requirements.txt: -------------------------------------------------------------------------------- 1 | sphinx>=1.7.0 2 | sphinx-autodoc-typehints>=1.6.0 3 | sphinxcontrib-trio>=1.1.0 4 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | sudo: false 3 | cache: pip 4 | 5 | python: 6 | - "3.5" 7 | - "3.6" 8 | 9 | matrix: 10 | include: 11 | # Python 3.7 workaround 12 | - python: 3.7.3 13 | dist: xenial 14 | sudo: true 15 | 16 | install: 17 | - pip install pylama 18 | - pip install -e . 19 | - pip install -U -r .readthedocs/requirements.txt 20 | 21 | script: 22 | # Linting 23 | - pylama . 24 | 25 | # Unit tests 26 | - python setup.py test 27 | 28 | # Build docs 29 | - make -C docs html 30 | 31 | before_deploy: 32 | # Remove junk generated by setup.py test 33 | - rm -rf *.egg-info 34 | 35 | deploy: 36 | provider: pypi 37 | user: $PYPI_DEPLOY_USER 38 | password: $PYPI_DEPLOY_PASSWORD 39 | skip_existing: true 40 | on: 41 | tags: true 42 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as contributors and maintainers pledge to making participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, gender identity and expression, level of experience, nationality, personal appearance, race, religion, or sexual identity and orientation. 6 | 7 | ## Our Standards 8 | 9 | Examples of behavior that contributes to creating a positive environment include: 10 | 11 | * Using welcoming and inclusive language 12 | * Being respectful of differing viewpoints and experiences 13 | * Gracefully accepting constructive criticism 14 | * Focusing on what is best for the community 15 | * Showing empathy towards other community members 16 | 17 | Examples of unacceptable behavior by participants include: 18 | 19 | * The use of sexualized language or imagery and unwelcome sexual attention or advances 20 | * Trolling, insulting/derogatory comments, and personal or political attacks 21 | * Public or private harassment 22 | * Publishing others' private information, such as a physical or electronic address, without explicit permission 23 | * Other conduct which could reasonably be considered inappropriate in a professional setting 24 | 25 | ## Our Responsibilities 26 | 27 | Project maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take appropriate and fair corrective action in response to any instances of unacceptable behavior. 28 | 29 | Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful. 30 | 31 | ## Scope 32 | 33 | This Code of Conduct applies both within project spaces and in public spaces when an individual is representing the project or its community. Examples of representing a project or community include using an official project e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. Representation of a project may be further defined and clarified by project maintainers. 34 | 35 | ## Enforcement 36 | 37 | Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team at valentin.be@protonmail.com. The project team will review and investigate all complaints, and will respond in a way that it deems appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. Further details of specific enforcement policies may be posted separately. 38 | 39 | Project maintainers who do not follow or enforce the Code of Conduct in good faith may face temporary or permanent repercussions as determined by other members of the project's leadership. 40 | 41 | ## Attribution 42 | 43 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, available at [http://contributor-covenant.org/version/1/4][version] 44 | 45 | [homepage]: http://contributor-covenant.org 46 | [version]: http://contributor-covenant.org/version/1/4/ 47 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to Clamor 2 | 3 | Clamor is a Discord API framework that couldn't exist without amazing people like you. 4 | So you decided to contribute? Great! But first, read these contribution guidelines to get started. 5 | 6 | ## Code of Conduct 7 | 8 | Please note that this project has a [Code of Conduct][CoC] to which all contributors must adhere. 9 | 10 | ## Reporting bugs 11 | 12 | Found a bug? Report it! Bug reports help us improving this software and making it more usable for everyone. 13 | 14 | **Please follow these simple standards for bug reports:** 15 | 16 | - Fill out the bug report template 17 | 18 | - Include the code you tried, all necessary details and the error you received 19 | 20 | ## Contributing code or docs 21 | 22 | Before you start, please browse the [open issues][issues]. Contributions corresponding to issues labeled with 23 | `help wanted` are especially appreciated. 24 | 25 | However, other contributions are welcome too. We love to hear your ideas and suggestions on how to improve this 26 | project or review your submissions of code or docs. If you're planning to do major changes to the code, please 27 | open an issue in advance to discuss the changes or join our [Discord guild][Discord]. 28 | 29 | If you're new to Pull Requests on GitHub, here's an [introduction][PR introduction]. 30 | 31 | Pull Requests should, as much as possible, be self-contained and only address one issue. Therefore ten Pull Requests 32 | for ten small and unrelated changes are more appreciated than one big Pull Request with everything mixed together. 33 | This way, it is easier for us to maintain and review the changes and if there are problems with one of them, it 34 | won't hold up the others. 35 | 36 | By making a contribution to this repository, you certify that: 37 | 38 | - The contribution was created in whole or in part by you and you have the right to submit it under the conditions 39 | of the [MIT License][MIT] this project uses. 40 | 41 | - All the code is covered by carefully crafted tests and works flawlessly. If this is not the case, feel free to 42 | ask for help in the corresponding issue and state in detail with what you need help, what you've tried so far and 43 | what's the thing that makes you struggle. 44 | 45 | - You didn't increase any version number yourself unless you were told to do so. That's what we usually do for new 46 | releases when we think it is appropriate to do so. 47 | 48 | - Your code is consistent, follows the style guidelines and is well-documented. 49 | 50 | After your PR has passed all checks, Continuous Integration and has been approved by the maintainers, 51 | it'll be merged. 52 | 53 | ## Code guidelines 54 | 55 | - Code in general should follow [PEP 7 standards for C code](https://www.python.org/dev/peps/pep-0008/) and 56 | [PEP 8 standards for Python code](https://www.python.org/dev/peps/pep-0008/). 57 | 58 | - Before committing, please lint your code by running `pylama .` and resolving the issues. 59 | This helps everyone to keep the code overall consistent, easy to read and maintainable. 60 | 61 | - Python source files are meant to start with `# -*- coding: utf-8 -*-`, followed by an empty line. 62 | 63 | - Please follow the way we structure `include` statements. 64 | 65 | ```python 66 | # Imports are to be arranged alphabetically 67 | 68 | # Imports from the standard library 69 | import audioop as audio 70 | import os.path 71 | import re 72 | # followed by from ... include ... statements from the standard library 73 | from json import dumps 74 | 75 | # Imports from external dependencies 76 | import anyio 77 | from anysocks import open_connection 78 | from asks import Session 79 | 80 | # Imports from Clamor itself 81 | from .meta import __version__ as version 82 | ``` 83 | 84 | - Please use expressive variable and function names. They should give users 85 | a clear understanding of what they are supposed to do. 86 | 87 | ```python 88 | # Good: 89 | heartbeat_interval = payload['hearbeat_interval'] 90 | 91 | # Bad: 92 | # What is xyz supposed to do? Why 5? What is it used for? 93 | xyz = 5 94 | ``` 95 | 96 | - Keep your code readable, self-explanatory and clean. If you're adding 97 | new source files or expand existing ones, don't forget to add corresponding 98 | unit tests to the `tests/` directory. 99 | 100 | - Documentation is important for users to give them a clear overview of the 101 | public API of this library. Attributes, functions, and methods starting with 102 | an underscore (`_`) are generally meant to be part of the private API and 103 | therefore doesn't need documentation. Please refer to the 104 | [NumPy docstring style guide](https://numpydoc.readthedocs.io/en/latest/format.html) 105 | or documented source code. 106 | 107 | - Keep it usable. No fast-and-loose JSON dicts, manual HTTP requests or gateway 108 | messages that aren't wrapped. 109 | 110 | ## Contribution ideas 111 | 112 | There are many ways of contributing. If there is no [open issue][issues] that arouses your interest, here 113 | are some suggestions on ways of contributing. All contributions are valued. 114 | 115 | - Help people in the issues or on our [Discord guild][Discord] 116 | 117 | - Use Clamor in a project and give us feedback about what worked and what didn't 118 | 119 | - Write a blog post about your experiences with Clamor, good or bad 120 | 121 | - Comment on issues. 122 | 123 | - Review Pull Requests 124 | 125 | - Add tests 126 | 127 | - Fix bugs 128 | 129 | - Add features 130 | 131 | - Improve code or documentation 132 | 133 | [CoC]: ./CONTRIBUTING.md 134 | [issues]: https://github.com/clamor-py/Clamor/issues 135 | [Discord]: https://discord.gg/HbKGrVT 136 | [PR introduction]: https://help.github.com/articles/using-pull-requests 137 | [MIT]: https://choosealicense.com/licenses/mit 138 | [editorconfig]: ./.editorconfig 139 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Valentin B. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | recursive-include clamor *.py 2 | recursive-include docs * 3 | recursive-include tests *.py 4 | include CODE_OF_CONDUCT.md CONTRIBUTING.md 5 | include README.rst 6 | include requirements.txt 7 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | .. image:: /docs/source/_static/banner.png 2 | :align: center 3 | :alt: Clamor - The Python Discord API Framework 4 | 5 | .. image:: https://discordapp.com/api/guilds/486621752202625024/embed.png 6 | :target: https://discord.gg/HbKGrVT 7 | :alt: Chat on Discord 8 | 9 | .. image:: https://travis-ci.org/clamor-py/Clamor.svg?branch=master 10 | :target: https://travis-ci.org/clamor-py/Clamor 11 | :alt: Clamor Build Status 12 | 13 | .. image:: https://api.codeclimate.com/v1/badges/2625e8b96b3e57ae0d09/maintainability 14 | :target: https://codeclimate.com/github/clamor-py/Clamor/maintainability 15 | :alt: Maintainability 16 | 17 | .. image:: https://www.codefactor.io/repository/github/clamor-py/clamor/badge 18 | :target: https://www.codefactor.io/repository/github/clamor-py/clamor 19 | :alt: CodeFactor 20 | 21 | .. image:: https://readthedocs.org/projects/clamor/badge/?version=latest 22 | :target: https://clamor.readthedocs.io/en/latest/?badge=latest 23 | :alt: Documentation Status 24 | 25 | .. image:: https://img.shields.io/pypi/v/clamor.svg 26 | :target: https://pypi.org/project/clamor/ 27 | :alt: PyPI 28 | 29 | Installation 30 | ------------ 31 | 32 | Documentation 33 | ------------- 34 | 35 | Features 36 | -------- 37 | 38 | Example 39 | ------- 40 | 41 | .. code-block:: python3 42 | 43 | # Coming soon 44 | 45 | Contributing 46 | ------------ 47 | 48 | Before you contribute, please read our `Contribution Guidelines `_ 49 | 50 | Credits 51 | ------- 52 | 53 | Clamor is currently being developed and maintained by `Vale `_ and 54 | `Wambo `_. 55 | 56 | In no particular order, we want to credit these amazing people for their invaluable contributions. 57 | 58 | - `Roman Gräf `_ 59 | 60 | - for the gateway codebase, 61 | - reviews on issues and pull requests, and 62 | - generally being a helpful and lovely person to work with 63 | 64 | - `Filip M. `_ 65 | 66 | - for his contributions to the wrappers of various Discord models, 67 | - and doing a great job as a staff member of the `Clamor Discord Guild`_ 68 | 69 | - `G3bE `_ 70 | 71 | - for addressing and fixing various bugs of the library, 72 | - and his time he spends on moderating the `Clamor Discord Guild`_ 73 | 74 | - `Stu `_ 75 | 76 | - for being a gatekeeper at the `Clamor Discord Guild`_ 77 | 78 | - `Zomatree `_ 79 | 80 | - for his contributions to the command framework, 81 | - and often being around on the `Clamor Discord Guild`_ 82 | 83 | - all the people being around on Discord, supporting the library. You rock! 84 | 85 | 86 | .. _Clamor Discord Guild: https://discord.gg/HbKGrVT 87 | -------------------------------------------------------------------------------- /clamor/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | """ 4 | Clamor 5 | ~~~~~~ 6 | 7 | The Python Discord API Framework. 8 | 9 | :copyright: (c) 2019 Valentin B. 10 | :license: MIT, see LICENSE for more details. 11 | """ 12 | 13 | from .meta import * 14 | from .rest import * 15 | 16 | import logging 17 | 18 | fmt = '[%(levelname)s] %(asctime)s - %(name)s:%(lineno)d - %(message)s' 19 | logging.basicConfig(format=fmt, level=logging.INFO) 20 | logging.getLogger(__name__).addHandler(logging.NullHandler()) 21 | -------------------------------------------------------------------------------- /clamor/exceptions.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | import logging 4 | from enum import IntEnum 5 | from itertools import starmap 6 | from typing import Optional, Union 7 | 8 | from asks.response_objects import Response 9 | 10 | __all__ = ( 11 | 'JSONErrorCode', 12 | 'ClamorError', 13 | 'RequestFailed', 14 | 'Unauthorized', 15 | 'Forbidden', 16 | 'NotFound', 17 | 'Hierarchied', 18 | ) 19 | 20 | logger = logging.getLogger(__name__) 21 | 22 | 23 | class JSONErrorCode(IntEnum): 24 | """Enum that holds the REST API JSON error codes.""" 25 | 26 | #: Unknown opcode. 27 | UNKNOWN = 0 28 | 29 | #: Unknown account. 30 | UNKNOWN_ACCOUNT = 10001 31 | #: Unknown application. 32 | UNKNOWN_APPLICATION = 10002 33 | #: Unknown channel. 34 | UNKNOWN_CHANNEL = 10003 35 | #: Unknown guild. 36 | UNKNOWN_GUILD = 10004 37 | #: Unknown integration. 38 | UNKNOWN_INTEGRATION = 10005 39 | #: Unknown invite. 40 | UNKNOWN_INVITE = 10006 41 | #: Unknown member. 42 | UNKNOWN_MEMBER = 10007 43 | #: Unknown message. 44 | UNKNOWN_MESSAGE = 10008 45 | #: Unknown overwrite. 46 | UNKNOWN_OVERWRITE = 10009 47 | #: Unknown provider. 48 | UNKNOWN_PROVIDER = 10010 49 | #: Unknown role. 50 | UNKNOWN_ROLE = 10011 51 | #: Unknown token. 52 | UNKNOWN_TOKEN = 10012 53 | #: Unknown user. 54 | UNKNOWN_USER = 10013 55 | #: Unknown emoji. 56 | UNKNOWN_EMOJI = 10014 57 | #: Unknown webhook. 58 | UNKNOWN_WEBHOOK = 10015 59 | #: Unknown ban. 60 | UNKNOWN_BAN = 10026 61 | 62 | #: Bots cannot use this endpoint. 63 | BOTS_NOT_ALLOWED = 20001 64 | #: Only bots can use this endpoint. 65 | ONLY_BOTS_ALLOWED = 20002 66 | 67 | #: Maximum number of guilds reached (100). 68 | MAX_GUILDS_LIMIT = 30001 69 | #: Maximum number of friends reached (1000). 70 | MAX_FRIENDS_LIMIT = 30002 71 | #: Maximum number of pinned messages reached (50). 72 | MAX_PINS_LIMIT = 30003 73 | #: Maximum number of recipients reached (10). 74 | MAX_USERS_PER_DM = 30004 75 | #: Maximum number of roles reached (250). 76 | MAX_ROLES_LIMIT = 30005 77 | #: Maximum number of reactions reached (20). 78 | MAX_REACTIONS_LIMIT = 30010 79 | #: Maximum number of guild channels reached (500). 80 | MAX_GUILD_CHANNELS_LIMIT = 30013 81 | 82 | #: Unauthorized. 83 | UNAUTHORIZED = 40001 84 | #: Missing access. 85 | MISSING_ACCESS = 50001 86 | #: Invalid account type. 87 | INVALID_ACCOUNT_TYPE = 50002 88 | #: Cannot execute action on DM channel. 89 | INVALID_DM_ACTION = 50003 90 | #: Widget disabled. 91 | WIDGET_DISABLED = 50004 92 | #: Cannot edit a message authored by another user. 93 | CANNOT_EDIT = 50005 94 | #: Cannot send an empty message. 95 | EMPTY_MESSAGE = 50006 96 | #: Cannot send messages to this user. 97 | CANNOT_SEND_TO_USER = 50007 98 | #: Cannot send messages in a voice channel. 99 | CANNOT_SEND_IN_VC = 50008 100 | #: Channel verification level is too high. 101 | VERIFICATION_LEVEL_TOO_HIGH = 50009 102 | #: OAuth2 application does not have a bot. 103 | OAUTH_WITHOUT_BOT = 50010 104 | #: OAuth2 application limit reached. 105 | MAX_OAUTH_APPS = 50011 106 | #: Invalid OAuth2 state. 107 | INVALID_OAUTH_STATE = 50012 108 | #: Missing permissions. 109 | MISSING_PERMISSIONS = 50013 110 | #: Invalid authentication token. 111 | INVALID_TOKEN = 50014 112 | #: Note is too long. 113 | NOTE_TOO_LONG = 50015 114 | #: Provided too few (at least 2) or too many (fewer than 100) messages to delete. 115 | INVALID_BULK_DELETE = 50016 116 | #: Invalid MFA level. 117 | INVALID_MFA_LEVEL = 50017 118 | #: Invalid password. 119 | INVALID_PASSWORD = 50018 120 | #: A message can only be pinned to the channel it was sent in. 121 | INVALID_PIN = 50019 122 | #: Invite code is either invalid or taken. 123 | INVALID_VANITY_URL = 50020 124 | #: Cannot execute action on a system message. 125 | INVALID_MESSAGE_TARGET = 50021 126 | #: Invalid OAuth2 access token. 127 | INVALID_OAUTH_TOKEN = 50025 128 | #: A message provided was too old to bulk delete. 129 | TOO_OLD_TO_BULK_DELETE = 50034 130 | #: Invalid form body. 131 | INVALID_FORM_BODY = 50035 132 | #: An invite was accepted to a guild the application's bot is not in. 133 | INVALID_INVITE_GUILD = 50036 134 | #: Invalid API version. 135 | INVALID_API_VERSION = 50041 136 | 137 | #: Reaction blocked. 138 | REACTION_BLOCKED = 90001 139 | 140 | #: Resource overloaded. 141 | RESOURCE_OVERLOADED = 130000 142 | 143 | @property 144 | def name(self) -> str: 145 | """Returns a human-readable version of the enum member's name.""" 146 | 147 | return ' '.join(part.capitalize() for part in self._name_.split('_')) 148 | 149 | 150 | class ClamorError(Exception): 151 | """Base exception class for any exceptions raised by this library. 152 | 153 | Therefore, catching :class:`~clamor.exceptions.ClamorException` may 154 | be used to handle **any** exceptions raised by this library. 155 | """ 156 | pass 157 | 158 | 159 | class RequestFailed(ClamorError): 160 | """Exception that will be raised for failed HTTP requests to the REST API. 161 | 162 | This extracts important components from the failed response 163 | and presents them to the user in a readable way. 164 | 165 | Parameters 166 | ---------- 167 | response : :class:`Response` 168 | The response for the failed request. 169 | data : Union[dict, str], optional 170 | The parsed response body. 171 | 172 | Attributes 173 | ---------- 174 | response : :class:`Response` 175 | The response for the failed request. 176 | status_code : int 177 | The HTTP status code for the request. 178 | bucket : Tuple[str, str] 179 | A tuple containing request method and URL for debugging purposes. 180 | error : :class:`~clamor.exceptions.JSONErrorCode` 181 | The JSON error code returned by the API. 182 | errors : dict 183 | The unflattened JSON error dict. 184 | message : str 185 | The error message returned by the API. 186 | """ 187 | 188 | def __init__(self, response: Response, data: Optional[Union[dict, str]]): 189 | self.response = response 190 | self.status_code = response.status_code 191 | self.bucket = (self.response.method.upper(), self.response.url) 192 | 193 | self.error = None 194 | self.errors = None 195 | self.message = None 196 | 197 | failed = 'Request to {0.bucket} failed with {0.error.value} {0.error.name}: {0.message}' 198 | 199 | # Try to get any useful data from the dict 200 | error_code = data.get('code', 0) 201 | if isinstance(data, dict): 202 | try: 203 | self.error = JSONErrorCode(error_code) 204 | except ValueError: 205 | logger.warning('Unknown error code %d', error_code) 206 | self.error = JSONErrorCode.UNKNOWN 207 | 208 | self.errors = data.get('errors', {}) 209 | self.message = data.get('message', '') 210 | 211 | else: 212 | self.message = data 213 | self.status_code = JSONErrorCode.UNKNOWN 214 | 215 | if self.errors: 216 | errors = self._flatten_errors(self.errors) 217 | error_list = '\n'.join( 218 | starmap('code: {1[0][code]}, message: {1[0][message]}'.format, errors.items())) 219 | failed += '\nAdditional errors: {}'.format(error_list) 220 | 221 | super().__init__(failed.format(self)) 222 | 223 | def _flatten_errors(self, errors: dict, key: str = '') -> dict: 224 | messages = [] 225 | 226 | for k, v in errors.items(): 227 | if k == 'message': 228 | continue 229 | 230 | new_key = k 231 | if key: 232 | if key.isdigit(): 233 | new_key = '{}.{}'.format(key, k) 234 | else: 235 | new_key = '{}.[{}]'.format(key, k) 236 | 237 | if isinstance(v, dict): 238 | try: 239 | _errors = v['_errors'] 240 | except KeyError: 241 | messages.extend(self._flatten_errors(v, new_key).items()) 242 | else: 243 | messages.append( 244 | (new_key, ' '.join(error.get('message', '') for error in _errors))) 245 | else: 246 | messages.append((new_key, v)) 247 | 248 | return dict(messages) 249 | 250 | 251 | class Unauthorized(RequestFailed): 252 | """Raised for HTTP status code ``401: Unauthorized``. 253 | 254 | Essentially denoting that the user's token is wrong. 255 | """ 256 | pass 257 | 258 | 259 | class Forbidden(RequestFailed): 260 | """Raised for HTTP status code ``403: Forbidden``. 261 | 262 | Essentially denoting that your token is not permitted 263 | to access a specific resource. 264 | """ 265 | pass 266 | 267 | 268 | class NotFound(RequestFailed): 269 | """Raised for HTTP status code ``404: Not Found``. 270 | 271 | Essentially denoting that the specified resource 272 | does not exist. 273 | """ 274 | pass 275 | 276 | 277 | class Hierarchied(ClamorError): 278 | """Raised when an action fails due to hierarchy. 279 | 280 | This error is occurring when your bot tries to 281 | edit someone with a higher role than their own 282 | regardless of permissions. 283 | 284 | Common examples: 285 | ---------------- 286 | 287 | - The bot is trying to edit the guild owner. 288 | 289 | - The bot is trying to kick/ban members with 290 | a higher role than their own. 291 | *Even occurs if the bot has ``Kick/Ban Members`` permissions.* 292 | """ 293 | pass 294 | -------------------------------------------------------------------------------- /clamor/meta.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from collections import namedtuple 4 | 5 | __all__ = ( 6 | '__author__', 7 | '__copyright__', 8 | '__license__', 9 | '__title__', 10 | '__url__', 11 | '__version__', 12 | 'version_info', 13 | ) 14 | 15 | __author__ = 'Valentin B.' 16 | __copyright__ = 'Copyright 2019 Valentin B.' 17 | __license__ = 'MIT' 18 | __title__ = 'Clamor' 19 | __url__ = 'https://github.com/clamor-py/Clamor' 20 | __version__ = '0.2.0' 21 | 22 | VersionInfo = namedtuple('VersionInfo', 'major minor micro releaselevel serial') 23 | version_info = VersionInfo(0, 2, 0, 'beta', 0) 24 | -------------------------------------------------------------------------------- /clamor/rest/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from . import endpoints 4 | from .http import * 5 | from .rate_limit import * 6 | from .routes import * 7 | -------------------------------------------------------------------------------- /clamor/rest/endpoints/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from . import base 4 | from .audit_log import * 5 | from .channel import * 6 | from .emoji import * 7 | from .gateway import * 8 | from .guild import * 9 | from .invite import * 10 | from .oauth import * 11 | from .user import * 12 | from .voice import * 13 | from .webhook import * 14 | -------------------------------------------------------------------------------- /clamor/rest/endpoints/audit_log.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from enum import IntEnum 4 | from typing import Union 5 | 6 | from ..routes import Routes 7 | from .base import * 8 | 9 | __all__ = ( 10 | 'AuditLogAction', 11 | 'AuditLogWrapper', 12 | ) 13 | 14 | 15 | class AuditLogAction(IntEnum): 16 | """Enum that holds the various Audit Log event types.""" 17 | 18 | #: Unknown event. 19 | UNKNOWN = 0 20 | 21 | #: Guild updated. 22 | GUILD_UPDATE = 1 23 | 24 | #: Channel created. 25 | CHANNEL_CREATE = 10 26 | #: Channel updated. 27 | CHANNEL_UPDATE = 11 28 | #: Channel deleted. 29 | CHANNEL_DELETE = 12 30 | #: Channel overwrite created. 31 | CHANNEL_OVERWRITE_CREATE = 13 32 | #: Channel overwrite updated. 33 | CHANNEL_OVERWRITE_UPDATE = 14 34 | #: Channel overwrite removed. 35 | CHANNEL_OVERWRITE_DELETE = 15 36 | 37 | #: Member kicked. 38 | MEMBER_KICK = 20 39 | #: Member pruned. 40 | MEMBER_PRUNE = 21 41 | #: Member banned. 42 | MEMBER_BAN_ADD = 22 43 | #: Member unbanned. 44 | MEMBER_BAN_REMOVE = 23 45 | #: Member updated. 46 | MEMBER_UPDATE = 24 47 | #: Member role updated. 48 | MEMBER_ROLE_UPDATE = 25 49 | 50 | #: Role created. 51 | ROLE_CREATE = 30 52 | #: Role updated. 53 | ROLE_UPDATE = 31 54 | #: Role deleted. 55 | ROLE_DELETE = 32 56 | 57 | #: Guild invite created. 58 | INVITE_CREATE = 40 59 | #: Guild invite updated. 60 | INVITE_UPDATE = 41 61 | #: Guild invite deleted. 62 | INVITE_DELETE = 42 63 | 64 | #: Webhook created. 65 | WEBHOOK_CREATE = 50 66 | #: Webhook updated. 67 | WEBHOOK_UPDATE = 51 68 | #: Webhook deleted. 69 | WEBHOOK_DELETE = 52 70 | 71 | #: Emoji added. 72 | EMOJI_CREATE = 60 73 | #: Emoji updated. 74 | EMOJI_UPDATE = 61 75 | #: Emoji deleted. 76 | EMOJI_DELETE = 62 77 | 78 | #: Message deleted. 79 | MESSAGE_DELETE = 72 80 | 81 | 82 | class AuditLogWrapper(EndpointsWrapper): 83 | """A higher-level wrapper around Audit Log endpoints. 84 | 85 | .. seealso:: Audit Log endpoints https://discordapp.com/developers/docs/resources/audit-log 86 | """ 87 | 88 | def __init__(self, token: str, guild_id: Snowflake): 89 | super().__init__(token) 90 | 91 | self.guild_id = guild_id 92 | 93 | async def get_guild_audit_log(self, 94 | user_id: Snowflake, 95 | action_type: Union[AuditLogAction, int] = None, 96 | before: Snowflake = None, 97 | limit: int = 50) -> dict: 98 | params = optional(**{ 99 | 'user_id': user_id, 100 | 'action_type': action_type if isinstance(action_type, int) else action_type.value, 101 | 'before': before, 102 | 'limit': limit, 103 | }) 104 | 105 | return await self.http.make_request(Routes.GET_GUILD_AUDIT_LOG, 106 | dict(guild=self.guild_id), 107 | params=params) 108 | -------------------------------------------------------------------------------- /clamor/rest/endpoints/base.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from contextlib import contextmanager 4 | from typing import NewType, Union 5 | 6 | from ..http import HTTP 7 | 8 | __all__ = ( 9 | 'Snowflake', 10 | 'optional', 11 | 'EndpointsWrapper', 12 | ) 13 | 14 | #: A type for denoting raw snowflake parameters. 15 | Snowflake = NewType('Snowflake', Union[int, str]) 16 | 17 | 18 | def optional(**kwargs) -> dict: 19 | """Given a dictionary, this filters out all values that are ``None``. 20 | 21 | Useful for routes where certain parameters are optional. 22 | """ 23 | 24 | return { 25 | key: value for key, value in kwargs.items() 26 | if value is not None 27 | } 28 | 29 | 30 | class EndpointsWrapper: 31 | """Base class for higher-level wrappers for API endpoints.""" 32 | 33 | __slots__ = ('http',) 34 | 35 | def __init__(self, token: str): 36 | self.http = HTTP(token) 37 | 38 | @property 39 | def token(self) -> str: 40 | """The token that is used for API authorization.""" 41 | 42 | return self.http.token 43 | 44 | @contextmanager 45 | def raw_responses(self): 46 | """A contextmanager that yields all raw responses this instance holds. 47 | 48 | .. warning:: 49 | 50 | Do not use this if you don't know what you're doing. 51 | """ 52 | 53 | try: 54 | yield self.http.responses 55 | finally: 56 | self.http.responses.clear() 57 | -------------------------------------------------------------------------------- /clamor/rest/endpoints/channel.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | import json 4 | import re 5 | from typing import List 6 | 7 | from ..routes import Routes 8 | from .base import * 9 | 10 | __all__ = ( 11 | 'ChannelWrapper', 12 | ) 13 | 14 | 15 | class ChannelWrapper(EndpointsWrapper): 16 | """A higher-level wrapper around Channel endpoints. 17 | 18 | .. seealso:: Channel endpoints https://discordapp.com/developers/docs/resources/channel 19 | """ 20 | 21 | def __init__(self, token: str, channel_id: Snowflake): 22 | super().__init__(token) 23 | 24 | self.channel_id = channel_id 25 | 26 | @staticmethod 27 | def _parse_emoji(emoji: str) -> str: 28 | match = re.match(r'', emoji) 29 | if match: 30 | emoji = match.group(1) 31 | 32 | return emoji 33 | 34 | async def get_channel(self) -> dict: 35 | return await self.http.make_request(Routes.GET_CHANNEL, dict(channel=self.channel_id)) 36 | 37 | async def modify_channel(self, 38 | name: str = None, 39 | position: int = None, 40 | topic: str = None, 41 | nsfw: bool = None, 42 | rate_limit_per_user: int = None, 43 | bitrate: int = None, 44 | user_limit: int = None, 45 | permission_overwrites: list = None, 46 | parent_id: Snowflake = None, 47 | reason: str = None) -> dict: 48 | params = optional(**{ 49 | 'name': name, 50 | 'position': position, 51 | 'topic': topic, 52 | 'nsfw': nsfw, 53 | 'rate_limit_per_user': rate_limit_per_user, 54 | 'bitrate': bitrate, 55 | 'user_limit': user_limit, 56 | 'permission_overwrites': permission_overwrites, 57 | 'parent_id': parent_id 58 | }) 59 | 60 | return await self.http.make_request(Routes.MODIFY_CHANNEL, 61 | dict(channel=self.channel_id), 62 | json=params, 63 | reason=reason) 64 | 65 | async def delete_channel(self, reason: str = None) -> dict: 66 | return await self.http.make_request(Routes.DELETE_CHANNEL, 67 | dict(channel=self.channel_id), 68 | reason=reason) 69 | 70 | async def get_channel_messages(self, 71 | around: Snowflake = None, 72 | before: Snowflake = None, 73 | after: Snowflake = None, 74 | limit: int = 50) -> list: 75 | params = optional(**{ 76 | 'around': around, 77 | 'before': before, 78 | 'after': after, 79 | 'limit': limit 80 | }) 81 | 82 | return await self.http.make_request(Routes.GET_CHANNEL_MESSAGES, 83 | dict(channel=self.channel_id), 84 | params=params) 85 | 86 | async def get_channel_message(self, message_id: Snowflake) -> dict: 87 | return await self.http.make_request(Routes.GET_CHANNEL_MESSAGE, 88 | dict(channel=self.channel_id, message=message_id)) 89 | 90 | async def create_message(self, 91 | content: str = None, 92 | nonce: Snowflake = None, 93 | tts: bool = False, 94 | files: list = None, 95 | embed: dict = None) -> dict: 96 | payload = optional(**{ 97 | 'content': content, 98 | 'nonce': nonce, 99 | 'tts': tts, 100 | 'embed': embed 101 | }) 102 | 103 | if files: 104 | if len(files) == 1: 105 | attachments = { 106 | 'file': tuple(files[0]), 107 | } 108 | else: 109 | attachments = { 110 | 'file{}'.format(index): tuple(file) for index, file in enumerate(files) 111 | } 112 | 113 | return await self.http.make_request(Routes.CREATE_MESSAGE, 114 | dict(channel=self.channel_id), 115 | files=attachments, 116 | data={'payload_json': json.dumps(payload)}) 117 | 118 | return await self.http.make_request(Routes.CREATE_MESSAGE, 119 | dict(channel=self.channel_id), 120 | json=payload) 121 | 122 | async def create_reaction(self, message_id: Snowflake, emoji: str): 123 | return await self.http.make_request(Routes.CREATE_REACTION, 124 | dict(channel=self.channel_id, 125 | message=message_id, 126 | emoji=self._parse_emoji(emoji))) 127 | 128 | async def delete_own_reaction(self, message_id: Snowflake, emoji: str): 129 | return await self.http.make_request(Routes.DELETE_OWN_REACTION, 130 | dict(channel=self.channel_id, 131 | message=message_id, 132 | emoji=self._parse_emoji(emoji))) 133 | 134 | async def delete_user_reaction(self, 135 | message_id: Snowflake, 136 | user_id: Snowflake, 137 | emoji: str): 138 | return await self.http.make_request(Routes.DELETE_USER_REACTION, 139 | dict(channel=self.channel_id, 140 | message=message_id, 141 | emoji=self._parse_emoji(emoji), 142 | user=user_id)) 143 | 144 | async def get_reactions(self, 145 | message_id: Snowflake, 146 | emoji: str, 147 | before: Snowflake = None, 148 | after: Snowflake = None, 149 | limit: int = 25) -> dict: 150 | params = optional(**{ 151 | 'before': before, 152 | 'after': after, 153 | 'limit': limit 154 | }) 155 | 156 | return await self.http.make_request(Routes.GET_REACTIONS, 157 | dict(channel=self.channel_id, 158 | message=message_id, 159 | emoji=self._parse_emoji(emoji)), 160 | params=params) 161 | 162 | async def delete_all_reactions(self, message_id: Snowflake): 163 | return await self.http.make_request(Routes.DELETE_ALL_REACTIONS, 164 | dict(channel=self.channel_id, message=message_id)) 165 | 166 | async def edit_message(self, 167 | message_id: Snowflake, 168 | content: str = None, 169 | embed: dict = None) -> dict: 170 | params = optional(**{ 171 | 'content': content, 172 | 'embed': embed, 173 | }) 174 | 175 | return await self.http.make_request(Routes.EDIT_MESSAGE, 176 | dict(channel=self.channel_id, message=message_id), 177 | json=params) 178 | 179 | async def delete_message(self, message_id: Snowflake, reason: str = None): 180 | return await self.http.make_request(Routes.DELETE_MESSAGE, 181 | dict(channel=self.channel_id, message=message_id), 182 | reason=reason) 183 | 184 | async def bulk_delete_messages(self, messages: List[Snowflake], reason: str = None): 185 | if 2 <= len(messages) <= 100: 186 | raise ValueError('Bulk delete requires a message count between 2 and 100') 187 | 188 | return await self.http.make_request(Routes.BULK_DELETE_MESSAGES, 189 | dict(channel=self.channel_id), 190 | json={'messages': messages}, 191 | reason=reason) 192 | 193 | async def edit_channel_permissions(self, 194 | overwrite_id: Snowflake, 195 | allow: int = None, 196 | deny: int = None, 197 | type: str = None, 198 | reason: str = None): 199 | params = optional(**{ 200 | 'allow': allow, 201 | 'deny': deny, 202 | 'type': type 203 | }) 204 | 205 | if params.get('type', 'member') not in ('member', 'role'): 206 | raise ValueError('Argument for type must be either "member" or "role"') 207 | 208 | return await self.http.make_request(Routes.EDIT_CHANNEL_PERMISSIONS, 209 | dict(channel=self.channel_id, overwrite=overwrite_id), 210 | json=params, 211 | reason=reason) 212 | 213 | async def get_channel_invites(self) -> list: 214 | return await self.http.make_request(Routes.GET_CHANNEL_INVITES, 215 | dict(channel=self.channel_id)) 216 | 217 | async def create_channel_invite(self, 218 | max_age: int = 86400, 219 | max_uses: int = 0, 220 | temporary: bool = False, 221 | unique: bool = False, 222 | reason: str = None) -> dict: 223 | params = optional(**{ 224 | 'max_age': max_age, 225 | 'max_uses': max_uses, 226 | 'temporary': temporary, 227 | 'unique': unique 228 | }) 229 | 230 | return await self.http.make_request(Routes.CREATE_CHANNEL_INVITE, 231 | dict(channel=self.channel_id), 232 | json=params, 233 | reason=reason) 234 | 235 | async def delete_channel_permission(self, overwrite_id: Snowflake, reason: str = None): 236 | return await self.http.make_request(Routes.DELETE_CHANNEL_PERMISSION, 237 | dict(channel=self.channel_id, overwrite=overwrite_id), 238 | reason=reason) 239 | 240 | async def trigger_typing_indicator(self): 241 | return await self.http.make_request(Routes.TRIGGER_TYPING_INDICATOR, 242 | dict(channel=self.channel_id)) 243 | 244 | async def get_pinned_messages(self) -> dict: 245 | return await self.http.make_request(Routes.GET_PINNED_MESSAGES, 246 | dict(channel=self.channel_id)) 247 | 248 | async def add_pinned_channel_message(self, message_id: Snowflake): 249 | return await self.http.make_request(Routes.ADD_PINNED_CHANNEL_MESSAGE, 250 | dict(channel=self.channel_id, message=message_id)) 251 | 252 | async def delete_pinned_channel_message(self, message_id: Snowflake, reason: str = None): 253 | return await self.http.make_request(Routes.DELETE_PINNED_CHANNEL_MESSAGE, 254 | dict(channel=self.channel_id, message=message_id), 255 | reason=reason) 256 | 257 | async def group_dm_add_recipient(self, 258 | user_id: Snowflake, 259 | access_token: str = None, 260 | nick: str = None): 261 | params = optional(**{ 262 | 'access_token': access_token, 263 | 'nick': nick 264 | }) 265 | 266 | return await self.http.make_request(Routes.GROUP_DM_ADD_RECIPIENT, 267 | dict(channel=self.channel_id, user=user_id), 268 | json=params) 269 | 270 | async def group_dm_remove_recipient(self, user_id: Snowflake): 271 | return await self.http.make_request(Routes.GROUP_DM_REMOVE_RECIPIENT, 272 | dict(channel=self.channel_id, user=user_id)) 273 | -------------------------------------------------------------------------------- /clamor/rest/endpoints/emoji.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from ..routes import Routes 4 | from .base import * 5 | 6 | __all__ = ( 7 | 'EmojiWrapper', 8 | ) 9 | 10 | 11 | class EmojiWrapper(EndpointsWrapper): 12 | """A higher-level wrapper around Emoji endpoints. 13 | 14 | .. seealso:: Emoji endpoints https://discordapp.com/developers/docs/resources/emoji 15 | """ 16 | 17 | def __init__(self, token: str, guild_id: Snowflake): 18 | super().__init__(token) 19 | 20 | self.guild_id = guild_id 21 | 22 | async def list_guild_emojis(self) -> list: 23 | return await self.http.make_request(Routes.LIST_GUILD_EMOJIS, 24 | dict(guild=self.guild_id)) 25 | 26 | async def get_guild_emoji(self, emoji_id: Snowflake) -> dict: 27 | return await self.http.make_request(Routes.GET_GUILD_EMOJI, 28 | dict(guild=self.guild_id, emoji=emoji_id)) 29 | 30 | async def create_guild_emoji(self, 31 | name: str, 32 | image: str, 33 | roles: list, 34 | reason: str = None) -> dict: 35 | params = { 36 | 'name': name, 37 | 'image': image, 38 | 'roles': roles 39 | } 40 | 41 | return await self.http.make_request(Routes.CREATE_GUILD_EMOJI, 42 | dict(guild=self.guild_id), 43 | json=params, 44 | reason=reason) 45 | 46 | async def modify_guild_emoji(self, 47 | emoji_id: Snowflake, 48 | name: str = None, 49 | roles: list = None, 50 | reason: str = None) -> dict: 51 | params = optional(**{ 52 | 'name': name, 53 | 'roles': roles 54 | }) 55 | 56 | return await self.http.make_request(Routes.MODIFY_GUILD_EMOJI, 57 | dict(guild=self.guild_id, emoji=emoji_id), 58 | json=params, 59 | reason=reason) 60 | 61 | async def delete_guild_emoji(self, emoji_id: Snowflake, reason: str = None): 62 | return await self.http.make_request(Routes.DELETE_GUILD_EMOJI, 63 | dict(guild=self.guild_id, emoji=emoji_id), 64 | reason=reason) 65 | -------------------------------------------------------------------------------- /clamor/rest/endpoints/gateway.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from ..routes import Routes 4 | from .base import * 5 | 6 | __all__ = ( 7 | 'GatewayWrapper', 8 | ) 9 | 10 | 11 | class GatewayWrapper(EndpointsWrapper): 12 | """A higher-level wrapper around Gateway endpoints. 13 | 14 | .. seealso:: Gateway endpoints https://discordapp.com/developers/docs/topics/gateway 15 | """ 16 | 17 | async def get_gateway(self) -> dict: 18 | return await self.http.make_request(Routes.GET_GATEWAY) 19 | 20 | async def get_gateway_bot(self) -> dict: 21 | return await self.http.make_request(Routes.GET_GATEWAY_BOT) 22 | -------------------------------------------------------------------------------- /clamor/rest/endpoints/guild.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from ..routes import Routes 4 | from .base import * 5 | 6 | __all__ = ( 7 | 'GuildWrapper', 8 | ) 9 | 10 | 11 | class GuildWrapper(EndpointsWrapper): 12 | """A higher-level wrapper around Guild endpoints. 13 | 14 | .. seealso:: Guild endpoints https://discordapp.com/developers/docs/resources/guild 15 | """ 16 | 17 | def __init__(self, token: str, guild_id: Snowflake): 18 | super().__init__(token) 19 | 20 | self.guild_id = guild_id 21 | 22 | async def create_guild(self, 23 | name: str, 24 | region: str, 25 | icon: str, 26 | verification_level: int, 27 | default_message_notifications: int, 28 | explicit_content_filter: int, 29 | roles: list, 30 | channels: list) -> dict: 31 | params = { 32 | "name": name, 33 | "region": region, 34 | "icon": icon, 35 | "verification_level": verification_level, 36 | "default_message_notifications": default_message_notifications, 37 | "explicit_content_filter": explicit_content_filter, 38 | "roles": roles, 39 | "channels": channels 40 | } 41 | 42 | return await self.http.make_request(Routes.CREATE_GUILD, 43 | json=params) 44 | 45 | async def get_guild(self) -> dict: 46 | return await self.http.make_request(Routes.GET_GUILD, 47 | dict(guild=self.guild_id)) 48 | 49 | async def modify_guild(self, 50 | name: str = None, 51 | region: str = None, 52 | verification_level: int = None, 53 | default_message_notifications: int = None, 54 | explicit_content_filter: int = None, 55 | afk_channel_id: Snowflake = None, 56 | afk_timout: int = None, 57 | icon: str = None, 58 | owner_id: Snowflake = None, 59 | splash: str = None, 60 | system_channel_id: Snowflake = None, 61 | reason: str = None) -> dict: 62 | params = optional(**{ 63 | "name": name, 64 | "region": region, 65 | "verification_level": verification_level, 66 | "default_message_notifications": default_message_notifications, 67 | "explicit_content_filter": explicit_content_filter, 68 | "afk_channel_id": afk_channel_id, 69 | "afk_timeout": afk_timout, 70 | "icon": icon, 71 | "owner_id": owner_id, 72 | "splash": splash, 73 | "system_channel_id": system_channel_id 74 | }) 75 | 76 | return await self.http.make_request(Routes.MODIFY_GUILD, 77 | dict(guild=self.guild_id), 78 | json=params, 79 | reason=reason) 80 | 81 | async def delete_guild(self): 82 | return await self.http.make_request(Routes.DELETE_GUILD, 83 | dict(guild=self.guild_id)) 84 | 85 | async def get_guild_channels(self) -> list: 86 | return await self.http.make_request(Routes.GET_GUILD_CHANNELS, 87 | dict(guild=self.guild_id)) 88 | 89 | async def create_guild_channel(self, 90 | name: str, 91 | channel_type: int = None, 92 | topic: str = None, 93 | bitrate: int = None, 94 | user_limit: int = None, 95 | rate_limit_per_user: int = None, 96 | position: int = None, 97 | permission_overwrites: list = None, 98 | parent_id: Snowflake = None, 99 | reason: str = None) -> dict: 100 | params = optional(**{ 101 | "name": name, 102 | "channel_type": channel_type, 103 | "topic": topic, 104 | "bitrate": bitrate, 105 | "user_limit": user_limit, 106 | "rate_limit_per_user": rate_limit_per_user, 107 | "position": position, 108 | "permission_overwrites": permission_overwrites, 109 | "parent_id": parent_id 110 | }) 111 | 112 | return await self.http.make_request(Routes.CREATE_GUILD_CHANNEL, 113 | dict(guild=self.guild_id), 114 | json=params, 115 | reason=reason) 116 | 117 | async def modify_guild_channel_positions(self, channels: list): 118 | return await self.http.make_request(Routes.MODIFY_GUILD_CHANNEL_POSITIONS, 119 | dict(guild=self.guild_id), 120 | json=channels) 121 | 122 | async def get_guild_member(self, user_id: Snowflake) -> dict: 123 | return await self.http.make_request(Routes.GET_GUILD_MEMBER, 124 | dict(guild=self.guild_id, member=user_id)) 125 | 126 | async def list_guild_members(self, 127 | limit: int = None, 128 | after: Snowflake = None) -> list: 129 | params = optional(**{ 130 | "limit": limit, 131 | "after": after 132 | }) 133 | 134 | return await self.http.make_request(Routes.LIST_GUILD_MEMBERS, 135 | dict(guild=self.guild_id), 136 | json=params) 137 | 138 | async def add_guild_member(self, 139 | user_id: Snowflake, 140 | access_token: str, 141 | nick: str = None, 142 | roles: list = None, 143 | mute: bool = None, 144 | deaf: bool = None) -> dict: 145 | params = optional(**{ 146 | "access_token": access_token, 147 | "nick": nick, 148 | "roles": roles, 149 | "mute": mute, 150 | "deaf": deaf 151 | }) 152 | 153 | return await self.http.make_request(Routes.ADD_GUILD_MEMBER, 154 | dict(guild=self.guild_id, member=user_id), 155 | json=params) 156 | 157 | async def modify_guild_member(self, 158 | user_id: Snowflake, 159 | nick: str = None, 160 | roles: list = None, 161 | mute: bool = None, 162 | deaf: bool = None, 163 | channel_id: Snowflake = None, 164 | reason: str = None): 165 | params = optional(**{ 166 | "nick": nick, 167 | "roles": roles, 168 | "mute": mute, 169 | "deaf": deaf, 170 | "channel_id": channel_id 171 | }) 172 | 173 | return await self.http.make_request(Routes.MODIFY_GUILD_MEMBER, 174 | dict(guild=self.guild_id, member=user_id), 175 | json=params, 176 | reason=reason) 177 | 178 | async def modify_current_user_nick(self, nick: str, reason: str = None) -> str: 179 | params = { 180 | "nick": nick 181 | } 182 | 183 | return await self.http.make_request(Routes.MODIFY_CURRENT_USER_NICK, 184 | dict(guild=self.guild_id), 185 | json=params, 186 | reason=reason) 187 | 188 | async def add_guild_member_role(self, 189 | user_id: Snowflake, 190 | role_id: Snowflake, 191 | reason: str = None): 192 | return await self.http.make_request(Routes.ADD_GUILD_MEMBER_ROLE, 193 | dict(guild=self.guild_id, member=user_id, role=role_id), 194 | reason=reason) 195 | 196 | async def remove_guild_member_role(self, 197 | user_id: Snowflake, 198 | role_id: Snowflake, 199 | reason: str = None): 200 | return await self.http.make_request(Routes.REMOVE_GUILD_MEMBER_ROLE, 201 | dict(guild=self.guild_id, member=user_id, role=role_id), 202 | reason=reason) 203 | 204 | async def remove_guild_member(self, user_id: Snowflake, reason: str = None): 205 | return await self.http.make_request(Routes.REMOVE_GUILD_MEMBER, 206 | dict(guild=self.guild_id, member=user_id), 207 | reason=reason) 208 | 209 | async def get_guild_bans(self) -> list: 210 | return await self.http.make_request(Routes.GET_GUILD_BANS, 211 | dict(guild=self.guild_id)) 212 | 213 | async def get_guild_ban(self, user_id: Snowflake) -> dict: 214 | return await self.http.make_request(Routes.GET_GUILD_BAN, 215 | dict(guild=self.guild_id, user=user_id)) 216 | 217 | async def create_guild_ban(self, 218 | user_id: Snowflake, 219 | delete_message_days: int = None, 220 | reason: str = None): 221 | params = optional(**{ 222 | "delete_message_days": delete_message_days, 223 | "reason": reason 224 | }) 225 | 226 | return await self.http.make_request(Routes.CREATE_GUILD_BAN, 227 | dict(guild=self.guild_id, user=user_id), 228 | json=params) 229 | 230 | async def remove_guild_ban(self, user_id: Snowflake, reason: str = None): 231 | return await self.http.make_request(Routes.REMOVE_GUILD_BAN, 232 | dict(guild=self.guild_id, user=user_id), 233 | reason=reason) 234 | 235 | async def get_guild_roles(self): 236 | return await self.http.make_request(Routes.GET_GUILD_ROLES, 237 | dict(guild=self.guild_id)) 238 | 239 | async def create_guild_role(self, 240 | name: str = None, 241 | permissions: int = None, 242 | color: int = None, 243 | hoist: bool = None, 244 | mentionable: bool = None, 245 | reason: str = None) -> dict: 246 | params = optional(**{ 247 | "name": name, 248 | "permissions": permissions, 249 | "color": color, 250 | "hoist": hoist, 251 | "mentionable": mentionable 252 | }) 253 | 254 | return await self.http.make_request(Routes.CREATE_GUILD_ROLE, 255 | dict(guild=self.guild_id), 256 | json=params, 257 | reason=reason) 258 | 259 | async def modify_guild_role_positions(self, roles: list, reason: str = None) -> list: 260 | return await self.http.make_request(Routes.MODIFY_GUILD_ROLE_POSITIONS, 261 | dict(guild=self.guild_id), 262 | json=roles, 263 | reason=reason) 264 | 265 | async def modify_guild_role(self, 266 | role_id: Snowflake, 267 | name: str = None, 268 | permissions: int = None, 269 | color: int = None, 270 | hoist: bool = None, 271 | mentionable: bool = None, 272 | reason: str = None) -> dict: 273 | params = optional(**{ 274 | "name": name, 275 | "permissions": permissions, 276 | "color": color, 277 | "hoist": hoist, 278 | "mentionable": mentionable 279 | }) 280 | 281 | return await self.http.make_request(Routes.MODIFY_GUILD_ROLE, 282 | dict(guild=self.guild_id, role=role_id), 283 | json=params, 284 | reason=reason) 285 | 286 | async def delete_guild_role(self, role_id: Snowflake, reason: str = None): 287 | return await self.http.make_request(Routes.DELETE_GUILD_ROLE, 288 | dict(guild=self.guild_id, role=role_id), 289 | reason=reason) 290 | 291 | async def get_guild_prune_count(self) -> dict: 292 | return await self.http.make_request(Routes.GET_GUILD_PRUNE_COUNT, 293 | dict(guild=self.guild_id)) 294 | 295 | async def begin_guild_prune(self, 296 | days: int, 297 | compute_prune_count: bool) -> dict: 298 | params = { 299 | "days": days, 300 | "compute_prune_count": compute_prune_count 301 | } 302 | 303 | return await self.http.make_request(Routes.BEGIN_GUILD_PRUNE, 304 | dict(guild=self.guild_id), 305 | json=params) 306 | 307 | async def get_guild_voice_regions(self) -> list: 308 | return await self.http.make_request(Routes.GET_GUILD_VOICE_REGIONS, 309 | dict(guild=self.guild_id)) 310 | 311 | async def get_guild_invites(self) -> list: 312 | return await self.http.make_request(Routes.GET_GUILD_INVITES, 313 | dict(guild=self.guild_id)) 314 | 315 | async def get_guild_integrations(self) -> list: 316 | return await self.http.make_request(Routes.GET_GUILD_INTEGRATIONS, 317 | dict(guild=self.guild_id)) 318 | 319 | async def create_guild_integration(self, 320 | int_type: str, 321 | int_id: Snowflake, 322 | reason: str = None): 323 | params = { 324 | "type": int_type, 325 | "id": int_id 326 | } 327 | 328 | return await self.http.make_request(Routes.CREATE_GUILD_INTEGRATION, 329 | dict(guild=self.guild_id), 330 | json=params, 331 | reason=reason) 332 | 333 | async def modify_guild_integration(self, 334 | integration_id: Snowflake, 335 | expire_behavior: int, 336 | expire_grace_period: int, 337 | enable_emoticons: bool, 338 | reason: str = None): 339 | params = { 340 | "expire_behavior": expire_behavior, 341 | "expire_grace_period": expire_grace_period, 342 | "enable_emoticons": enable_emoticons 343 | } 344 | 345 | return await self.http.make_request(Routes.MODIFY_GUILD_INTEGRATION, 346 | dict(guild=self.guild_id, integration=integration_id), 347 | json=params, 348 | reason=reason) 349 | 350 | async def delete_guild_integration(self, integration_id: Snowflake, reason: str = None): 351 | return await self.http.make_request(Routes.DELETE_GUILD_INTEGRATION, 352 | dict(guild=self.guild_id, integration=integration_id), 353 | reason=reason) 354 | 355 | async def sync_guild_integration(self, integration_id: Snowflake): 356 | return await self.http.make_request(Routes.SYNC_GUILD_INTEGRATION, 357 | dict(guild=self.guild_id, integration=integration_id)) 358 | 359 | async def get_guild_embed(self) -> dict: 360 | return await self.http.make_request(Routes.GET_GUILD_EMBED, 361 | dict(guild=self.guild_id)) 362 | 363 | async def modify_guild_embed(self, 364 | enabled: bool, 365 | channel_id: Snowflake, 366 | reason: str = None): 367 | params = { 368 | "enabled": enabled, 369 | "channel_id": channel_id 370 | } 371 | 372 | return await self.http.make_request(Routes.MODIFY_GUILD_EMBED, 373 | dict(guild=self.guild_id), 374 | json=params, 375 | reason=reason) 376 | 377 | async def get_guild_vanity_url(self): 378 | return await self.http.make_request(Routes.GET_GUILD_VANITY_URL, 379 | dict(guild=self.guild_id)) 380 | 381 | async def get_guild_widget_image(self, style: str): 382 | return await self.http.make_request(Routes.GET_GUILD_WIDGET_IMAGE, 383 | dict(guild=self.guild_id), 384 | json={"style": style}) 385 | -------------------------------------------------------------------------------- /clamor/rest/endpoints/invite.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from ..routes import Routes 4 | from .base import * 5 | 6 | __all__ = ( 7 | 'InviteWrapper', 8 | ) 9 | 10 | 11 | class InviteWrapper(EndpointsWrapper): 12 | """A higher-level wrapper around Invite endpoints. 13 | 14 | .. seealso:: Invite endpoints https://discordapp.com/developers/docs/resources/invite 15 | """ 16 | 17 | async def get_invite(self, invite_code: str, with_counts: bool = False) -> dict: 18 | return await self.http.make_request(Routes.GET_INVITE, 19 | dict(invite=invite_code), 20 | params=optional(**{'with_counts': with_counts})) 21 | 22 | async def delete_invite(self, invite_code: str, reason: str = None) -> dict: 23 | return await self.http.make_request(Routes.DELETE_INVITE, 24 | dict(invite=invite_code), 25 | reason=reason) 26 | -------------------------------------------------------------------------------- /clamor/rest/endpoints/oauth.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from ..routes import Routes 4 | from .base import * 5 | 6 | __all__ = ( 7 | 'OAuthWrapper', 8 | ) 9 | 10 | 11 | class OAuthWrapper(EndpointsWrapper): 12 | """A higher-level wrapper around OAuth2 endpoints. 13 | 14 | .. seealso:: OAuth2 endpoints https://discordapp.com/developers/docs/topics/oauth2 15 | """ 16 | 17 | async def get_current_application_info(self) -> dict: 18 | return await self.http.make_request(Routes.GET_CURRENT_APPLICATION_INFO) 19 | -------------------------------------------------------------------------------- /clamor/rest/endpoints/user.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from typing import List, Optional 4 | 5 | from ..routes import Routes 6 | from .base import * 7 | 8 | __all__ = ( 9 | 'UserWrapper', 10 | ) 11 | 12 | 13 | class UserWrapper(EndpointsWrapper): 14 | """A higher-level wrapper around User endpoints. 15 | 16 | .. seealso:: User endpoints https://discordapp.com/developers/docs/resources/user 17 | """ 18 | 19 | @staticmethod 20 | def _check_username(username: str) -> Optional[str]: 21 | if not username: 22 | return None 23 | 24 | if 2 > len(username) > 32: 25 | raise ValueError('Usernames must be beween 2 and 32 characters long') 26 | 27 | if username in ('discordtag', 'everyone', 'here'): 28 | raise ValueError('Restricted username') 29 | 30 | if any(c in ('@', '#', ':', '```') for c in username): 31 | raise ValueError('Usernames must not contain "@", "#", ":" or "```"') 32 | 33 | return username.strip() 34 | 35 | async def get_current_user(self) -> dict: 36 | return await self.http.make_request(Routes.GET_CURRENT_USER) 37 | 38 | async def get_user(self, user_id: Snowflake) -> dict: 39 | return await self.http.make_request(Routes.GET_USER, 40 | dict(user=user_id)) 41 | 42 | async def modify_current_user(self, username: str = None, avatar: str = None) -> dict: 43 | params = optional(**{ 44 | 'username': self._check_username(username), 45 | 'avatar': avatar 46 | }) 47 | 48 | return await self.http.make_request(Routes.MODIFY_CURRENT_USER, 49 | json=params) 50 | 51 | async def get_current_user_guilds(self, 52 | before: Snowflake = None, 53 | after: Snowflake = None, 54 | limit: int = 100) -> list: 55 | params = optional(**{ 56 | 'before': before, 57 | 'after': after, 58 | 'limit': limit 59 | }) 60 | 61 | return await self.http.make_request(Routes.GET_CURRENT_USER_GUILDS, 62 | params=params) 63 | 64 | async def leave_guild(self, guild_id: Snowflake): 65 | return await self.http.make_request(Routes.LEAVE_GUILD, 66 | dict(guild=guild_id)) 67 | 68 | async def get_user_dms(self) -> list: 69 | return await self.http.make_request(Routes.GET_USER_DMS) 70 | 71 | async def create_dm(self, recipient_id: Snowflake) -> dict: 72 | return await self.http.make_request(Routes.CREATE_DM, 73 | json={'recipient_id': recipient_id}) 74 | 75 | async def create_group_dm(self, access_tokens: List[str], nicks: dict) -> dict: 76 | params = { 77 | 'access_tokens': access_tokens, 78 | 'nicks': nicks, 79 | } 80 | 81 | return await self.http.make_request(Routes.CREATE_GROUP_DM, 82 | json=params) 83 | 84 | async def get_user_connections(self) -> list: 85 | return await self.http.make_request(Routes.GET_USER_CONNECTIONS) 86 | -------------------------------------------------------------------------------- /clamor/rest/endpoints/voice.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from ..routes import Routes 4 | from .base import * 5 | 6 | __all__ = ( 7 | 'VoiceWrapper', 8 | ) 9 | 10 | 11 | class VoiceWrapper(EndpointsWrapper): 12 | """A higher-level wrapper around Voice endpoints. 13 | 14 | .. seealso:: Voice endpoints https://discordapp.com/developers/docs/resources/voice 15 | """ 16 | 17 | async def list_voice_regions(self) -> list: 18 | return await self.http.make_request(Routes.LIST_VOICE_REGIONS) 19 | -------------------------------------------------------------------------------- /clamor/rest/endpoints/webhook.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | import json 4 | from typing import Optional 5 | 6 | from ..routes import Routes 7 | from .base import * 8 | 9 | __all__ = ( 10 | 'WebhookWrapper', 11 | ) 12 | 13 | 14 | class WebhookWrapper(EndpointsWrapper): 15 | """A higher-level wrapper around Webhook endpoints. 16 | 17 | .. seealso:: Webhook endpoints https://discordapp.com/developers/docs/resources/webhook 18 | """ 19 | 20 | @staticmethod 21 | def _check_name(name: str) -> Optional[str]: 22 | if 2 > len(name) > 32: 23 | raise ValueError('Name must be between 2 and 32 characters long') 24 | 25 | return name.strip() 26 | 27 | async def create_webhook(self, 28 | channel_id: Snowflake, 29 | name: str, 30 | avatar: str = None, 31 | reason: str = None) -> dict: 32 | params = { 33 | 'name': self._check_name(name), 34 | 'avatar': avatar, 35 | } 36 | 37 | return await self.http.make_request(Routes.CREATE_WEBHOOK, 38 | dict(channel=channel_id), 39 | json=params, 40 | reason=reason) 41 | 42 | async def get_channel_webhooks(self, channel_id: Snowflake) -> list: 43 | return await self.http.make_request(Routes.GET_CHANNEL_WEBHOOKS, 44 | dict(channel=channel_id)) 45 | 46 | async def get_guild_webhooks(self, guild_id: Snowflake) -> list: 47 | return await self.http.make_request(Routes.GET_GUILD_WEBHOOKS, 48 | dict(guild=guild_id)) 49 | 50 | async def get_webhook(self, webhook_id: Snowflake) -> dict: 51 | return await self.http.make_request(Routes.GET_WEBHOOK, 52 | dict(webhook=webhook_id)) 53 | 54 | async def get_webhook_with_token(self, webhook_id: Snowflake, webhook_token: str) -> dict: 55 | return await self.http.make_request(Routes.GET_WEBHOOK_WITH_TOKEN, 56 | dict(webhook=webhook_id, token=webhook_token)) 57 | 58 | async def modify_webhook(self, 59 | webhook_id: Snowflake, 60 | name: str = None, 61 | avatar: str = None, 62 | channel_id: Snowflake = None, 63 | reason: str = None) -> dict: 64 | params = optional(**{ 65 | 'name': self._check_name(name), 66 | 'avatar': avatar, 67 | 'channel_id': channel_id 68 | }) 69 | 70 | return await self.http.make_request(Routes.MODIFY_WEBHOOK, 71 | dict(webhook=webhook_id), 72 | json=params, 73 | reason=reason) 74 | 75 | async def modify_webhook_with_token(self, 76 | webhook_id: Snowflake, 77 | webhook_token: str, 78 | name: str = None, 79 | avatar: str = None, 80 | reason: str = None) -> dict: 81 | params = optional(**{ 82 | 'name': self._check_name(name), 83 | 'avatar': avatar 84 | }) 85 | 86 | return await self.http.make_request(Routes.MODIFY_WEBHOOK_WITH_TOKEN, 87 | dict(webhook=webhook_id, token=webhook_token), 88 | json=params, 89 | reason=reason) 90 | 91 | async def delete_webhook(self, webhook_id: Snowflake, reason: str = None): 92 | return await self.http.make_request(Routes.DELETE_WEBHOOK, 93 | dict(webhook=webhook_id), 94 | reason=reason) 95 | 96 | async def delete_webhook_with_token(self, 97 | webhook_id: Snowflake, 98 | webhook_token: str, 99 | reason: str = None): 100 | return await self.http.make_request(Routes.DELETE_WEBHOOK_WITH_TOKEN, 101 | dict(webhook=webhook_id, token=webhook_token), 102 | reason=reason) 103 | 104 | async def execute_webhook(self, 105 | webhook_id: Snowflake, 106 | webhook_token: str, 107 | content: str = None, 108 | username: str = None, 109 | avatar_url: str = None, 110 | tts: bool = False, 111 | files: list = None, 112 | embeds: list = None, 113 | wait: bool = False): 114 | if not content and not files and not embeds: 115 | raise ValueError('At least one of content, files or embeds is required') 116 | 117 | payload = optional(**{ 118 | 'content': content, 119 | 'username': username, 120 | 'avatar_url': avatar_url, 121 | 'tts': tts, 122 | 'embeds': embeds 123 | }) 124 | 125 | params = optional(**{ 126 | 'wait': wait 127 | }) 128 | 129 | if files: 130 | if len(files) == 1: 131 | attachments = { 132 | 'file': tuple(files[0]), 133 | } 134 | else: 135 | attachments = { 136 | 'file{}'.format(index): tuple(file) for index, file in enumerate(files) 137 | } 138 | 139 | return await self.http.make_request(Routes.EXECUTE_WEBHOOK, 140 | dict(webhook=webhook_id, token=webhook_token), 141 | files=attachments, 142 | data={'payload_json': json.dumps(payload)}, 143 | params=params) 144 | 145 | return await self.http.make_request(Routes.EXECUTE_WEBHOOK, 146 | dict(webhook=webhook_id, token=webhook_token), 147 | json=payload, 148 | params=params) 149 | -------------------------------------------------------------------------------- /clamor/rest/http.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | import logging 4 | import sys 5 | from random import randint 6 | from typing import Optional, Union 7 | from urllib.parse import quote 8 | 9 | import anyio 10 | import asks 11 | from asks.response_objects import Response 12 | 13 | from ..exceptions import RequestFailed, Unauthorized, Forbidden, NotFound 14 | from ..meta import __url__ as clamor_url, __version__ as clamor_version 15 | from .rate_limit import Bucket, RateLimiter 16 | from .routes import APIRoute 17 | 18 | __all__ = ( 19 | 'HTTP', 20 | ) 21 | 22 | logger = logging.getLogger(__name__) 23 | 24 | 25 | class _ReattemptRequest(Exception): 26 | def __init__(self, status_code: int, data: Optional[Union[dict, list, str]], *args): 27 | self.status_code = status_code 28 | self.data = data 29 | 30 | super().__init__(*args) 31 | 32 | 33 | class HTTP: 34 | r"""An interface to perform requests to the Discord API. 35 | 36 | Parameters 37 | ---------- 38 | token : str 39 | The token to use for API authorization. 40 | \**kwargs : dict 41 | See below. 42 | 43 | Keyword Arguments 44 | ----------------- 45 | session : :class:`asks.Session`, optional 46 | The session to use. If none provided, a new one is created. 47 | app : str 48 | The application type for the ``Authorization`` header. 49 | Either ``Bot`` or ``Bearer``, defaults to ``Bot``. 50 | 51 | Attributes 52 | ---------- 53 | rate_limiter : :class:`~clamor.rest.rate_limit.RateLimiter` 54 | The rate limiter to use for requests. 55 | headers : dict 56 | The default headers included in every request. 57 | """ 58 | 59 | #: The API version to use. 60 | API_VERSION = 7 61 | #: The Discord API URL. 62 | BASE_URL = 'https://discordapp.com/api/v{}'.format(API_VERSION) 63 | #: The total amount of allowed retries for failed requests. 64 | MAX_RETRIES = 5 65 | 66 | #: The log message format for successful requests. 67 | LOG_SUCCESSS = 'Success, {bucket} has received {text}!' 68 | #: The log message format for failed requests. 69 | LOG_FAILURE = 'Request to {bucket} failed with {code}: {error}' 70 | 71 | def __init__(self, token: str, **kwargs): 72 | self._token = token 73 | self._session = kwargs.get('session', asks.Session()) 74 | self.rate_limiter = RateLimiter() 75 | 76 | self._responses = [] 77 | self.headers = { 78 | 'User-Agent': self.user_agent, 79 | 'Authorization': kwargs.get('app', 'Bot') + ' ' + self._token, 80 | } 81 | 82 | @property 83 | def token(self) -> str: 84 | """The token used for API authorization.""" 85 | 86 | return self._token 87 | 88 | @property 89 | def user_agent(self) -> str: 90 | """The ``User-Agent`` header sent in every request.""" 91 | 92 | fmt = 'DiscordBot ({0}, v{1}) / Python {2[0]}.{2[1]}.{2[2]}' 93 | return fmt.format(clamor_url, clamor_version, sys.version_info) 94 | 95 | @property 96 | def responses(self): 97 | """All API responses this instance has received.""" 98 | 99 | return self._responses 100 | 101 | @staticmethod 102 | def _parse_response(response: Response) -> Optional[Union[dict, list, str]]: 103 | if response.headers['Content-Type'] == 'application/json': 104 | return response.json(encoding='utf-8') 105 | return response.text.encode('utf-8') 106 | 107 | async def make_request(self, 108 | route: APIRoute, 109 | fmt: dict = None, 110 | **kwargs) -> Optional[Union[dict, list, str]]: 111 | r"""Makes a request to a given route with a set of arguments. 112 | 113 | It also handles rate limits, non-success status codes and 114 | safely parses the response with :meth:`HTTP.parse_response`. 115 | 116 | Parameters 117 | ---------- 118 | route : Tuple[:class:`~clamor.rest.routes.Method`, str] 119 | A tuple containing HTTP method and the route to make the request to. 120 | fmt : dict 121 | A dictionary holding endpoint parameters to dynamically format a route. 122 | \**kwargs : dict 123 | See below. 124 | 125 | Keyword Arguments 126 | ----------------- 127 | headers : dict, optional 128 | Optional HTTP headers to include in the request. 129 | retries : int, optional 130 | The amount of retries that have yet been attempted. 131 | reason : str, optional 132 | Additional reason string for the ``X-Audit-Log-Reason`` 133 | header. 134 | 135 | Returns 136 | ------- 137 | Union[dict, list, str], optional 138 | The parsed response. 139 | 140 | Raises 141 | ------ 142 | :exc:`clamor.exceptions.Unauthorized` 143 | Raised for status code ``401`` and means your token is invalid. 144 | :exc:`clamor.exceptions.Forbidden` 145 | Raised for status code ``403`` and means that your token 146 | doesn't have permissions for a certain action. 147 | :exc:`clamor.exceptions.NotFound` 148 | Raised for status code ``404`` and means that the passed API 149 | route doesn't exist. 150 | :exc:`clamor.exceptions.RequestFailed` 151 | Generic exception raised when either retries are exceeded 152 | or a non-success status code not listed above occurred. 153 | """ 154 | 155 | fmt = fmt or {} 156 | retries = kwargs.pop('retries', 0) 157 | # The API shares rate limits with minor routes of guild, channel 158 | # and webhook endpoints. To make our lives easier through preparing 159 | # the buckets so that they share the same rate limit buckets by 160 | # default. Therefore no need to deal with X-RateLimit-Bucket. 161 | bucket_fmt = { 162 | key: value 163 | if key in ('guild', 'channel', 'webhook') else '' 164 | for key, value in fmt.items() 165 | } 166 | 167 | # Prepare the headers. 168 | if 'headers' in kwargs: 169 | kwargs['headers'].update(self.headers) 170 | else: 171 | kwargs['headers'] = self.headers 172 | 173 | # The additional header for audit logs. 174 | if 'reason' in kwargs and kwargs['reason'] is not None: 175 | kwargs['headers']['X-Audit-Log-Reason'] = quote(kwargs['reason'], '/ ') 176 | 177 | method = route[0].value 178 | url = self.BASE_URL + route[1].format(**fmt) 179 | bucket = (method, route[1].format(**bucket_fmt)) 180 | logger.debug('Performing request to bucket %s', bucket) 181 | 182 | async with self.rate_limiter(bucket): 183 | response = await self._session.request(method, url, **kwargs) 184 | 185 | await self.rate_limiter.update_bucket(bucket, response) 186 | self._responses.append(response) 187 | 188 | try: 189 | result = await self.parse_response(bucket, response) 190 | except _ReattemptRequest as error: 191 | logger.debug(self.LOG_FAILURE.format( 192 | bucket=bucket, code=error.status_code, error=response.content)) 193 | 194 | retries += 1 195 | if retries > self.MAX_RETRIES: 196 | raise RequestFailed(response, error.data) 197 | 198 | retry_after = randint(1000, 50000) / 1000.0 199 | await anyio.sleep(retry_after) 200 | 201 | return await self.make_request(route, fmt, **kwargs) 202 | else: 203 | return result 204 | 205 | async def parse_response(self, 206 | bucket: Bucket, 207 | response: Response) -> Optional[Union[dict, list, str]]: 208 | """Parses a given response and handles non-success status codes. 209 | 210 | Parameters 211 | ---------- 212 | bucket : Union[Tuple[str, str], str] 213 | The request bucket. 214 | response : :class:`Response` 215 | The response to parse. 216 | 217 | Returns 218 | ------- 219 | Union[dict, list, str], optional 220 | The extracted response content. 221 | 222 | Raises 223 | ------ 224 | :exc:`clamor.exceptions.Unauthorized` 225 | Raised for status code ``401`` and means your token is invalid. 226 | :exc:`clamor.exceptions.Forbidden` 227 | Raised for status code ``403`` and means that your token 228 | doesn't have permissions for a certain action. 229 | :exc:`clamor.exceptions.NotFound` 230 | Raised for status code ``404`` and means that the passed API 231 | route doesn't exist. 232 | :exc:`clamor.exceptions.RequestFailed` 233 | Generic exception raised when either retries are exceeded 234 | or a non-success status code not listed above occurred. 235 | """ 236 | 237 | data = self._parse_response(response) 238 | status = response.status_code 239 | 240 | if 200 <= status < 300: 241 | # These status codes indicate successful requests. 242 | # Therefore we can return the JSON response body. 243 | logger.debug(self.LOG_SUCCESSS.format(bucket=bucket, text=data)) 244 | return data 245 | 246 | elif status != 429 and 400 <= status < 500: 247 | # These status codes are user errors and won't disappear 248 | # with another request. In this case, we'll throw an 249 | # exception. 250 | if status == 401: 251 | raise Unauthorized(response, data) 252 | 253 | elif status == 403: 254 | raise Forbidden(response, data) 255 | 256 | elif status == 404: 257 | raise NotFound(response, data) 258 | 259 | else: 260 | raise RequestFailed(response, data) 261 | 262 | else: 263 | # Something weird happened here...Let's reattempt the request. 264 | raise _ReattemptRequest(status, data) 265 | 266 | async def close(self): 267 | """Closes the underlying :class:`Session`.""" 268 | 269 | await self._session.close() 270 | -------------------------------------------------------------------------------- /clamor/rest/rate_limit.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | import logging 4 | from datetime import datetime, timezone 5 | from email.utils import parsedate_to_datetime 6 | from typing import NewType, Tuple, Union 7 | 8 | import anyio 9 | from async_generator import async_generator, asynccontextmanager, yield_ 10 | from asks.response_objects import Response 11 | 12 | __all__ = ( 13 | 'Bucket', 14 | 'CooldownBucket', 15 | 'RateLimiter', 16 | ) 17 | 18 | logger = logging.getLogger(__name__) 19 | 20 | #: A type to denote rate limit buckets. 21 | Bucket = NewType('Bucket', Union[Tuple[str, str], str]) 22 | 23 | 24 | class CooldownBucket: 25 | """Wraps around a request bucket to handle rate limits. 26 | 27 | Instances of this class should be handled by :class:`~clamor.rest.rate_limit.RateLimiter`. 28 | They are constantly updated updated by :meth:`~CooldownBucket.update` given 29 | :class:`Response` objects. 30 | 31 | CooldownBuckets extract rate limit information from headers and provide 32 | properties and methods that make it easy to deal with them. 33 | 34 | Parameters 35 | ---------- 36 | bucket : Union[Tuple[str, str], str] 37 | The bucket for the route that should be covered. 38 | response : :class:`Response` 39 | The initial response object to initialize this class with. 40 | 41 | Attributes 42 | ---------- 43 | bucket : Union[Tuple[str, str], str] 44 | The bucket for the route that should be covered. 45 | lock : :class:`~Lock` 46 | The lock that is used when cooling down a route. 47 | """ 48 | 49 | __slots__ = ('bucket', '_date', '_remaining', '_reset', 'lock') 50 | 51 | def __init__(self, bucket: Bucket, response: Response): 52 | self.bucket = bucket 53 | 54 | # These values will be set later. 55 | self._date = None 56 | self._remaining = 0 57 | self._reset = None 58 | 59 | self.lock = anyio.create_lock() 60 | 61 | self.update(response) 62 | 63 | def __repr__(self) -> str: 64 | return ''.format( 65 | ' '.join((self.bucket,) if isinstance(self.bucket, str) else self.bucket) 66 | ) 67 | 68 | @property 69 | def will_rate_limit(self) -> bool: 70 | """Whether the next request is going to exhaust a rate limit or not.""" 71 | 72 | return self._remaining == 0 73 | 74 | def update(self, response: Response): 75 | """Updates this instance given a response that holds rate limit headers. 76 | 77 | Parameters 78 | ---------- 79 | response : :class:`Response` 80 | The response object for the most recent request to the bucket 81 | this instance holds. 82 | """ 83 | 84 | headers = response.headers 85 | 86 | # Rate limit headers is basically all or nothing. 87 | # If one of the headers is missing, this applies 88 | # to the other headers as well. 89 | # Therefore it is sufficient to check for one header. 90 | if 'X-RateLimit-Remaining' not in headers: 91 | return 92 | 93 | self._date = parsedate_to_datetime(headers.get('Date')) 94 | self._remaining = int(headers.get('X-RateLimit-Remaining')) 95 | self._reset = datetime.fromtimestamp(int(headers.get('X-RateLimit-Reset')), timezone.utc) 96 | 97 | async def cooldown(self) -> float: 98 | """Cools down the bucket this instance holds. 99 | 100 | Returns 101 | ------- 102 | float 103 | The duration the bucket has been cooled down for. 104 | """ 105 | 106 | delay = (self._reset - self._date).total_seconds() + .5 107 | logger.debug('Cooling bucket %s for %d seconds', self, delay) 108 | await anyio.sleep(delay) 109 | 110 | return delay 111 | 112 | 113 | class RateLimiter: 114 | """A rate limiter to keep track of per-bucket rate limits. 115 | 116 | This is responsible for updating and cooling down buckets 117 | before another request is made. 118 | :meth:`RateLimiter.update_bucket` and :meth:`RateLimiter.cooldown_bucket` 119 | can be used for that. It can also be used as an async 120 | contextmanager. 121 | 122 | Buckets are stored in a dictionary as literal bucket and 123 | :class:`~clamor.rest.rate_limit.CooldownBucket` objects. 124 | 125 | .. code-block:: python3 126 | 127 | buckets = { 128 | ('GET', '/channels/1234'): , 129 | ('PATCH', '/users/@me'): , 130 | ... 131 | } 132 | 133 | Example 134 | ------- 135 | 136 | .. code-block:: python3 137 | 138 | limiter = RateLimiter() 139 | 140 | ... 141 | 142 | # Option 1: 143 | 144 | # Make sure no global rate limit is exhausted. 145 | async with limiter.global_lock: 146 | pass 147 | 148 | await limiter.cooldown_bucket(bucket) # Blocks if rate limit is exhausted. 149 | response = await asks.request(bucket[0], 150 | 'https://discordapp.com/api/' + bucket[1], ...) 151 | await limiter.update_bucket(bucket, response) 152 | 153 | # Option 2: 154 | 155 | async with limiter(bucket): 156 | response = await asks.request(bucket[0], 157 | 'https://discordapp.com/api/' + bucket[1], ...) 158 | await limiter.update_bucket(bucket, response) 159 | 160 | Attributes 161 | ---------- 162 | global_lock : :class:`Lock` 163 | Separate lock for global rate limits. 164 | """ 165 | 166 | def __init__(self): 167 | self._buckets = {} 168 | self.global_lock = anyio.create_lock() 169 | 170 | @asynccontextmanager 171 | @async_generator 172 | async def __call__(self, bucket: Bucket): 173 | # If a global rate limit occurred, this is going to block 174 | # until the lock has been released after a cooldown. 175 | # If no global limit is exhausted, the lock will be 176 | # released immediately. 177 | async with self.global_lock: 178 | pass 179 | 180 | try: 181 | if await self.cooldown_bucket(bucket) > 0: 182 | logger.debug('Bucket %s cooled down', bucket) 183 | 184 | await yield_(self) 185 | finally: 186 | pass 187 | 188 | @property 189 | def buckets(self) -> dict: 190 | """The buckets this instance holds.""" 191 | 192 | return self._buckets 193 | 194 | async def cooldown_bucket(self, bucket: Bucket) -> float: 195 | """Cools down a given bucket. 196 | 197 | If no rate limit is exhausted, this returns immediately. 198 | 199 | .. note:: 200 | 201 | This acquires the lock the bucket holds. 202 | 203 | Parameters 204 | ---------- 205 | bucket : Union[Tuple[str, str], str] 206 | The bucket to cool down. 207 | 208 | Returns 209 | ------- 210 | float 211 | The duration this bucket has been cooled down for. 212 | """ 213 | 214 | if bucket in self._buckets: 215 | async with self._buckets[bucket].lock: 216 | if self._buckets[bucket].will_rate_limit: 217 | return await self._buckets[bucket].cooldown() 218 | 219 | return 0.0 220 | 221 | async def update_bucket(self, bucket: Bucket, response: Response): 222 | """Updates a bucket by a given response. 223 | 224 | .. note:: 225 | 226 | This also checks for global rate limits 227 | and handles them if necessary. 228 | 229 | Parameters 230 | ---------- 231 | bucket : Union[Tuple[str, str], str] 232 | The bucket to update. 233 | response : :class:`Response` 234 | The response object to extract rate limit headers from. 235 | """ 236 | 237 | if 'X-RateLimit-Global' in response.headers: 238 | async with self.global_lock: 239 | await anyio.sleep( 240 | int(response.headers.get('Retry-After')) / 1000.0 241 | ) 242 | 243 | if bucket in self._buckets: 244 | self._buckets[bucket].update(response) 245 | else: 246 | self._buckets[bucket] = CooldownBucket(bucket, response) 247 | -------------------------------------------------------------------------------- /clamor/rest/routes.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from enum import Enum 4 | from typing import NewType, Tuple 5 | 6 | __all__ = ( 7 | 'Method', 8 | 'Routes', 9 | 'APIRoute', 10 | ) 11 | 12 | 13 | class Method(Enum): 14 | """Enum denoting valid HTTP methods for requests to the Discord API.""" 15 | 16 | #: GET method. 17 | GET = 'GET' 18 | #: POST method. 19 | POST = 'POST' 20 | #: PUT method. 21 | PUT = 'PUT' 22 | #: PATCH method. 23 | PATCH = 'PATCH' 24 | #: DELETE method. 25 | DELETE = 'DELETE' 26 | 27 | 28 | class Routes: 29 | """This acts as a namespace for API routes. 30 | 31 | Routes are denoted as tuples where the first index 32 | is a :class:`~clamor.rest.routes.Method` member and 33 | the second index a string denoting the actual endpoint. 34 | """ 35 | 36 | # Guild 37 | GUILD = '/guilds' 38 | CREATE_GUILD = (Method.POST, GUILD) 39 | GET_GUILD = (Method.GET, GUILD + '/{guild}') 40 | MODIFY_GUILD = (Method.PATCH, GUILD + '/{guild}') 41 | DELETE_GUILD = (Method.DELETE, GUILD + '/{guild}') 42 | GET_GUILD_CHANNELS = (Method.GET, GUILD + '/{guild}/channels') 43 | CREATE_GUILD_CHANNEL = (Method.POST, GUILD + '/{guild}/channels') 44 | MODIFY_GUILD_CHANNEL_POSITIONS = (Method.PATCH, '/{guild}/channels') 45 | GET_GUILD_MEMBER = (Method.GET, GUILD + '/{guild}/members/{member}') 46 | LIST_GUILD_MEMBERS = (Method.GET, GUILD + '/{guild}/members') 47 | ADD_GUILD_MEMBER = (Method.PUT, GUILD + '/{guild}/members/{member}') 48 | MODIFY_GUILD_MEMBER = (Method.PATCH, GUILD + '/{guild}/members/{member}') 49 | MODIFY_CURRENT_USER_NICK = (Method.PATCH, GUILD + '/{guild}/members/@me/nick') 50 | ADD_GUILD_MEMBER_ROLE = (Method.PUT, GUILD + '/{guild}/members/{member}/roles/{role}') 51 | REMOVE_GUILD_MEMBER_ROLE = (Method.DELETE, GUILD + '/{guild}/members/{member}/roles/{role}') 52 | REMOVE_GUILD_MEMBER = (Method.DELETE, GUILD + '/{guild}/members/{member}') 53 | GET_GUILD_BANS = (Method.GET, GUILD + '/{guild}/bans') 54 | GET_GUILD_BAN = (Method.GET, GUILD + '/{guild}/bans/{user}') 55 | CREATE_GUILD_BAN = (Method.PUT, GUILD + '/{guild}/bans/{user}') 56 | REMOVE_GUILD_BAN = (Method.DELETE, GUILD + '/{guild}/bans/{user}') 57 | GET_GUILD_ROLES = (Method.GET, GUILD + '/{guild}/roles') 58 | CREATE_GUILD_ROLE = (Method.POST, GUILD + '/{guild}/roles') 59 | MODIFY_GUILD_ROLE_POSITIONS = (Method.PATCH, GUILD + '/{guild}/roles') 60 | MODIFY_GUILD_ROLE = (Method.PATCH, GUILD + '/{guild}/roles/{role}') 61 | DELETE_GUILD_ROLE = (Method.DELETE, GUILD + '/{guild}/roles/{role}') 62 | GET_GUILD_PRUNE_COUNT = (Method.GET, GUILD + '/{guild}/prune') 63 | BEGIN_GUILD_PRUNE = (Method.POST, GUILD + '/{guild}/prune') 64 | GET_GUILD_VOICE_REGIONS = (Method.GET, GUILD + '/{guild}/regions') 65 | GET_GUILD_INVITES = (Method.GET, GUILD + '/{guild}/invites') 66 | GET_GUILD_INTEGRATIONS = (Method.GET, GUILD + '/{guild}/integrations') 67 | CREATE_GUILD_INTEGRATION = (Method.POST, GUILD + '/{guild}/integrations') 68 | MODIFY_GUILD_INTEGRATION = (Method.PATCH, GUILD + '/{guild}/integrations/{integration}') 69 | DELETE_GUILD_INTEGRATION = (Method.DELETE, GUILD + '/{guild}/integrations/{integration}') 70 | SYNC_GUILD_INTEGRATION = (Method.POST, GUILD + '/{guild}/integrations/{integration}/sync') 71 | GET_GUILD_EMBED = (Method.GET, GUILD + '/{guild}/embed') 72 | MODIFY_GUILD_EMBED = (Method.PATCH, GUILD + '/{guild}/embed') 73 | GET_GUILD_VANITY_URL = (Method.GET, GUILD + '/{guild}/vanity-url') 74 | GET_GUILD_WIDGET_IMAGE = (Method.GET, GUILD + '/{guild}/widget.png') 75 | 76 | # Channel 77 | CHANNEL = '/channels/{channel}' 78 | GET_CHANNEL = (Method.GET, CHANNEL) 79 | MODIFY_CHANNEL = (Method.PATCH, CHANNEL) 80 | DELETE_CHANNEL = (Method.DELETE, CHANNEL) 81 | GET_CHANNEL_MESSAGES = (Method.GET, CHANNEL + '/messages') 82 | GET_CHANNEL_MESSAGE = (Method.GET, CHANNEL + '/messages/{message}') 83 | CREATE_MESSAGE = (Method.POST, CHANNEL + '/messages') 84 | CREATE_REACTION = (Method.PUT, CHANNEL + '/messages/{message}/reactions/{emoji}/@me') 85 | DELETE_OWN_REACTION = (Method.DELETE, CHANNEL + '/messages/{message}/reactions/{emoji}/@me') 86 | DELETE_USER_REACTION = (Method.DELETE, CHANNEL + '/messages/{message}/reactions/{emoji}/{user}') # noqa 87 | GET_REACTIONS = (Method.GET, CHANNEL + '/messages/{message}/reactions/{emoji}') 88 | DELETE_ALL_REACTIONS = (Method.DELETE, CHANNEL + '/messages/{message}/reactions') 89 | EDIT_MESSAGE = (Method.PATCH, CHANNEL + '/messages/{message}') 90 | DELETE_MESSAGE = (Method.DELETE, CHANNEL + '/messages/{message}') 91 | BULK_DELETE_MESSAGES = (Method.POST, CHANNEL + '/messages/bulk-delete') 92 | EDIT_CHANNEL_PERMISSIONS = (Method.PUT, CHANNEL + '/permissions/{permission}') 93 | GET_CHANNEL_INVITES = (Method.GET, CHANNEL + '/invites') 94 | CREATE_CHANNEL_INVITE = (Method.POST, CHANNEL + '/invites') 95 | DELETE_CHANNEL_PERMISSION = (Method.DELETE, CHANNEL + '/permissions/{permission}') 96 | TRIGGER_TYPING_INDICATOR = (Method.POST, CHANNEL + '/typing') 97 | GET_PINNED_MESSAGES = (Method.GET, CHANNEL + '/pins') 98 | ADD_PINNED_CHANNEL_MESSAGE = (Method.PUT, CHANNEL + '/pins/{message}') 99 | DELETE_PINNED_CHANNEL_MESSAGE = (Method.DELETE, CHANNEL + '/pins/{message}') 100 | GROUP_DM_ADD_RECIPIENT = (Method.PUT, CHANNEL + '/recipients/{user}') 101 | GROUP_DM_REMOVE_RECIPIENT = (Method.DELETE, CHANNEL + '/recipients/{user}') 102 | 103 | # Audit Log 104 | GET_GUILD_AUDIT_LOG = (Method.GET, GUILD + '/{guild}/audit-logs') 105 | 106 | # Emoji 107 | EMOJI = '/emojis' # noqa 108 | LIST_GUILD_EMOJIS = (Method.GET, GUILD + '/{guild}' + EMOJI) 109 | GET_GUILD_EMOJI = (Method.GET, GUILD + '/{guild}' + EMOJI + '/{emoji}') 110 | CREATE_GUILD_EMOJI = (Method.POST, GUILD + '/{guild}' + EMOJI) 111 | MODIFY_GUILD_EMOJI = (Method.PATCH, GUILD + '/{guild}' + EMOJI + '/{emoji}') 112 | DELETE_GUILD_EMOJI = (Method.DELETE, GUILD + '/{guild}' + EMOJI + '/{emoji}') 113 | 114 | # Invite 115 | INVITE = '/invites/{invite}' 116 | GET_INVITE = (Method.GET, INVITE) 117 | DELETE_INVITE = (Method.DELETE, INVITE) 118 | 119 | # User 120 | USER = '/users' 121 | GET_CURRENT_USER = (Method.GET, USER + '/@me') 122 | GET_USER = (Method.GET, USER + '/{user}') 123 | MODIFY_CURRENT_USER = (Method.PATCH, USER + '/@me') 124 | GET_CURRENT_USER_GUILDS = (Method.GET, USER + '/@me/guilds') 125 | LEAVE_GUILD = (Method.DELETE, USER + '/@me/guilds/{guild}') 126 | GET_USER_DMS = (Method.GET, USER + '/@me/channels') 127 | CREATE_DM = (Method.POST, USER + '/@me/channels') 128 | CREATE_GROUP_DM = (Method.POST, USER + '/@me/channels') 129 | GET_USER_CONNECTIONS = (Method.GET, USER + '/@me/connections') 130 | 131 | # Voice 132 | VOICE = '/voice/regions' 133 | LIST_VOICE_REGIONS = (Method.GET, VOICE) 134 | 135 | # Webhook 136 | WEBHOOK = '/webhooks' 137 | CREATE_WEBHOOK = (Method.POST, CHANNEL + WEBHOOK) 138 | GET_CHANNEL_WEBHOOKS = (Method.GET, CHANNEL + WEBHOOK) 139 | GET_GUILD_WEBHOOKS = (Method.GET, GUILD + '/{guild}' + WEBHOOK) 140 | GET_WEBHOOK = (Method.GET, WEBHOOK + '/{webhook}') # noqa 141 | GET_WEBHOOK_WITH_TOKEN = (Method.GET, WEBHOOK + '/{webhook}/{token}') 142 | MODIFY_WEBHOOK = (Method.PATCH, WEBHOOK + '/{webhook}') 143 | MODIFY_WEBHOOK_WITH_TOKEN = (Method.PATCH, WEBHOOK + '/{webhook}/{token}') 144 | DELETE_WEBHOOK = (Method.DELETE, WEBHOOK + '/{webhook}') 145 | DELETE_WEBHOOK_WITH_TOKEN = (Method.DELETE, WEBHOOK + '/{webhook}/{token}') 146 | EXECUTE_WEBHOOK = (Method.POST, WEBHOOK + '/{webhook}/{token}') 147 | EXECUTE_SLACK_COMPATIBLE_WEBHOOK = (Method.POST, WEBHOOK + '/{webhook}/{token}/slack') 148 | EXECUTE_GITHUB_COMPATIBLE_WEBHOOK = (Method.POST, WEBHOOK + '/{webhook}/{token}/github') 149 | 150 | # OAuth2 151 | OAUTH = '/oauth2/applications' 152 | GET_CURRENT_APPLICATION_INFO = (Method.GET, OAUTH + '/@me') 153 | 154 | # Gateway 155 | GATEWAY = '/gateway' 156 | GET_GATEWAY = (Method.GET, GATEWAY) 157 | GET_GATEWAY_BOT = (Method.GET, GATEWAY + '/bot') 158 | 159 | 160 | #: A type to denote Discord API routes. 161 | APIRoute = NewType('Route', Tuple[Method, str]) 162 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line, and also 5 | # from the environment for the first two. 6 | SPHINXOPTS ?= 7 | SPHINXBUILD ?= sphinx-build 8 | SOURCEDIR = source 9 | BUILDDIR = _build 10 | 11 | # Put it first so that "make" without argument is like "make help". 12 | help: 13 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 14 | 15 | .PHONY: help Makefile 16 | 17 | # Catch-all target: route all unknown targets to Sphinx using the new 18 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 19 | %: Makefile 20 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 21 | -------------------------------------------------------------------------------- /docs/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | pushd %~dp0 4 | 5 | REM Command file for Sphinx documentation 6 | 7 | if "%SPHINXBUILD%" == "" ( 8 | set SPHINXBUILD=sphinx-build 9 | ) 10 | set SOURCEDIR=source 11 | set BUILDDIR=_build 12 | 13 | if "%1" == "" goto help 14 | 15 | %SPHINXBUILD% >NUL 2>NUL 16 | if errorlevel 9009 ( 17 | echo. 18 | echo.The 'sphinx-build' command was not found. Make sure you have Sphinx 19 | echo.installed, then set the SPHINXBUILD environment variable to point 20 | echo.to the full path of the 'sphinx-build' executable. Alternatively you 21 | echo.may add the Sphinx directory to PATH. 22 | echo. 23 | echo.If you don't have Sphinx installed, grab it from 24 | echo.http://sphinx-doc.org/ 25 | exit /b 1 26 | ) 27 | 28 | %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 29 | goto end 30 | 31 | :help 32 | %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 33 | 34 | :end 35 | popd 36 | -------------------------------------------------------------------------------- /docs/source/_static/banner.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/clamor-py/Clamor/13222b90532938e6ebdbe8aea0430512e7d22817/docs/source/_static/banner.png -------------------------------------------------------------------------------- /docs/source/_static/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/clamor-py/Clamor/13222b90532938e6ebdbe8aea0430512e7d22817/docs/source/_static/favicon.ico -------------------------------------------------------------------------------- /docs/source/_static/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/clamor-py/Clamor/13222b90532938e6ebdbe8aea0430512e7d22817/docs/source/_static/logo.png -------------------------------------------------------------------------------- /docs/source/_static/style.css: -------------------------------------------------------------------------------- 1 | @import url('https://fonts.googleapis.com/css?family=Roboto|Source+Sans+Pro'); 2 | 3 | body, 4 | div.body h1, div.body h2, div.body h3, div.body h4, div.body h5, div.body h6, 5 | div.sphinxsidebar h3, div.sphinxsidebar h4, div.sphinxsidebar input { 6 | font-family: 'Source Sans Pro', 'Roboto', sans-serif; 7 | } 8 | 9 | dl.describe > dt, 10 | dl.function > dt, 11 | dl.attribute > dt, 12 | dl.classmethod > dt, 13 | dl.method > dt, 14 | dl.class > dt, 15 | dl.exception > dt { 16 | background-color: #a7a7a7; 17 | padding: 2px 10px; 18 | margin: 5px auto; 19 | } 20 | -------------------------------------------------------------------------------- /docs/source/_templates/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/clamor-py/Clamor/13222b90532938e6ebdbe8aea0430512e7d22817/docs/source/_templates/.gitkeep -------------------------------------------------------------------------------- /docs/source/conf.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | # 4 | # Configuration file for the Sphinx documentation builder. 5 | # 6 | # This file only contains a selection of the most common options. For a full 7 | # list see the documentation: 8 | # http://www.sphinx-doc.org/en/master/config 9 | 10 | # -- Path setup -------------------------------------------------------------- 11 | 12 | # If extensions (or modules to document with autodoc) are in another directory, 13 | # add these directories to sys.path here. If the directory is relative to the 14 | # documentation root, use os.path.abspath to make it absolute, like shown here. 15 | # 16 | import os 17 | import re 18 | import sys 19 | 20 | sys.path.insert(0, os.path.abspath('../..')) 21 | 22 | 23 | # -- Project information ----------------------------------------------------- 24 | 25 | project = 'Clamor' 26 | copyright = '2019, Valentin B' 27 | author = 'Valentin B.' 28 | 29 | # The short X.Y version 30 | with open('../../clamor/meta.py', encoding='utf-8') as f: 31 | version = re.search(r'^__version__\s*=\s*[\'"]([^\'"]*)[\'"]', f.read(), re.MULTILINE).group(1) 32 | # The full version, including alpha/beta/rc tags 33 | release = version 34 | 35 | html_favicon = '_static/favicon.ico' 36 | html_logo = '_static/logo.png' 37 | 38 | 39 | def setup(app): 40 | app.add_stylesheet('style.css') 41 | 42 | 43 | # -- General configuration --------------------------------------------------- 44 | 45 | # If your documentation needs a minimal Sphinx version, state it here. 46 | # 47 | needs_sphinx = '1.7.0' 48 | 49 | # Add any Sphinx extension module names here, as strings. They can be 50 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 51 | # ones. 52 | extensions = [ 53 | 'sphinx.ext.autodoc', 54 | 'sphinx.ext.extlinks', 55 | 'sphinx.ext.ifconfig', 56 | 'sphinx.ext.intersphinx', 57 | 'sphinx.ext.napoleon', 58 | 'sphinx.ext.todo', 59 | 'sphinx.ext.viewcode', 60 | 'sphinx_autodoc_typehints', 61 | 'sphinxcontrib_trio', 62 | ] 63 | 64 | # Add any paths that contain templates here, relative to this directory. 65 | templates_path = ['_templates'] 66 | 67 | # The suffix(es) of source filenames. 68 | # You can specify multiple suffix as a list of string: 69 | # 70 | # source_suffix = ['.rst', '.md'] 71 | source_suffix = '.rst' 72 | 73 | # The master toctree document. 74 | master_doc = 'index' 75 | 76 | # The language for content autogenerated by Sphinx. Refer to documentation 77 | # for a list of supported languages. 78 | # 79 | # This is also used if you do content translation via gettext catalogs. 80 | # Usually you set "language" from the command line for these cases. 81 | language = None 82 | 83 | # Support for documentation in multiple languages. 84 | locale_dirs = ['locale/'] 85 | gettext_compact = False 86 | 87 | # List of patterns, relative to source directory, that match files and 88 | # directories to ignore when looking for source files. 89 | # This pattern also affects html_static_path and html_extra_path. 90 | exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store'] 91 | 92 | # The name of the Pygments (syntax highlighting) style to use. 93 | pygments_style = 'friendly' 94 | 95 | # The language to use for syntax highlighting. 96 | highlight_language = 'python3' 97 | 98 | 99 | # -- Options for HTML output ------------------------------------------------- 100 | 101 | html_experimental_html5_writer = True 102 | 103 | # The theme to use for HTML and HTML Help pages. See the documentation for 104 | # a list of builtin themes. 105 | # 106 | html_theme = 'alabaster' 107 | 108 | # Add any paths that contain custom static files (such as style sheets) here, 109 | # relative to this directory. They are copied after the builtin static files, 110 | # so a file named "default.css" will overwrite the builtin "default.css". 111 | html_static_path = ['_static'] 112 | 113 | 114 | # -- Options for HTMLHelp output --------------------------------------------- 115 | 116 | # Output file base name for HTML help builder. 117 | htmlhelp_basename = 'Clamordoc' 118 | 119 | 120 | # -- Options for LaTeX output ------------------------------------------------ 121 | 122 | latex_elements = { 123 | # The paper size ('letterpaper' or 'a4paper'). 124 | # 125 | # 'papersize': 'letterpaper', 126 | 127 | # The font size ('10pt', '11pt' or '12pt'). 128 | # 129 | # 'pointsize': '10pt', 130 | 131 | # Additional stuff for the LaTeX preamble. 132 | # 133 | # 'preamble': '', 134 | 135 | # Latex figure (float) alignment 136 | # 137 | # 'figure_align': 'htbp', 138 | } 139 | 140 | # Grouping the document tree into LaTeX files. List of tuples 141 | # (source start file, target name, title, 142 | # author, documentclass [howto, manual, or own class]). 143 | latex_documents = [ 144 | (master_doc, 'Clamor.tex', 'Clamor Documentation', 145 | 'Valentin B.', 'manual'), 146 | ] 147 | 148 | 149 | # -- Options for manual page output ------------------------------------------ 150 | 151 | # One entry per manual page. List of tuples 152 | # (source start file, name, description, authors, manual section). 153 | man_pages = [ 154 | (master_doc, 'Clamor', u'Clamor Documentation', 155 | [author], 1) 156 | ] 157 | 158 | 159 | # -- Options for Texinfo output ---------------------------------------------- 160 | 161 | # Grouping the document tree into Texinfo files. List of tuples 162 | # (source start file, target name, title, author, 163 | # dir menu entry, description, category) 164 | texinfo_documents = [ 165 | (master_doc, 'Clamor', 'Clamor Documentation', 166 | author, 'Clamor', 'The Python Discord API Framework', 167 | 'Miscellaneous'), 168 | ] 169 | 170 | 171 | # -- Options for Epub output ------------------------------------------------- 172 | 173 | # Bibliographic Dublin Core info. 174 | epub_title = project 175 | 176 | # The unique identifier of the text. This can be a ISBN number 177 | # or the project homepage. 178 | # 179 | # epub_identifier = '' 180 | 181 | # A unique identification for the text. 182 | # 183 | # epub_uid = '' 184 | 185 | # A list of files that should not be packed into the epub file. 186 | epub_exclude_files = ['search.html'] 187 | 188 | 189 | # -- Extension configuration ------------------------------------------------- 190 | 191 | # sphinx.ext.autodoc 192 | autodoc_inherit_docstrings = False 193 | autodoc_member_order = 'bysource' 194 | autosummary_generate = True 195 | 196 | # sphinx.ext.extlinks 197 | extlinks = { 198 | 'issue': ('https://github.com/clamor-py/Clamor/issues/%s', 'Issue '), 199 | } 200 | 201 | # sphinx.ext.intersphinx 202 | intersphinx_mapping = { 203 | 'python': ('https://docs.python.org/3', None), 204 | 'anyio': ('https://anyio.readthedocs.io/en/latest', None), 205 | 'anysocks': ('https://anysocks.readthedocs.io/en/latest', None), 206 | 'asks': ('https://asks.readthedocs.io/en/latest', None), 207 | } 208 | 209 | # sphinx.ext.napoleon 210 | napoleon_numpy_docstring = True 211 | 212 | # sphinx.ext.todo 213 | todo_include_todos = True 214 | -------------------------------------------------------------------------------- /docs/source/index.rst: -------------------------------------------------------------------------------- 1 | .. Clamor documentation master file, created by 2 | sphinx-quickstart on Sat Jul 6 18:02:20 2019. 3 | You can adapt this file completely to your liking, but it should at least 4 | contain the root `toctree` directive. 5 | 6 | Welcome to Clamor's documentation! 7 | ================================== 8 | 9 | .. image:: /_static/banner.png 10 | :align: center 11 | :alt: Clamor is a Python framework for the Discord API. 12 | 13 | Clamor is easy to use, simple and expressive. `anyio `_ for underlying async I/O 14 | makes the library compatible to `asyncio `_, 15 | `trio `_ and 16 | `curio `_ backends and is therefore really flexible. 17 | 18 | .. toctree:: 19 | :maxdepth: 2 20 | :caption: Table of Contents: 21 | 22 | 23 | 24 | Indices and tables 25 | ================== 26 | 27 | * :ref:`genindex` 28 | * :ref:`modindex` 29 | * :ref:`search` 30 | -------------------------------------------------------------------------------- /pylama.ini: -------------------------------------------------------------------------------- 1 | [pylama] 2 | skip = */.tox/*,*/.env/*,venv/* 3 | ignore = E731,W0401,W0611 4 | 5 | [pylama:pycodestyle] 6 | max_line_length = 100 7 | 8 | [pylama:pylint] 9 | max_line_length = 100 10 | disable = R 11 | 12 | [pylama:pyflakes] 13 | builtins = _ 14 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | # Compatibility layer for asyncio, trio and curio; Required for underlying I/O 2 | anyio==1.0.0 3 | 4 | ## HTTP library based on anyio; Required for requests to the Discord REST API 5 | asks>=2.3.5 6 | 7 | # WebSocket library based on anyio; Required for connections to the Discord gateways 8 | anysocks>=0.1.2 9 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | 4 | import re 5 | import sys 6 | from pathlib import Path 7 | from setuptools import find_packages, setup 8 | 9 | ROOT = Path(__file__).parent 10 | 11 | if sys.version_info < (3, 5): 12 | raise SystemExit('Clamor requires Python 3.5+, consider upgrading.') 13 | 14 | with open(str(ROOT / 'clamor' / 'meta.py'), encoding='utf-8') as f: 15 | VERSION = re.search(r'^__version__\s*=\s*[\'"]([^\'"]*)[\'"]', f.read(), re.MULTILINE).group(1) 16 | 17 | with open(str(ROOT / 'README.rst'), encoding='utf-8') as f: 18 | README = f.read() 19 | 20 | with open(str(ROOT / 'requirements.txt'), encoding='utf-8') as f: 21 | REQUIREMENTS = f.read().splitlines() 22 | 23 | EXTRAS_REQUIRE = {} 24 | 25 | 26 | setup( 27 | name='clamor', 28 | author='Valentin B.', 29 | author_email='valentin.be@protonmail.com', 30 | url='https://github.com/clamor-py/Clamor', 31 | license='MIT', 32 | description='The Python Discord API Framework', 33 | long_description=README, 34 | long_description_content_type='text/x-rst', 35 | project_urls={ 36 | 'Documentation': 'https://clamor.readthedocs.io/en/latest', 37 | 'Source': 'https://github.com/clamor-py/Clamor', 38 | 'Issue tracker': 'https://github.com/clamor-py/Clamor/issues' 39 | }, 40 | version=VERSION, 41 | packages=find_packages(), 42 | include_package_data=True, 43 | install_requires=REQUIREMENTS, 44 | extras_require=EXTRAS_REQUIRE, 45 | python_requires='>=3.5.0', 46 | keywords='discord discord-api rest-api api wrapper websocket api-client library framework', 47 | classifiers=[ 48 | 'Development Status :: 4 - Beta', 49 | 'License :: OSI Approved :: MIT License', 50 | 'Intended Audience :: Developers', 51 | 'Natural Language :: English', 52 | 'Operating System :: OS Independent', 53 | 'Programming Language :: Python :: 3.5', 54 | 'Programming Language :: Python :: 3.6', 55 | 'Programming Language :: Python :: 3.7', 56 | 'Programming Language :: Python :: Implementation :: CPython', 57 | 'Topic :: Software Development :: Libraries', 58 | 'Topic :: Software Development :: Libraries :: Application Frameworks', 59 | 'Topic :: Software Development :: Libraries :: Python Modules', 60 | 'Topic :: Utilities', 61 | ], 62 | test_suite='tests.suite', 63 | ) 64 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | import os 4 | import sys 5 | import unittest.runner 6 | 7 | _dir = os.path.dirname(__file__) 8 | 9 | 10 | def suite(): 11 | test_loader = unittest.TestLoader() 12 | test_suite = test_loader.discover(_dir, 'test_*.py') 13 | 14 | return test_suite 15 | 16 | 17 | if __name__ == '__main__': 18 | runner = unittest.TextTestRunner() 19 | result = runner.run(suite()) 20 | 21 | sys.exit(not result.wasSuccessful()) 22 | -------------------------------------------------------------------------------- /tests/test_rest_http.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | import sys 4 | import unittest 5 | 6 | import anyio 7 | 8 | from clamor import __url__, __version__, HTTP, Routes 9 | 10 | 11 | class HTTPTests(unittest.TestCase): 12 | def test_user_agent(self): 13 | async def main(): 14 | http = HTTP('secret') 15 | self.assertIsInstance(http, HTTP) 16 | 17 | self.assertEqual( 18 | http.user_agent, 19 | 'DiscordBot ({0}, v{1}) / Python {2[0]}.{2[1]}.{2[2]}'.format( 20 | __url__, __version__, sys.version_info) 21 | ) 22 | 23 | anyio.run(main) 24 | 25 | def test_authorization_header(self): 26 | async def main(): 27 | http = HTTP('secret') 28 | self.assertIsInstance(http, HTTP) 29 | 30 | self.assertEqual(http.headers['Authorization'], 31 | 'Bot {}'.format(http.token)) 32 | 33 | anyio.run(main) 34 | 35 | def test_route(self): 36 | route = Routes.CREATE_MESSAGE 37 | self.assertIsInstance(route[0].value, str) 38 | self.assertEqual(route[0].value.lower(), 'post') 39 | self.assertIsInstance(route[1], str) 40 | 41 | def test_http_request(self): 42 | async def main(): 43 | http = HTTP('secret') 44 | self.assertIsInstance(http, HTTP) 45 | 46 | resp = await http.make_request(Routes.GET_GATEWAY) 47 | self.assertIsInstance(resp, dict) 48 | self.assertEqual(resp['url'], 'wss://gateway.discord.gg') 49 | 50 | await http.close() 51 | 52 | anyio.run(main) 53 | -------------------------------------------------------------------------------- /tests/test_rest_rate_limit.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | import unittest 4 | from datetime import datetime, timezone 5 | from email.utils import format_datetime 6 | from random import randint 7 | 8 | import anyio 9 | 10 | from clamor import RateLimiter 11 | 12 | 13 | class RateLimitTests(unittest.TestCase): 14 | def test_rate_limiter(self): 15 | async def main(): 16 | limiter = RateLimiter() 17 | self.assertIsInstance(limiter, RateLimiter) 18 | 19 | # Hack a fake response object. 20 | response = object() 21 | response.headers = { 22 | 'Date': format_datetime(datetime.now(timezone.utc)), 23 | 'X-RateLimit-Remaining': randint(1, 10), 24 | # 2 minutes in the future. 25 | 'X-RateLimit-Reset': datetime.now(timezone.utc).timestamp() + (2 * 60), 26 | } 27 | 28 | bucket = ('POST', '/random') 29 | 30 | while True: 31 | if await limiter.cooldown_bucket(bucket) > 0: 32 | break 33 | 34 | # The loop is supposed to be interrupted 35 | # before this is no longer true. 36 | self.assertGreaterEqual(limiter.buckets[bucket]._remaining, 0) 37 | 38 | # Update headers 39 | response.headers['Date'] = format_datetime(datetime.now(timezone.utc)) 40 | response.headers['X-RateLimit-Remaining'] -= 1 41 | 42 | # Update the limiter 43 | await limiter.update_bucket(bucket, response) 44 | 45 | anyio.run(main) 46 | --------------------------------------------------------------------------------