├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.yml │ ├── config.yml │ └── feature_request.yml ├── PULL_REQUEST_TEMPLATE.md └── workflows │ ├── bandit.yml │ ├── codeql-analysis.yml │ ├── lint.yml │ └── pyright.yml ├── .gitignore ├── .readthedocs.yml ├── CODE_OF_CONDUCT.md ├── LICENSE ├── README.rst ├── assets ├── ss.jpg ├── ss.png └── voltage.png ├── docs ├── Makefile ├── make.bat └── source │ ├── _static │ └── style.css │ ├── api.rst │ ├── assets │ ├── bot_thingy.png │ ├── bot_username_prompt.png │ ├── create_bots_button.png │ ├── logo.png │ ├── my_bots_button.png │ ├── revolt_homepage.png │ └── setting_menue.png │ ├── conf.py │ ├── extensions │ └── attributetable.py │ ├── getting_started │ ├── creating_a_bot.rst │ ├── index.rst │ ├── installation.rst │ ├── starting_the_bot.rst │ ├── using_the_commands_framework.rst │ └── using_the_library.rst │ ├── index.rst │ └── requirements.txt ├── examples ├── cogs.py ├── commands.py ├── embed.py ├── message_checker.py ├── ping_bot.py └── wait_for.py ├── requirements.txt ├── setup.py └── voltage ├── __init__.py ├── asset.py ├── categories.py ├── channels.py ├── client.py ├── embed.py ├── enums.py ├── errors.py ├── ext └── commands │ ├── __init__.py │ ├── check.py │ ├── client.py │ ├── cog.py │ ├── command.py │ ├── converters.py │ └── help.py ├── file.py ├── flag.py ├── internals ├── __init__.py ├── cache.py ├── http.py └── ws.py ├── invites.py ├── member.py ├── message.py ├── messageable.py ├── notsupplied.py ├── permissions.py ├── py.typed ├── roles.py ├── server.py ├── types ├── __init__.py ├── channel.py ├── embed.py ├── file.py ├── http.py ├── message.py ├── server.py ├── user.py └── ws.py ├── user.py └── utils.py /.github/ISSUE_TEMPLATE/bug_report.yml: -------------------------------------------------------------------------------- 1 | name: Bug Report 2 | description: Report broken or incorrect behaviour 3 | labels: unconfirmed bug 4 | body: 5 | - type: markdown 6 | attributes: 7 | value: > 8 | Thanks for taking the time to fill out a bug. 9 | If you want real-time support, consider joining our [Revolt server](https://rvlt.gg/bwtscg1F) instead. 10 | 11 | Please note that this form is for bugs only! 12 | - type: input 13 | attributes: 14 | label: Summary 15 | description: A simple summary of your bug report 16 | validations: 17 | required: true 18 | - type: textarea 19 | attributes: 20 | label: Reproduction Steps 21 | description: > 22 | What you did to make it happen. 23 | validations: 24 | required: true 25 | - type: textarea 26 | attributes: 27 | label: Minimal Reproducible Code 28 | description: > 29 | A short snippet of code that showcases the bug. 30 | render: python 31 | - type: textarea 32 | attributes: 33 | label: Expected Results 34 | description: > 35 | What did you expect to happen? 36 | validations: 37 | required: true 38 | - type: textarea 39 | attributes: 40 | label: Actual Results 41 | description: > 42 | What actually happened? 43 | validations: 44 | required: true 45 | - type: checkboxes 46 | attributes: 47 | label: Checklist 48 | description: > 49 | Let's make sure you've properly done due diligence when reporting this issue! 50 | options: 51 | - label: I have searched the open issues for duplicates. 52 | required: true 53 | - label: I have shown the entire traceback, if possible. 54 | required: true 55 | - label: I have removed my token from display, if visible. 56 | required: true 57 | - type: textarea 58 | attributes: 59 | label: Additional Context 60 | description: If there is anything else to say, please do so here. 61 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | contact_links: 2 | - name: Ask a question 3 | about: Ask questions and discuss with other users of the library. 4 | url: https://github.com/EnokiUN/pyvolt/discussions 5 | - name: Revolt Server 6 | about: Use our official Revolt server to ask for help and questions as well. 7 | url: https://rvlt.gg/bwtscg1F 8 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.yml: -------------------------------------------------------------------------------- 1 | name: Feature Request 2 | description: Suggest a feature for this library 3 | labels: feature request 4 | body: 5 | - type: input 6 | attributes: 7 | label: Summary 8 | description: > 9 | A short summary of what your feature request is. 10 | validations: 11 | required: true 12 | - type: dropdown 13 | attributes: 14 | multiple: false 15 | label: What is the feature request for? 16 | options: 17 | - The core library 18 | - The documentation 19 | - Utilities 20 | validations: 21 | required: true 22 | - type: textarea 23 | attributes: 24 | label: The Problem 25 | description: > 26 | What problem is your feature trying to solve? 27 | What becomes easier or possible when this feature is implemented? 28 | validations: 29 | required: true 30 | - type: textarea 31 | attributes: 32 | label: The Ideal Solution 33 | description: > 34 | What is your ideal solution to the problem? 35 | What would you like this feature to do? 36 | validations: 37 | required: true 38 | - type: textarea 39 | attributes: 40 | label: The Current Solution 41 | description: > 42 | What is the current solution to the problem, if any? 43 | validations: 44 | required: false 45 | - type: textarea 46 | attributes: 47 | label: Additional Context 48 | description: If there is anything else to say, please do so here. 49 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ## Summary 2 | 3 | 4 | 5 | ## Checklist 6 | 7 | 8 | 9 | - [ ] If code changes were made then they have been tested. 10 | - [ ] I have updated the documentation to reflect the changes. 11 | - [ ] This PR fixes an issue. 12 | - [ ] This PR adds something new (e.g. new method or parameters). 13 | - [ ] This PR is a breaking change (e.g. methods or parameters removed/renamed) 14 | - [ ] This PR is **not** a code change (e.g. documentation, README, …) 15 | -------------------------------------------------------------------------------- /.github/workflows/bandit.yml: -------------------------------------------------------------------------------- 1 | name: bandit 2 | on: [pull_request, push] 3 | jobs: 4 | bandit: 5 | if: github.event.pull_request.user.type != 'Bot' && !contains(github.event.pull_request.labels.*.name, 'skip-ci') 6 | runs-on: ubuntu-latest 7 | steps: 8 | - uses: actions/checkout@v2 9 | - uses: actions/setup-python@v2 10 | - run: pip install bandit 11 | - run: bandit --recursive ./voltage 12 | -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | name: "CodeQL" 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | pull_request: 7 | branches: [ main ] 8 | 9 | jobs: 10 | analyze: 11 | name: Analyze 12 | runs-on: ubuntu-latest 13 | permissions: 14 | actions: read 15 | contents: read 16 | security-events: write 17 | 18 | strategy: 19 | fail-fast: false 20 | matrix: 21 | language: [ 'python' ] 22 | 23 | steps: 24 | - name: Checkout repository 25 | uses: actions/checkout@v2 26 | 27 | - name: Initialize CodeQL 28 | uses: github/codeql-action/init@v1 29 | with: 30 | languages: ${{ matrix.language }} 31 | 32 | - name: Autobuild 33 | uses: github/codeql-action/autobuild@v1 34 | 35 | - name: Perform CodeQL Analysis 36 | uses: github/codeql-action/analyze@v1 37 | -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | name: Lint 2 | 3 | on: [push] # what if it lints for something that isn't there :panik: 4 | 5 | jobs: 6 | build: 7 | runs-on: ubuntu-latest 8 | strategy: 9 | matrix: 10 | python-version: ["3.9"] 11 | steps: 12 | - uses: actions/checkout@v2 13 | - name: Set up Python ${{ matrix.python-version }} 14 | uses: actions/setup-python@v2 15 | with: 16 | python-version: ${{ matrix.python-version }} 17 | - name: Install dependencies 18 | run: | 19 | python -m pip install --upgrade pip 20 | pip install flynt black isort[requirements_deprecated_finder] 21 | - name: isort 22 | run: isort . --profile black 23 | - name: Black 24 | run: black ./ --line-length=120 25 | - name: Flynt 26 | run: flynt ./ -tc 27 | - name: Setup Git 28 | run: | 29 | git config user.name "Automated Linter" 30 | - name: Push To Git 31 | continue-on-error: true 32 | run: | 33 | git pull 34 | git add . || exit 0 35 | git commit --reuse-message=HEAD || exit 0 36 | git push || exit 0 37 | -------------------------------------------------------------------------------- /.github/workflows/pyright.yml: -------------------------------------------------------------------------------- 1 | name: pyright 2 | on: [pull_request, push] 3 | jobs: 4 | mypy: 5 | if: github.event.pull_request.user.type != 'Bot' && !contains(github.event.pull_request.labels.*.name, 'skip-ci') 6 | runs-on: ubuntu-latest 7 | steps: 8 | - uses: actions/checkout@v2 9 | - uses: actions/setup-python@v2 10 | - run: pip install pyright 11 | - run: pip install -r requirements.txt 12 | - run: pyright voltage 13 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | pip-wheel-metadata/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .nox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | *.py,cover 51 | .hypothesis/ 52 | .pytest_cache/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | target/ 76 | 77 | # Jupyter Notebook 78 | .ipynb_checkpoints 79 | 80 | # IPython 81 | profile_default/ 82 | ipython_config.py 83 | 84 | # pyenv 85 | .python-version 86 | 87 | # pipenv 88 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 89 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 90 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 91 | # install all needed dependencies. 92 | #Pipfile.lock 93 | 94 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 95 | __pypackages__/ 96 | 97 | # Celery stuff 98 | celerybeat-schedule 99 | celerybeat.pid 100 | 101 | # SageMath parsed files 102 | *.sage.py 103 | 104 | # Environments 105 | .env 106 | .venv 107 | env/ 108 | venv/ 109 | ENV/ 110 | env.bak/ 111 | venv.bak/ 112 | 113 | # Spyder project settings 114 | .spyderproject 115 | .spyproject 116 | 117 | # Rope project settings 118 | .ropeproject 119 | 120 | # mkdocs documentation 121 | /site 122 | 123 | # mypy 124 | .mypy_cache/ 125 | .dmypy.json 126 | dmypy.json 127 | 128 | # Pyre type checker 129 | .pyre/ 130 | 131 | # VSCode stuff 132 | .vscode/ 133 | 134 | # PyCharm stuff 135 | .idea/ 136 | 137 | # le tests 138 | *test* 139 | 140 | # venv 141 | .voltage/ 142 | -------------------------------------------------------------------------------- /.readthedocs.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | sphinx: 4 | configuration: docs/source/conf.py 5 | 6 | python: 7 | version: "3.8" 8 | install: 9 | - requirements: docs/source/requirements.txt 10 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | We as members, contributors, and leaders pledge to make participation in our 6 | community a harassment-free experience for everyone, regardless of age, body 7 | size, visible or invisible disability, ethnicity, sex characteristics, gender 8 | identity and expression, level of experience, education, socio-economic status, 9 | nationality, personal appearance, race, religion, or sexual identity 10 | and orientation. 11 | 12 | We pledge to act and interact in ways that contribute to an open, welcoming, 13 | diverse, inclusive, and healthy community. 14 | 15 | ## Our Standards 16 | 17 | Examples of behavior that contributes to a positive environment for our 18 | community include: 19 | 20 | * Demonstrating empathy and kindness toward other people 21 | * Being respectful of differing opinions, viewpoints, and experiences 22 | * Giving and gracefully accepting constructive feedback 23 | * Accepting responsibility and apologizing to those affected by our mistakes, 24 | and learning from the experience 25 | * Focusing on what is best not just for us as individuals, but for the 26 | overall community 27 | 28 | Examples of unacceptable behavior include: 29 | 30 | * The use of sexualized language or imagery, and sexual attention or 31 | advances of any kind 32 | * Trolling, insulting or derogatory comments, and personal or political attacks 33 | * Public or private harassment 34 | * Publishing others' private information, such as a physical or email 35 | address, without their explicit permission 36 | * Other conduct which could reasonably be considered inappropriate in a 37 | professional setting 38 | 39 | ## Enforcement Responsibilities 40 | 41 | Community leaders are responsible for clarifying and enforcing our standards of 42 | acceptable behavior and will take appropriate and fair corrective action in 43 | response to any behavior that they deem inappropriate, threatening, offensive, 44 | or harmful. 45 | 46 | Community leaders have the right and responsibility to remove, edit, or reject 47 | comments, commits, code, wiki edits, issues, and other contributions that are 48 | not aligned to this Code of Conduct, and will communicate reasons for moderation 49 | decisions when appropriate. 50 | 51 | ## Scope 52 | 53 | This Code of Conduct applies within all community spaces, and also applies when 54 | an individual is officially representing the community in public spaces. 55 | Examples of representing our community include using an official e-mail address, 56 | posting via an official social media account, or acting as an appointed 57 | representative at an online or offline event. 58 | 59 | ## Enforcement 60 | 61 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 62 | reported to the community leaders responsible for enforcement at 63 | [the Support server](https://rvlt.gg/bwtscg1F). 64 | All complaints will be reviewed and investigated promptly and fairly. 65 | 66 | All community leaders are obligated to respect the privacy and security of the 67 | reporter of any incident. 68 | 69 | ## Enforcement Guidelines 70 | 71 | Community leaders will follow these Community Impact Guidelines in determining 72 | the consequences for any action they deem in violation of this Code of Conduct: 73 | 74 | ### 1. Correction 75 | 76 | **Community Impact**: Use of inappropriate language or other behavior deemed 77 | unprofessional or unwelcome in the community. 78 | 79 | **Consequence**: A private, written warning from community leaders, providing 80 | clarity around the nature of the violation and an explanation of why the 81 | behavior was inappropriate. A public apology may be requested. 82 | 83 | ### 2. Warning 84 | 85 | **Community Impact**: A violation through a single incident or series 86 | of actions. 87 | 88 | **Consequence**: A warning with consequences for continued behavior. No 89 | interaction with the people involved, including unsolicited interaction with 90 | those enforcing the Code of Conduct, for a specified period of time. This 91 | includes avoiding interactions in community spaces as well as external channels 92 | like social media. Violating these terms may lead to a temporary or 93 | permanent ban. 94 | 95 | ### 3. Temporary Ban 96 | 97 | **Community Impact**: A serious violation of community standards, including 98 | sustained inappropriate behavior. 99 | 100 | **Consequence**: A temporary ban from any sort of interaction or public 101 | communication with the community for a specified period of time. No public or 102 | private interaction with the people involved, including unsolicited interaction 103 | with those enforcing the Code of Conduct, is allowed during this period. 104 | Violating these terms may lead to a permanent ban. 105 | 106 | ### 4. Permanent Ban 107 | 108 | **Community Impact**: Demonstrating a pattern of violation of community 109 | standards, including sustained inappropriate behavior, harassment of an 110 | individual, or aggression toward or disparagement of classes of individuals. 111 | 112 | **Consequence**: A permanent ban from any sort of public interaction within 113 | the community. 114 | 115 | ## Attribution 116 | 117 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 118 | version 2.0, available at 119 | https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. 120 | 121 | Community Impact Guidelines were inspired by [Mozilla's code of conduct 122 | enforcement ladder](https://github.com/mozilla/diversity). 123 | 124 | [homepage]: https://www.contributor-covenant.org 125 | 126 | For answers to common questions about this code of conduct, see the FAQ at 127 | https://www.contributor-covenant.org/faq. Translations are available at 128 | https://www.contributor-covenant.org/translations. 129 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022-present Enoki 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 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | ------- 2 | Voltage 3 | ------- 4 | 5 | |Support Server| |PyPi| |Docs| |Checks| 6 | 7 | A simple pythonic asynchronous API wrapper for `Revolt. `_ 8 | 9 | |Screenshot| 10 | 11 | ===== 12 | Usage 13 | ===== 14 | 15 | .. code-block:: python3 16 | 17 | import voltage # Import voltage. 18 | 19 | client = voltage.Client() # Initialize the client. 20 | 21 | 22 | @client.listen("ready") # Listen to an event. 23 | async def on_ready(): 24 | print(f"Logged in as {client.user}") 25 | 26 | 27 | @client.listen("message") 28 | async def on_message(message): # Doesn't matter what you call the function. 29 | if message.content == "-ping": 30 | await message.channel.send("pong!") # Send a message. 31 | elif message.content == "-embed": 32 | embed = voltage.SendableEmbed(title="Hello World", description="This is an embed") # Create an embed. 33 | # Reply to a message. 34 | await message.reply(embed=embed) 35 | 36 | # Run the client which is an abstraction of calling the start coroutine. 37 | client.run("TOKEN") # Replace with your token. 38 | 39 | Commands framework example: 40 | 41 | .. code-block:: python3 42 | 43 | import voltage 44 | from voltage.ext import commands # Import the commands module from ``voltage.ext`` 45 | 46 | client = commands.CommandsClient("-") # Create a CommandsClient (client that has commands (original ik)) with the prefix set to "-". 47 | 48 | @client.listen("ready") # You can still listen to events. 49 | async def ready(): 50 | print("Gaaah, It's rewind time.") 51 | 52 | @client.command() # Register a command using the ``command`` decorator. 53 | async def ping(ctx): 54 | """Sends Pong!""" # Name and description can be passed in the decorator or automatically inferred. 55 | await ctx.reply("Pong") # Reply to the context's message. 56 | 57 | client.run("TOKEN") # Again, replace with your bot token. 58 | 59 | For more examples check the `examples `_ folder which has a lot of useful, ready to go, and explained examples. 60 | 61 | ============ 62 | Installation 63 | ============ 64 | 65 | Voltage is available on `PyPI `_! 66 | 67 | To install voltage just run: 68 | 69 | .. code-block:: sh 70 | 71 | pip install voltage 72 | 73 | If you want to install the main branch which may have more features but will be more unstable you run: 74 | 75 | .. code-block:: sh 76 | 77 | pip install git+https://github.com/EnokiUN/voltage 78 | 79 | .. note:: 80 | Python 3.8 or higher is required to install voltage. 81 | 82 | Installing from GitHub requires the Git CLI to be available on your machine. 83 | 84 | ======= 85 | Credits 86 | ======= 87 | 88 | - **Contributors**, thank you :) 89 | 90 | - `Revolt.py `_, when shit broke, that's where I went. 91 | 92 | - `Revolt.js `_, when the docs fail you. 93 | 94 | - `Discord.py `_, also a really great help while making this. 95 | 96 | - **Revolt development team**, absolute chads. 97 | 98 | - **FatalErrorCoded**, Vortex guys, carrying with voice implementation, eats chadness. 99 | 100 | - **RGBCube**, Came up with the amazing name "Voltage". 101 | 102 | .. |Support Server| image:: https://img.shields.io/badge/dynamic/json?color=ff4655&labelColor=111823&label=Support%20Server&query=member_count&suffix=%20Members&url=https%3A%2F%2Fapi.revolt.chat%2Finvites%2Fbwtscg1F&style=for-the-badge&logo=data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAA4AAAAOCAYAAAAfSC3RAAABiElEQVQoFYVSO0sDQRCefVzUXCI+wGClWPoHrISIlaSy8a9Y2mplYyP2gq21f0ArBSWgRTAi0UgkkJh77q0z4545UHHg25nvm5nbud0V75vbkJvAIMyynLK3uAaZgYdgBDPag4v3N9Z1oWoXY4XoOI2+Q30DxL1DiJ5NzyqqZdvndcydTM6DlYlJezkc1JDwlrKQ/S8Ua5XqKxbxTrqTJHlDZdHzhjlpx9E6ztmdlqo+q/Wx08XW/EIV476eEvQrPw3VFjY+d9Mkw8bvAl9JJlr80ehL1QABg6XSxGne9ZFlZy+JocMC/ft+AHPj8bgvsfaub9JDJBE3ZnzinPvrG5x8iqOduzBsMsFF3gQBOJRykfxjHDWKvKa9DeR0r2wywJfi8H2hXyl762rY+Uod+QBlBBAkzZeDK9yiQZhWFC0XtdWyX49RIEh6mQ4Gw3wUi4eRXo0+2gNj9lAnszXPO0dfIaJ7aUqerH8djGqTQnoWbDo0poeabYbBQVWpEwFCpDYzeH0BFX8CUB2RWiqWVAgAAAAASUVORK5CYII= 103 | :target: https://rvlt.gg/bwtscg1F 104 | :alt: Revolt Support Server 105 | .. |PyPi| image:: https://img.shields.io/pypi/v/voltage.svg?labelColor=111823&logo=pypi&logoColor=white&style=for-the-badge 106 | :target: https://pypi.org/project/voltage 107 | :alt: PyPi Page. 108 | .. |Docs| image:: https://img.shields.io/readthedocs/revolt-voltage/latest?labelColor=111823&style=for-the-badge&logo=readthedocs&logoColor=white 109 | :alt: Docs Status 110 | :target: https://revolt-voltage.readthedocs.io/ 111 | .. |Checks| image:: https://img.shields.io/github/actions/workflow/status/enokiun/voltage/pyright.yml?label=checks&labelColor=111823&logo=github&style=for-the-badge 112 | :alt: GitHub Workflow Status 113 | .. |Logo| image:: https://github.com/EnokiUN/voltage/blob/main/assets/voltage.png 114 | :alt: Voltage Logo 115 | :width: 80 116 | .. |Screenshot| image:: https://github.com/EnokiUN/voltage/blob/main/assets/ss.png 117 | :alt: Screenshot Of Starting A Bot. 118 | -------------------------------------------------------------------------------- /assets/ss.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EnokiUN/voltage/c95550f99017b0cccc0c67ac179e67b8d203da0a/assets/ss.jpg -------------------------------------------------------------------------------- /assets/ss.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EnokiUN/voltage/c95550f99017b0cccc0c67ac179e67b8d203da0a/assets/ss.png -------------------------------------------------------------------------------- /assets/voltage.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EnokiUN/voltage/c95550f99017b0cccc0c67ac179e67b8d203da0a/assets/voltage.png -------------------------------------------------------------------------------- /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.https://www.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/style.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --attrtable-title-color: #EE5151; 3 | --attrtable-entry-border: #9CA0A5; 4 | --attrtable-entry-text: #9CA0A5; 5 | --attrtable-entry-hover-bg: #1E2124; 6 | --attrtable-entry-hover-border: #368CE2; 7 | --attrtable-badge: #EE5151; 8 | } 9 | 10 | 11 | /* attributetable styles addapted from discord.py */ 12 | .py-attribute-table { 13 | display: flex; 14 | flex-wrap: wrap; 15 | flex-direction: row; 16 | margin: 0 2em; 17 | padding-top: 16px; 18 | } 19 | 20 | .py-attribute-table-column { 21 | flex: 1 1 auto; 22 | } 23 | 24 | .py-attribute-table-column:not(:first-child) { 25 | margin-top: 1em; 26 | } 27 | 28 | .py-attribute-table-column > span { 29 | font-weight: bold; 30 | color: var(--attrtable-title-color); 31 | } 32 | 33 | main .py-attribute-table > ul { 34 | list-style: none; 35 | margin: 4px 0px; 36 | padding-left: 0; 37 | font-size: 0.95em; 38 | } 39 | 40 | .py-attribute-table-entry { 41 | margin: 0; 42 | padding: 2px 0; 43 | border-left: 2px solid var(--attrtable-entry-border); 44 | display: flex; 45 | line-height: 1.2em; 46 | } 47 | 48 | .py-attribute-table-entry > a { 49 | padding-left: 0.5em; 50 | color: var(--attrtable-entry-text); 51 | flex-grow: 1; 52 | } 53 | 54 | .py-attribute-table-entry:hover { 55 | background-color: var(--attrtable-entry-hover-bg); 56 | border-left: 2px solid var(--attrtable-entry-hover-border); 57 | text-decoration: none; 58 | } 59 | 60 | .py-attribute-table-badge { 61 | flex-basis: 3em; 62 | text-align: right; 63 | font-size: 0.9em; 64 | color: var(--attrtable-badge); 65 | -moz-user-select: none; 66 | -webkit-user-select: none; 67 | user-select: none; 68 | } 69 | 70 | -------------------------------------------------------------------------------- /docs/source/api.rst: -------------------------------------------------------------------------------- 1 | .. currentmodule:: voltage 2 | 3 | API Reference 4 | ============= 5 | 6 | Client 7 | ====== 8 | 9 | .. attributetable:: voltage.Client 10 | 11 | .. autoclass:: voltage.Client 12 | :members: 13 | :inherited-members: 14 | 15 | Abstract Base Classes 16 | ===================== 17 | 18 | .. attributetable:: voltage.Messageable 19 | 20 | .. autoclass:: voltage.Messageable 21 | :members: 22 | :inherited-members: 23 | 24 | .. attributetable:: voltage.Channel 25 | 26 | .. autoclass:: voltage.Channel 27 | :members: 28 | :inherited-members: 29 | 30 | Models 31 | ====== 32 | 33 | Servers 34 | ~~~~~~~ 35 | 36 | .. attributetable:: voltage.Server 37 | 38 | .. autoclass:: voltage.Server 39 | :members: 40 | :inherited-members: 41 | 42 | .. attributetable:: voltage.ServerBan 43 | 44 | .. autoclass:: voltage.ServerBan 45 | :members: 46 | :inherited-members: 47 | 48 | .. attributetable:: voltage.SystemMessages 49 | 50 | .. autoclass:: voltage.SystemMessages 51 | :members: 52 | :inherited-members: 53 | 54 | Members 55 | ~~~~~~~ 56 | 57 | .. attributetable:: voltage.Member 58 | 59 | .. autoclass:: voltage.Member 60 | :members: 61 | :inherited-members: 62 | 63 | Categories 64 | ~~~~~~~~~~ 65 | 66 | .. attributetable:: voltage.Category 67 | 68 | .. autoclass:: voltage.Category 69 | :members: 70 | :inherited-members: 71 | 72 | Channels 73 | ~~~~~~~~ 74 | 75 | .. attributetable:: voltage.SavedMessageChannel 76 | 77 | .. autoclass:: voltage.SavedMessageChannel 78 | :members: 79 | :inherited-members: 80 | 81 | .. attributetable:: voltage.DMChannel 82 | 83 | .. autoclass:: voltage.DMChannel 84 | :members: 85 | :inherited-members: 86 | 87 | .. attributetable:: voltage.GroupDMChannel 88 | 89 | .. autoclass:: voltage.GroupDMChannel 90 | :members: 91 | :inherited-members: 92 | 93 | .. attributetable:: voltage.TextChannel 94 | 95 | .. autoclass:: voltage.TextChannel 96 | :members: 97 | :inherited-members: 98 | 99 | .. attributetable:: voltage.VoiceChannel 100 | 101 | .. autoclass:: voltage.VoiceChannel 102 | :members: 103 | :inherited-members: 104 | 105 | Messages 106 | ~~~~~~~~ 107 | 108 | .. attributetable:: voltage.Message 109 | 110 | .. autoclass:: voltage.Message 111 | :members: 112 | :inherited-members: 113 | 114 | .. attributetable:: voltage.MessageReply 115 | 116 | .. autoclass:: voltage.MessageReply 117 | :members: 118 | :inherited-members: 119 | 120 | .. attributetable:: voltage.MessageMasquerade 121 | 122 | .. autoclass:: voltage.MessageMasquerade 123 | :members: 124 | :inherited-members: 125 | 126 | Roles 127 | ~~~~~ 128 | 129 | .. attributetable:: voltage.Role 130 | 131 | .. autoclass:: voltage.Role 132 | :members: 133 | :inherited-members: 134 | 135 | Permissions 136 | ~~~~~~~~~~~ 137 | 138 | .. attributetable:: voltage.Permissions 139 | 140 | .. autoclass:: voltage.Permissions 141 | :members: 142 | :inherited-members: 143 | 144 | Invites 145 | ~~~~~~~ 146 | 147 | .. attributetable:: voltage.Invite 148 | 149 | .. autoclass:: voltage.Invite 150 | :members: 151 | :inherited-members: 152 | 153 | Files 154 | ~~~~~ 155 | 156 | .. attributetable:: voltage.File 157 | 158 | .. autoclass:: voltage.File 159 | :members: 160 | :inherited-members: 161 | 162 | Assets 163 | ~~~~~~ 164 | 165 | .. attributetable:: voltage.Asset 166 | 167 | .. autoclass:: voltage.Asset 168 | :members: 169 | :inherited-members: 170 | 171 | .. attributetable:: voltage.PartialAsset 172 | 173 | .. autoclass:: voltage.PartialAsset 174 | :members: 175 | :inherited-members: 176 | 177 | Users 178 | ~~~~~ 179 | 180 | .. attributetable:: voltage.User 181 | 182 | .. autoclass:: voltage.User 183 | :members: 184 | :inherited-members: 185 | 186 | 187 | Enums 188 | ===== 189 | 190 | .. autoclass:: voltage.EmbedType 191 | 192 | .. autoclass:: voltage.SortType 193 | 194 | .. autoclass:: voltage.ChannelType 195 | 196 | .. autoclass:: voltage.AssetType 197 | 198 | .. autoclass:: voltage.PresenceType 199 | 200 | .. autoclass:: voltage.RelationshipType 201 | 202 | -------------------------------------------------------------------------------- /docs/source/assets/bot_thingy.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EnokiUN/voltage/c95550f99017b0cccc0c67ac179e67b8d203da0a/docs/source/assets/bot_thingy.png -------------------------------------------------------------------------------- /docs/source/assets/bot_username_prompt.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EnokiUN/voltage/c95550f99017b0cccc0c67ac179e67b8d203da0a/docs/source/assets/bot_username_prompt.png -------------------------------------------------------------------------------- /docs/source/assets/create_bots_button.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EnokiUN/voltage/c95550f99017b0cccc0c67ac179e67b8d203da0a/docs/source/assets/create_bots_button.png -------------------------------------------------------------------------------- /docs/source/assets/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EnokiUN/voltage/c95550f99017b0cccc0c67ac179e67b8d203da0a/docs/source/assets/logo.png -------------------------------------------------------------------------------- /docs/source/assets/my_bots_button.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EnokiUN/voltage/c95550f99017b0cccc0c67ac179e67b8d203da0a/docs/source/assets/my_bots_button.png -------------------------------------------------------------------------------- /docs/source/assets/revolt_homepage.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EnokiUN/voltage/c95550f99017b0cccc0c67ac179e67b8d203da0a/docs/source/assets/revolt_homepage.png -------------------------------------------------------------------------------- /docs/source/assets/setting_menue.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EnokiUN/voltage/c95550f99017b0cccc0c67ac179e67b8d203da0a/docs/source/assets/setting_menue.png -------------------------------------------------------------------------------- /docs/source/conf.py: -------------------------------------------------------------------------------- 1 | # Configuration file for the Sphinx documentation builder. 2 | # 3 | # This file only contains a selection of the most common options. For a full 4 | # list see the documentation: 5 | # https://www.sphinx-doc.org/en/master/usage/configuration.html 6 | 7 | # -- Path setup -------------------------------------------------------------- 8 | 9 | # If extensions (or modules to document with autodoc) are in another directory, 10 | # add these directories to sys.path here. If the directory is relative to the 11 | # documentation root, use os.path.abspath to make it absolute, like shown here. 12 | # 13 | import os 14 | import sys 15 | 16 | sys.path.insert(0, os.path.abspath("../..")) 17 | sys.path.insert(0, os.path.abspath("extensions")) 18 | 19 | 20 | # -- Project information ----------------------------------------------------- 21 | 22 | project = "Voltage" 23 | copyright = "2022-present EnokiUN" 24 | author = "EnokiUN" 25 | 26 | # The full version, including alpha/beta/rc tags 27 | release = "0.1.4a5" 28 | 29 | 30 | # -- General configuration --------------------------------------------------- 31 | 32 | # Add any Sphinx extension module names here, as strings. They can be 33 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 34 | # ones. 35 | extensions = [ 36 | "sphinx.ext.autodoc", 37 | "sphinx.ext.napoleon", 38 | "sphinx.ext.viewcode", 39 | "sphinx_copybutton", 40 | "sphinxext.opengraph", 41 | "sphinxcontrib_trio", 42 | "attributetable", 43 | ] 44 | 45 | # Add any paths that contain templates here, relative to this directory. 46 | templates_path = ["_templates"] 47 | 48 | # List of patterns, relative to source directory, that match files and 49 | # directories to ignore when looking for source files. 50 | # This pattern also affects html_static_path and html_extra_path. 51 | exclude_patterns = [] 52 | 53 | resource_links = { 54 | "server": "https://rvlt.gg/bwtscg1F", 55 | "issues": "https://github.com/EnokiUN/voltage/issues", 56 | "discussions": "https://github.com/EnokiUN/voltage/discussions", 57 | "examples": "https://github.com/EnokiUN/voltage/tree/main/examples", 58 | } 59 | 60 | 61 | # -- Options for HTML output ------------------------------------------------- 62 | 63 | # The theme to use for HTML and HTML Help pages. See the documentation for 64 | # a list of builtin themes. 65 | # 66 | html_theme = "furo" 67 | 68 | html_theme_options = {} 69 | 70 | navigation_with_keys = True 71 | 72 | html_logo = "assets/logo.png" 73 | 74 | pygments_style = "friendly" 75 | 76 | # Add any paths that contain custom static files (such as style sheets) here, 77 | # relative to this directory. They are copied after the builtin static files, 78 | # so a file named "default.css" will overwrite the builtin "default.css". 79 | html_static_path = ["_static"] 80 | 81 | 82 | def setup(app): 83 | app.add_css_file("style.css") 84 | -------------------------------------------------------------------------------- /docs/source/extensions/attributetable.py: -------------------------------------------------------------------------------- 1 | # The attributetable extensions is blatantly copied from discord.py (https://github.com/Rapptz/discord.py) 2 | # and is licensed under the MIT license. 3 | # Copyright (c) 2015-2020 Rapptz 4 | 5 | # Voltage does not claim ownership of the attributetable extension and has not added a single line of code to it. 6 | 7 | 8 | import importlib 9 | import inspect 10 | import os 11 | import re 12 | from collections import OrderedDict, namedtuple 13 | 14 | from docutils import nodes 15 | from sphinx import addnodes 16 | from sphinx.locale import _ 17 | from sphinx.util.docutils import SphinxDirective 18 | 19 | 20 | class attributetable(nodes.General, nodes.Element): 21 | pass 22 | 23 | 24 | class attributetablecolumn(nodes.General, nodes.Element): 25 | pass 26 | 27 | 28 | class attributetabletitle(nodes.TextElement): 29 | pass 30 | 31 | 32 | class attributetableplaceholder(nodes.General, nodes.Element): 33 | pass 34 | 35 | 36 | class attributetablebadge(nodes.TextElement): 37 | pass 38 | 39 | 40 | class attributetable_item(nodes.Part, nodes.Element): 41 | pass 42 | 43 | 44 | def visit_attributetable_node(self, node): 45 | class_ = node["python-class"] 46 | self.body.append(f'
') 47 | 48 | 49 | def visit_attributetablecolumn_node(self, node): 50 | self.body.append(self.starttag(node, "div", CLASS="py-attribute-table-column")) 51 | 52 | 53 | def visit_attributetabletitle_node(self, node): 54 | self.body.append(self.starttag(node, "span")) 55 | 56 | 57 | def visit_attributetablebadge_node(self, node): 58 | attributes = { 59 | "class": "py-attribute-table-badge", 60 | "title": node["badge-type"], 61 | } 62 | self.body.append(self.starttag(node, "span", **attributes)) 63 | 64 | 65 | def visit_attributetable_item_node(self, node): 66 | self.body.append(self.starttag(node, "li", CLASS="py-attribute-table-entry")) 67 | 68 | 69 | def depart_attributetable_node(self, node): 70 | self.body.append("
") 71 | 72 | 73 | def depart_attributetablecolumn_node(self, node): 74 | self.body.append("") 75 | 76 | 77 | def depart_attributetabletitle_node(self, node): 78 | self.body.append("") 79 | 80 | 81 | def depart_attributetablebadge_node(self, node): 82 | self.body.append("") 83 | 84 | 85 | def depart_attributetable_item_node(self, node): 86 | self.body.append("") 87 | 88 | 89 | _name_parser_regex = re.compile(r"(?P[\w.]+\.)?(?P\w+)") 90 | 91 | 92 | class PyAttributeTable(SphinxDirective): 93 | has_content = False 94 | required_arguments = 1 95 | optional_arguments = 0 96 | final_argument_whitespace = False 97 | option_spec = {} 98 | 99 | def parse_name(self, content): 100 | path, name = _name_parser_regex.match(content).groups() 101 | if path: 102 | modulename = path.rstrip(".") 103 | else: 104 | modulename = self.env.temp_data.get("autodoc:module") 105 | if not modulename: 106 | modulename = self.env.ref_context.get("py:module") 107 | if modulename is None: 108 | raise RuntimeError(f"modulename somehow None for {content} in {self.env.docname}.") 109 | 110 | return modulename, name 111 | 112 | def run(self): 113 | """If you're curious on the HTML this is meant to generate: 114 | 115 |
116 |
117 | _('Attributes') 118 | 123 |
124 | 133 |
134 | 135 | However, since this requires the tree to be complete 136 | and parsed, it'll need to be done at a different stage and then 137 | replaced. 138 | """ 139 | content = self.arguments[0].strip() 140 | node = attributetableplaceholder("") 141 | modulename, name = self.parse_name(content) 142 | node["python-doc"] = self.env.docname 143 | node["python-module"] = modulename 144 | node["python-class"] = name 145 | node["python-full-name"] = f"{modulename}.{name}" 146 | return [node] 147 | 148 | 149 | def build_lookup_table(env): 150 | # Given an environment, load up a lookup table of 151 | # full-class-name: objects 152 | result = {} 153 | domain = env.domains["py"] 154 | 155 | ignored = { 156 | "data", 157 | "exception", 158 | "module", 159 | "class", 160 | } 161 | 162 | for fullname, _, objtype, docname, _, _ in domain.get_objects(): 163 | if objtype in ignored: 164 | continue 165 | 166 | classname, _, child = fullname.rpartition(".") 167 | try: 168 | result[classname].append(child) 169 | except KeyError: 170 | result[classname] = [child] 171 | 172 | return result 173 | 174 | 175 | TableElement = namedtuple("TableElement", "fullname label badge") 176 | 177 | 178 | def process_attributetable(app, doctree, fromdocname): 179 | env = app.builder.env 180 | 181 | lookup = build_lookup_table(env) 182 | for node in doctree.traverse(attributetableplaceholder): 183 | modulename, classname, fullname = ( 184 | node["python-module"], 185 | node["python-class"], 186 | node["python-full-name"], 187 | ) 188 | groups = get_class_results(lookup, modulename, classname, fullname) 189 | table = attributetable("") 190 | for label, subitems in groups.items(): 191 | if not subitems: 192 | continue 193 | table.append(class_results_to_node(label, sorted(subitems, key=lambda c: c.label))) 194 | 195 | table["python-class"] = fullname 196 | 197 | if not table: 198 | node.replace_self([]) 199 | else: 200 | node.replace_self([table]) 201 | 202 | 203 | def get_class_results(lookup, modulename, name, fullname): 204 | module = importlib.import_module(modulename) 205 | cls = getattr(module, name) 206 | 207 | groups = OrderedDict( 208 | [ 209 | (_("Attributes"), []), 210 | (_("Methods"), []), 211 | ] 212 | ) 213 | 214 | try: 215 | members = lookup[fullname] 216 | except KeyError: 217 | return groups 218 | 219 | for attr in members: 220 | attrlookup = f"{fullname}.{attr}" 221 | key = _("Attributes") 222 | badge = None 223 | label = attr 224 | value = None 225 | 226 | for base in cls.__mro__: 227 | value = base.__dict__.get(attr) 228 | if value is not None: 229 | break 230 | 231 | if value is not None: 232 | doc = value.__doc__ or "" 233 | if inspect.iscoroutinefunction(value) or doc.startswith("|coro|"): 234 | key = _("Methods") 235 | badge = attributetablebadge("async", "async") 236 | badge["badge-type"] = _("coroutine") 237 | elif isinstance(value, classmethod): 238 | key = _("Methods") 239 | label = f"{name}.{attr}" 240 | badge = attributetablebadge("cls", "cls") 241 | badge["badge-type"] = _("classmethod") 242 | elif inspect.isfunction(value): 243 | if doc.startswith(("A decorator", "A shortcut decorator")): 244 | # finicky but surprisingly consistent 245 | badge = attributetablebadge("@", "@") 246 | badge["badge-type"] = _("decorator") 247 | key = _("Methods") 248 | else: 249 | key = _("Methods") 250 | badge = attributetablebadge("def", "def") 251 | badge["badge-type"] = _("method") 252 | 253 | groups[key].append(TableElement(fullname=attrlookup, label=label, badge=badge)) 254 | 255 | return groups 256 | 257 | 258 | def class_results_to_node(key, elements): 259 | title = attributetabletitle(key, key) 260 | ul = nodes.bullet_list("") 261 | for element in elements: 262 | ref = nodes.reference( 263 | "", 264 | "", 265 | internal=True, 266 | refuri=f"#{element.fullname}", 267 | anchorname="", 268 | *[nodes.Text(element.label)], 269 | ) 270 | para = addnodes.compact_paragraph("", "", ref) 271 | if element.badge is not None: 272 | ul.append(attributetable_item("", element.badge, para)) 273 | else: 274 | ul.append(attributetable_item("", para)) 275 | 276 | return attributetablecolumn("", title, ul) 277 | 278 | 279 | def setup(app): 280 | app.add_directive("attributetable", PyAttributeTable) 281 | app.add_node(attributetable, html=(visit_attributetable_node, depart_attributetable_node)) 282 | app.add_node( 283 | attributetablecolumn, 284 | html=(visit_attributetablecolumn_node, depart_attributetablecolumn_node), 285 | ) 286 | app.add_node( 287 | attributetabletitle, 288 | html=(visit_attributetabletitle_node, depart_attributetabletitle_node), 289 | ) 290 | app.add_node( 291 | attributetablebadge, 292 | html=(visit_attributetablebadge_node, depart_attributetablebadge_node), 293 | ) 294 | app.add_node( 295 | attributetable_item, 296 | html=(visit_attributetable_item_node, depart_attributetable_item_node), 297 | ) 298 | app.add_node(attributetableplaceholder) 299 | app.connect("doctree-resolved", process_attributetable) 300 | -------------------------------------------------------------------------------- /docs/source/getting_started/creating_a_bot.rst: -------------------------------------------------------------------------------- 1 | Creating a Bot 2 | -------------- 3 | 4 | When you open revolt you will be faced with this screen: 5 | 6 | .. image:: ../assets/revolt_homepage.png 7 | :alt: Revolt Homepage 8 | 9 | Click on the "Open Settings" button on the bottom right to open the settings menue, you'll be faced with *you guessed it* the settings menue, what we're really looking for however is the side bar which should look something like this: 10 | 11 | .. image:: ../assets/setting_menue.png 12 | :alt: Revolt Settings Menue 13 | 14 | Next we click on the "My Bots" button, aka this one: 15 | 16 | .. image:: ../assets/my_bots_button.png 17 | :alt: My Bots Button 18 | 19 | Now press the "Create a Bot" button, yes the wide imposing one at the top of the screen with "Create a Bot" written on it if you couldn't guess. 20 | 21 | .. image:: ../assets/create_bots_button.png 22 | :alt: Create Bot Button 23 | 24 | You'll be prompted with a prompt that asks for your preferred username (for the bot ofc) which looks something like this: 25 | 26 | .. image:: ../assets/bot_username_prompt.png 27 | :alt: Bot Username Prompt 28 | 29 | Enter your desired username and you'll have something like this: 30 | 31 | .. image:: ../assets/bot_thingy.png 32 | :alt: Bot in Settings Menu. 33 | 34 | For the sake of this tutorial I'm just going to use my pre-existing bot aka RedCrewmate (yeah, we do Among Us jokes here). 35 | 36 | .. note:: 37 | 38 | You can press the "Edit" button to change the bot's profile, banner and such but I'll leave that to you to figure out ;) 39 | 40 | All you really need from this page from the point forward is the token so copy that bad boy and we'll paste it in a moment. 41 | 42 | Congratulations, you've created a bot account, wooooo! 43 | 44 | Now to actually make it do stuff. 45 | -------------------------------------------------------------------------------- /docs/source/getting_started/index.rst: -------------------------------------------------------------------------------- 1 | --------------- 2 | Getting Started 3 | --------------- 4 | 5 | .. note:: 6 | 7 | This part and other parts of the documentation are still work in progress. 8 | 9 | If you are willing to help please state that you're interested in helping via the revolt support server or github discussion. 10 | 11 | 12 | This tutorial is designed to get you up and running with the basics of Voltage and the revolt api. 13 | 14 | This tutorial will cover: 15 | 16 | .. toctree:: 17 | :maxdepth: 2 18 | 19 | installation 20 | creating_a_bot 21 | starting_the_bot 22 | using_the_library 23 | using_the_commands_framework 24 | 25 | -------------------------------------------------------------------------------- /docs/source/getting_started/installation.rst: -------------------------------------------------------------------------------- 1 | Installation 2 | ============ 3 | 4 | Voltage is available on `PyPi `_. 5 | 6 | To install the stable release, run: 7 | 8 | .. code-block:: console 9 | 10 | $ pip install voltage 11 | 12 | .. note:: 13 | 14 | You might have to have to use ``python3 -m pip`` depending on your os. 15 | 16 | If you want to install the development version, run: 17 | 18 | .. code-block:: console 19 | 20 | $ pip install git+https://github.com/enokiun/voltage 21 | 22 | .. note:: 23 | 24 | This requires `git `_ to be installed on your machine. 25 | 26 | A minimum of Python 3.8 is required. 27 | 28 | -------------------------------------------------------------------------------- /docs/source/getting_started/starting_the_bot.rst: -------------------------------------------------------------------------------- 1 | Starting The Bot 2 | ---------------- 3 | 4 | So you installed the library and have your bot token ready, great! 5 | 6 | Now I'd suggest you make a folder and then make a file called ``main.py`` or ``bot.py`` or whatever you want to call your main script file but before that we need to get our enviroment set up (or that's atleast what I'd do) so go ahead and install ``python-dotenv`` if you haven't already using this command: 7 | 8 | .. code-block:: console 9 | 10 | $ pip install python-dotenv 11 | 12 | Nice, now add your token to your ``.env`` file, it should be in the same folder as your script file. 13 | 14 | Let's say that your token is ``qwertyuiop1234567890`` and you want to put it in your ``.env`` file, you'd do this: 15 | 16 | .. code-block:: 17 | 18 | TOKEN=qwertyuiop1234567890 19 | 20 | Cool, now open your main script file and write the following, I'll explain it all in just a second: 21 | 22 | .. code-block:: python3 23 | 24 | import dotenv 25 | import voltage 26 | 27 | client = voltage.Client() 28 | 29 | @client.listen("message") 30 | async def on_message(message): 31 | if message.content == "!ping": 32 | await message.reply("Pong!") 33 | 34 | dotenv.load_dotenv() 35 | client.run(os.getenv("TOKEN")) 36 | 37 | Alright so to explain what's going on, first we import the dotenv library to be able to access our enviroment and voltage, 38 | 39 | Next we initialize our bot client using the :class:`voltage.Client` class. 40 | 41 | Then we registered a decorator using the :meth:`voltage.Client.listen` decorator, we passed in the ``message`` event to listen for when a message is sent. 42 | 43 | We then defined an asynchronous function called ``on_message`` tho it doesn't really matter what you call it, what matters however is that it expects one parameter which is the message object. 44 | 45 | After that we check if the message's content is equal to ``!ping``, if it is we reply with ``Pong!``. 46 | 47 | Then we load the enviroment using the :func:`dotenv.load_dotenv` function and then run the client using the :meth:`voltage.Client.run` method. 48 | 49 | Now if you run this code your bot should go online and if you were to type ``!ping`` it will reply to your message with ``Pong!`` 50 | 51 | Great, we have finally made our bot, now what? 52 | 53 | Next up we will go into more detail about how to use the voltage library and it's various classes, functions and so on! 54 | -------------------------------------------------------------------------------- /docs/source/getting_started/using_the_commands_framework.rst: -------------------------------------------------------------------------------- 1 | Using The Commands Framework 2 | ============================ 3 | 4 | This section is still WIP. 5 | 6 | Feel free to help making it not WIP. 7 | -------------------------------------------------------------------------------- /docs/source/getting_started/using_the_library.rst: -------------------------------------------------------------------------------- 1 | Using The Library 2 | ================= 3 | 4 | This section is still WIP. 5 | 6 | Feel free to help making it not WIP. 7 | -------------------------------------------------------------------------------- /docs/source/index.rst: -------------------------------------------------------------------------------- 1 | .. Voltage documentation master file, created by 2 | sphinx-quickstart on Thu Mar 24 14:47:40 2022. 3 | You can adapt this file completely to your liking, but it should at least 4 | contain the root `toctree` directive. 5 | 6 | ======= 7 | Voltage 8 | ======= 9 | Voltage is a simple, pythonic wrapper for the Revolt API. 10 | 11 | .. note:: 12 | 13 | The docs are still WIP and are in need of alot of work so feel free to contribute! 14 | 15 | .. toctree:: 16 | :maxdepth: 1 17 | 18 | getting_started/index 19 | api 20 | 21 | 22 | Indices and tables 23 | ================== 24 | 25 | * :ref:`genindex` 26 | * :ref:`search` 27 | -------------------------------------------------------------------------------- /docs/source/requirements.txt: -------------------------------------------------------------------------------- 1 | sphinx 2 | furo 3 | sphinx-copybutton 4 | sphinxcontrib-trio 5 | sphinxext-opengraph 6 | 7 | aiohttp 8 | typing-extensions 9 | py-ulid 10 | -------------------------------------------------------------------------------- /examples/cogs.py: -------------------------------------------------------------------------------- 1 | import voltage # Import voltage. 2 | from voltage.ext import ( # Importing the commands framework so we that we're able to create a Cog object. 3 | commands, 4 | ) 5 | 6 | 7 | # Next up is the setup function, aka where all the magic happens. 8 | # This is what's actually called by the client to be used for `add_cog`. 9 | # Here you define your cog, define its commands then return the cog so that the client is able to add it. 10 | # Additionally, any args / kwargs you pass to the `client.add_extension` function will be passed to the setup function. 11 | # The client is passed by default however. 12 | def setup(client) -> commands.Cog: 13 | test = commands.Cog( # Create a new Cog object. 14 | # Give it a name. # And an optional description. 15 | "Test", 16 | "Some commands for testing.", 17 | ) # The name and description will be used in the help command. 18 | 19 | # Register a command as normal, difference is you use the Cog object instead of the client in the decorator. 20 | @test.command() 21 | async def ping(ctx): # No self parameter. 22 | """Sends Pong!""" 23 | await ctx.reply("Pong from inside a Cog!") 24 | 25 | return test # Finally, return the cog object. 26 | 27 | 28 | # To load a Cog to the client, you can use `client.add_extension(path, *args, **kwargs)` with the path being the Python dotpath to your file, with args and kwargs an optional attribute. 29 | 30 | # discord.py style subclassed cogs are also supported but aren't "that" tested yet. 31 | # Feel free to create an issue if they dont work, but for now you can use this format to keep yourself in the habit. 32 | 33 | 34 | class MyCog(commands.SubclassedCog): 35 | """My beautiful Cog!.""" # Name and description are taken automatically from the class declaration, otherwise you could set them manually. 36 | 37 | def __init__(self, client): 38 | self.client = client 39 | 40 | # What setting the name and description manually looks like 41 | self.name = "My Cog" 42 | self.description = "My beautiful Cog!" 43 | 44 | @commands.command() 45 | async def ping(self, ctx): 46 | await ctx.reply("Pong from inside a Cog!") 47 | 48 | 49 | # The setup function will still return with the cog object. 50 | 51 | 52 | def setup(client) -> commands.SubclassedCog: 53 | return MyCog(client) 54 | -------------------------------------------------------------------------------- /examples/commands.py: -------------------------------------------------------------------------------- 1 | import voltage 2 | from voltage.ext import commands # Import the commands module from ``voltage.ext`` 3 | 4 | client = commands.CommandsClient( 5 | "-" 6 | ) # Create a CommandsClient (client that has commands (original)) with the prefix set to "-". 7 | 8 | 9 | @client.listen("ready") # You can still listen to events. 10 | async def ready(): 11 | print("Gaaah, it's rewind time.") 12 | 13 | 14 | @client.command() # Register a command using the ``command`` decorator. 15 | async def ping(ctx): 16 | """Sends Pong!""" # Name and description can be passed in the decorator or automatically inferred. 17 | await ctx.reply("Pong") # Reply to the context's message. 18 | 19 | 20 | # When you set a command's name explicitly the function's name is disregarded. 21 | # Automatic type conversion is a thing I suppose. 22 | @client.command(name="whois", description="Tells you who a person ***truly*** is", aliases=["wi"]) 23 | async def whoiscommand(ctx, person: voltage.Member): 24 | await ctx.reply("{0} is !!{0}!!".format(person.name)) 25 | 26 | 27 | client.run("TOKEN") # Again, replace with your bot token. 28 | -------------------------------------------------------------------------------- /examples/embed.py: -------------------------------------------------------------------------------- 1 | import voltage # Import voltage. 2 | 3 | client = voltage.Client() # Initialize the client. 4 | 5 | 6 | @client.listen("message") # Listen for pings 7 | async def on_ping(message): # Doesn't matter what you call the function. 8 | if message.content.startswith("embed"): 9 | # Create the embed. Most of these attributes are self-explanatory 10 | embed = voltage.SendableEmbed( 11 | title="Cat", # The title of the embed 12 | description="Cat", # The description of the embed 13 | colour="#DEADBF", # The colour of the "strip" at the side of the embed 14 | icon_url=message.author.display_avatar.url, # The icon beside the title of the embed. "message.author.display_avatar.url" gets the user's avatar. 15 | media="https://http.cat/200", # The media for the embed. Here, we have an image. 16 | ) 17 | # Reply to a message. 18 | await message.reply(content="Cat", embed=embed) # You can use "#" if you don't want any content. 19 | 20 | 21 | # Run the client 22 | client.run("TOKEN") # Replace with your own token 23 | -------------------------------------------------------------------------------- /examples/message_checker.py: -------------------------------------------------------------------------------- 1 | import voltage # Import the Voltage module 2 | from voltage.ext import commands # Importing commands for our client 3 | 4 | client = commands.CommandsClient("!") # Initializing our client 5 | 6 | ungodly_words = [ 7 | "sus", 8 | "baka", 9 | "suppose", 10 | "real", 11 | "amogus", 12 | ] # Creating our list to iterate through and add items later 13 | 14 | 15 | @client.listen("message") # Specify what we're going to listen to 16 | async def on_message(message): # The name for this can be anything you want it to be 17 | if any( 18 | [word in message.content.lower() for word in ungodly_words] 19 | ): # Run the if statement to trigger if the message has words in the array 20 | await message.reply( 21 | "*GASP!* You can't say that word!", delete_after=5 22 | ) # Reply to the message sent and delete OUR message after 5 seconds 23 | await message.delete() # Delete the USERS message 24 | await client.handle_commands(message) 25 | # Handle afterwards so other commands will work after the on_message, 26 | 27 | 28 | @commands.command(name="add_word") # Create our command 29 | async def add_word(ctx, word): # Define our command 30 | ungodly_words.append(word.lower()) # Append to our list 31 | await ctx.send( 32 | f"Added `{word.lower()}` to the list of `{len(ungodly_words)}` words!" 33 | ) # Tell user that the word was added to the list. 34 | 35 | 36 | # P.S: When the bot goes offline, the list of words is cleared as its only a LOCAL array. 37 | 38 | client.run("TOKEN") # Run the bot 39 | -------------------------------------------------------------------------------- /examples/ping_bot.py: -------------------------------------------------------------------------------- 1 | import voltage # Import voltage. 2 | 3 | client = voltage.Client() # Initialize the client. 4 | 5 | 6 | @client.listen("ready") # Listen to an event. 7 | async def on_ready(): 8 | print(f"Logged in as {client.user}") 9 | 10 | 11 | @client.listen("message") 12 | async def on_message(message): # Doesn't matter what you call the function. 13 | if message.content == "-ping": 14 | await message.channel.send("pong!") # Send a message. 15 | if message.content == "-embed": 16 | embed = voltage.SendableEmbed(title="Hello World", description="This is an embed") # Create an embed. 17 | # Reply to a message. 18 | await message.reply(content="embed", embed=embed) # Obligatory message content. 19 | 20 | 21 | # Run the client which is an abstraction of calling the start coroutine. 22 | client.run("TOKEN") # Replace with your token. 23 | -------------------------------------------------------------------------------- /examples/wait_for.py: -------------------------------------------------------------------------------- 1 | # This example explains how to use client.wait_for() in voltage, which waits for a user message, and returns the voltage.Messageable object. 2 | 3 | import voltage # Importing the main library 4 | 5 | client = voltage.Client() # Initializing the client 6 | 7 | 8 | @client.listen("message") # Tell our client to listen for a message 9 | async def on_message( 10 | message, 11 | ): # The name for this function doesnt matter, but we'll be naming it on_message for this example. 12 | if message.content.lower() == "-send": 13 | await message.reply("Send me something nice!") 14 | msg = await bot.wait_for( 15 | "message", 16 | check=lambda message: message.author.id != bot.user.id, 17 | timeout=30, 18 | ) # Assign this to a variable as it returns a Messageable object for later. 19 | await message.reply( 20 | f"I appreciate the message, {message.author.name}, ill remember your words.. `{msg.content}`" 21 | ) # Replying with the message content sent by the user. 22 | 23 | 24 | client.run("token") # Replace with your token string and run! 25 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | aiohttp 2 | typing_extensions 3 | py-ulid 4 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | from setuptools import setup # type: ignore 4 | 5 | with open("voltage/__init__.py") as f: 6 | match = re.search(r'^__version__\s*=\s*[\'"]([^\'"]*)[\'"]', f.read(), re.MULTILINE) 7 | if match is not None: 8 | version = match.group(1) 9 | else: 10 | raise RuntimeError("version is not set") 11 | 12 | with open("requirements.txt") as f: 13 | requirements = f.read().splitlines() 14 | 15 | 16 | with open("README.rst") as f: 17 | readme = f.read() 18 | 19 | setup( 20 | name="voltage", 21 | author="EnokiUN", 22 | url="https://github.com/EnokiUN/voltage", 23 | project_urls={ 24 | "Documentation": "https://revolt-voltage.readthedocs.io/en/latest", 25 | "Issue tracker": "https://github.com/enokiun/voltage/issues", 26 | }, 27 | version=version, 28 | license="MIT", 29 | packages=["voltage", "voltage.types", "voltage.internals", "voltage.ext.commands"], 30 | install_requires=requirements, 31 | description="A Simple Pythonic Asynchronous API wrapper for Revolt.", 32 | long_description=readme, 33 | long_description_content_type="text/x-rst", 34 | include_package_data=True, 35 | python_requires=">=3.8.0", 36 | classifiers=[ 37 | "Development Status :: 5 - Production/Stable", 38 | "License :: OSI Approved :: MIT License", 39 | "Intended Audience :: Developers", 40 | "Natural Language :: English", 41 | "Operating System :: OS Independent", 42 | "Programming Language :: Python :: 3.8", 43 | "Programming Language :: Python :: 3.9", 44 | "Programming Language :: Python :: 3.10", 45 | "Programming Language :: Python :: 3.11", 46 | "Topic :: Internet", 47 | "Topic :: Software Development :: Libraries", 48 | "Topic :: Utilities", 49 | ], 50 | ) 51 | -------------------------------------------------------------------------------- /voltage/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | ------- 3 | Voltage 4 | ------- 5 | 6 | A Simple Pythonic Asynchronous API wrapper for Revolt. 7 | 8 | To whoever sees this: The impostor is sus. 9 | """ 10 | 11 | # 2 = 1 # How's this haru? 12 | 13 | # Edit: apparently defying the laws of math isn't enough to make all my problems dissappear and I have to expend some actual effort and time making my stuff works or else I get stupid errors, like WTF! 14 | 15 | __title__ = "Voltage" 16 | __author__ = "EnokiUN" 17 | __license__ = "MIT" 18 | __copyright__ = "Copyright (c) 2021-present EnokiUN" 19 | __version__ = "0.1.5a8" # Updating this is such a pain. 20 | 21 | from .asset import Asset as Asset 22 | from .asset import PartialAsset as PartialAsset 23 | from .categories import Category as Category 24 | from .channels import Channel as Channel 25 | from .channels import DMChannel as DMChannel 26 | from .channels import GroupDMChannel as GroupDMChannel 27 | from .channels import SavedMessageChannel as SavedMessageChannel 28 | from .channels import TextChannel as TextChannel 29 | from .channels import VoiceChannel as VoiceChannel 30 | from .channels import create_channel as create_channel 31 | from .client import Client as Client 32 | from .embed import Embed as Embed 33 | from .embed import SendableEmbed as SendableEmbed 34 | from .enums import AssetType as AssetType 35 | from .enums import ChannelType as ChannelType 36 | from .enums import EmbedType as EmbedType 37 | from .enums import PresenceType as PresenceType 38 | from .enums import RelationshipType as RelationshipType 39 | from .enums import SortType as SortType 40 | from .errors import BotNotEnoughPerms as BotNotEnoughPerms 41 | from .errors import ChannelNotFound as ChannelNotFound 42 | from .errors import CommandNotFound as CommandNotFound 43 | from .errors import HTTPError as HTTPError 44 | from .errors import MemberNotFound as MemberNotFound 45 | from .errors import NotBotOwner as NotBotOwner 46 | from .errors import NotEnoughArgs as NotEnoughArgs 47 | from .errors import NotEnoughPerms as NotEnoughPerms 48 | from .errors import NotFoundException as NotFoundException 49 | from .errors import PermissionError as PermissionError 50 | from .errors import RoleNotFound as RoleNotFound 51 | from .errors import UserNotFound as UserNotFound 52 | from .errors import VoltageException as VoltageException 53 | from .file import File as File 54 | from .invites import Invite as Invite 55 | from .member import Member as Member 56 | from .message import Message as Message 57 | from .message import MessageMasquerade as MessageMasquerade 58 | from .message import MessageReply as MessageReply 59 | from .messageable import Messageable as Messageable 60 | from .permissions import Permissions as Permissions 61 | from .permissions import PermissionsFlags as PermissionsFlags 62 | from .roles import Role as Role 63 | from .server import Server as Server 64 | from .server import ServerBan as ServerBan 65 | from .server import SystemMessages as SystemMessages 66 | from .user import User as User 67 | from .utils import get as get 68 | -------------------------------------------------------------------------------- /voltage/asset.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING 4 | 5 | # Internal imports 6 | from .enums import AssetType 7 | 8 | if TYPE_CHECKING: 9 | from .internals import HTTPHandler 10 | from .types import FilePayload 11 | 12 | 13 | class Asset: 14 | """ 15 | A class that represents a revolt asset. 16 | 17 | Attributes 18 | ---------- 19 | id: :class:`str` 20 | The id of the asset. 21 | tag: :class:`str` 22 | The tag of the asset. 23 | size: :class:`int` 24 | The size of the asset. 25 | name: :class:`str` 26 | The name of the asset. 27 | width: Optional[:class:`int`] 28 | The width of the asset. 29 | height: Optional[:class:`int`] 30 | The height of the asset. 31 | type: Optional[:class:`AssetType`] 32 | The type of the asset. 33 | content_type: :class:`str` 34 | The content type of the asset. 35 | url: :class:`str` 36 | The url of the asset. 37 | """ 38 | 39 | __slots__ = ( 40 | "id", 41 | "tag", 42 | "size", 43 | "name", 44 | "width", 45 | "height", 46 | "type", 47 | "content_type", 48 | "url", 49 | "http", 50 | "data", 51 | ) 52 | 53 | def __init__(self, data: FilePayload, http: HTTPHandler): 54 | self.data = data 55 | self.http = http 56 | 57 | self.id = data.get("_id") 58 | self.tag = data.get("tag") 59 | self.size = data.get("size") 60 | self.name = data.get("filename") 61 | 62 | metadata = data.get("metadata") 63 | if metadata: 64 | self.width = metadata.get("width") 65 | self.height = metadata.get("height") 66 | self.type = AssetType(metadata.get("type")) 67 | 68 | self.content_type = data.get("content_type") 69 | 70 | if http.api_info: 71 | url = http.api_info["features"]["autumn"]["url"] 72 | self.url = f"{url}/{self.tag}/{self.id}" 73 | else: 74 | self.url = "" # this cannot happen lmfao 75 | 76 | async def get_binary(self) -> bytes: 77 | """ 78 | Gets the binary data of the asset. 79 | 80 | Returns 81 | ------- 82 | :class:`bytes` 83 | The binary data of the asset. 84 | """ 85 | return await self.http.get_file_binary(self.url) 86 | 87 | 88 | class PartialAsset(Asset): 89 | """ 90 | A partial asset caused by data lack. 91 | 92 | Attributes 93 | ---------- 94 | url: :class:`str` 95 | The url of the asset. 96 | id: :class:`str` 97 | The id of the asset. 98 | created_at: :class:`int` 99 | The timestamp of when the asset was created. 100 | tag: Optional[:class:`str`] 101 | The tag of the asset. 102 | size: :class:`int` 103 | The size of the asset. 104 | name: :class:`str` 105 | The name of the asset. 106 | width: Optional[:class:`int`] 107 | The width of the asset. 108 | height: Optional[:class:`int`] 109 | The height of the asset. 110 | type: Optional[:class:`AssetType`] 111 | The type of the asset. 112 | """ 113 | 114 | __slots__ = ( 115 | "url", 116 | "http", 117 | "id", 118 | "created_at", 119 | "tag", 120 | "size", 121 | "name", 122 | "width", 123 | "height", 124 | "type", 125 | "content_type", 126 | ) 127 | 128 | def __init__(self, url: str, http: HTTPHandler): 129 | self.url = url 130 | self.http = http 131 | 132 | self.id = "0" 133 | self.created_at = 0 134 | self.tag = None 135 | self.size = 0 136 | self.name = "" 137 | self.width = None 138 | self.height = None 139 | self.type = AssetType.file 140 | self.content_type = None 141 | -------------------------------------------------------------------------------- /voltage/categories.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING 4 | 5 | if TYPE_CHECKING: 6 | from .channels import Channel 7 | from .internals import CacheHandler 8 | from .types import CategoryPayload 9 | 10 | 11 | class Category: 12 | """ 13 | A class that represents a Voltage category. 14 | 15 | Attributes 16 | ---------- 17 | id: :class:`str` 18 | The category's ID. 19 | name: :class:`str` 20 | The name of the category. 21 | description: Optional[:class:`str`] 22 | The name of the category. 23 | channels: List[:class:`Channel`] 24 | A list of all channels in the category. 25 | """ 26 | 27 | __slots__ = ("name", "id", "channel_ids", "cache", "description") 28 | 29 | def __init__(self, data: CategoryPayload, cache: CacheHandler): 30 | self.name = data["title"] 31 | self.description = data.get("description") 32 | self.id = data["id"] 33 | self.channel_ids = [channel for channel in data["channels"]] 34 | self.cache = cache 35 | 36 | @property 37 | def channels(self) -> list[Channel]: 38 | return [self.cache.get_channel(channel_id) for channel_id in self.channel_ids] 39 | 40 | def __repr__(self): 41 | return f"" 42 | 43 | def __str__(self): 44 | return self.name 45 | -------------------------------------------------------------------------------- /voltage/client.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from asyncio import Future, get_event_loop, wait_for 4 | from typing import TYPE_CHECKING, Any, Callable, Dict, Literal, Optional, Union 5 | 6 | import aiohttp 7 | 8 | # Internal imports 9 | from .internals import CacheHandler, HTTPHandler, WebSocketHandler 10 | 11 | if TYPE_CHECKING: 12 | from .channels import Channel 13 | from .enums import PresenceType 14 | from .member import Member 15 | from .server import Server 16 | from .user import User 17 | 18 | 19 | class Client: 20 | """ 21 | Base voltage client. 22 | 23 | Attributes 24 | ---------- 25 | cache_message_limit: :class:`int` 26 | The maximum amount of messages to cache. 27 | user: :class:`User` 28 | The user of the client. 29 | members: List[:class:`Member`] 30 | The members the client has cached. 31 | servers: List[:class:`Server`] 32 | The servers the client is in. 33 | users: List[:class:`User`] 34 | The users the client has cached. 35 | channels: List[:class:`Channel`] 36 | The channels the client has cached. 37 | 38 | Methods 39 | ------- 40 | listen: 41 | Registers a function to listen for an event. 42 | run: 43 | Runs the client. 44 | """ 45 | 46 | __slots__ = ( 47 | "cache_message_limit", 48 | "client", 49 | "error_handlers", 50 | "listeners", 51 | "loop", 52 | "raw_listeners", 53 | "raw_waits", 54 | "waits", 55 | "ws", 56 | "http", 57 | "cache", 58 | "user", 59 | ) 60 | 61 | def __init__(self, *, cache_message_limit: int = 5000): 62 | self.cache_message_limit = cache_message_limit 63 | self.client = None 64 | self.http: HTTPHandler 65 | self.ws: WebSocketHandler 66 | self.listeners: Dict[str, Callable[..., Any]] = {} 67 | self.raw_listeners: Dict[str, Callable[[Dict], Any]] = {} 68 | self.waits: Dict[str, list[tuple[Callable[..., bool], Future[Any]]]] = {} 69 | self.loop = get_event_loop() 70 | self.cache: CacheHandler 71 | self.user: User 72 | self.error_handlers: Dict[str, Callable[..., Any]] = {} 73 | 74 | def listen(self, event: str, *, raw: bool = False): 75 | """ 76 | Registers a function to listen for an event. 77 | 78 | This function is meant to be used as a decorator. 79 | 80 | Parameters 81 | ---------- 82 | func: Callable[..., Any] 83 | The function to call when the event is triggered. 84 | event: :class:`str` 85 | The event to listen for. 86 | raw: :class:`bool` 87 | Whether or not to listen for raw events. 88 | 89 | Examples 90 | -------- 91 | 92 | .. code-block:: python3 93 | 94 | @client.listen("message") 95 | async def any_name_you_want(message): 96 | if message.content == "ping": 97 | await message.channel.send("pong") 98 | 99 | # example of a raw event 100 | @client.listen("message", raw=True) 101 | async def raw(payload): 102 | if payload["content"] == "ping": 103 | await client.http.send_message(payload["channel"], "pong") 104 | 105 | """ 106 | 107 | def inner(func: Callable[..., Any]): 108 | if raw: 109 | self.raw_listeners[event.lower()] = func 110 | else: 111 | self.listeners[event.lower()] = func # Why would we have more than one listener for the same event? 112 | return func 113 | 114 | return inner # Returns the function so the user can use it by itself 115 | 116 | def error(self, event: str): 117 | """ 118 | Registers a function to handle errors for a specific **non-raw** event. 119 | 120 | This function is meant to be used as a decorator. 121 | 122 | Parameters 123 | ---------- 124 | event: :class:`str` 125 | The event to handle errors for. 126 | 127 | Examples 128 | -------- 129 | 130 | .. code-block:: python3 131 | 132 | @client.error("message") 133 | async def message_error(error, message): 134 | if isinstance(error, IndexError): # You probably don't want to handle all the index errors like this but this is just an example. 135 | await message.reply("Not enough arguments.") 136 | 137 | """ 138 | 139 | def inner(func: Callable[..., Any]): 140 | self.error_handlers[event.lower()] = func 141 | return func 142 | 143 | return inner 144 | 145 | def run(self, token: str, *, bot: bool = True, banner: bool = True): 146 | """ 147 | Run the client. 148 | 149 | Parameters 150 | ---------- 151 | token: :class:`str` 152 | The bot token. 153 | bot: :class:`bool` 154 | Whether or not the client is a bot. 155 | banner: :class:`bool` 156 | Whether or not to print startup banner. 157 | """ 158 | self.loop.run_until_complete(self.start(token, bot=bot, banner=banner)) 159 | 160 | async def wait_for( 161 | self, 162 | event: str, 163 | *, 164 | timeout: Optional[float] = None, 165 | check: Optional[Callable[..., bool]] = None, 166 | ) -> Any: 167 | """ 168 | Waits for an event to be triggered. 169 | 170 | .. note:: 171 | 172 | The event can be *anything*, be it a message, userupdate or whatever. :trol: 173 | 174 | Parameters 175 | ---------- 176 | event: :class:`str` 177 | The event to wait for. 178 | timeout: Optional[:class:`float`] 179 | The amount of time to wait for the event to be triggered. 180 | check: Optional[Callable[..., bool]] 181 | A function to filter events to a matching predicate, ***must*** return a boolean for it to work properly. 182 | 183 | Raises 184 | ------ 185 | :class:`asyncio.TimeoutError` 186 | If the event wasn't triggered within the timeout. 187 | 188 | Examples 189 | -------- 190 | 191 | .. code-block:: python3 192 | 193 | import voltage 194 | 195 | client = voltage.Client() 196 | 197 | @client.listen("message") 198 | async def message(message): 199 | if message.content == "-wait": 200 | await message.reply("Okay, send something") 201 | msg = await client.wait_for("message", check=lambda m: m.author == message.author) 202 | await message.reply("You sent: " + msg.content) 203 | 204 | client.run("token") 205 | 206 | """ 207 | if check is None: 208 | check = lambda *_, **__: True 209 | 210 | future = self.loop.create_future() 211 | self.waits[event] = self.waits.get(event, []) + [(check, future)] 212 | 213 | try: 214 | return await wait_for(future, timeout) 215 | except TimeoutError: 216 | self.waits[event].remove((check, future)) 217 | raise 218 | 219 | @property 220 | def servers(self) -> list[Server]: 221 | """The list of servers the client is in.""" 222 | return list(self.cache.servers.values()) 223 | 224 | @property 225 | def users(self) -> list[User]: 226 | """The list of users the client has cached.""" 227 | return list(self.cache.users.values()) 228 | 229 | @property 230 | def channels(self) -> list[Any]: 231 | """The list of channels the client has cached.""" 232 | return list(self.cache.channels.values()) 233 | 234 | @property 235 | def members(self) -> list[Member]: 236 | """The list of members the client has cached.""" 237 | members: list[Member] = list() 238 | for server, servermembers in self.cache.members.items(): 239 | members += list(servermembers.values()) 240 | return members 241 | 242 | async def start(self, token: str, *, bot: bool = True, banner: bool = True): 243 | """ 244 | Start the client. 245 | 246 | Parameters 247 | ---------- 248 | token: :class:`str` 249 | The bot token. 250 | bot: :class:`bool` 251 | Whether or not the client is a bot. 252 | banner: :class:`bool` 253 | Whether or not to print startup banner. 254 | """ 255 | self.client = aiohttp.ClientSession() 256 | self.http = HTTPHandler(self.client, token, bot=bot) 257 | self.cache = CacheHandler(self.http, self.loop, self.cache_message_limit) 258 | self.ws = WebSocketHandler(self.client, self.http, self.cache, token, self.dispatch, self.raw_dispatch) 259 | await self.http.get_api_info() 260 | self.user = self.cache.add_user(await self.http.fetch_self()) 261 | await self.ws.connect(banner) 262 | 263 | async def dispatch(self, event: str, *args, **kwargs): 264 | event = event.lower() 265 | 266 | for i in self.waits.get(event, []): 267 | if i[0](*args, **kwargs): 268 | i[1].set_result(*args, **kwargs) 269 | self.waits[event].remove(i) 270 | 271 | if func := self.listeners.get(event): 272 | if self.error_handlers.get(event): 273 | try: 274 | await func(*args, **kwargs) 275 | except Exception as e: 276 | await self.error_handlers[event](e, *args, **kwargs) 277 | else: 278 | await func(*args, **kwargs) 279 | 280 | async def raw_dispatch(self, payload: Dict[Any, Any]): 281 | event = payload["type"].lower() # Subject to change 282 | if func := self.raw_listeners.get(event): 283 | await func(payload) 284 | 285 | def get_user(self, user: str) -> Optional[User]: 286 | """ 287 | Gets a user from the cache by ID, mention or name. 288 | 289 | Parameters 290 | ---------- 291 | user: :class:`str` 292 | The ID, mention or name of the user. 293 | 294 | Returns 295 | ------- 296 | Optional[:class:`User`] 297 | The user. 298 | """ 299 | return self.cache.get_user(user) 300 | 301 | def get_channel(self, channel_id: str) -> Optional[Channel]: 302 | """ 303 | Gets a channel from the cache by ID. 304 | 305 | Parameters 306 | ---------- 307 | channel_id: :class:`str` 308 | The ID of the channel. 309 | 310 | Returns 311 | ------- 312 | Optional[:class:`Channel`] 313 | The channel. 314 | """ 315 | try: 316 | return self.cache.get_channel(channel_id) 317 | except ValueError: 318 | return None 319 | 320 | def get_server(self, server_id: str) -> Optional[Server]: 321 | """ 322 | Gets a server from the cache by ID. 323 | 324 | Parameters 325 | ---------- 326 | server_id: :class:`str` 327 | The ID of the server. 328 | 329 | Returns 330 | ------- 331 | Optional[:class:`Server`] 332 | The server. 333 | """ 334 | try: 335 | return self.cache.get_server(server_id) 336 | except ValueError: 337 | return None 338 | 339 | async def set_status(self, text: Optional[str] = None, presence: Optional[PresenceType] = None): 340 | """ 341 | Sets the client's status. 342 | 343 | Parameters 344 | ---------- 345 | text: Optional[:class:`str`] 346 | The text to set the status to. 347 | presence: Optional[:class:`str`] 348 | The presence to set the status to. 349 | """ 350 | data: dict[ 351 | Literal["text", "presence"], 352 | Union[str, Literal["Online", "Busy", "Idle", "Offline"]], 353 | ] = {} 354 | if text: 355 | data["text"] = text 356 | if presence: 357 | data["presence"] = presence.value 358 | await self.http.edit_self(status=data) 359 | -------------------------------------------------------------------------------- /voltage/embed.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING, Optional, Union 4 | 5 | from .asset import Asset 6 | 7 | # Internal imports 8 | from .enums import EmbedType 9 | from .file import File, get_file_from_url 10 | 11 | if TYPE_CHECKING: 12 | from .internals import HTTPHandler 13 | from .types import ( 14 | EmbedPayload, 15 | ImageEmbedPayload, 16 | SendableEmbedPayload, 17 | TextEmbedPayload, 18 | WebsiteEmbedPayload, 19 | ) 20 | 21 | 22 | class WebsiteEmbed: 23 | """ 24 | A class that represents a website embed. 25 | 26 | Attributes 27 | ---------- 28 | title: Optional[:class:`str`] 29 | The title of the embed. 30 | description: Optional[:class:`str`] 31 | The description of the embed. 32 | url: Optional[:class:`str`] 33 | The url of the embed. 34 | colour: Optional[:class:`int`] 35 | The colour of the embed. 36 | color: Optional[:class:`int`] 37 | Alias for :attr:`colour`. 38 | special: Optional[:class:`str`] 39 | The special data of the embed. 40 | image: Optional[:class:`str`] 41 | The image of the embed. 42 | video: Optional[:class:`str`] 43 | The video of the embed. 44 | icon_url: Optional[:class:`str`] 45 | The icon url of the embed. 46 | site_name: Optional[:class:`str`] 47 | The site name of the embed. 48 | """ 49 | 50 | __slots__ = ( 51 | "title", 52 | "description", 53 | "url", 54 | "colour", 55 | "color", 56 | "special", 57 | "image", 58 | "video", 59 | "icon_url", 60 | "site_name", 61 | ) 62 | 63 | type = EmbedType.website 64 | 65 | def __init__(self, embed: WebsiteEmbedPayload): 66 | self.title = embed.get("title") 67 | self.description = embed.get("description") 68 | self.url = embed.get("url") 69 | self.colour = embed.get("colour") 70 | self.color = self.colour 71 | self.special = embed.get("special") 72 | self.image = embed.get("image") 73 | self.video = embed.get("video") 74 | self.icon_url = embed.get("icon_url") 75 | self.site_name = embed.get("site_name") 76 | 77 | 78 | class ImageEmbed: 79 | """ 80 | A class that represents an image embed. 81 | 82 | Attributes 83 | ---------- 84 | url: Optional[:class:`str`] 85 | The url of the embed. 86 | size: Optional[:class:`int`] 87 | The size of the embed. 88 | height: Optional[:class:`int`] 89 | The height of the embed. 90 | width: Optional[:class:`int`] 91 | The width of the embed. 92 | """ 93 | 94 | __slots__ = ("url", "size", "height", "width") 95 | 96 | type = EmbedType.image 97 | 98 | def __init__(self, embed: ImageEmbedPayload): 99 | self.url = embed.get("url") 100 | self.size = embed.get("size") 101 | self.height = embed.get("height") 102 | self.width = embed.get("width") 103 | 104 | 105 | class TextEmbed: 106 | """ 107 | A class that represents a text embed. 108 | 109 | Attributes 110 | ---------- 111 | title: Optional[:class:`str`] 112 | The title of the embed. 113 | description: Optional[:class:`str`] 114 | The description of the embed. 115 | url: Optional[:class:`str`] 116 | The url of the embed. 117 | colour: Optional[:class:`str`] 118 | The colour of the embed. 119 | color: Optional[:class:`str`] 120 | Alias for :attr:`colour`. 121 | icon_url: Optional[:class:`str`] 122 | The icon url of the embed. 123 | media: Optional[:class:`Asset`] 124 | The media of the embed. 125 | """ 126 | 127 | __slots__ = ("title", "description", "url", "colour", "color", "icon_url", "media") 128 | 129 | type = EmbedType.text 130 | 131 | def __init__(self, embed: TextEmbedPayload, http: HTTPHandler): 132 | self.title = embed.get("title") 133 | self.description = embed.get("description") 134 | self.url = embed.get("url") 135 | self.colour = embed.get("colour") 136 | self.color = self.colour 137 | self.icon_url = embed.get("icon_url") 138 | media = embed.get("media") 139 | self.media = Asset(media, http) if media else None 140 | 141 | 142 | class NoneEmbed: 143 | type = EmbedType.none 144 | 145 | 146 | Embed = Union[WebsiteEmbed, ImageEmbed, TextEmbed, NoneEmbed] 147 | 148 | 149 | def create_embed(data: EmbedPayload, http: HTTPHandler) -> Embed: 150 | """ 151 | A function that creates an embed from a dict. 152 | You shouldn't run this method yourself. 153 | 154 | Parameters 155 | ---------- 156 | data: :class:`EmbedPayload` 157 | The embed data. 158 | http: :class:`HTTPHandler` 159 | The http handler. 160 | 161 | Returns 162 | ------- 163 | :class:`Embed` 164 | The embed. 165 | """ 166 | if data["type"] == "Website": 167 | return WebsiteEmbed(data) 168 | elif data["type"] == "Image": 169 | return ImageEmbed(data) 170 | elif data["type"] == "Text": 171 | return TextEmbed(data, http) 172 | else: 173 | return NoneEmbed() 174 | 175 | 176 | class SendableEmbed: # It's Zoma's fault the name is this long. 177 | """ 178 | A class that represents a sendable TextEmbed. 179 | 180 | Attributes 181 | ---------- 182 | title: Optional[:class:`str`] 183 | The title of the embed. 184 | description: Optional[:class:`str`] 185 | The description of the embed. 186 | url: Optional[:class:`str`] 187 | The url of the embed. 188 | colour: Optional[Union[:class:`str`]] 189 | The colour of the embed. 190 | color: Optional[Union[:class:`str`]] 191 | Alias for :attr:`colour`. 192 | icon_url: Optional[:class:`str`] 193 | The icon url of the embed. 194 | media: Optional[:class:`str`] 195 | The media of the embed. 196 | """ 197 | 198 | __slots__ = ( 199 | "title", 200 | "description", 201 | "url", 202 | "colour", 203 | "color", 204 | "icon_url", 205 | "media", 206 | ) 207 | 208 | def __init__( 209 | self, 210 | title: Optional[str] = None, 211 | description: Optional[str] = None, 212 | url: Optional[str] = None, 213 | colour: Optional[str] = None, 214 | color: Optional[str] = None, 215 | icon_url: Optional[str] = None, 216 | media: Optional[Union[str, File]] = None, 217 | ): 218 | self.title = title 219 | self.description = description 220 | self.url = url 221 | self.colour = colour or color 222 | self.color = self.colour 223 | self.icon_url = icon_url 224 | self.media = media 225 | 226 | async def to_dict(self, http: HTTPHandler) -> SendableEmbedPayload: 227 | """ 228 | A function that returns an embed as a dict for api purposes. 229 | You shouldn't run this method yourself. 230 | 231 | Returns 232 | ------- 233 | :class:`SendableEmbedPayload` 234 | The embed as a dict. 235 | """ 236 | embed: SendableEmbedPayload = {"type": "Text"} 237 | if self.title is not None: 238 | embed["title"] = self.title 239 | if self.description is not None: 240 | embed["description"] = self.description 241 | if self.url is not None: 242 | embed["url"] = self.url 243 | if self.colour is not None: 244 | embed["colour"] = self.colour 245 | if self.icon_url is not None: 246 | embed["icon_url"] = self.icon_url 247 | if self.media is not None: 248 | if isinstance(self.media, File): 249 | embed["media"] = await self.media.get_id(http) 250 | else: 251 | embed["media"] = await (await get_file_from_url(http, self.media)).get_id(http) 252 | return embed 253 | -------------------------------------------------------------------------------- /voltage/enums.py: -------------------------------------------------------------------------------- 1 | import enum 2 | 3 | 4 | class EmbedType(enum.Enum): 5 | """ 6 | An enum which represents an Embed's type. 7 | """ 8 | 9 | website = "Website" 10 | image = "Image" 11 | text = "Text" 12 | none = "None" 13 | 14 | 15 | class SortType(enum.Enum): 16 | """ 17 | An enum which represents the sort type for fetching messages. 18 | """ 19 | 20 | latest = "Latest" 21 | oldest = "Oldest" 22 | relevance = "Relevance" 23 | 24 | 25 | class ChannelType(enum.Enum): 26 | """ 27 | An enum which represents the channel type. 28 | """ 29 | 30 | text_channel = "TextChannel" 31 | voice_channel = "VoiceChannel" 32 | group = "Group" 33 | direct_message = "DirectMessage" 34 | saved_message = "SavedMessages" 35 | 36 | 37 | class AssetType(enum.Enum): 38 | """ 39 | An enum which represents the type of an asset. 40 | """ 41 | 42 | image = "Image" 43 | video = "Video" 44 | audio = "Audio" 45 | file = "File" 46 | text = "Text" 47 | 48 | 49 | class PresenceType(enum.Enum): 50 | """ 51 | An enum which represents the type of a presence. 52 | """ 53 | 54 | busy = "Busy" 55 | idle = "Idle" 56 | online = "Online" 57 | invisible = "Invisible" 58 | focus = "Focus" 59 | 60 | 61 | class RelationshipType(enum.Enum): 62 | """ 63 | An enum which represents the type of a relationship between two users. 64 | """ 65 | 66 | friend = "Friend" 67 | blocked = "Blocked" 68 | blocked_other = "BlockedOther" 69 | incoming_request = "Incoming" 70 | outgoing_request = "Outgoing" 71 | user = "User" 72 | none = "None" 73 | -------------------------------------------------------------------------------- /voltage/errors.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING, Union 4 | 5 | from aiohttp import ClientResponse 6 | 7 | if TYPE_CHECKING: 8 | from .ext import commands 9 | from .member import Member 10 | from .user import User 11 | 12 | 13 | class VoltageException(Exception): 14 | """ 15 | Base class for all Voltage exceptions. This could be used to catch all exceptions made from this library. 16 | """ 17 | 18 | pass 19 | 20 | 21 | class HTTPError(VoltageException): 22 | """ 23 | Exception that's raised when an HTTP request operation fails. 24 | 25 | Attributes 26 | ---------- 27 | response: aiohttp.ClientResponse 28 | The response of the failed HTTP request. This is an 29 | instance of :class:`aiohttp.ClientResponse`. 30 | status: int 31 | The status code of the HTTP request. 32 | """ 33 | 34 | def __init__(self, response: ClientResponse): 35 | self.response = response 36 | 37 | 38 | class PermissionError(VoltageException): 39 | """ 40 | An exception that's raised when the client doesn't have the required permissions to perform an action. 41 | """ 42 | 43 | pass 44 | 45 | 46 | class CommandNotFound(VoltageException): 47 | """ 48 | An exception that's raised when a command is not found. 49 | 50 | Attributes 51 | ---------- 52 | command: :class:`str` 53 | The name of the command that was not found. 54 | """ 55 | 56 | def __init__(self, command: str): 57 | self.command = command 58 | 59 | def __str__(self): 60 | return f"Command {self.command} not found" 61 | 62 | 63 | class NotEnoughArgs(VoltageException): 64 | """ 65 | An exception that is raised when not enough args are supplied. 66 | 67 | Attributes 68 | ---------- 69 | command: :class:`Command` 70 | The command that was being called. 71 | expected: :class:`int` 72 | The number of args that were expected. 73 | actual: :class:`int` 74 | The number of args that were actually supplied. 75 | """ 76 | 77 | def __init__(self, command: commands.Command, expected: int, actual: int): 78 | self.command = command 79 | self.expected = expected 80 | self.actual = actual 81 | 82 | def __str__(self): 83 | s = "s" if self.expected > 1 else "" 84 | return f"{self.command.name} expected {self.expected} arg{s}, got {self.actual}" 85 | 86 | 87 | class NotFoundException(VoltageException): 88 | """ 89 | An exception that is raised when a resource is not found. 90 | 91 | Attributes 92 | ---------- 93 | resource: :class:`str` 94 | The name of the resource that was not found. 95 | """ 96 | 97 | def __init__(self, resource: str): 98 | self.resource = resource 99 | 100 | def __str__(self): 101 | return f"{self.resource} not found" 102 | 103 | 104 | class UserNotFound(NotFoundException): 105 | """ 106 | An exception that is raised when a user is not found. 107 | """ 108 | 109 | def __str__(self): 110 | return f"User {self.resource} not found" 111 | 112 | 113 | class MemberNotFound(UserNotFound): 114 | """ 115 | An exception that is raised when a member is not found. 116 | """ 117 | 118 | def __str__(self): 119 | return f"Member {self.resource} not found" 120 | 121 | 122 | class ChannelNotFound(NotFoundException): 123 | """ 124 | An exception that is raised when a channel is not found. 125 | """ 126 | 127 | def __str__(self): 128 | return f"Channel {self.resource} not found" 129 | 130 | 131 | class RoleNotFound(NotFoundException): 132 | """ 133 | An exception that is raised when a role is not found. 134 | """ 135 | 136 | def __str__(self): 137 | return f"Role {self.resource} not found" 138 | 139 | 140 | class NotBotOwner(VoltageException): 141 | """ 142 | An exception that is raised when a user is not the bot owner. 143 | 144 | Called by the :func:`~voltage.ext.commands.is_owner` check 145 | 146 | Attributes 147 | ---------- 148 | user: Union[:class:`voltage.User`, :class:`voltage.Member`] 149 | The user that tried to envoke the command. 150 | """ 151 | 152 | def __init__(self, user: Union[User, Member]): 153 | self.user = user 154 | 155 | def __str__(self): 156 | return "You are not this bot's owner" 157 | 158 | 159 | class NotEnoughPerms(VoltageException): 160 | """ 161 | An exception that is raised when a user does not have enough permissions. 162 | 163 | Called by the :func:`~voltage.ext.commands.has_perms` check 164 | 165 | Attributes 166 | ---------- 167 | user: Union[:class:`voltage.User`, :class:`voltage.Member`] 168 | The user that tried to envoke the command. 169 | """ 170 | 171 | def __init__(self, user: Union[User, Member], perm: str): 172 | self.user = user 173 | self.perm = perm 174 | 175 | def __str__(self): 176 | return f"You do not have the {self.perm} permission required to use this command." 177 | 178 | 179 | class BotNotEnoughPerms(VoltageException): 180 | """ 181 | An exception that is raised when the bot does not have enough permissions. 182 | 183 | Called by the :func:`~voltage.ext.commands.has_perms` check 184 | 185 | Attributes 186 | ---------- 187 | user: Union[:class:`voltage.User`, :class:`voltage.Member`] 188 | The user that tried to envoke the command. 189 | """ 190 | 191 | def __init__(self, perm: str): 192 | self.perm = perm 193 | 194 | def __str__(self): 195 | return f"I am lacking the {self.perm} permission required to use this command." 196 | -------------------------------------------------------------------------------- /voltage/ext/commands/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | The built-in voltage commands framework. 3 | 4 | Commands frameworks example: 5 | 6 | .. code-block:: python3 7 | 8 | import voltage 9 | from voltage.ext import commands # Import the commands module from ``voltage.ext`` 10 | 11 | client = commands.CommandsClient("-") # Create a CommandsClient (client that has commands (original ik)) with the prefix set to "-". 12 | 13 | @client.listen("ready") # You can still listen to events. 14 | async def ready(): 15 | print("Gaaah, It's rewind time.") 16 | 17 | @client.command() # Register a command using the ``command`` decorator. 18 | async def ping(ctx): # Name and description can be passed in the decorator or automatically inferred. 19 | await ctx.reply("Pong") # Reply to the context's message. 20 | 21 | client.run("TOKEN") # Again, replace with your bot token. 22 | 23 | """ 24 | 25 | from .check import Check as Check 26 | from .check import bot_has_perms as bot_has_perms 27 | from .check import check as check 28 | from .check import has_perms as has_perms 29 | from .check import is_owner as is_owner 30 | from .check import is_server_owner 31 | from .client import CommandsClient as CommandsClient 32 | from .cog import Cog as Cog 33 | from .cog import SubclassedCog as SubclassedCog 34 | from .command import Command as Command 35 | from .command import CommandContext as CommandContext 36 | from .command import command as command 37 | from .converters import Converter as Converter 38 | from .converters import converter as converter 39 | from .help import HelpCommand as HelpCommand 40 | 41 | __all__ = [ 42 | "Check", 43 | "bot_has_perms", 44 | "check", 45 | "has_perms", 46 | "is_owner", 47 | "is_server_owner", 48 | "CommandsClient", 49 | "Cog", 50 | "SubclassedCog", 51 | "Command", 52 | "CommandContext", 53 | "command", 54 | "Converter", 55 | "converter", 56 | "HelpCommand", 57 | ] 58 | -------------------------------------------------------------------------------- /voltage/ext/commands/check.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING, Any, Awaitable, Callable 4 | 5 | from voltage import BotNotEnoughPerms, Member, NotEnoughPerms, User 6 | 7 | if TYPE_CHECKING: 8 | from .command import Command, CommandContext 9 | 10 | 11 | class Check: 12 | """ 13 | A class which represent's a command pre-invoke check. 14 | 15 | If a check returns ``False``, the command will not be invoked. 16 | 17 | Alternatively, if a check raises an error the command will also not be invoked. 18 | 19 | Checks are ran in parallel using ``asyncio.gather``. 20 | """ 21 | 22 | def __init__( 23 | self, 24 | func: Callable[..., Awaitable[Callable[[CommandContext], Awaitable[bool]]]], 25 | ) -> None: 26 | self.func = func 27 | self.args: tuple[Any, ...] = () 28 | self.kwargs: dict[str, Any] = {} 29 | 30 | async def check(self, context: CommandContext) -> bool: 31 | check = await self.func(*self.args, **self.kwargs) 32 | return await check(context) 33 | 34 | def __call__(self, *args, **kwargs): 35 | def inner(command: Command): 36 | self.args = args 37 | self.kwargs = kwargs 38 | command.checks.append(self) 39 | return command 40 | 41 | return inner 42 | 43 | 44 | def check(func: Callable[..., Awaitable[Callable[[CommandContext], Awaitable[bool]]]]) -> Check: 45 | """ 46 | A decorator which creates a check from a function. 47 | """ 48 | return Check(func) 49 | 50 | 51 | @check 52 | async def is_owner() -> Callable[[CommandContext], Awaitable[bool]]: 53 | """ 54 | Checks if the user invoking the command is the bot owner. 55 | """ 56 | 57 | async def check(ctx: CommandContext) -> bool: 58 | return ctx.author.id == ctx.client.user.owner_id 59 | 60 | return check 61 | 62 | 63 | @check 64 | async def is_server_owner() -> Callable[[CommandContext], Awaitable[bool]]: 65 | """ 66 | Checks if the user invoking the command is the server owner. 67 | """ 68 | 69 | async def check(ctx: CommandContext) -> bool: 70 | if not ctx.server: 71 | return False 72 | return ctx.author.id == ctx.server.owner_id 73 | 74 | return check 75 | 76 | 77 | @check 78 | async def has_perms(**kwargs) -> Callable[[CommandContext], Awaitable[bool]]: 79 | """ 80 | Checks if the user invoking the command has the required perms. 81 | """ 82 | 83 | async def check(ctx: CommandContext) -> bool: 84 | if isinstance(ctx.author, User): 85 | return True 86 | for permission, state in kwargs.items(): 87 | if state: 88 | if not hasattr(ctx.author.permissions, permission): 89 | raise ValueError(f"Permission {permission} does not exist") 90 | if not getattr(ctx.author.permissions, permission): 91 | raise NotEnoughPerms(ctx.author, permission) 92 | return True 93 | 94 | return check 95 | 96 | 97 | @check 98 | async def bot_has_perms(**kwargs) -> Callable[[CommandContext], Awaitable[bool]]: 99 | """ 100 | Checks if bot has the required perms in that channel. 101 | """ 102 | 103 | async def check(ctx: CommandContext) -> bool: 104 | if not isinstance(ctx.me, Member): 105 | return True 106 | for permission, state in kwargs.items(): 107 | if state: 108 | if not hasattr(ctx.me.permissions, permission): 109 | raise ValueError(f"Permission {permission} does not exist") 110 | if not getattr(ctx.me.permissions, permission): 111 | raise BotNotEnoughPerms(permission) 112 | return True 113 | 114 | return check 115 | -------------------------------------------------------------------------------- /voltage/ext/commands/client.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import sys 4 | from importlib import import_module, reload 5 | from os import listdir, sep 6 | from types import ModuleType 7 | from typing import TYPE_CHECKING, Any, Awaitable, Callable, Optional, Type, Union 8 | 9 | # internal imports 10 | from voltage import Client, CommandNotFound, Message 11 | 12 | from .command import Command, CommandContext 13 | from .help import HelpCommand 14 | 15 | if TYPE_CHECKING: 16 | from .cog import Cog 17 | 18 | 19 | def get_extensions_from_dir(path: str) -> list[str]: 20 | """Gets all files that end with ``.py`` in a directory and returns a python dotpath.""" 21 | dirdotpath = ".".join(path.split(sep)[1:]) # we ignore the first part because we don't want to add the ``./``. 22 | return [f"{dirdotpath}.{file}" for file in listdir(path) if file.endswith(".py")] # Hello olivier. 23 | 24 | 25 | class CommandsClient(Client): 26 | """A class representing a client that uses commands. 27 | 28 | Attributes 29 | ---------- 30 | prefix: Union[str, list[str], Callable[[Message, CommandsClient], Awaitable[Any]]] 31 | The prefixes that the client will respond to. 32 | help_command: Type[HelpCommand] 33 | The help command class. 34 | commands: dict[str, Command] 35 | The commands that the client will respond to. 36 | cogs: List[:class:`Cog`] 37 | The cogs that are loaded. 38 | extensions: dict[str, tuple[ModuleType, str]] 39 | The extensions that are loaded. 40 | """ 41 | 42 | __slots__ = ("cogs", "extensions", "help_command", "prefix", "commands") 43 | 44 | def __init__( 45 | self, 46 | prefix: Union[str, list[str], Callable[[Message, CommandsClient], Awaitable[Any]]], 47 | help_command: Type[HelpCommand] = HelpCommand, 48 | cache_message_limit: int = 5000, 49 | ): 50 | super().__init__(cache_message_limit=cache_message_limit) 51 | self.listeners = {"message": self.handle_commands} 52 | self.prefix = prefix 53 | self.cogs: dict[str, Cog] = {} 54 | self.extensions: dict[str, tuple[ModuleType, str]] = {} 55 | self.help_command = help_command(self) 56 | self.commands: dict[str, Command] = { 57 | "help": Command(self.help, "help", "Displays help for a command.", ["h", "help"], None) 58 | } 59 | 60 | async def help(self, ctx: CommandContext, target: str = None): # type: ignore 61 | """Basic help command.""" 62 | if target is None: 63 | return await self.help_command.send_help(ctx) 64 | elif command := self.commands.get(target): 65 | return await self.help_command.send_command_help(ctx, command) 66 | elif cog := self.cogs.get(target): 67 | return await self.help_command.send_cog_help(ctx, cog) 68 | await self.help_command.send_not_found(ctx, target) 69 | 70 | async def get_prefix( 71 | self, 72 | message: Message, 73 | prefix: Union[str, list[str], Callable[[Message, CommandsClient], Awaitable[Any]]], 74 | ) -> str: 75 | if message.content is None: 76 | return "" 77 | if isinstance(prefix, str): 78 | return prefix 79 | elif isinstance(prefix, list): 80 | for p in prefix: 81 | if message.content.startswith(p): 82 | return p 83 | elif callable(prefix): 84 | return await self.get_prefix(message, await prefix(message, self)) 85 | return str(prefix) 86 | 87 | def add_command(self, command: Command): 88 | """Adds a command to the client. 89 | 90 | Parameters 91 | ---------- 92 | command: :class:`Command` 93 | The command to add. 94 | """ 95 | if command.name in self.commands: 96 | raise ValueError(f"Command {command.name} already exists.") 97 | self.commands[command.name] = command 98 | if command.aliases is None: 99 | return 100 | for alias in command.aliases: 101 | if alias in self.commands: 102 | raise ValueError(f"Command {alias} already exists.") 103 | self.commands[alias] = command 104 | 105 | def add_cog(self, cog: Cog): 106 | """Adds a cog to the client. 107 | 108 | Parameters 109 | ---------- 110 | cog: :class:`Cog` 111 | The cog to add. 112 | """ 113 | self.cogs[cog.name] = cog 114 | for command in cog.commands: 115 | self.add_command(command) 116 | if func := cog.listeners.get("load"): 117 | func() 118 | 119 | def remove_cog(self, cog: Cog) -> Cog: 120 | """Removes a cog from the client. 121 | 122 | Parameters 123 | ---------- 124 | cog: :class:`Cog` 125 | The cog to remove. 126 | 127 | Returns 128 | ------- 129 | :class:`Cog` 130 | The cog that was removed. 131 | """ 132 | items = list(self.commands.items()) 133 | for command_name, command in items: 134 | if command.cog: 135 | if command.cog.name == cog.name: 136 | cmd = self.commands.pop(command_name) 137 | del cmd 138 | cog = self.cogs.pop(cog.name) 139 | if func := cog.listeners.get("unload"): 140 | func() 141 | return cog 142 | 143 | def add_extension(self, path: str, *args, **kwargs): 144 | """Adds an extension to the client. 145 | 146 | Parameters 147 | ---------- 148 | path: :class:`str` 149 | The path to the extension as a python dotpath. 150 | """ 151 | module = import_module(path) 152 | cog = module.setup(self, *args, **kwargs) 153 | self.extensions[path] = (module, cog.name) 154 | if not hasattr(module, "setup"): 155 | raise AttributeError(f"Extension {path} does not have a setup function.") 156 | reload(module) 157 | self.add_cog(cog) 158 | 159 | def add_extensions_from_dir(self, path: str, *args, **kwargs): 160 | """Adds all the extensions in a directory. 161 | 162 | .. note: 163 | 164 | This attempts to add all files that end with ``.py`` and isn't recursive. 165 | 166 | Parameters 167 | ---------- 168 | path: :class:`str` 169 | The path to the directory with the extensions as a normal path. 170 | """ 171 | [self.add_extension(extension, *args, **kwargs) for extension in get_extensions_from_dir(path)] 172 | 173 | def reload_extension(self, path: str): 174 | """Reloads an extension. 175 | 176 | Parameters 177 | ---------- 178 | path: :class:`str` 179 | The path to the extension as a python dotpath. 180 | """ 181 | self.remove_extension(path) 182 | self.add_extension(path) 183 | 184 | def remove_extension(self, path: str): 185 | """Removes an extension. 186 | 187 | Parameters 188 | ---------- 189 | path: :class:`str` 190 | The path to the extension as a python dotpath. 191 | """ 192 | if not path in self.extensions: 193 | raise KeyError(f"Extension {path} does not exist.") 194 | module, name = self.extensions.pop(path) 195 | cog = self.remove_cog(self.cogs[name]) 196 | del cog 197 | del module 198 | del name 199 | mod = sys.modules.pop(path) 200 | del mod 201 | 202 | def command( 203 | self, 204 | name: Optional[str] = None, 205 | description: Optional[str] = None, 206 | aliases: Optional[list[str]] = None, 207 | ): 208 | """A decorator for adding commands to the client. 209 | 210 | Parameters 211 | ---------- 212 | name: Optional[:class:`str`] 213 | The name of the command. 214 | description: Optional[:class:`str`] 215 | The description of the command. 216 | aliases: Optional[List[:class:`str`]] 217 | The aliases of the command. 218 | """ 219 | 220 | def decorator(func: Callable[..., Awaitable[Any]]): 221 | command = Command(func, name, description, aliases) 222 | self.add_command(command) 223 | return command 224 | 225 | return decorator 226 | 227 | async def cog_dispatch(self, event: str, cog: Cog, *args, **kwargs): 228 | if func := cog.listeners.get(event): 229 | if cog.subclassed: 230 | coro = func(cog, *args, **kwargs) 231 | else: 232 | coro = func(*args, **kwargs) 233 | if self.error_handlers.get(event): 234 | try: 235 | await coro 236 | except Exception as e: 237 | await self.error_handlers[event](e, *args, **kwargs) 238 | else: 239 | await coro 240 | 241 | async def dispatch(self, event: str, *args, **kwargs): 242 | event = event.lower() 243 | 244 | for i in self.waits.get(event, []): 245 | if i[0](*args, **kwargs): 246 | i[1].set_result(*args, **kwargs) 247 | self.waits[event].remove(i) 248 | 249 | for cog in self.cogs.values(): 250 | self.loop.create_task(self.cog_dispatch(event, cog, *args, **kwargs)) 251 | 252 | if func := self.listeners.get(event): 253 | if self.error_handlers.get(event): 254 | try: 255 | await func(*args, **kwargs) 256 | except Exception as e: 257 | await self.error_handlers[event](e, *args, **kwargs) 258 | else: 259 | await func(*args, **kwargs) 260 | 261 | async def cog_raw_dispatch(self, event: str, cog: Cog, payload: dict[Any, Any]): 262 | if func := cog.raw_listeners.get(event): 263 | if cog.subclassed: 264 | await func(payload) 265 | else: 266 | await func(cog, payload) 267 | 268 | async def raw_dispatch(self, payload: dict[Any, Any]): 269 | event = payload["type"].lower() 270 | 271 | for cog in self.cogs.values(): 272 | self.loop.create_task(self.cog_raw_dispatch(event, cog, payload)) 273 | 274 | if func := self.raw_listeners.get(event): 275 | await func(payload) 276 | 277 | async def handle_commands(self, message: Message): 278 | prefix = await self.get_prefix(message, self.prefix) 279 | if message.content is None: 280 | return 281 | if message.content.startswith(prefix): 282 | content = message.content[len(prefix) :] 283 | command = content.split(" ")[0] 284 | if not command: 285 | return 286 | if command in self.commands: 287 | if "command" in self.error_handlers: 288 | try: 289 | return await self.commands[command].invoke( 290 | CommandContext(message, self.commands[command], self, prefix), 291 | prefix, 292 | ) 293 | except Exception as e: 294 | return await self.error_handlers["command"]( 295 | e, 296 | CommandContext(message, self.commands[command], self, prefix), 297 | ) 298 | return await self.commands[command].invoke( 299 | CommandContext(message, self.commands[command], self, prefix), 300 | prefix, 301 | ) 302 | raise CommandNotFound(command) 303 | -------------------------------------------------------------------------------- /voltage/ext/commands/cog.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import Any, Awaitable, Callable, Optional 4 | 5 | from .command import Command 6 | 7 | 8 | class Cog: 9 | """ 10 | A class representing a cog. 11 | 12 | Attributes 13 | ---------- 14 | name: :class:`str` 15 | The name of the cog. 16 | description: Optional[:class:`str`] 17 | The description of the cog. 18 | commands: List[:class:`Command`] 19 | The commands in the cog. 20 | """ 21 | 22 | def __init__(self, name: str, description: Optional[str] = None): 23 | self.name = name 24 | self.description = description 25 | self.commands: list[Command] = [] 26 | self.listeners: dict[str, Callable[..., Any]] = {} 27 | self.raw_listeners: dict[str, Callable[..., Any]] = {} 28 | self.subclassed: bool = False 29 | 30 | def listen(self, event: str, *, raw: bool = False): 31 | """ 32 | Registers a function to listen for an event. 33 | 34 | This function is meant to be used as a decorator. 35 | 36 | Parameters 37 | ---------- 38 | func: Callable[..., Any] 39 | The function to call when the event is triggered. 40 | event: :class:`str` 41 | The event to listen for. 42 | raw: :class:`bool` 43 | Whether or not to listen for raw events. 44 | 45 | Examples 46 | -------- 47 | 48 | .. code-block:: python3 49 | 50 | Fun = Cog("Fun") 51 | 52 | @Fun.listen("message") 53 | async def any_name_you_want(message): 54 | if message.content == "ping": 55 | await message.channel.send("pong") 56 | 57 | # example of a raw event 58 | @Fun.listen("message", raw=True) 59 | async def raw(payload): 60 | if payload["content"] == "ping": 61 | await client.http.send_message(payload["channel"], "pong") 62 | 63 | """ 64 | 65 | def inner(func: Callable[..., Any]): 66 | if raw: 67 | self.raw_listeners[event.lower()] = func 68 | else: 69 | self.listeners[event.lower()] = func 70 | return func 71 | 72 | return inner 73 | 74 | def add_command(self, command: Command): 75 | """ 76 | Adds a command to the cog. 77 | 78 | idk why you're doing thit but consider using the decorator for this /shrug. 79 | 80 | Parameters 81 | ---------- 82 | command: :class:`Command` 83 | The command to add. 84 | """ 85 | if command.cog is not None: 86 | raise RuntimeError("Command already has a cog.") 87 | command.cog = self 88 | self.commands.append(command) 89 | 90 | def command( 91 | self, 92 | name: Optional[str] = None, 93 | description: Optional[str] = None, 94 | aliases: Optional[list[str]] = None, 95 | ): 96 | """ 97 | A decorator for adding commands to the cog. 98 | 99 | Parameters 100 | ---------- 101 | name: Optional[:class:`str`] 102 | The name of the command. 103 | description: Optional[:class:`str`] 104 | The description of the command. 105 | aliases: Optional[List[:class:`str`]] 106 | The aliases of the command. 107 | """ 108 | 109 | def decorator(func: Callable[..., Awaitable[Any]]): 110 | command = Command(func, name, description, aliases) 111 | self.add_command(command) 112 | return command 113 | 114 | return decorator 115 | 116 | 117 | class SubclassedCog(Cog): 118 | """ 119 | A class representing a cog. 120 | 121 | Attributes 122 | ---------- 123 | name: :class:`str` 124 | The name of the cog. 125 | description: Optional[:class:`str`] 126 | The description of the cog. 127 | commands: List[:class:`Command`] 128 | The commands in the cog. 129 | """ 130 | 131 | name: str 132 | description: Optional[str] 133 | commands: list[Command] = [] 134 | listeners: dict[str, Callable[..., Any]] = {} 135 | raw_listeners: dict[str, Callable[..., Any]] = {} 136 | subclassed: bool = False 137 | 138 | def __new__(cls, *args, **kwargs): 139 | cls.name = cls.__name__ 140 | cls.description = cls.__doc__ 141 | cls.subclassed = False 142 | cls.commands = [] 143 | cls.listeners = {} 144 | cls.raw_listeners = {} 145 | for name, attr in cls.__dict__.items(): 146 | if isinstance(attr, Command): 147 | attr.subclassed = True 148 | cls.commands.append(attr) 149 | cls.subclassed = True 150 | elif isinstance(attr, Callable): 151 | if name.startswith("on_"): 152 | cls.listeners[name[3:].lower()] = attr 153 | cls.subclassed = True 154 | elif name.startswith("raw_"): 155 | cls.raw_listeners[name[4:].lower()] = attr 156 | cls.subclassed = True 157 | return super().__new__(cls) 158 | -------------------------------------------------------------------------------- /voltage/ext/commands/command.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from asyncio import gather 4 | from inspect import Parameter, _empty, isclass, signature 5 | from itertools import zip_longest 6 | from re import findall 7 | from typing import TYPE_CHECKING, Any, Awaitable, Callable, Optional 8 | 9 | # internal imports 10 | from voltage import Member, Message, NotEnoughArgs 11 | 12 | from . import converters 13 | 14 | if TYPE_CHECKING: 15 | from .check import Check 16 | from .client import CommandsClient 17 | from .cog import Cog 18 | 19 | 20 | async def dummy_func(self, *args, **kwargs): 21 | return NotImplemented 22 | 23 | 24 | class CommandContext: 25 | """ 26 | A context for a command. 27 | 28 | Attributes 29 | ---------- 30 | message: :class:`voltage.Message` 31 | The message that invoked the command. 32 | content: Optional[:class:`str`] 33 | The content of the message that invoked the command. 34 | author: Union[:class:`voltage.User`, :class:`voltage.Member`] 35 | The author of the message that invoked the command. 36 | channel: :class:`voltage.Channel` 37 | The channel that the command was invoked in. 38 | server: :class:`voltage.Server` 39 | The server that the command was invoked in. 40 | command: :class:`Command` 41 | The command that was invoked. 42 | prefix: :class:`str` 43 | The prefix used to invoke the command. 44 | """ 45 | 46 | __slots__ = ( 47 | "message", 48 | "content", 49 | "author", 50 | "channel", 51 | "server", 52 | "send", 53 | "typing", 54 | "reply", 55 | "delete", 56 | "command", 57 | "me", 58 | "client", 59 | "prefix", 60 | ) 61 | 62 | def __init__(self, message: Message, command: Command, client: CommandsClient, prefix: str): 63 | self.message = message 64 | self.content = message.content 65 | self.author = message.author 66 | self.channel = message.channel 67 | self.server = message.server 68 | self.reply = message.reply 69 | self.delete = message.delete 70 | self.command = command 71 | self.client = client 72 | 73 | self.send = getattr(message.channel, "send", dummy_func) 74 | self.typing = getattr(message.channel, "typing", dummy_func) 75 | if message.server: 76 | self.me: Optional[Member] = client.cache.get_member(message.server.id, client.user.id) 77 | else: 78 | self.me = None 79 | 80 | self.prefix = prefix 81 | 82 | 83 | class Command: 84 | """ 85 | A class representing a command. 86 | 87 | Attributes 88 | ---------- 89 | name: :class:`str` 90 | The name of the command. 91 | description: Optional[:class:`str`] 92 | The description of the command. 93 | aliases: Optional[List[:class:`str`]] 94 | The aliases of the command. 95 | cog: Optional[:class:`Cog`] 96 | The cog that the command belongs to. 97 | checks: list[:class:`Check`] 98 | The checks that must be passed for the command to be invoked. 99 | usage: str 100 | The usage of the command. 101 | """ 102 | 103 | __slots__ = ( 104 | "func", 105 | "name", 106 | "description", 107 | "aliases", 108 | "error_handler", 109 | "signature", 110 | "cog", 111 | "checks", 112 | "usage_str", 113 | "subclassed", 114 | ) 115 | 116 | def __init__( 117 | self, 118 | func: Callable[..., Awaitable[Any]], 119 | name: Optional[str] = None, 120 | description: Optional[str] = None, 121 | aliases: Optional[list[str]] = None, 122 | cog: Optional[Cog] = None, 123 | ): 124 | self.func = func 125 | self.name = name or func.__name__ 126 | self.description = description or func.__doc__ 127 | self.aliases = aliases 128 | self.error_handler: Optional[Callable[[Exception, CommandContext], Awaitable[Any]]] = None 129 | self.signature = signature(func) 130 | self.cog = cog 131 | self.checks: list[Check] = [] 132 | self.subclassed = False 133 | 134 | self.usage_str = "" 135 | 136 | @property 137 | def usage(self) -> str: 138 | """ 139 | The usage of the command. 140 | """ 141 | if self.usage_str: 142 | return self.usage_str 143 | usage = list() 144 | start = 2 if self.subclassed else 1 145 | for name, param in list(self.signature.parameters.items())[start:]: 146 | if param.default is not _empty: 147 | if param.default is not _empty and param.default is not None: 148 | usage.append(f"[{name}={param.default}]") 149 | else: 150 | usage.append(f"[{name}]") 151 | else: 152 | usage.append(f"<{name}>") 153 | 154 | self.usage_str = f"{self.name} {' '.join(usage)}" 155 | return self.usage_str 156 | 157 | def error(self, func: Callable[[Exception, CommandContext], Awaitable[Any]]): 158 | """ 159 | Sets the error handler for this command. 160 | 161 | Parameters 162 | ---------- 163 | func: :class:`Callable[[Exception, CommandContext], Awaitable[Any]]` 164 | The function to call when an error occurs. 165 | """ 166 | self.error_handler = func 167 | return self 168 | 169 | async def convert_arg(self, arg: Parameter, given: str, context: CommandContext) -> Any: 170 | annotation = arg.annotation 171 | if isinstance(annotation, str): 172 | return given 173 | if given is None: 174 | return None 175 | elif annotation is _empty or annotation is Any or issubclass(annotation, str): 176 | return given 177 | if isclass(annotation): 178 | if issubclass(annotation, converters.Converter): 179 | return await annotation().convert(context, given) 180 | if func := getattr(converters, f"{annotation.__name__.capitalize()}Converter", None): 181 | return await func().convert(context, given) 182 | return str(given) 183 | 184 | async def invoke(self, context: CommandContext, prefix: str): 185 | if context.content is None: 186 | return 187 | if self.checks: 188 | results = await gather(*[check.check(context) for check in self.checks]) 189 | if any([check is False for check in results]): 190 | return 191 | 192 | start_index = 2 if self.subclassed else 1 193 | 194 | if len((params := self.signature.parameters)) > start_index: 195 | param_start = len(prefix) + len(context.content[len(prefix) :].split()[0]) 196 | given = findall( 197 | r'(?:[^\s,"]|"(?:\\.|[^"])*")+', 198 | context.content[param_start:], 199 | ) # https://stackoverflow.com/a/16710842 200 | args: list[str] = [] 201 | kwargs = {} 202 | 203 | for i, (param, arg) in enumerate(zip_longest(list(params.items())[start_index:], given)): 204 | if param is None: 205 | break 206 | name, data = param 207 | 208 | if data.kind == data.VAR_POSITIONAL or data.kind == data.POSITIONAL_OR_KEYWORD: 209 | if arg is None: 210 | if data.default is _empty: 211 | raise NotEnoughArgs(self, len(params) - 1, len(args)) 212 | arg = data.default 213 | args.append(await self.convert_arg(data, arg, context)) 214 | 215 | elif data.kind == data.KEYWORD_ONLY: 216 | if i == len(params) - 2: 217 | if arg is None: 218 | if data.default is _empty: 219 | raise NotEnoughArgs(self, len(params) - 1, len(given)) 220 | kwargs[name] = await self.convert_arg(data, data.default, context) 221 | break 222 | kwargs[name] = await self.convert_arg( 223 | data, 224 | context.content[param_start + len(" ".join(given[:i])) + 1 :], 225 | context, 226 | ) 227 | else: 228 | if arg is None: 229 | if data.default is _empty: 230 | raise NotEnoughArgs(self, len(params) - 1, len(given)) 231 | arg = data.default 232 | kwargs[name] = await self.convert_arg(data, arg, context) 233 | 234 | coro = ( 235 | self.func(self.cog, context, *args, **kwargs) 236 | if self.subclassed 237 | else self.func(context, *args, **kwargs) 238 | ) 239 | 240 | if self.error_handler: 241 | try: 242 | return await coro 243 | except Exception as e: 244 | return await self.error_handler(e, context) 245 | return await coro 246 | 247 | coro = self.func(self.cog, context) if self.subclassed else self.func(context) 248 | 249 | if self.error_handler: 250 | try: 251 | return await coro 252 | except Exception as e: 253 | return await self.error_handler(e, context) 254 | return await coro 255 | 256 | 257 | def command( 258 | name: Optional[str] = None, 259 | description: Optional[str] = None, 260 | aliases: Optional[list[str]] = None, 261 | ): 262 | """ 263 | A decorator that creates a :class:`Command` from an asynchronous function. 264 | 265 | Parameters 266 | ---------- 267 | name: Optional[:class:`str`] 268 | The name of the command. 269 | description: Optional[:class:`str`] 270 | The description of the command. 271 | aliases: Optional[List[:class:`str`]] 272 | The aliases of the command. 273 | """ 274 | 275 | def decorator(func: Callable[..., Awaitable[Any]]): 276 | command = Command(func, name, description, aliases) 277 | return command 278 | 279 | return decorator 280 | -------------------------------------------------------------------------------- /voltage/ext/commands/converters.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from re import compile 4 | from typing import TYPE_CHECKING, Any, Awaitable, Callable, Type 5 | 6 | from voltage import ChannelNotFound, MemberNotFound, RoleNotFound, UserNotFound, get 7 | 8 | if TYPE_CHECKING: 9 | from voltage import Channel, Member, Role, User 10 | 11 | from .command import CommandContext 12 | 13 | 14 | class Converter: 15 | """ 16 | Base class that all converters inherit from. 17 | 18 | The only important method is the `convert` method, which takes a context and a string then returns an object. 19 | """ 20 | 21 | async def convert(self, ctx: CommandContext, arg: str): 22 | """ 23 | Convert a string into an object. 24 | 25 | This method should be overridden by subclasses. 26 | """ 27 | raise NotImplementedError("Converter.convert must be overridden by subclasses") 28 | 29 | 30 | class StrConverter(Converter): 31 | """ 32 | A converter that converts a string into a string. 33 | """ 34 | 35 | async def convert(self, ctx: CommandContext, arg: str) -> str: 36 | return arg 37 | 38 | 39 | class IntConverter(Converter): 40 | """ 41 | A converter that converts a string into an integer. 42 | """ 43 | 44 | async def convert(self, ctx: CommandContext, arg: str) -> int: 45 | return int(arg) 46 | 47 | 48 | class FloatConverter(Converter): 49 | """ 50 | A converter that converts a string into a float. 51 | """ 52 | 53 | async def convert(self, ctx: CommandContext, arg: str) -> float: 54 | return float(arg) 55 | 56 | 57 | id_regex = compile(r"[0-9A-HJ-KM-NP-TV-Z]{26}") 58 | 59 | 60 | class UserConverter(Converter): 61 | """ 62 | A converter that converts a string into a user. 63 | """ 64 | 65 | async def convert(self, ctx: CommandContext, arg: str) -> User: 66 | if match := id_regex.search(arg): 67 | return ctx.client.cache.get_user(match.group(0)) 68 | arg = arg.replace("@", "").lower() 69 | if user := get(ctx.client.cache.users.values(), lambda u: u.name.lower() == arg): 70 | return user 71 | raise UserNotFound(arg) 72 | 73 | 74 | class MemberConverter(Converter): 75 | """ 76 | A converter that converts a string into a member. 77 | """ 78 | 79 | async def convert(self, ctx: CommandContext, arg: str) -> Member: 80 | if ctx.server is None: 81 | raise ValueError("Cannot convert a member to a member without a server") 82 | if match := id_regex.search(arg): 83 | return ctx.client.cache.get_member(ctx.server.id, match.group(0)) 84 | arg = arg.replace("@", "").lower() 85 | if member := get( 86 | ctx.client.cache.members[ctx.server.id].values(), 87 | lambda m: m.name.lower() == arg, 88 | ): 89 | return member 90 | if member := get( 91 | ctx.client.cache.members[ctx.server.id].values(), 92 | lambda m: m.nickname.lower() == arg if m.nickname else False, 93 | ): 94 | return member 95 | raise MemberNotFound(arg) 96 | 97 | 98 | class ChannelConverter(Converter): 99 | """ 100 | A converter that converts a string into a channel. 101 | """ 102 | 103 | async def convert(self, ctx: CommandContext, arg: str) -> Channel: 104 | if match := id_regex.search(arg): 105 | return ctx.client.cache.get_channel(match.group(0)) 106 | arg = arg.replace("#", "").lower() 107 | if channel := get( 108 | ctx.client.cache.channels.values(), 109 | lambda c: c.name.lower() == arg if c.name else False, 110 | ): 111 | return channel 112 | raise ChannelNotFound(arg) 113 | 114 | 115 | class RoleConverter(Converter): 116 | """ 117 | A converter that converts a string into a role. 118 | """ 119 | 120 | async def convert(self, ctx: CommandContext, arg: str) -> Role: 121 | if ctx.server is None: 122 | raise ValueError("Cannot convert a role to a role without a server") 123 | if match := id_regex.search(arg): 124 | if role := ctx.server.get_role(match.group(0)): 125 | return role 126 | arg = arg.replace("@", "").lower() 127 | if role := get(ctx.server.roles, lambda r: r.name.lower() == arg): 128 | return role 129 | raise RoleNotFound(arg) 130 | 131 | 132 | def converter( 133 | converter: Callable[[CommandContext, str], Awaitable[Any]], 134 | ) -> Type[Converter]: 135 | """ 136 | A decorator that converts a function into a converter. 137 | """ 138 | 139 | class Wrapper(Converter): 140 | converter = converter 141 | 142 | return Wrapper 143 | -------------------------------------------------------------------------------- /voltage/ext/commands/help.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING 4 | 5 | from voltage import SendableEmbed 6 | 7 | if TYPE_CHECKING: 8 | from .client import CommandsClient 9 | from .cog import Cog 10 | from .command import Command, CommandContext 11 | 12 | 13 | class HelpCommand: 14 | """ 15 | A simple subclassable help command. 16 | """ 17 | 18 | def __init__(self, client: CommandsClient): 19 | self.client = client 20 | 21 | async def send_help(self, ctx: CommandContext): 22 | embed = SendableEmbed( 23 | title="Help", 24 | description=f"Use `{ctx.prefix}help ` to get help for a command.", 25 | colour="#fff0f0", 26 | icon_url=getattr(self.client.user.display_avatar, "url"), 27 | ) 28 | text = "\n### **No Category**\n" 29 | covered = [] 30 | for command in self.client.commands.values(): 31 | if command in covered: # will fix this shittiness after rewrite 32 | continue 33 | if command.cog is None: 34 | text += f"> {command.name}\n" 35 | covered.append(command) 36 | for i in self.client.cogs.values(): 37 | text += f"\n### **{i.name}**\n{i.description}\n" 38 | for j in i.commands: 39 | text += f"\n> {j.name}" 40 | covered.append(j) 41 | if embed.description: 42 | embed.description += text 43 | return await ctx.reply("Here, have a help embed", embed=embed) 44 | 45 | async def send_command_help(self, ctx: CommandContext, command: Command): 46 | embed = SendableEmbed( 47 | title=f"Help for {command.name}", 48 | colour="#0000ff", 49 | icon_url=getattr(self.client.user.display_avatar, "url"), 50 | ) 51 | text = str() 52 | text += f"\n### **Usage**\n> `{ctx.prefix}{command.usage}`" 53 | if command.aliases: 54 | text += f"\n\n### **Aliases**\n> {ctx.prefix}{', '.join(command.aliases)}" 55 | embed.description = command.description + text if command.description else text 56 | return await ctx.reply("Here, have a help embed", embed=embed) 57 | 58 | async def send_cog_help(self, ctx: CommandContext, cog: Cog): 59 | embed = SendableEmbed( 60 | title=f"Help for {cog.name}", 61 | colour="#0000ff", 62 | icon_url=getattr(self.client.user.display_avatar, "url"), 63 | ) 64 | text = str() 65 | text += f"\n### **Description**\n{cog.description}" 66 | text += f"\n\n### **Commands**\n" 67 | for command in cog.commands: 68 | text += f"> {ctx.prefix}{command.name}\n" 69 | embed.description = text 70 | return await ctx.reply("Here, have a help embed", embed=embed) 71 | 72 | async def send_not_found(self, ctx: CommandContext, target: str): 73 | return await ctx.send(f"Command {target} not found") 74 | -------------------------------------------------------------------------------- /voltage/file.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING, Optional, Union 4 | 5 | # Internal imports 6 | if TYPE_CHECKING: 7 | from .internals import HTTPHandler 8 | 9 | 10 | async def get_file_from_url(http: HTTPHandler, url: str, filename: str = "Attachment", spoiler: bool = False) -> File: 11 | """ 12 | Returns a file object from the supplied URL. 13 | 14 | Parameters 15 | ---------- 16 | http: :class:`HTTPHandler` 17 | The HTTP handler to use. 18 | url: :class:`str` 19 | The URL to get the file from. 20 | 21 | Returns 22 | ------- 23 | :class:`File` 24 | The file object. 25 | """ 26 | return File( 27 | await http.get_file_binary(url.split("?")[0]), 28 | filename=filename, 29 | spoiler=spoiler, 30 | ) 31 | 32 | 33 | class File: 34 | """ 35 | The Object representing a generic file that can be sent in a Message. 36 | 37 | Parameters 38 | ---------- 39 | f: Union[:class:`str`, :class:`bytes`] 40 | The file to send, can either be a local filename (str) or bytes. 41 | filename: Optional[:class:`str`] 42 | The name of the file. 43 | spoiler: Optional[:class:`bool`] 44 | Whether or not the file is a spoiler. 45 | 46 | Examples 47 | -------- 48 | 49 | .. code-block:: python3 50 | 51 | f = voltage.File("image.png", filename="interesting file", spoiler=True) 52 | 53 | await channel.send("Obligatory Message Content", attachment=f) # Uploads the file to autumn, gets the id and sends it. 54 | 55 | # You can also send files in embeds. 56 | 57 | embed = voltage.SendableEmbed(media=f) 58 | await channel.send("Obligatory Message Content", embed=embed) 59 | 60 | """ 61 | 62 | __slots__ = ("file", "filename", "spoiler") 63 | 64 | def __init__( 65 | self, 66 | f: Union[str, bytes], 67 | *, 68 | filename: Optional[str] = None, 69 | spoiler: Optional[bool] = False, 70 | ) -> None: 71 | if isinstance(f, str): 72 | with open(f, "rb") as file: 73 | self.file = file.read() 74 | elif isinstance(f, bytes): 75 | self.file = f 76 | else: 77 | raise TypeError("f must be a string or bytes") 78 | 79 | filename = filename if not filename is None else "file" 80 | 81 | if spoiler and not filename.startswith("SPOILER_"): 82 | filename = f"SPOILER_{filename}" 83 | 84 | self.filename = filename 85 | 86 | async def get_id(self, http: HTTPHandler) -> str: 87 | """ 88 | Uploads a file to autumn then returns its id for sending. 89 | You won't need to run this method yourself. 90 | 91 | Parameters 92 | ---------- 93 | http: :class:`HTTPHandler` 94 | The http handler. 95 | 96 | Returns 97 | ------- 98 | :class:`str` 99 | The autumn id of the file. 100 | """ 101 | file = await http.upload_file(self.file, self.filename, "attachments") 102 | return file["id"] 103 | -------------------------------------------------------------------------------- /voltage/flag.py: -------------------------------------------------------------------------------- 1 | # Ah shit here we go again 2 | from __future__ import annotations 3 | 4 | from typing import Any, Callable, Optional, Type, TypeVar, Union 5 | 6 | # Typing, boooo 7 | FB = TypeVar("FB", bound="FlagBase") 8 | FV = TypeVar("FV", bound="FlagValue") 9 | 10 | 11 | class FlagValue: 12 | """ 13 | A class which represents a flag's value and provides an interface to interact with it. 14 | 15 | Attributes 16 | ---------- 17 | value: :class:`int` 18 | The flag's value. 19 | """ 20 | 21 | def __init__(self, func: Callable[[Any], int]): 22 | self.value = func(None) 23 | self.__doc__ = func.__doc__ # DOCUMENTATION GO BRRRRRRR 24 | 25 | def __get__(self: FV, instance: Optional[FB], owner: Type[FB]) -> Union[bool, FV]: 26 | if instance: 27 | return instance._has_flag(self.value) 28 | return self 29 | 30 | def __set__(self, instance: FlagBase, value: bool): 31 | instance._set_flag(self.value, value) 32 | 33 | def __repr__(self): 34 | return f"" 35 | 36 | 37 | # Important for flags, they all gotta inherit from this. 38 | class FlagBase: 39 | """ 40 | The base class for all voltage flags. 41 | 42 | Attributes 43 | ---------- 44 | flags: :class:`int` 45 | The value of the flags. 46 | """ 47 | 48 | __slots__ = ("flags",) 49 | 50 | def __init__(self, **kwargs: bool): 51 | self.flags = 0 52 | 53 | for k, v in kwargs.items(): 54 | setattr(self, k, v) # tfw self.__setattr__ isn't the "right" way to do this 55 | 56 | @classmethod 57 | def new_with_flags(cls, flags): # Talk about convinience. 58 | """ 59 | Creates a new instance of this class with the given flags. 60 | 61 | Parameters 62 | ---------- 63 | flags: :class:`int` 64 | The flags to set. 65 | 66 | Returns 67 | ------- 68 | :class:`FlagBase` 69 | The new instance. 70 | """ 71 | inst = cls.__new__(cls) # tfw cls() no work sometimes :/ 72 | inst.flags = flags 73 | return inst 74 | 75 | def __eq__(self, other: object) -> bool: 76 | if not isinstance(other, self.__class__): 77 | return NotImplemented 78 | return self.flags == other.flags 79 | 80 | def __ne__(self, other: object) -> bool: 81 | return not self.__eq__(other) 82 | 83 | def __hash__(self): 84 | return hash(self.flags) 85 | 86 | def __repr__(self): 87 | return f"<{self.__class__.__name__} flags={self.flags:#x}>" 88 | 89 | def __iter__(self): 90 | for name, value in self.__class__.__dict__.items(): 91 | if isinstance(value, FlagValue): 92 | yield name, value.__get__(self, self.__class__) 93 | 94 | def __or__(self: FB, other: FB) -> FB: 95 | return self.__class__.new_with_flags(self.flags | other.flags) 96 | 97 | def __and__(self: FB, other: FB) -> FB: 98 | return self.__class__.new_with_flags(self.flags & other.flags) 99 | 100 | def __xor__(self: FB, other: FB) -> FB: 101 | return self.__class__.new_with_flags(self.flags ^ other.flags) 102 | 103 | def __invert__(self: FB) -> FB: 104 | return self.__class__.new_with_flags(~self.flags) 105 | 106 | def __add__(self: FB, other: FB) -> FB: 107 | return self.__class__.new_with_flags(self.flags | other.flags) 108 | 109 | def __sub__(self: FB, other: FB) -> FB: 110 | return self.__class__.new_with_flags(self.flags & ~other.flags) 111 | 112 | def __le__(self: FB, other: FB) -> bool: 113 | return (self.flags & other.flags) == self.flags 114 | 115 | def __ge__(self: FB, other: FB) -> bool: 116 | return (self.flags | other.flags) == other.flags 117 | 118 | def __lt__(self: FB, other: FB) -> bool: 119 | return (self.flags <= other.flags) and self.flags != other.flags 120 | 121 | def __gt__(self: FB, other: FB) -> bool: 122 | return (self.flags > other.flags) and self.flags != other.flags 123 | 124 | def _has_flag(self, flag: int) -> bool: 125 | return (self.flags & flag) == flag 126 | 127 | def _set_flag(self, flag: int, value: bool): 128 | if value: 129 | self.flags |= flag 130 | else: 131 | self.flags &= ~flag 132 | 133 | 134 | class UserFlags(FlagBase): 135 | """ 136 | A class which represents a user's flags (aka badges). 137 | """ 138 | 139 | @FlagValue 140 | def developer(self): 141 | """ 142 | Whether the user has the developer badge. 143 | """ 144 | return 1 << 0 145 | 146 | @FlagValue 147 | def translator(self): 148 | """ 149 | Whether the user has the translator badge. 150 | """ 151 | return 1 << 1 152 | 153 | @FlagValue 154 | def supporter(self): 155 | """ 156 | Whether the user has the supporter badge. 157 | """ 158 | return 1 << 2 159 | 160 | @FlagValue 161 | def responsible_disclosure(self): 162 | """ 163 | Whether the user has the responsible disclosure badge. 164 | """ 165 | return 1 << 3 166 | 167 | @FlagValue 168 | def founder(self): 169 | """ 170 | Whether the user has the founder badge. 171 | """ 172 | return 1 << 4 173 | 174 | @FlagValue 175 | def platform_moderator(self): 176 | """ 177 | Whether the user has the platform moderator badge. 178 | """ 179 | return 1 << 5 180 | 181 | @FlagValue 182 | def active_supporter(self): 183 | """ 184 | Whether the user has the active supporter badge. 185 | """ 186 | return 1 << 6 187 | 188 | @FlagValue 189 | def paw(self): 190 | """ 191 | Whether the user has the paw badge. 192 | """ 193 | return 1 << 7 194 | 195 | @FlagValue 196 | def early_adopter(self): 197 | """ 198 | Whether the user has the early adopter badge. 199 | """ 200 | return 1 << 8 201 | 202 | @FlagValue 203 | def reserved_relevant_joke_badge_1(self): 204 | """ 205 | Whether the user has the relevant joke 1 (amogus) badge. 206 | """ 207 | return 1 << 9 208 | 209 | @FlagValue 210 | def reserved_relevant_joke_badge_2(self): 211 | """ 212 | Whether the user has the relevant joke 2 (amorbus) badge. 213 | """ 214 | 215 | return 1 << 10 216 | -------------------------------------------------------------------------------- /voltage/internals/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | The Voltage internal library, aka where the magic happens. 3 | 4 | You probably shouldn't be using this directly, but rather through the client unless you're curious or are helping out developing Voltage. 5 | """ 6 | 7 | from .cache import CacheHandler 8 | from .http import HTTPHandler 9 | from .ws import WebSocketHandler 10 | -------------------------------------------------------------------------------- /voltage/invites.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING, Union 4 | 5 | # internal imports 6 | from .utils import get 7 | 8 | if TYPE_CHECKING: 9 | from .internals import CacheHandler 10 | from .types import InvitePayload, PartialInvitePayload 11 | 12 | 13 | class Invite: 14 | """ 15 | A class which represents a Voltage invite. 16 | 17 | Attributes 18 | ---------- 19 | code: :class:`str` 20 | The invite code. 21 | type: :class:`str` 22 | The invite type. 23 | server_id: :class:`str` 24 | The server ID. 25 | server: :class:`Server` 26 | The server the invite is for. 27 | channel_id: :class:`str` 28 | The channel ID. 29 | channel: :class:`Channel` 30 | The channel the invite is for. 31 | member_count: :class:`int` 32 | The member count. 33 | author_name: :class:`str` 34 | The author name. 35 | user: :class:`User` 36 | The user who created the invite. 37 | avatar: :class:`Asset` 38 | The avatar of the user who created the invite. 39 | """ 40 | 41 | __slots__ = ( 42 | "code", 43 | "type", 44 | "payload", 45 | "server_id", 46 | "server", 47 | "channel_id", 48 | "channel", 49 | "member_count", 50 | "user", 51 | "cache", 52 | ) 53 | 54 | def __init__(self, data: InvitePayload, code: str, cache: CacheHandler): 55 | self.code = code 56 | self.type = data["type"] 57 | self.payload: Union[InvitePayload, PartialInvitePayload] = data 58 | 59 | self.server_id = data["server_id"] 60 | self.server = cache.get_server(self.server_id) 61 | 62 | self.channel_id = data["channel_id"] 63 | self.channel = cache.get_channel(self.channel_id) 64 | self.member_count = data["member_count"] 65 | 66 | self.user = get(cache.users.values(), lambda x: x.name == data["user_name"]) 67 | 68 | self.cache = cache 69 | 70 | @staticmethod 71 | def from_partial(code: str, data: PartialInvitePayload, cache: CacheHandler) -> Invite: 72 | """ 73 | A utility function that creates an Invite object from a partial payload. 74 | 75 | Parameters 76 | ---------- 77 | code: :class:`str` 78 | The invite code. 79 | data: :class:`PartialInvitePayload` 80 | The partial payload. 81 | cache: :class:`CacheHandler` 82 | The cache handler. 83 | """ 84 | self = Invite.__new__(Invite) 85 | 86 | self.code = code 87 | self.payload = data 88 | self.cache = cache 89 | self.type = "Server" 90 | 91 | self.server_id = data["server"] 92 | self.server = cache.get_server(self.server_id) 93 | self.member_count = len(self.server.members) 94 | 95 | self.channel_id = data["channel"] 96 | self.channel = cache.get_channel(self.channel_id) 97 | 98 | self.user = cache.get_user(data["creator"]) 99 | 100 | return self 101 | 102 | @property 103 | def url(self) -> str: 104 | """Returns the invite URL.""" 105 | return f"https://rvlt.gg/{self.code}" 106 | 107 | async def delete(self): 108 | return await self.cache.http.delete_invite(self.code) 109 | -------------------------------------------------------------------------------- /voltage/member.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING, Any, Optional, Union 4 | 5 | from .asset import Asset 6 | from .permissions import Permissions 7 | 8 | # Internal imports 9 | from .user import User 10 | 11 | if TYPE_CHECKING: 12 | from .internals import CacheHandler 13 | from .roles import Role 14 | from .server import Server 15 | from .types import MemberPayload, OnServerMemberUpdatePayload, OverrideFieldPayload 16 | 17 | 18 | def make_member_dot_zip( 19 | member: Member, user: User 20 | ): # very excellanto functiono it take memberru object and it users objecto and give it all atrr like naem, avartar and sow on. 21 | for i in user.__slots__: 22 | setattr(member, i, getattr(user, i)) 23 | 24 | 25 | class Member(User): 26 | """ 27 | A class that represents a Voltage server member. 28 | 29 | This class is a subclass of :class:`User` and inherits all of its attributes. 30 | 31 | Attributes 32 | ---------- 33 | server: :class:`Server` 34 | The server that the member belongs to. 35 | nickname: Optional[:class:`str`] 36 | The member's nickname. 37 | server_avatar: Optional[:class:`Asset`] 38 | The member's avatar. 39 | roles: List[:class:`Role`] 40 | The member's roles. 41 | permissions: :class:`Permissions` 42 | The member's permissions. 43 | """ 44 | 45 | __slots__ = ("nickname", "server_avatar", "roles", "server", "permissions") 46 | 47 | def __init__(self, data: MemberPayload, server: Server, cache: CacheHandler): 48 | user = cache.get_user(data["_id"]["user"]) 49 | make_member_dot_zip(self, user) 50 | 51 | self.nickname = data.get("nickname") 52 | 53 | if av := data.get("avatar"): 54 | self.server_avatar: Optional[Asset] = Asset(av, cache.http) 55 | else: 56 | self.server_avatar = None 57 | 58 | roles = [] 59 | for i in data.get("roles", []): 60 | role = server.get_role(i) 61 | if role: 62 | roles.append(role) 63 | 64 | self.roles: list[Role] = sorted(roles, key=lambda r: r.rank, reverse=True) 65 | self.permissions: Permissions 66 | self._caclulate_perms() 67 | 68 | self.server = server 69 | 70 | def __repr__(self): 71 | return f"" 72 | 73 | @property 74 | def display_name(self): 75 | """ 76 | Returns the member's display name. 77 | 78 | This is the member's masquerade name or nickname if they have one, otherwise their username. 79 | """ 80 | return self.masquerade_name or self.nickname or self.name 81 | 82 | @property 83 | def display_avatar(self): 84 | """ 85 | Returns the member's display avatar. 86 | 87 | This is the member's masquerade avatar or their server's avatar if they have one, otherwise their avatar. 88 | """ 89 | return self.masquerade_avatar or self.server_avatar or self.avatar or self.default_avatar 90 | 91 | async def kick(self): 92 | """ 93 | A method that kicks the member from the server. 94 | """ 95 | await self.cache.http.kick_member(self.server.id, self.id) 96 | 97 | async def ban(self, reason: Optional[str] = None): 98 | """ 99 | A method that bans the member from the server. 100 | 101 | Parameters 102 | ---------- 103 | reason: Optional[:class:`str`] 104 | The reason for banning the member. 105 | """ 106 | await self.cache.http.ban_member(self.server.id, self.id, reason=reason) 107 | 108 | async def unban(self): 109 | """ 110 | A method that unbans the member from the server. 111 | """ 112 | await self.cache.http.unban_member(self.server.id, self.id) 113 | 114 | async def add_roles(self, *roles: Role): 115 | """ 116 | A method that adds roles to the member. 117 | 118 | Parameters 119 | ---------- 120 | *roles: :class:`Role` 121 | The roles to add to the member. 122 | """ 123 | await self.cache.http.edit_member( 124 | self.server.id, 125 | self.id, 126 | roles=[r.id for r in roles] + [r.id for r in self.roles], 127 | ) 128 | 129 | async def set_nickname(self, nickname: Optional[str]): 130 | """ 131 | A method that sets the member's nickname. 132 | 133 | Parameters 134 | ---------- 135 | nickname: Optional[:class:`str`] 136 | The nickname to set. 137 | """ 138 | if nickname: 139 | return await self.cache.http.edit_member(self.server.id, self.id, nickname=nickname) 140 | await self.cache.http.edit_member(self.server.id, self.id, remove="Nickname") 141 | 142 | async def remove_avatar(self): 143 | """ 144 | A method that removes the member's avatar. 145 | """ 146 | await self.cache.http.edit_member(self.server.id, self.id, remove="Avatar") 147 | 148 | def _caclulate_perms(self): 149 | perms: OverrideFieldPayload = {"a": 0, "d": 0} 150 | for role in self.roles: 151 | perms["a"] |= role.permissions.allow.flags 152 | perms["d"] |= role.permissions.deny.flags 153 | self.permissions = Permissions(perms) 154 | 155 | def _update(self, data: Union[Any, OnServerMemberUpdatePayload]): # god bless mypy 156 | if clear := data.get("clear"): 157 | if clear == "Nickname": 158 | self.nickname = None 159 | elif clear == "Avatar": 160 | self.server_avatar = None 161 | 162 | if new := data.get("data"): 163 | if new.get("nickname"): 164 | self.nickname = new["nickname"] 165 | if new.get("avatar"): 166 | self.server_avatar = Asset(new["avatar"], self.cache.http) 167 | if new.get("roles"): 168 | roles = [] 169 | for i in new["roles"]: 170 | role = self.server.get_role(i) 171 | if role: 172 | roles.append(role) 173 | 174 | self.roles = sorted(roles, key=lambda r: r.rank, reverse=True) 175 | self._caclulate_perms() 176 | -------------------------------------------------------------------------------- /voltage/message.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from asyncio import sleep 4 | from datetime import datetime 5 | from typing import TYPE_CHECKING, Dict, List, NamedTuple, Optional, Union 6 | 7 | from ulid import ULID 8 | 9 | from .asset import Asset, PartialAsset 10 | from .embed import SendableEmbed, create_embed 11 | 12 | if TYPE_CHECKING: 13 | from .file import File 14 | from .internals import CacheHandler 15 | from .member import Member 16 | from .types import ( 17 | MessagePayload, 18 | MessageReplyPayload, 19 | OnMessageUpdatePayload, 20 | SendableEmbedPayload, 21 | ) 22 | from .user import User 23 | 24 | 25 | class MessageReply(NamedTuple): 26 | """A named tuple that represents a message reply. 27 | 28 | Attributes 29 | ---------- 30 | message: :class:`Message` 31 | The message that was replied to, 32 | mention: :class:`bool` 33 | Wether or not the reply mentions the author of the message. 34 | """ 35 | 36 | message: Message 37 | mention: bool 38 | 39 | def to_dict(self) -> MessageReplyPayload: 40 | """Returns a dictionary representation of the message reply.""" 41 | return {"id": self.message.id, "mention": self.mention} 42 | 43 | 44 | class MessageMasquerade(NamedTuple): 45 | """A named tuple that represents a message's masquerade. 46 | 47 | Attributes 48 | ---------- 49 | name: Optional[:class:`str`] 50 | The name of the masquerade. 51 | avatar: Optional[:class:`str`] 52 | The url to the masquerade avatar. 53 | colour: Optional[:class:`str`] 54 | CSS-compatible colour of the username. 55 | color: Optional[:class:`str`] 56 | CSS-compatible color of the username. 57 | """ 58 | 59 | name: Optional[str] = None 60 | avatar: Optional[str] = None 61 | colour: Optional[str] = None 62 | color: Optional[str] = None 63 | 64 | def to_dict(self) -> dict: 65 | """Returns a dictionary representation of the message masquerade.""" 66 | data = {} 67 | if self.name is not None: 68 | data["name"] = self.name 69 | if self.avatar is not None: 70 | data["avatar"] = self.avatar 71 | colour = self.colour or self.color 72 | if colour is not None: 73 | data["colour"] = colour 74 | return data 75 | 76 | 77 | class MessageInteractions: 78 | """A named tuple that represents a message's interactions. 79 | 80 | Attributes 81 | ---------- 82 | reactions: Dict[:class:`str`, List[:class:`User`]] 83 | The reactions always below this messsage. 84 | restrict_reactions: Optional[:class:`bool`] 85 | Only allow reactions specified. 86 | """ 87 | 88 | def __init__(self): 89 | self.reactions: Dict[str, List[User]] = {} 90 | self.restrict_reactions = False 91 | 92 | def to_dict(self) -> dict: 93 | """Returns a dictionary representation of the message interactions.""" 94 | return { 95 | "reactions": self.reactions if self.reactions else None, 96 | "restrict_reactions": self.restrict_reactions if self.restrict_reactions is not None else None, 97 | } 98 | 99 | 100 | class Message: 101 | """A class that represents a Voltage message. 102 | 103 | Attributes 104 | ---------- 105 | id: Optional[:class:`str`] 106 | The id of the message. 107 | created_at: :class:`int` 108 | The timestamp of when the message was created. 109 | channel: :class:`Channel` 110 | The channel the message was sent in. 111 | attachments: List[:class:`Asset`]] 112 | The attachments of the message. 113 | embeds: List[:class:`Embed`] 114 | The embeds of the message. 115 | content: :class:`str` 116 | The content of the message. 117 | author: Union[:class:`User`, :class:`Member`] 118 | The author of the message. 119 | replies: List[:class:`Message`] 120 | The replies of the message. 121 | mentions: List[Union[:class:`User`, :class:`Member`]] 122 | A list of mentioned users/members. 123 | """ 124 | 125 | __slots__ = ( 126 | "id", 127 | "created_at", 128 | "channel", 129 | "attachments", 130 | "server", 131 | "embeds", 132 | "content", 133 | "author", 134 | "edited_at", 135 | "mention_ids", 136 | "reply_ids", 137 | "replies", 138 | "cache", 139 | "interactions", 140 | ) 141 | 142 | def __init__(self, data: MessagePayload, cache: CacheHandler): 143 | self.cache = cache 144 | self.id = data["_id"] 145 | self.created_at = ULID().decode(self.id) 146 | self.content = data.get("content") 147 | self.attachments = [Asset(a, cache.http) for a in data.get("attachments", [])] 148 | self.embeds = [create_embed(e, cache.http) for e in data.get("embeds", [])] 149 | self.interactions = MessageInteractions() 150 | 151 | self.channel = cache.get_channel(data["channel"]) 152 | 153 | self.server = self.channel.server 154 | self.author = ( 155 | cache.get_member(self.server.id, data["author"]) if self.server else cache.get_user(data["author"]) 156 | ) 157 | 158 | if masquerade := data.get("masquerade"): 159 | if av := masquerade.get("avatar"): 160 | avatar = PartialAsset(av, cache.http) 161 | else: 162 | avatar = None 163 | self.author.set_masquerade(masquerade.get("name"), avatar) 164 | 165 | self.edited_at: Optional[datetime] 166 | if edited := data.get("edited"): 167 | self.edited_at = datetime.strptime(edited, "%Y-%m-%dT%H:%M:%S.%fz") 168 | else: 169 | self.edited_at = None 170 | 171 | self.reply_ids = data.get("replies", []) 172 | self.replies: List[Message] = [] 173 | for i in self.reply_ids: 174 | try: 175 | self.replies.append(cache.get_message(i)) 176 | except KeyError: 177 | pass 178 | 179 | self.mention_ids = data.get("mentions", []) 180 | 181 | if interactions := data.get("interactions"): 182 | self.interactions.restrict_reactions = interactions.get("restrict_reactions") or False 183 | 184 | if reactions := data.get("reactions"): 185 | for emoji_id, users in reactions.items(): 186 | reaction_users = [] 187 | for user_id in users: 188 | try: 189 | reaction_users.append(cache.get_user(user_id)) 190 | except KeyError: 191 | pass 192 | self.interactions.reactions[emoji_id] = reaction_users 193 | 194 | async def full_replies(self): 195 | """Returns the full list of replies of the message.""" 196 | replies = [] 197 | for i in self.reply_ids: 198 | replies.append(await self.cache.fetch_message(self.channel.id, i)) 199 | return replies 200 | 201 | async def edit( 202 | self, 203 | content: Optional[str] = None, 204 | *, 205 | embed: Optional[Union[SendableEmbedPayload, SendableEmbed]] = None, 206 | embeds: Optional[List[Union[SendableEmbedPayload, SendableEmbed]]] = None, 207 | ): 208 | """Edits the message. 209 | 210 | Parameters 211 | ---------- 212 | content: Optional[:class:`str`] 213 | The new content of the message. 214 | embed: Optional[:class:`SendableEmbed`] 215 | The new embed of the message. 216 | embeds: Optional[:class:`List[SendableEmbed]`] 217 | The new embeds of the message. 218 | """ 219 | if content is None and embed is None and embeds is None: 220 | raise ValueError("You must provide at least one of the following: content, embed, embeds") 221 | 222 | if embed: 223 | embeds = [embed] 224 | 225 | content = str(content) if content else None 226 | 227 | await self.cache.http.edit_message(self.channel.id, self.id, content=content, embeds=embeds) 228 | 229 | async def delete(self, *, delay: Optional[float] = None): 230 | """Deletes the message.""" 231 | if delay is not None: 232 | await sleep(delay) 233 | await self.cache.http.delete_message(self.channel.id, self.id) 234 | 235 | async def reply( 236 | self, 237 | content: Optional[str] = None, 238 | *, 239 | embed: Optional[Union[SendableEmbed, SendableEmbedPayload]] = None, 240 | embeds: Optional[List[Union[SendableEmbed, SendableEmbedPayload]]] = None, 241 | attachment: Optional[Union[File, str]] = None, 242 | attachments: Optional[List[Union[File, str]]] = None, 243 | masquerade: Optional[MessageMasquerade] = None, 244 | interactions: Optional[MessageInteractions] = None, 245 | mention: bool = True, 246 | delete_after: Optional[float] = None, 247 | ) -> Message: 248 | """Replies to the message. 249 | 250 | Parameters 251 | ---------- 252 | content: Optional[:class:`str`] 253 | The content of the message. 254 | embed: Optional[:class:`Embed`] 255 | The embed of the message. 256 | embeds: Optional[List[:class:`Embed`]] 257 | The embeds of the message. 258 | attachment: Optional[:class:`File`] 259 | The attachment of the message. 260 | attachments: Optional[List[:class:`File`]] 261 | The attachments of the message. 262 | masquerade: Optional[:class:`MessageMasquerade`] 263 | The masquerade of the message. 264 | interactions: Optional[:class:`MessageInteractions`] 265 | The interactions of the message. 266 | mention: Optional[:class:`bool`] 267 | Wether or not the reply mentions the author of the message. 268 | delete_after: Optional[:class:`float`] 269 | The amount of seconds to wait before deleting the message, if ``None`` the message will not be deleted. 270 | 271 | Returns 272 | ------- 273 | :class:`Message` 274 | The message that got sent. 275 | """ 276 | embeds = [embed] if embed else embeds 277 | attachments = [attachment] if attachment else attachments 278 | replies = MessageReply(self, mention) 279 | 280 | content = str(content) if content else None 281 | 282 | message = await self.cache.http.send_message( 283 | self.channel.id, 284 | content=content, 285 | embeds=embeds, 286 | attachments=attachments, 287 | replies=[replies], 288 | masquerade=masquerade, 289 | interactions=interactions, 290 | ) 291 | msg = self.cache.add_message(message) 292 | if delete_after is not None: 293 | self.cache.loop.create_task(msg.delete(delay=delete_after)) 294 | return msg 295 | 296 | async def react(self, emoji: str): 297 | await self.cache.http.add_reaction(self.channel.id, self.id, emoji) 298 | 299 | async def unreact(self, emoji: str): 300 | await self.cache.http.delete_reaction(self.channel.id, self.id, emoji) 301 | 302 | async def remove_reactions(self): 303 | await self.cache.http.delete_all_reaction(self.channel.id, self.id) 304 | 305 | @property 306 | def reactions(self) -> Dict[str, List[User]]: 307 | return self.interactions.reactions 308 | 309 | @property 310 | def jump_url(self) -> str: 311 | """Returns a URL that allows the client to jump to the message.""" 312 | server_segment = "" if self.server is None else f"/server/{self.server.id}" 313 | return f"https://app.revolt.chat{server_segment}/channel/{self.channel.id}/{self.id}" 314 | 315 | @property 316 | def mentions(self) -> list[Union[User, Member]]: 317 | mentioned: list[Union[User, Member]] = [] 318 | for mention in self.mention_ids: 319 | if self.server: 320 | mentioned.append(self.cache.get_member(self.server.id, mention)) 321 | continue 322 | mentioned.append(self.cache.get_user(mention)) 323 | return mentioned 324 | 325 | def _update(self, data: OnMessageUpdatePayload): 326 | if new := data.get("data"): 327 | if edited := new.get("edited"): 328 | self.edited_at = datetime.strptime(edited, "%Y-%m-%dT%H:%M:%S.%fz") 329 | if content := new.get("content"): 330 | self.content = content 331 | if embeds := new.get("embeds"): 332 | self.embeds = [create_embed(e, self.cache.http) for e in embeds] 333 | -------------------------------------------------------------------------------- /voltage/messageable.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from asyncio import Task, sleep 4 | from typing import TYPE_CHECKING, List, Optional, Union 5 | 6 | # Internal imports 7 | from .enums import SortType 8 | from .errors import HTTPError 9 | from .message import Message, MessageInteractions 10 | 11 | if TYPE_CHECKING: 12 | from .embed import SendableEmbed 13 | from .file import File 14 | from .internals import CacheHandler 15 | from .message import MessageMasquerade, MessageReply 16 | from .types import MessagePayload, MessageReplyPayload, SendableEmbedPayload 17 | 18 | 19 | class Typing: 20 | """ 21 | A simple context manager for typing. 22 | """ 23 | 24 | def __init__(self, channel: Messageable): 25 | self.channel = channel 26 | self.ws = channel.cache.ws 27 | self.loop = channel.cache.loop 28 | self.task: Task 29 | 30 | async def keep_typing(self): 31 | while True: 32 | await self.channel.start_typing() 33 | await sleep(2) 34 | 35 | def __enter__(self): 36 | self.task = self.loop.create_task(self.keep_typing()) 37 | return self 38 | 39 | def __exit__(self, *_): 40 | self.task.cancel() 41 | self.loop.create_task(self.channel.end_typing()) 42 | 43 | async def __aenter__(self): 44 | self.task = self.loop.create_task(self.keep_typing()) 45 | return self 46 | 47 | async def __aexit__(self, *_): 48 | self.task.cancel() 49 | await self.channel.end_typing() 50 | 51 | 52 | class MessageIterator: 53 | def __init__(self, data: list[MessagePayload], channel: Messageable): 54 | self.data = data 55 | self.processed: list[Message] = [] 56 | self.channel = channel 57 | 58 | def _process_next(self): 59 | if len(self.data) == 0: 60 | if len(self.processed) == 0: 61 | return None 62 | else: 63 | self.processed.append(Message(self.data.pop(0), self.channel.cache)) 64 | return self.processed[-1] 65 | 66 | def _get_next_message(self): 67 | return self.processed.pop(0) 68 | 69 | def __iter__(self): 70 | return self 71 | 72 | def __next__(self): 73 | if len(self.data) == 0: 74 | raise StopIteration 75 | return Message(self.data.pop(0), self.channel.cache) 76 | 77 | def __len__(self): 78 | return len(self.data) + len(self.processed) 79 | 80 | def __getitem__(self, index: int): 81 | while len(self.processed) <= index: 82 | self._process_next() 83 | return Message(self.data[index], self.channel.cache) 84 | 85 | def __reversed__(self): 86 | return MessageIterator(list(reversed(self.data)), self.channel) 87 | 88 | 89 | class Messageable: # Really missing rust traits rn :( 90 | """ 91 | A class which all messageable have to inhertit from. 92 | 93 | Attributes 94 | ---------- 95 | channel_id: :class:`str` 96 | The ID of the messageable object's channel. 97 | cache: :class:`CacheHandler` 98 | The cache handler of the messageable object. 99 | """ 100 | 101 | __slots__ = () 102 | 103 | cache: CacheHandler 104 | 105 | async def get_id(self) -> str: 106 | """ 107 | Get the ID of the messageable object's channel. 108 | 109 | Returns 110 | ------- 111 | :class:`str` 112 | The ID of the messageable object's channel. 113 | """ 114 | return NotImplemented # TIL: NotImplemented is a thing, thank you mr random stackoverflow user. 115 | 116 | async def send( 117 | self, 118 | content: Optional[str] = None, 119 | *, 120 | embed: Optional[Union[SendableEmbed, SendableEmbedPayload]] = None, 121 | embeds: Optional[List[Union[SendableEmbed, SendableEmbedPayload]]] = None, 122 | attachment: Optional[Union[File, str]] = None, 123 | attachments: Optional[List[Union[File, str]]] = None, 124 | reply: Optional[MessageReply] = None, 125 | replies: Optional[List[Union[MessageReply, MessageReplyPayload]]] = None, 126 | masquerade: Optional[MessageMasquerade] = None, 127 | interactions: Optional[MessageInteractions] = None, 128 | delete_after: Optional[float] = None, 129 | ) -> Message: # YEAH BABY, THAT'S WHAT WE'VE BEEN WAITING FOR, THAT'S WHAT IT'S ALL ABOUT, WOOOOOOOOOOOOOOOO 130 | """ 131 | Send a message to the messageable object's channel. 132 | 133 | Parameters 134 | ---------- 135 | content: Optional[:class:`str`] 136 | The content of the message. 137 | embed: Optional[:class:`Embed`] 138 | The embed of the message. 139 | embeds: Optional[List[:class:`Embed`]] 140 | The embeds of the message. 141 | attachment: Optional[:class:`File`] 142 | The attachment of the message. 143 | attachments: Optional[List[:class:`File`]] 144 | The attachments of the message. 145 | reply: Optional[:class:`MessageReply`] 146 | The reply of the message. 147 | replies: Optional[List[:class:`MessageReply`]] 148 | The replies of the message. 149 | masquerade: Optional[:class:`MessageMasquerade`] 150 | The masquerade of the message. 151 | interactions: Optional[:class:`MessageInteractions`] 152 | The interactions of the message. 153 | 154 | Returns 155 | ------- 156 | :class:`Message` 157 | The message that got sent. 158 | """ 159 | embeds = [embed] if embed else embeds 160 | replies = [reply] if reply else replies 161 | attachments = [attachment] if attachment else attachments 162 | 163 | content = str(content) if content else None 164 | 165 | message = await self.cache.http.send_message( 166 | await self.get_id(), 167 | content=content, 168 | embeds=embeds, 169 | attachments=attachments, 170 | replies=replies, 171 | masquerade=masquerade, 172 | interactions=interactions, 173 | ) 174 | msg = self.cache.add_message(message) 175 | if delete_after is not None: 176 | self.cache.loop.create_task(msg.delete(delay=delete_after)) 177 | return msg 178 | 179 | async def fetch_message(self, message_id: str) -> Message: 180 | """ 181 | Fetch a message from the messageable object's channel. 182 | 183 | Parameters 184 | ---------- 185 | message_id: :class:`str` 186 | The ID of the message to fetch. 187 | 188 | Returns 189 | ------- 190 | :class:`Message` 191 | The message that got fetched. 192 | """ 193 | return await self.cache.fetch_message(await self.get_id(), message_id) 194 | 195 | async def history( 196 | self, 197 | limit: int = 100, 198 | *, 199 | sort: SortType = SortType.latest, 200 | before: Optional[str] = None, 201 | after: Optional[str] = None, 202 | nearby: Optional[str] = None, 203 | ) -> MessageIterator: 204 | """ 205 | Fetch the messageable object's channel's history. 206 | 207 | Parameters 208 | ---------- 209 | limit: Optional[:class:`int`] 210 | The limit of the history. 211 | sort: Optional[:class:`SortType`] 212 | The sort type of the history. 213 | before: Optional[:class:`str`] 214 | The ID of the message to fetch before. 215 | after: Optional[:class:`str`] 216 | The ID of the message to fetch after. 217 | nearby: Optional[:class:`str`] 218 | The ID of the message to fetch nearby. 219 | 220 | Returns 221 | ------- 222 | List[:class:`Message`] 223 | The messages that got fetched. 224 | """ 225 | messages = await self.cache.http.fetch_messages(await self.get_id(), sort.value, limit=limit, before=before, after=after, nearby=nearby, include_users=False) # type: ignore 226 | returned = [] 227 | for i in messages: 228 | if i["author"] != "00000000000000000000000000": 229 | returned.append(i) 230 | return MessageIterator(returned, self) 231 | 232 | async def search( 233 | self, 234 | query: str, 235 | *, 236 | sort: SortType = SortType.latest, # type: ignore 237 | limit: int = 100, 238 | before: Optional[str] = None, 239 | after: Optional[str] = None, 240 | ) -> MessageIterator: 241 | """ 242 | Search for messages in the messageable object's channel. 243 | 244 | Parameters 245 | ---------- 246 | query: :class:`str` 247 | The query to search for. 248 | sort: Optional[:class:`SortType`] 249 | The sort type of the search. 250 | limit: Optional[:class:`int`] 251 | The limit of the search. 252 | before: Optional[:class:`str`] 253 | The ID of the message to fetch before. 254 | after: Optional[:class:`str`] 255 | The ID of the message to fetch after. 256 | 257 | Returns 258 | ------- 259 | List[:class:`Message`] 260 | The messages that got found. 261 | """ 262 | messages = await self.cache.http.search_for_message( 263 | await self.get_id(), 264 | query, 265 | sort=sort.value, 266 | limit=limit, 267 | before=before, 268 | after=after, 269 | include_users=False, 270 | ) 271 | return MessageIterator(messages, self) 272 | 273 | async def purge(self, amount: int): 274 | """ 275 | Purge messages from the messageable object's channel. 276 | 277 | Parameters 278 | ---------- 279 | amount: :class:`int` 280 | The amount of messages to purge. 281 | """ 282 | channel_id = await self.get_id() 283 | for i in await self.cache.http.fetch_messages(channel_id, "Latest", limit=amount): 284 | try: 285 | await self.cache.http.delete_message(channel_id, i["_id"]) 286 | except HTTPError as e: 287 | status = e.response.status 288 | if status == 404: 289 | pass 290 | else: 291 | raise 292 | 293 | def typing(self) -> Typing: 294 | """ 295 | A context manager that sends a typing indicator to the messageable object's channel. 296 | """ 297 | return Typing(self) 298 | 299 | async def start_typing(self): 300 | """ 301 | Send a typing indicator to the messageable object's channel. 302 | """ 303 | await self.cache.ws.begin_typing(await self.get_id()) 304 | 305 | async def end_typing(self): 306 | """ 307 | Stop sending a typing indicator to the messageable object's channel. 308 | """ 309 | await self.cache.ws.end_typing(await self.get_id()) 310 | -------------------------------------------------------------------------------- /voltage/notsupplied.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import Any 4 | 5 | 6 | class _NotSupplied: 7 | """ 8 | A stand in class for when a value is not supplied and we still want to use ``None``'s functionality' 9 | """ 10 | 11 | def __eq__(self, other): 12 | return isinstance(other, NotSupplied) 13 | 14 | def __bool__(self): 15 | return False 16 | 17 | def __repr__(self): 18 | return "NotSupplied" 19 | 20 | 21 | NotSupplied: Any = _NotSupplied() 22 | -------------------------------------------------------------------------------- /voltage/permissions.py: -------------------------------------------------------------------------------- 1 | # Thanks Jan <3 2 | from __future__ import annotations 3 | 4 | from typing import TYPE_CHECKING, Union 5 | 6 | # Internal Imports 7 | from .flag import FlagBase, FlagValue 8 | 9 | if TYPE_CHECKING: 10 | from .types import OverrideFieldPayload 11 | 12 | 13 | # https://github.com/revoltchat/revolt.js/blob/master/src/permissions/definitions.ts 14 | class PermissionsFlags(FlagBase): 15 | """A class which represents a channel permissions object. 16 | 17 | Methods 18 | ------- 19 | none: :class:`PermissionsFlags` 20 | Returns a new :class:`PermissionsFlags` object with all permissions set to ``False``. 21 | all: :class:`ChannelPermissions` 22 | Returns a new :class:`PermissionsFlags` object with all permissions set to ``True``. 23 | """ 24 | 25 | @classmethod 26 | def none(cls) -> PermissionsFlags: 27 | return cls.new_with_flags(0b0) 28 | 29 | @classmethod 30 | def all(cls) -> PermissionsFlags: 31 | return cls.new_with_flags(0xFFFFFFFFFF) 32 | 33 | @FlagValue 34 | def manage_channels(self): 35 | """Whether the manage channels permission is granted.""" 36 | return 1 << 0 37 | 38 | @FlagValue 39 | def manage_server(self): 40 | """Whether the manager server permission is granted.""" 41 | return 1 << 1 42 | 43 | @FlagValue 44 | def manage_permissions(self): 45 | """Whether the manage permissions permission is granted.""" 46 | return 1 << 2 47 | 48 | @FlagValue 49 | def manage_role(self): 50 | """Whether the manage role permission is granted.""" 51 | return 1 << 3 52 | 53 | @FlagValue 54 | def kick_members(self): 55 | """Whether the kick members permission is granted.""" 56 | return 1 << 6 57 | 58 | @FlagValue 59 | def ban_members(self): 60 | """Whether the ban members permission is granted.""" 61 | return 1 << 7 62 | 63 | @FlagValue 64 | def timeout_members(self): 65 | """Whether the timeout members permission is granted.""" 66 | return 1 << 8 67 | 68 | @FlagValue 69 | def assign_roles(self): 70 | """Whether the assign roles permission is granted.""" 71 | return 1 << 9 72 | 73 | @FlagValue 74 | def change_nickname(self): 75 | """Whether the change nickname permission is granted.""" 76 | return 1 << 10 77 | 78 | @FlagValue 79 | def manage_nicknames(self): 80 | """Whether the manager nicknames permission is granted.""" 81 | return 1 << 11 82 | 83 | @FlagValue 84 | def change_avatar(self): 85 | """Whether the change avatar permission is granted.""" 86 | return 1 << 12 87 | 88 | @FlagValue 89 | def remove_avatars(self): 90 | """Whether the remove avatars permission is granted.""" 91 | return 1 << 13 92 | 93 | @FlagValue 94 | def view_channel(self): 95 | """Whether the view channel permission is granted.""" 96 | return 1 << 20 97 | 98 | @FlagValue 99 | def read_message_history(self): 100 | """Whether the read message history permission is granted.""" 101 | return 1 << 21 102 | 103 | @FlagValue 104 | def send_message(self): 105 | """Whether the send message permission is granted.""" 106 | return 1 << 22 107 | 108 | @FlagValue 109 | def manage_messages(self): 110 | """Whether the manage messages permission is granted.""" 111 | return 1 << 23 112 | 113 | @FlagValue 114 | def manage_webhooks(self): 115 | """Whether the manage webhooks permission is granted.""" 116 | return 1 << 24 117 | 118 | @FlagValue 119 | def invite_others(self): 120 | """Whether the invite others permission is granted.""" 121 | return 1 << 25 122 | 123 | @FlagValue 124 | def send_embeds(self): 125 | """Whether the send embeds permission is granted.""" 126 | return 1 << 26 127 | 128 | @FlagValue 129 | def upload_files(self): 130 | """Whether the upload files permission is granted.""" 131 | return 1 << 27 132 | 133 | @FlagValue 134 | def masquerade(self): 135 | """Whether the masquerade permission is granted.""" 136 | return 1 << 28 137 | 138 | @FlagValue 139 | def connect(self): 140 | """Whether the connect permission is granted.""" 141 | return 1 << 30 142 | 143 | @FlagValue 144 | def speak(self): 145 | """Whether the speak permission is granted.""" 146 | return 1 << 31 147 | 148 | @FlagValue 149 | def video(self): 150 | """Whether the video permission is granted.""" 151 | return 1 << 31 152 | 153 | @FlagValue 154 | def mute_members(self): 155 | """Whether the mute members permission is granted.""" 156 | return 1 << 32 157 | 158 | @FlagValue 159 | def defen_members(self): 160 | """Whether the defen members permission is granted.""" 161 | return 1 << 33 162 | 163 | @FlagValue 164 | def move_members(self): 165 | """Whether the move members permission is granted.""" 166 | return 1 << 34 167 | 168 | 169 | class Permissions: 170 | """A class which represents a member's permissions.""" 171 | 172 | def __init__(self, flags: Union[OverrideFieldPayload, int]): 173 | data: OverrideFieldPayload = {"a": flags, "d": 0} if isinstance(flags, int) else flags 174 | 175 | self.allow = PermissionsFlags.new_with_flags(data["a"]) 176 | self.deny = PermissionsFlags.new_with_flags(data["d"]) 177 | 178 | self.actual = PermissionsFlags.new_with_flags(data["a"] - data["d"]) 179 | 180 | for name in dir(self.actual): 181 | if isinstance((val := getattr(self.actual, name)), bool): 182 | setattr(self, name, val) 183 | 184 | def to_dict(self) -> OverrideFieldPayload: 185 | """Turns a permission object to a dictionary for api sending purposes.""" 186 | return {"a": self.allow.flags, "d": self.deny.flags} 187 | 188 | @classmethod 189 | def from_flags(cls, allow: PermissionsFlags, deny: PermissionsFlags) -> Permissions: 190 | """Creates a Permissions object from two PermissionsFlags. 191 | 192 | Also note :meth:`PermissionsFlags.none()` 193 | 194 | Attributes 195 | ---------- 196 | allow: :class:`PermissionsFlags` 197 | The allowed permissions. 198 | deny: :class:`PermissionsFlags` 199 | The denied permissions. 200 | """ 201 | flags: OverrideFieldPayload = {"a": allow.flags, "d": deny.flags} 202 | return cls(flags) 203 | -------------------------------------------------------------------------------- /voltage/py.typed: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EnokiUN/voltage/c95550f99017b0cccc0c67ac179e67b8d203da0a/voltage/py.typed -------------------------------------------------------------------------------- /voltage/roles.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING, Literal, Optional 4 | 5 | from ulid import ULID 6 | 7 | from .notsupplied import NotSupplied 8 | 9 | # Internal imports 10 | from .permissions import Permissions 11 | 12 | if TYPE_CHECKING: 13 | from .internals import HTTPHandler 14 | from .server import Server 15 | from .types import OnServerRoleUpdatePayload, RolePayload 16 | 17 | 18 | class Role: 19 | """ 20 | A class that represents a Voltage role. 21 | 22 | Attributes 23 | ---------- 24 | id: :class:`str` 25 | The role's ID. 26 | created_at: :class:int: 27 | The timestamp of when the role was created. 28 | name: :class:`str` 29 | The role's name. 30 | colour: :class:`str` 31 | The role's colour. 32 | color: :class:`str` 33 | Alias for :attr:`colour`. 34 | hoist: :class:`bool` 35 | Whether the role is hoisted. 36 | rank: :class:`int` 37 | The role's position in the role hierarchy. 38 | permissions: :class:`Permissions` 39 | The role's permissions.. 40 | server: :class:`Server` 41 | The server the role belongs to. 42 | server_id: :class:`str` 43 | The ID of the server the role belongs to. 44 | """ 45 | 46 | __slots__ = ( 47 | "id", 48 | "created_at", 49 | "name", 50 | "colour", 51 | "color", 52 | "hoist", 53 | "rank", 54 | "permissions", 55 | "server", 56 | "server_id", 57 | "http", 58 | ) 59 | 60 | def __init__(self, data: RolePayload, id: str, server: Server, http: HTTPHandler): 61 | self.id = id 62 | self.created_at = ULID().decode(id) 63 | self.name = data["name"] 64 | self.colour = data.get("colour") 65 | self.color = self.colour 66 | self.hoist = data.get("hoist", False) 67 | self.rank = data["rank"] 68 | self.permissions = Permissions(data["permissions"]) 69 | self.server = server 70 | self.server_id = server.id 71 | self.http = http 72 | 73 | def __str__(self): 74 | return self.name 75 | 76 | def __repr__(self): 77 | return f"" 78 | 79 | async def set_permissions(self, permissions: Permissions): 80 | """ 81 | Sets the role's permissions. 82 | 83 | Parameters 84 | ---------- 85 | permissions: Optional[:class:`Permissions`] 86 | The new server permissions. 87 | """ 88 | await self.http.set_role_permission(self.server_id, self.id, permissions.to_dict()) 89 | 90 | async def delete(self): 91 | """ 92 | Deletes the role. 93 | """ 94 | await self.http.delete_role(self.server_id, self.id) 95 | 96 | async def edit( 97 | self, 98 | *, 99 | name: Optional[str] = None, 100 | colour: Optional[str] = NotSupplied, 101 | color: Optional[str] = NotSupplied, 102 | hoist: Optional[bool] = None, 103 | rank: Optional[int] = None, 104 | ): 105 | """ 106 | Edits the role. 107 | 108 | Parameters 109 | ---------- 110 | name: Optional[:class:`str`] 111 | The new name of the role. 112 | colour: Optional[:class:`str`] 113 | The new colour of the role. 114 | color: Optional[:class:`str`] 115 | Alias for :attr:`colour`. 116 | hoist: Optional[:class:`bool`] 117 | Whether the role is hoisted. 118 | rank: Optional[:class:`int`] 119 | The new rank of the role. 120 | """ 121 | if name is None and colour is NotSupplied and hoist is None and rank is None: 122 | raise ValueError("You must provide at least one of the following: name, colour, hoist, rank") 123 | 124 | if name is None: 125 | name = self.name 126 | 127 | if name is None: 128 | raise ValueError( 129 | "You must provide a name" 130 | ) # god forgive me for I have sinned in the name of appeasing pyright. 131 | 132 | if colour is NotSupplied and color is not NotSupplied: 133 | colour = color 134 | 135 | remove: Optional[Literal["Colour"]] = "Colour" if colour is None else None 136 | await self.http.edit_role( 137 | self.server_id, 138 | self.id, 139 | name, 140 | colour=colour, 141 | hoist=hoist, 142 | rank=rank, 143 | remove=remove, 144 | ) 145 | 146 | def __lt__(self, other: Role): 147 | return self.rank < other.rank 148 | 149 | def __le__(self, other: Role): 150 | return self.rank <= other.rank 151 | 152 | def __gt__(self, other: Role): 153 | return self.rank > other.rank 154 | 155 | def __ge__(self, other: Role): 156 | return self.rank >= other.rank 157 | 158 | def _update(self, data: OnServerRoleUpdatePayload): 159 | if clear := data.get("clear"): 160 | if clear == "colour": 161 | self.colour = None 162 | 163 | if new := data.get("data"): 164 | if name := new.get("name"): 165 | self.name = name 166 | 167 | if colour := new.get("colour"): 168 | self.colour = colour 169 | 170 | if hoist := new.get("hoist"): 171 | self.hoist = hoist 172 | 173 | if rank := new.get("rank"): 174 | self.rank = rank 175 | -------------------------------------------------------------------------------- /voltage/types/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | The internal Voltage library that provides types. 3 | 4 | Heavily taken from revolt.py (https://github.com/revoltchat/revolt.py) as I couldn't find what I needed in the docs. 5 | """ 6 | 7 | from .channel import * 8 | from .embed import * 9 | from .file import * 10 | from .http import * 11 | from .message import * 12 | from .server import * 13 | from .user import * 14 | from .ws import * 15 | -------------------------------------------------------------------------------- /voltage/types/channel.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING, Dict, List, Literal, TypedDict, Union 4 | 5 | from typing_extensions import NotRequired 6 | 7 | if TYPE_CHECKING: 8 | from .file import FilePayload 9 | from .message import MessagePayload 10 | 11 | 12 | class OverrideFieldPayload(TypedDict): 13 | a: int 14 | d: int 15 | 16 | 17 | class BaseChannelPayload(TypedDict): 18 | _id: str 19 | nonce: NotRequired[str] 20 | 21 | 22 | class SavedMessagePayload(BaseChannelPayload): 23 | user: str 24 | channel_type: Literal["SavedMessages"] 25 | 26 | 27 | class DMChannelPayload(BaseChannelPayload): 28 | active: bool 29 | recipients: List[str] 30 | channel_type: Literal["DirectMessage"] 31 | last_message: MessagePayload 32 | 33 | 34 | class GroupDMChannelPayload(BaseChannelPayload): 35 | recipients: List[str] 36 | name: str 37 | owner: str 38 | channel_type: Literal["Group"] 39 | icon: NotRequired[FilePayload] 40 | permissions: NotRequired[int] 41 | description: NotRequired[str] 42 | 43 | 44 | class TextChannelPayload(BaseChannelPayload): 45 | server: str 46 | name: str 47 | description: NotRequired[str] 48 | icon: NotRequired[FilePayload] 49 | default_permissions: NotRequired[OverrideFieldPayload] 50 | role_permissions: NotRequired[Dict[str, OverrideFieldPayload]] 51 | last_message: NotRequired[str] 52 | channel_type: Literal["TextChannel"] 53 | 54 | 55 | class VoiceChannelPayload(BaseChannelPayload): 56 | server: str 57 | name: str 58 | description: NotRequired[str] 59 | icon: NotRequired[FilePayload] 60 | default_permissions: NotRequired[OverrideFieldPayload] 61 | role_permissions: NotRequired[Dict[str, OverrideFieldPayload]] 62 | channel_type: Literal["VoiceChannel"] 63 | 64 | 65 | ChannelPayload = Union[ 66 | SavedMessagePayload, 67 | DMChannelPayload, 68 | GroupDMChannelPayload, 69 | TextChannelPayload, 70 | VoiceChannelPayload, 71 | ] 72 | 73 | 74 | class CategoryPayload(TypedDict): 75 | id: str 76 | title: str 77 | channels: List[str] 78 | -------------------------------------------------------------------------------- /voltage/types/embed.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING, Literal, TypedDict, Union 4 | 5 | from typing_extensions import NotRequired 6 | 7 | if TYPE_CHECKING: 8 | from .file import FilePayload 9 | 10 | 11 | # Almost ripped out from https://github.com/revoltchat/revolt.py/blob/master/revolt/types/embed.py 12 | 13 | 14 | class YoutubeEmbedPayload(TypedDict): 15 | type: Literal["Youtube"] 16 | id: str 17 | timestamp: NotRequired[str] 18 | 19 | 20 | class TwitchEmbedPayload(TypedDict): 21 | type: Literal["Twitch"] 22 | id: str 23 | content_type: Literal["Channel", "Video", "Clip"] 24 | 25 | 26 | class SpotifyEmbedPayload(TypedDict): 27 | type: Literal["Spotify"] 28 | id: str 29 | content_type: Literal["Track", "Album", "Playlist"] 30 | 31 | 32 | class SoundCloudEmbedPayload(TypedDict): 33 | type: Literal["SoundCloud"] 34 | 35 | 36 | class BandcampEmbedPayload(TypedDict): 37 | type: Literal["Bandcamp"] 38 | id: str 39 | content_type: str 40 | 41 | 42 | SpecialWebsiteEmbedPayload = Union[ 43 | YoutubeEmbedPayload, 44 | TwitchEmbedPayload, 45 | SpotifyEmbedPayload, 46 | SoundCloudEmbedPayload, 47 | BandcampEmbedPayload, 48 | ] 49 | 50 | 51 | class JanuaryImagePayload(TypedDict): 52 | url: str 53 | width: int 54 | height: int 55 | size: Literal["Large", "Preview"] 56 | 57 | 58 | class JanuaryVideoPayload(TypedDict): 59 | url: str 60 | width: int 61 | height: int 62 | 63 | 64 | class WebsiteEmbedPayload(TypedDict): 65 | type: Literal["Website"] 66 | url: NotRequired[str] 67 | special: NotRequired[SpecialWebsiteEmbedPayload] 68 | title: NotRequired[str] 69 | description: NotRequired[str] 70 | image: NotRequired[JanuaryImagePayload] 71 | video: NotRequired[JanuaryVideoPayload] 72 | site_name: NotRequired[str] 73 | icon_url: NotRequired[str] 74 | colour: NotRequired[str] 75 | 76 | 77 | class ImageEmbedPayload(TypedDict): # TODO? idk 78 | type: Literal["Image"] 79 | 80 | 81 | class TextEmbedPayload(TypedDict): 82 | type: Literal["Text"] 83 | title: NotRequired[str] 84 | description: NotRequired[str] 85 | url: NotRequired[str] 86 | media: NotRequired[FilePayload] 87 | icon_url: NotRequired[str] 88 | colour: NotRequired[str] 89 | 90 | 91 | class NoneEmbed(TypedDict): 92 | type: Literal["None"] 93 | 94 | 95 | EmbedPayload = Union[WebsiteEmbedPayload, ImageEmbedPayload, TextEmbedPayload, NoneEmbed] 96 | 97 | 98 | class SendableEmbedPayload(TypedDict): 99 | type: Literal["Text"] 100 | title: NotRequired[str] 101 | description: NotRequired[str] 102 | url: NotRequired[str] 103 | media: NotRequired[str] 104 | icon_url: NotRequired[str] 105 | colour: NotRequired[str] 106 | -------------------------------------------------------------------------------- /voltage/types/file.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import Literal, TypedDict 4 | 5 | from typing_extensions import NotRequired 6 | 7 | 8 | class FileMetadataPayload(TypedDict): 9 | type: Literal["Video", "Image", "File", "Text", "Audio"] 10 | height: NotRequired[int] 11 | width: NotRequired[int] 12 | 13 | 14 | class FilePayload(TypedDict): 15 | _id: str 16 | tag: str 17 | size: int 18 | filename: str 19 | metadata: FileMetadataPayload 20 | content_type: str 21 | -------------------------------------------------------------------------------- /voltage/types/http.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING, Any, Dict, List, TypedDict 4 | 5 | if TYPE_CHECKING: 6 | from .message import MessagePayload 7 | from .server import MemberPayload 8 | from .user import UserPayload 9 | 10 | BaseRequestReturnPayload = Dict[Any, Any] 11 | 12 | 13 | class ApiFeaturePayload(TypedDict): 14 | enabled: bool 15 | url: str 16 | 17 | 18 | class VosoFeaturePayload(TypedDict): 19 | ws: str 20 | 21 | 22 | class FeaturesPayload(TypedDict): 23 | email: bool 24 | invite_only: bool 25 | captcha: ApiFeaturePayload 26 | autumn: ApiFeaturePayload 27 | voso: VosoFeaturePayload 28 | january: ApiFeaturePayload 29 | 30 | 31 | class ApiInfoPayload(TypedDict): 32 | revolt: str 33 | features: FeaturesPayload 34 | ws: str 35 | app: str 36 | vapid: str 37 | 38 | 39 | class AutumnPayload(TypedDict): 40 | id: str 41 | 42 | 43 | class GetServerMembersPayload(TypedDict): 44 | members: List[MemberPayload] 45 | users: List[UserPayload] 46 | 47 | 48 | class MessageWihUserDataPayload(TypedDict): 49 | messages: List[MessagePayload] 50 | users: List[UserPayload] 51 | member: List[MemberPayload] 52 | -------------------------------------------------------------------------------- /voltage/types/message.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING, Dict, List, TypedDict 4 | 5 | from typing_extensions import NotRequired 6 | 7 | if TYPE_CHECKING: 8 | from .embed import EmbedPayload 9 | from .file import FilePayload 10 | 11 | 12 | class ContentPayload(TypedDict): 13 | id: str 14 | by: NotRequired[str] 15 | name: NotRequired[str] 16 | 17 | 18 | class MasqueradePayload(TypedDict): 19 | name: str 20 | avatar: str 21 | colour: str 22 | 23 | 24 | class MessageInteractionsPayload(TypedDict): 25 | reactions: NotRequired[list[str]] 26 | restrict_reactions: NotRequired[bool] 27 | 28 | 29 | class MessagePayload(TypedDict): 30 | _id: str 31 | channel: str 32 | author: str 33 | content: NotRequired[str] 34 | attachments: NotRequired[List[FilePayload]] 35 | edited: NotRequired[str] 36 | embeds: NotRequired[List[EmbedPayload]] 37 | mentions: NotRequired[List[str]] 38 | replies: NotRequired[List[str]] 39 | masquerade: NotRequired[MasqueradePayload] 40 | reactions: NotRequired[Dict[str, List[str]]] 41 | interactions: NotRequired[MessageInteractionsPayload] 42 | 43 | 44 | class MessageReplyPayload(TypedDict): 45 | id: str 46 | mention: bool 47 | -------------------------------------------------------------------------------- /voltage/types/server.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING, Dict, List, Literal, TypedDict 4 | 5 | from typing_extensions import NotRequired 6 | 7 | from voltage.types.channel import CategoryPayload 8 | 9 | if TYPE_CHECKING: 10 | from .channel import OverrideFieldPayload 11 | from .file import FilePayload 12 | 13 | 14 | class _MemberBase(TypedDict): 15 | nickname: NotRequired[str] 16 | avatar: NotRequired[FilePayload] 17 | roles: NotRequired[List[str]] 18 | 19 | 20 | class MemberIDPayload(_MemberBase): 21 | server: str 22 | user: str 23 | 24 | 25 | class MemberPayload(_MemberBase): 26 | _id: MemberIDPayload 27 | 28 | 29 | class PartialRolePayload(TypedDict): 30 | name: str 31 | permissions: OverrideFieldPayload 32 | 33 | 34 | class RolePayload(TypedDict): 35 | name: str 36 | permissions: OverrideFieldPayload 37 | colour: NotRequired[str] 38 | hoist: NotRequired[bool] 39 | rank: int 40 | 41 | 42 | class InvitePayload(TypedDict): 43 | type: Literal["Server"] 44 | server_id: str 45 | server_name: str 46 | server_icon: NotRequired[str] 47 | server_banner: NotRequired[str] 48 | channel_id: str 49 | channel_name: str 50 | channel_description: NotRequired[str] 51 | user_name: str 52 | user_avatar: NotRequired[str] 53 | member_count: int 54 | 55 | 56 | class PartialInvitePayload(TypedDict): 57 | _id: str 58 | server: str 59 | channel: str 60 | creator: str 61 | 62 | 63 | class SystemMessagesConfigPayload(TypedDict): 64 | user_joined: NotRequired[str] 65 | user_left: NotRequired[str] 66 | user_kicked: NotRequired[str] 67 | user_banned: NotRequired[str] 68 | 69 | 70 | class ServerPayload(TypedDict): 71 | _id: str 72 | name: str 73 | owner: str 74 | channels: List[str] 75 | default_permissions: OverrideFieldPayload 76 | nonce: NotRequired[str] 77 | description: NotRequired[str] 78 | categories: NotRequired[List[CategoryPayload]] 79 | system_messages: NotRequired[SystemMessagesConfigPayload] 80 | roles: NotRequired[Dict[str, RolePayload]] 81 | icon: NotRequired[FilePayload] 82 | banner: NotRequired[FilePayload] 83 | nsfw: NotRequired[bool] 84 | flags: NotRequired[int] 85 | analytics: NotRequired[bool] 86 | discoverable: NotRequired[bool] 87 | 88 | 89 | class BannedUserPayload(TypedDict): 90 | _id: str 91 | username: str 92 | avatar: NotRequired[FilePayload] 93 | 94 | 95 | class BanIdPayload(TypedDict): 96 | server: str 97 | user: str 98 | 99 | 100 | class BanPayload(TypedDict): 101 | _id: BanIdPayload 102 | reason: NotRequired[str] 103 | 104 | 105 | class ServerBansPayload(TypedDict): 106 | users: List[BannedUserPayload] 107 | bans: List[BanPayload] 108 | -------------------------------------------------------------------------------- /voltage/types/user.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING, List, Literal, TypedDict 4 | 5 | from typing_extensions import NotRequired 6 | 7 | if TYPE_CHECKING: 8 | from .file import FilePayload 9 | 10 | RelationPayload = Literal["Block", "BlockedOther", "Friend", "Incoming", "None", "Outgoing", "User"] 11 | 12 | 13 | class UserBotPayload(TypedDict): 14 | owner: str 15 | 16 | 17 | class StatusPayload(TypedDict): 18 | text: NotRequired[str] 19 | presence: NotRequired[Literal["Busy", "Idle", "Online", "Invisible"]] 20 | 21 | 22 | class UserRelationPayload(TypedDict): 23 | status: RelationPayload 24 | _id: str 25 | 26 | 27 | class UserPayload(TypedDict): 28 | _id: str 29 | username: str 30 | discriminator: str 31 | avatar: NotRequired[FilePayload] 32 | bot: NotRequired[UserBotPayload] 33 | relations: NotRequired[List[UserRelationPayload]] 34 | badges: NotRequired[int] 35 | status: NotRequired[StatusPayload] 36 | online: NotRequired[bool] 37 | relationship: NotRequired[UserRelationPayload] 38 | flags: NotRequired[int] 39 | 40 | 41 | class UserProfilePayload(TypedDict): 42 | content: NotRequired[str] 43 | background: NotRequired[FilePayload] 44 | -------------------------------------------------------------------------------- /voltage/types/ws.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING, List, Literal, TypedDict, Union 4 | 5 | from .channel import ( 6 | DMChannelPayload, 7 | GroupDMChannelPayload, 8 | SavedMessagePayload, 9 | TextChannelPayload, 10 | VoiceChannelPayload, 11 | ) 12 | from .message import MessagePayload 13 | 14 | if TYPE_CHECKING: 15 | from .channel import ChannelPayload 16 | from .embed import EmbedPayload 17 | from .server import MemberIDPayload, MemberPayload, ServerPayload 18 | from .user import StatusPayload, UserPayload 19 | 20 | 21 | class BasePayload(TypedDict): 22 | type: str 23 | 24 | 25 | class AuthenticatePayload(BasePayload): 26 | token: str 27 | 28 | 29 | class OnReadyPayload(BasePayload): 30 | users: List[UserPayload] 31 | servers: List[ServerPayload] 32 | channels: List[ChannelPayload] 33 | members: List[MemberPayload] 34 | 35 | 36 | class OnMessagePayload(BasePayload, MessagePayload): 37 | pass 38 | 39 | 40 | class MessageUpdateDataPayload(BasePayload): 41 | content: str 42 | edited: str 43 | embeds: list[EmbedPayload] 44 | 45 | 46 | class OnMessageUpdatePayload(BasePayload): 47 | id: str 48 | channel: str 49 | data: MessageUpdateDataPayload 50 | 51 | 52 | class OnMessageDeletePayload(BasePayload): 53 | id: str 54 | channel: str 55 | 56 | 57 | class OnMessageReactPayload(BasePayload): 58 | id: str 59 | channel_id: str 60 | user_id: str 61 | emoji_id: str 62 | 63 | 64 | class OnMessageRemoveReactionPayload(BasePayload): 65 | id: str 66 | channel_id: str 67 | emoji_id: str 68 | 69 | 70 | class OnChannelCreatePayload_SavedMessage(BasePayload, SavedMessagePayload): 71 | pass 72 | 73 | 74 | class OnChannelCreatePayload_Group(BasePayload, GroupDMChannelPayload): 75 | pass 76 | 77 | 78 | class OnChannelCreatePayload_TextChannel(BasePayload, TextChannelPayload): 79 | pass 80 | 81 | 82 | class OnChannelCreatePayload_VoiceChannel(BasePayload, VoiceChannelPayload): 83 | pass 84 | 85 | 86 | class OnChannelCreatePayload_DMChannel(BasePayload, DMChannelPayload): 87 | pass 88 | 89 | 90 | OnChannelCreatePayload = Union[ 91 | OnChannelCreatePayload_SavedMessage, 92 | OnChannelCreatePayload_Group, 93 | OnChannelCreatePayload_DMChannel, 94 | OnChannelCreatePayload_VoiceChannel, 95 | OnChannelCreatePayload_TextChannel, 96 | ] 97 | 98 | 99 | class OnChannelUpdatePayload(BasePayload): 100 | id: str 101 | data: ChannelPayload 102 | clear: Literal["Icon", "Description"] 103 | 104 | 105 | class OnChannelDeletePayload(BasePayload): 106 | id: str 107 | 108 | 109 | class OnChannelStartTypingPayload(BasePayload): 110 | id: str 111 | user: str 112 | 113 | 114 | OnChannelDeleteTypingPayload = OnChannelStartTypingPayload 115 | 116 | 117 | class OnServerCreatePayload(BasePayload): 118 | id: str 119 | server: ServerPayload 120 | channels: list[ChannelPayload] 121 | 122 | 123 | class OnServerUpdatePayload(BasePayload): 124 | id: str 125 | data: dict 126 | clear: Literal["Icon", "Banner", "Description"] 127 | 128 | 129 | class OnServerDeletePayload(BasePayload): 130 | id: str 131 | 132 | 133 | class OnServerMemberUpdatePayload(BasePayload): 134 | id: MemberIDPayload 135 | data: dict 136 | clear: Literal["Nickname", "Avatar"] 137 | 138 | 139 | class OnServerMemberJoinPayload(BasePayload): 140 | id: str 141 | user: str 142 | 143 | 144 | OnServerMemberLeavePayload = OnServerMemberJoinPayload 145 | 146 | 147 | class OnServerRoleUpdatePayload(BasePayload): 148 | id: str 149 | role_id: str 150 | data: dict 151 | clear: Literal["Color"] 152 | 153 | 154 | class OnServerRoleDeletePayload(BasePayload): 155 | id: str 156 | role_id: str 157 | 158 | 159 | class OnUserUpdatePayload(BasePayload): 160 | id: str 161 | data: dict 162 | clear: Literal["ProfileContent", "ProfileBackground", "StatusText", "Avatar"] 163 | 164 | 165 | class OnUserRelationshipPayload(BasePayload): 166 | id: str 167 | user: str 168 | status: StatusPayload 169 | -------------------------------------------------------------------------------- /voltage/user.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING, NamedTuple, Optional 4 | 5 | from ulid import ULID 6 | 7 | from .asset import Asset, PartialAsset 8 | from .enums import PresenceType, RelationshipType 9 | from .flag import UserFlags 10 | from .messageable import Messageable 11 | 12 | if TYPE_CHECKING: 13 | from .internals import CacheHandler 14 | from .types import OnUserUpdatePayload, UserPayload 15 | 16 | 17 | class Relationship(NamedTuple): 18 | """ 19 | A tuple that represents the relationship between two users. 20 | 21 | Attributes 22 | ---------- 23 | type: :class:`RelationshipType` 24 | The type of relationship between the two users. 25 | user: :class:`User` 26 | The user that is the target of the relationship. 27 | """ 28 | 29 | type: RelationshipType 30 | user: User 31 | 32 | 33 | class Status(NamedTuple): 34 | """ 35 | A tuple that represents the status of a user. 36 | 37 | Attributes 38 | ---------- 39 | text: Optional[:class:`str`] 40 | The status message of the user. 41 | presence: :class:`PresenceType` 42 | The presence of the user. 43 | """ 44 | 45 | text: Optional[str] 46 | presence: PresenceType 47 | 48 | 49 | class UserProfile(NamedTuple): 50 | """ 51 | A tuple that represent's a user's profile. 52 | 53 | Attributes 54 | ---------- 55 | content: Optional[:class:`str`] 56 | The content of the user's profile. 57 | background: Optional[:class:`PartialAsset`] 58 | The background of the user's profile. 59 | """ 60 | 61 | content: Optional[str] 62 | background: Optional[Asset] 63 | 64 | 65 | class User(Messageable): 66 | """ 67 | A class that represents a Voltage user. 68 | 69 | Attributes 70 | ---------- 71 | id: :class:`str` 72 | The user's ID. 73 | created_at: :class:`int` 74 | The epoch time when the user was created. 75 | name: :class:`str` 76 | The user's name. 77 | discriminator: :class:`str` 78 | The user's discriminator. 79 | avatar: :class:`Asset` 80 | The user's avatar. 81 | badges: :class:`UserFlags` 82 | The user's badges. 83 | online: :class:`bool` 84 | Whether the user is online or not. 85 | status: :class:`Status` 86 | The user's status. 87 | relationships: :class:`list` of :class:`Relationship` 88 | The user's relationships. 89 | profile: :class:`UserProfile` 90 | The user's profile. 91 | bot: :class:`bool` 92 | Whether the user is a bot or not. 93 | owner: :class:`User` 94 | The bot's owner. 95 | """ 96 | 97 | __slots__ = ( 98 | "id", 99 | "created_at", 100 | "name", 101 | "discriminator", 102 | "avatar", 103 | "dm_channel", 104 | "default_avatar", 105 | "flags", 106 | "badges", 107 | "online", 108 | "status", 109 | "relationships", 110 | "avatar", 111 | "_profile", 112 | "_profile_fetched", 113 | "bot", 114 | "owner_id", 115 | "cache", 116 | "masquerade_name", 117 | "masquerade_avatar", 118 | ) 119 | 120 | def __init__(self, data: UserPayload, cache: CacheHandler): 121 | self.cache = cache 122 | self.id = data["_id"] 123 | self.created_at = ULID().decode(self.id)[0] 124 | 125 | self.name = data["username"] 126 | self.discriminator = data["discriminator"] 127 | self.dm_channel = cache.get_dm_channel(self.id) 128 | self.flags = data.get("flags", 0) 129 | self.badges = UserFlags.new_with_flags(data.get("badges", 0)) 130 | self.online = data.get("online", False) 131 | 132 | avatar = data.get("avatar") 133 | self.avatar = Asset(avatar, cache.http) if avatar else None 134 | self.default_avatar = PartialAsset(f"{cache.http.api_url}/users/{self.id}/default_avatar", cache.http) 135 | 136 | relationships = [] 137 | for i in data.get("relations", []): 138 | try: 139 | if user := cache.get_user(i["_id"]): 140 | relationships.append(Relationship(RelationshipType(i["status"]), user)) 141 | except KeyError: 142 | continue 143 | 144 | self.relationships = relationships 145 | 146 | if status := data.get("status"): 147 | if presence := status.get("presence"): 148 | self.status = Status(status.get("text"), PresenceType(presence)) 149 | else: 150 | self.status = Status(status.get("text"), PresenceType.invisible) 151 | else: 152 | self.status = Status(None, PresenceType.invisible) 153 | 154 | self._profile = UserProfile(None, None) 155 | self._profile_fetched = False 156 | 157 | bot = data.get("bot", {}) 158 | self.bot = True if bot else False 159 | self.owner_id = bot.get("owner") 160 | 161 | self.masquerade_name: Optional[str] = None 162 | self.masquerade_avatar: Optional[PartialAsset] = None 163 | 164 | def set_masquerade(self, name: Optional[str], avatar: Optional[PartialAsset]): 165 | """ 166 | A method which sets a user's masquerade. 167 | 168 | Parameters 169 | ---------- 170 | name: :class:`str` 171 | The masquerade name. 172 | avatar: :class:`PartialAsset` 173 | The masquerade avatar. 174 | """ 175 | self.masquerade_name = name 176 | self.masquerade_avatar = avatar 177 | 178 | async def get_id(self): 179 | if self.dm_channel is None: 180 | self.dm_channel = await self.cache.fetch_dm_channel(self.id) 181 | return self.dm_channel.id 182 | 183 | def __str__(self): 184 | return f"@{self.name}#{self.discriminator}" 185 | 186 | def __repr__(self): 187 | return f"" 188 | 189 | @property 190 | def profile(self) -> UserProfile: 191 | if not self._profile_fetched: 192 | self.cache.loop.create_task(self.fetch_profile()) 193 | self._profile_fetched = True 194 | return self._profile 195 | 196 | @property 197 | def mention(self): 198 | return f"<@{self.id}>" 199 | 200 | @property 201 | def display_name(self): 202 | return self.masquerade_name or self.name 203 | 204 | @property 205 | def display_avatar(self): 206 | return self.masquerade_avatar or self.avatar or self.default_avatar 207 | 208 | @property 209 | def owner(self): 210 | return self.cache.get_user(self.owner_id) if self.bot and self.owner_id else None 211 | 212 | async def fetch_profile(self) -> UserProfile: 213 | """ 214 | A method which fetches a user's profile. 215 | 216 | Returns 217 | ------- 218 | :class:`UserProfile` 219 | The user's profile. 220 | """ 221 | data = await self.cache.http.fetch_user_profile(self.id) 222 | bg = data.get("background") 223 | background = Asset(bg, self.cache.http) if bg is not None else None 224 | self._profile = UserProfile(data.get("content"), background) 225 | return self.profile 226 | 227 | def _update(self, data: OnUserUpdatePayload): 228 | if clear := data.get("clear"): 229 | if clear == "ProfileContent": 230 | self._profile = UserProfile(None, self._profile.background) 231 | elif clear == "ProfileBackground": 232 | self._profile = UserProfile(self._profile.content, None) 233 | elif clear == "StatusText": 234 | self.status = Status(None, self.status.presence) 235 | elif clear == "Avatar": 236 | self.avatar = None 237 | 238 | if new := data.get("data"): 239 | if status := new.get("status"): 240 | presence = status.get("presence") or self.status.presence 241 | self.status = Status(status.get("text"), PresenceType(presence)) 242 | if bg := new.get("profile.background"): 243 | self._profile = UserProfile(self._profile.content, Asset(bg, self.cache.http)) 244 | if content := new.get("profile.content"): 245 | self._profile = UserProfile(content, self._profile.background) 246 | if avatar := new.get("avatar"): 247 | self.avatar = Asset(avatar, self.cache.http) 248 | if online := new.get("online"): 249 | self.online = online 250 | -------------------------------------------------------------------------------- /voltage/utils.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING, Callable, Iterable, Optional, TypeVar 4 | 5 | if TYPE_CHECKING: 6 | pass 7 | 8 | T = TypeVar("T") 9 | 10 | 11 | def get(base: Iterable[T], predicate: Callable[[T], bool]) -> Optional[T]: 12 | for item in base: 13 | if predicate(item): 14 | return item 15 | return None 16 | --------------------------------------------------------------------------------