mirror of
https://github.com/Cog-Creators/Red-DiscordBot.git
synced 2025-11-06 03:08:55 -05:00
Merge branch 'V3/develop' into V3/feature/mutes
This commit is contained in:
commit
677d700363
1
.github/CODEOWNERS
vendored
1
.github/CODEOWNERS
vendored
@ -62,3 +62,4 @@ redbot/setup.py @tekulvw
|
||||
# Others
|
||||
.travis.yml @Kowlin
|
||||
crowdin.yml @Kowlin
|
||||
.github/workflows/* @Kowlin
|
||||
|
||||
9
.github/CONTRIBUTING.md
vendored
9
.github/CONTRIBUTING.md
vendored
@ -30,9 +30,8 @@ Red is an open source project. This means that each and every one of the develop
|
||||
We love receiving contributions from our community. Any assistance you can provide with regards to bug fixes, feature enhancements, and documentation is more than welcome.
|
||||
|
||||
# 2. Ground Rules
|
||||
We've made a point to use [ZenHub](https://www.zenhub.com/) (a plugin for GitHub) as our main source of collaboration and coordination. Your experience contributing to Red will be greatly improved if you go get that plugin.
|
||||
1. Ensure cross compatibility for Windows, Mac OS and Linux.
|
||||
2. Ensure all Python features used in contributions exist and work in Python 3.7 and above.
|
||||
2. Ensure all Python features used in contributions exist and work in Python 3.8.1 and above.
|
||||
3. Create new tests for code you add or bugs you fix. It helps us help you by making sure we don't accidentally break anything :grinning:
|
||||
4. Create any issues for new features you'd like to implement and explain why this feature is useful to everyone and not just you personally.
|
||||
5. Don't add new cogs unless specifically given approval in an issue discussing said cog idea.
|
||||
@ -54,7 +53,7 @@ Red's repository is configured to follow a particular development workflow, usin
|
||||
|
||||
### 4.1 Setting up your development environment
|
||||
The following requirements must be installed prior to setting up:
|
||||
- Python 3.7.0 or greater
|
||||
- Python 3.8.1 or greater
|
||||
- git
|
||||
- pip
|
||||
|
||||
@ -83,7 +82,7 @@ If you're not on Windows, you should also have GNU make installed, and you can o
|
||||
We've recently started using [tox](https://github.com/tox-dev/tox) to run all of our tests. It's extremely simple to use, and if you followed the previous section correctly, it is already installed to your virtual environment.
|
||||
|
||||
Currently, tox does the following, creating its own virtual environments for each stage:
|
||||
- Runs all of our unit tests with [pytest](https://github.com/pytest-dev/pytest) on python 3.7 (test environment `py37`)
|
||||
- Runs all of our unit tests with [pytest](https://github.com/pytest-dev/pytest) on python 3.8 (test environment `py38`)
|
||||
- Ensures documentation builds without warnings, and all hyperlinks have a valid destination (test environment `docs`)
|
||||
- Ensures that the code meets our style guide with [black](https://github.com/ambv/black) (test environment `style`)
|
||||
|
||||
@ -107,7 +106,7 @@ You may have noticed we have a `Makefile` and a `make.bat` in the top-level dire
|
||||
|
||||
The other make recipes are most likely for project maintainers rather than contributors.
|
||||
|
||||
You can specify the Python executable used in the make recipes with the `PYTHON` environment variable, e.g. `make PYTHON=/usr/bin/python3.7 newenv`.
|
||||
You can specify the Python executable used in the make recipes with the `PYTHON` environment variable, e.g. `make PYTHON=/usr/bin/python3.8 newenv`.
|
||||
|
||||
### 4.5 Keeping your dependencies up to date
|
||||
Whenever you pull from upstream (V3/develop on the main repository) and you notice either of the files `setup.cfg` or `tools/dev-requirements.txt` have been changed, it can often mean some package dependencies have been updated, added or removed. To make sure you're testing and formatting with the most up-to-date versions of our dependencies, run `make syncenv`. You could also simply do `make newenv` to install them to a clean new virtual environment.
|
||||
|
||||
7
.github/workflows/auto_labeler.yml
vendored
7
.github/workflows/auto_labeler.yml
vendored
@ -13,9 +13,14 @@ jobs:
|
||||
with:
|
||||
github-token: ${{secrets.GITHUB_TOKEN}}
|
||||
script: |
|
||||
const is_status_label = (label) => label.name.startsWith('Status: ');
|
||||
if (context.payload.issue.labels.some(is_status_label)) {
|
||||
console.log('Issue already has Status label, skipping...');
|
||||
return;
|
||||
}
|
||||
github.issues.addLabels({
|
||||
issue_number: context.issue.number,
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
labels: ['Status: Needs Triage']
|
||||
})
|
||||
});
|
||||
|
||||
28
.github/workflows/publish_crowdin.yml
vendored
Normal file
28
.github/workflows/publish_crowdin.yml
vendored
Normal file
@ -0,0 +1,28 @@
|
||||
name: Publish to Crowdin
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "*"
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v1
|
||||
with:
|
||||
python-version: '3.8'
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
curl https://artifacts.crowdin.com/repo/GPG-KEY-crowdin | sudo apt-key add -
|
||||
echo "deb https://artifacts.crowdin.com/repo/deb/ /" | sudo tee -a /etc/apt/sources.list
|
||||
sudo apt-get update -qq
|
||||
sudo apt-get install -y crowdin
|
||||
pip install redgettext==3.1
|
||||
- name: Publish
|
||||
env:
|
||||
CROWDIN_API_KEY: ${{ secrets.crowdin_token}}
|
||||
CROWDIN_PROJECT_ID: ${{ secrets.crowdin_identifier }}
|
||||
run: |
|
||||
make upload_translations
|
||||
26
.github/workflows/publish_pypi.yml
vendored
Normal file
26
.github/workflows/publish_pypi.yml
vendored
Normal file
@ -0,0 +1,26 @@
|
||||
name: Publish to PyPI
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "*"
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v1
|
||||
with:
|
||||
python-version: '3.8'
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install setuptools wheel twine
|
||||
- name: Build and publish
|
||||
env:
|
||||
TWINE_USERNAME: __token__
|
||||
TWINE_PASSWORD: ${{ secrets.pypi_token }}
|
||||
run: |
|
||||
python setup.py sdist bdist_wheel
|
||||
twine upload dist/*
|
||||
73
.github/workflows/tests.yml
vendored
Normal file
73
.github/workflows/tests.yml
vendored
Normal file
@ -0,0 +1,73 @@
|
||||
name: Tests
|
||||
on: [push, pull_request]
|
||||
|
||||
jobs:
|
||||
tox:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
python_version:
|
||||
- "3.8"
|
||||
tox_env:
|
||||
- py
|
||||
- style
|
||||
- docs
|
||||
include:
|
||||
- tox_env: py
|
||||
friendly_name: Tests
|
||||
- tox_env: style
|
||||
friendly_name: Style
|
||||
- tox_env: docs
|
||||
friendly_name: Docs
|
||||
fail-fast: false
|
||||
name: Tox - ${{ matrix.friendly_name }}
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v1
|
||||
with:
|
||||
python-version: ${{ matrix.python_version }}
|
||||
- name: Install tox
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install tox
|
||||
- name: Tox test
|
||||
env:
|
||||
TOXENV: ${{ matrix.tox_env }}
|
||||
run: tox
|
||||
|
||||
tox-postgres:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
python_version:
|
||||
- "3.8"
|
||||
fail-fast: false
|
||||
name: Tox - Postgres
|
||||
services:
|
||||
postgresql:
|
||||
image: postgres:10
|
||||
ports:
|
||||
- 5432:5432
|
||||
env:
|
||||
POSTGRES_DB: red_db
|
||||
POSTGRES_PASSWORD: postgres
|
||||
POSTGRES_USER: postgres
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v1
|
||||
with:
|
||||
python-version: ${{ matrix.python_version }}
|
||||
- name: Install tox
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install tox
|
||||
- name: Tox test
|
||||
env:
|
||||
TOXENV: postgres
|
||||
PGDATABASE: red_db
|
||||
PGUSER: postgres
|
||||
PGPASSWORD: postgres
|
||||
PGPORT: 5432
|
||||
run: tox
|
||||
@ -16,7 +16,7 @@
|
||||
<img src="https://img.shields.io/badge/Support-Red!-yellow.svg" alt="Support Red on Patreon!">
|
||||
</a>
|
||||
<a href="https://www.python.org/downloads/">
|
||||
<img src="https://img.shields.io/badge/Made%20With-Python%203.7-blue.svg?style=for-the-badge" alt="Made with Python 3.7">
|
||||
<img src="https://img.shields.io/badge/Made%20With-Python%203.8-blue.svg?style=for-the-badge" alt="Made with Python 3.8">
|
||||
</a>
|
||||
<a href="https://crowdin.com/project/red-discordbot">
|
||||
<img src="https://d322cqt584bo4o.cloudfront.net/red-discordbot/localized.svg" alt="Localized with Crowdin">
|
||||
@ -26,8 +26,8 @@
|
||||
</a>
|
||||
</p>
|
||||
<p align="center">
|
||||
<a href="https://travis-ci.com/Cog-Creators/Red-DiscordBot">
|
||||
<img src="https://api.travis-ci.com/Cog-Creators/Red-DiscordBot.svg?branch=V3/develop" alt="Travis CI">
|
||||
<a href="https://github.com/Cog-Creators/Red-DiscordBot/actions">
|
||||
<img src="https://github.com/Cog-Creators/Red-DiscordBot/workflows/Tests/badge.svg" alt="GitHub Actions">
|
||||
</a>
|
||||
<a href="http://red-discordbot.readthedocs.io/en/stable/?badge=stable">
|
||||
<img src="https://readthedocs.org/projects/red-discordbot/badge/?version=stable" alt="Red on readthedocs.org">
|
||||
|
||||
9
docs/_templates/layout.html
vendored
Normal file
9
docs/_templates/layout.html
vendored
Normal file
@ -0,0 +1,9 @@
|
||||
{% extends '!layout.html' %}
|
||||
{% block document %}
|
||||
{{ super() }}
|
||||
<a href="https://github.com/Cog-Creators/Red-DiscordBot">
|
||||
<img style="position: absolute; top: 0; right: 0; border: 0;"
|
||||
src="https://github.blog/wp-content/uploads/2008/12/forkme_right_darkblue_121621.png?resize=149%2C149"
|
||||
class="attachment-full size-full" alt="Fork me on GitHub">
|
||||
</a>
|
||||
{% endblock %}
|
||||
@ -17,18 +17,20 @@ Start by installing Node.JS and NPM via your favorite package distributor. From
|
||||
After PM2 is installed, run the following command to enable your Red instance to be managed by PM2. Replace the brackets with the required information.
|
||||
You can add additional Red based arguments after the instance, such as :code:`--dev`.
|
||||
|
||||
:code:`pm2 start redbot --name "<Insert a name here>" --interpreter "<Location to your Python Interpreter>" -- <Red Instance> --no-prompt`
|
||||
.. code-block:: none
|
||||
|
||||
pm2 start redbot --name "<Insert a name here>" --interpreter "<Location to your Python Interpreter>" --interpreter-args "-O" -- <Red Instance> --no-prompt
|
||||
|
||||
.. code-block:: none
|
||||
|
||||
Arguments to replace.
|
||||
|
||||
--name ""
|
||||
<Insert a name here>
|
||||
A name to identify the bot within pm2, this is not your Red instance.
|
||||
|
||||
--interpreter ""
|
||||
The location of your Python interpreter, to find out where that is use the following command:
|
||||
which python3.6
|
||||
<Location to your Python Interpreter>
|
||||
The location of your Python interpreter, to find out where that is use the following command inside activated venv:
|
||||
which python
|
||||
|
||||
<Red Instance>
|
||||
The name of your Red instance.
|
||||
|
||||
@ -14,11 +14,11 @@ In order to create the service file, you will first need the location of your :c
|
||||
|
||||
# If redbot is installed in a virtualenv
|
||||
source redenv/bin/activate
|
||||
which python
|
||||
|
||||
# If you are using pyenv
|
||||
pyenv shell <name>
|
||||
|
||||
which redbot
|
||||
pyenv which python
|
||||
|
||||
Then create the new service file:
|
||||
|
||||
@ -33,7 +33,7 @@ Paste the following and replace all instances of :code:`username` with the usern
|
||||
After=multi-user.target
|
||||
|
||||
[Service]
|
||||
ExecStart=path %I --no-prompt
|
||||
ExecStart=path -O -m redbot %I --no-prompt
|
||||
User=username
|
||||
Group=username
|
||||
Type=idle
|
||||
@ -71,4 +71,4 @@ type the following command in the terminal, still by adding the instance name af
|
||||
|
||||
To view Red’s log, you can acccess through journalctl:
|
||||
|
||||
:code:`sudo journalctl -u red@instancename`
|
||||
:code:`sudo journalctl -eu red@instancename`
|
||||
|
||||
@ -1,5 +1,68 @@
|
||||
.. 3.2.x Changelogs
|
||||
|
||||
Redbot 3.2.3 (2020-01-17)
|
||||
=========================
|
||||
|
||||
Core Bot Changes
|
||||
----------------
|
||||
|
||||
- Further improvements have been made to bot startup and shutdown.
|
||||
- Prefixes are now cached for performance.
|
||||
- Added the means for cog creators to use a global preinvoke hook.
|
||||
- The bot now ensures it has at least the bare neccessary permissions before running commands.
|
||||
- Deleting instances works as intended again.
|
||||
- Sinbad stopped fighting it and embraced the entrypoint madness.
|
||||
|
||||
Core Commands
|
||||
-------------
|
||||
|
||||
- The servers command now also shows the ids.
|
||||
|
||||
Admin Cog
|
||||
---------
|
||||
|
||||
- The selfrole command now has reasonable expectations about hierarchy.
|
||||
|
||||
Help Formatter
|
||||
--------------
|
||||
|
||||
- ``[botname]`` is now replaced with the bot's display name in help text.
|
||||
- New features added for cog creators to further customize help behavior.
|
||||
|
||||
- Check out our command reference for details on new ``format_help_for_context`` method.
|
||||
- Embed settings are now consistent.
|
||||
|
||||
Downloader
|
||||
----------
|
||||
|
||||
- Improved a few user facing messages.
|
||||
- Added pagination of output on cog update.
|
||||
- Added logging of failures.
|
||||
|
||||
Docs
|
||||
----
|
||||
|
||||
There's more detail to the below changes, so go read the docs.
|
||||
For some reason, documenting documentation changes is hard.
|
||||
|
||||
- Added instructions about git version.
|
||||
- Clarified instructions for installation and update.
|
||||
- Added more details to the API key reference.
|
||||
- Fixed some typos and versioning mistakes.
|
||||
|
||||
|
||||
Audio
|
||||
-----
|
||||
|
||||
Draper did things.
|
||||
|
||||
- No seriously, Draper did things.
|
||||
- Wait you wanted details? Ok, I guess we can share those.
|
||||
- Audio properly disconnects with autodisconnect, even if notify is being used.
|
||||
- Symbolic links now work as intended for local tracks.
|
||||
- Bump play now shows the correct time till next track.
|
||||
- Multiple user facing messages have been made more correct.
|
||||
|
||||
Redbot 3.2.2 (2020-01-10)
|
||||
=========================
|
||||
|
||||
@ -49,7 +112,8 @@ Breaking Changes
|
||||
- ``bot.get_mod_role_ids`` (`#2967 <https://github.com/Cog-Creators/Red-DiscordBot/issues/2967>`_)
|
||||
- Reserved some command names for internal Red use. These are available programatically as ``redbot.core.commands.RESERVED_COMMAND_NAMES``. (`#2973 <https://github.com/Cog-Creators/Red-DiscordBot/issues/2973>`_)
|
||||
- Removed ``bot._counter``, Made a few more attrs private (``cog_mgr``, ``main_dir``). (`#2976 <https://github.com/Cog-Creators/Red-DiscordBot/issues/2976>`_)
|
||||
- ``bot.wait_until_ready`` should no longer be used during extension setup. (`#3073 <https://github.com/Cog-Creators/Red-DiscordBot/issues/3073>`_)
|
||||
- Extension's ``setup()`` function should no longer assume that we are, or even will be connected to Discord.
|
||||
This also means that cog creators should no longer use ``bot.wait_until_ready()`` inside it. (`#3073 <https://github.com/Cog-Creators/Red-DiscordBot/issues/3073>`_)
|
||||
- Removed the mongo driver. (`#3099 <https://github.com/Cog-Creators/Red-DiscordBot/issues/3099>`_)
|
||||
|
||||
|
||||
@ -174,7 +238,7 @@ Removals
|
||||
~~~~~~~~
|
||||
|
||||
- ``[p]set owner`` and ``[p]set token`` have been removed in favor of managing server side. (`#2928 <https://github.com/Cog-Creators/Red-DiscordBot/issues/2928>`_)
|
||||
- Shared libraries are marked for removal in Red 3.3. (`#3106 <https://github.com/Cog-Creators/Red-DiscordBot/issues/3106>`_)
|
||||
- Shared libraries are marked for removal in Red 3.4. (`#3106 <https://github.com/Cog-Creators/Red-DiscordBot/issues/3106>`_)
|
||||
- Removed ``[p]backup``. Use the cli command ``redbot-setup backup`` instead. (`#3235 <https://github.com/Cog-Creators/Red-DiscordBot/issues/3235>`_)
|
||||
- Removed the functions ``safe_delete``, ``fuzzy_command_search``, ``format_fuzzy_results`` and ``create_backup`` from ``redbot.core.utils``. (`#3240 <https://github.com/Cog-Creators/Red-DiscordBot/issues/3240>`_)
|
||||
- Removed a lot of the launcher's handled behavior. (`#3289 <https://github.com/Cog-Creators/Red-DiscordBot/issues/3289>`_)
|
||||
|
||||
102
docs/changelog_3_3_0.rst
Normal file
102
docs/changelog_3_3_0.rst
Normal file
@ -0,0 +1,102 @@
|
||||
.. 3.3.x Changelogs
|
||||
|
||||
Redbot 3.3.1 (2020-02-05)
|
||||
=========================
|
||||
|
||||
|
||||
Core Bot
|
||||
--------
|
||||
|
||||
- Add a cli flag for setting a max size of message cache
|
||||
- Allow to edit prefix from command line using ``redbot --edit``.
|
||||
- Some functions have been changed to no longer use deprecated asyncio functions
|
||||
|
||||
Core Commands
|
||||
-------------
|
||||
|
||||
- The short help text for dm has been made more useful
|
||||
- dm no longer allows owners to have the bot attempt to DM itself
|
||||
|
||||
Utils
|
||||
-----
|
||||
|
||||
- Passing the event loop explicitly in utils is deprecated (Removal in 3.4)
|
||||
|
||||
Mod Cog
|
||||
-------
|
||||
|
||||
- Hackban now works properly without being provided a number of days
|
||||
|
||||
Documentation Changes
|
||||
---------------------
|
||||
|
||||
- Add ``-e`` flag to ``journalctl`` command in systemd guide so that it takes the user to the end of logs automatically.
|
||||
- Added section to install docs for CentOS 8
|
||||
- Improve usage of apt update in docs
|
||||
|
||||
Redbot 3.3.0 (2020-01-26)
|
||||
=========================
|
||||
|
||||
Core Bot
|
||||
--------
|
||||
|
||||
- The bot's description is now configurable.
|
||||
- We now use discord.py 1.3.1, this comes with added teams support.
|
||||
- The commands module has been slightly restructured to provide more useful data to developers.
|
||||
- Help is now self consistent in the extra formatting used.
|
||||
|
||||
Core Commands
|
||||
-------------
|
||||
|
||||
- Slowmode should no longer error on nonsensical time quantities.
|
||||
- Embed use can be configured per channel as well.
|
||||
|
||||
Documentation
|
||||
-------------
|
||||
|
||||
- We've made some small fixes to inaccurate instructions about installing with pyenv.
|
||||
- Notes about deprecating in 3.3 have been altered to 3.4 to match the intended timeframe.
|
||||
|
||||
Admin
|
||||
-----
|
||||
|
||||
- Gives feedback when adding or removing a role doesn't make sense.
|
||||
|
||||
Audio
|
||||
-----
|
||||
|
||||
- Playlist finding is more intuitive.
|
||||
- disconnect and repeat commands no longer interfere with eachother.
|
||||
|
||||
CustomCom
|
||||
---------
|
||||
|
||||
- No longer errors when exiting an interactive menu.
|
||||
|
||||
Cleanup
|
||||
-------
|
||||
|
||||
- A rare edge case involving messages which are deleted during cleanup and are the only message was fixed.
|
||||
|
||||
Downloader
|
||||
----------
|
||||
|
||||
- Some user facing messages were improved.
|
||||
- Downloader's initialization can no longer time out at startup.
|
||||
|
||||
General
|
||||
-------
|
||||
|
||||
- Roll command will no longer attempt to roll obscenely large amounts.
|
||||
|
||||
Mod
|
||||
---
|
||||
|
||||
- You can set a default amount of days to clean up when banning.
|
||||
- Ban and hackban now use that default.
|
||||
- Users can now optionally be DMed their ban reason.
|
||||
|
||||
Permissions
|
||||
-----------
|
||||
|
||||
- Now has stronger enforcement of prioritizing botwide settings.
|
||||
@ -6,7 +6,7 @@ Shared API Keys
|
||||
|
||||
Red has a central API key storage utilising the core bots config. This allows cog creators to add a single location to store API keys for their cogs which may be shared between other cogs.
|
||||
|
||||
There needs to be some consistency between cog creators when using shared API keys between cogs. To help make this easier service should be all **lowercase** and the key names should match the naming convetion of the API being accessed.
|
||||
There needs to be some consistency between cog creators when using shared API keys between cogs. To help make this easier service should be all **lowercase** and the key names should match the naming convention of the API being accessed.
|
||||
|
||||
Example:
|
||||
|
||||
@ -60,3 +60,16 @@ Event Reference
|
||||
:type service_name: :class:`str`
|
||||
:param api_tokens: New Mapping of token names to tokens. This contains api tokens that weren't changed too.
|
||||
:type api_tokens: Mapping[:class:`str`, :class:`str`]
|
||||
|
||||
|
||||
*********************
|
||||
Additional References
|
||||
*********************
|
||||
|
||||
.. py:currentmodule:: redbot.core.bot
|
||||
|
||||
.. automethod:: Red.get_shared_api_tokens
|
||||
|
||||
.. automethod:: Red.set_shared_api_tokens
|
||||
|
||||
.. automethod:: Red.remove_shared_api_tokens
|
||||
|
||||
@ -7,7 +7,7 @@ Commands Package
|
||||
This package acts almost identically to :doc:`discord.ext.commands <dpy:ext/commands/api>`; i.e.
|
||||
all of the attributes from discord.py's are also in ours.
|
||||
Some of these attributes, however, have been slightly modified, while others have been added to
|
||||
extend functionlities used throughout the bot, as outlined below.
|
||||
extend functionalities used throughout the bot, as outlined below.
|
||||
|
||||
.. autofunction:: redbot.core.commands.command
|
||||
|
||||
@ -15,6 +15,7 @@ extend functionlities used throughout the bot, as outlined below.
|
||||
|
||||
.. autoclass:: redbot.core.commands.Command
|
||||
:members:
|
||||
:inherited-members: format_help_for_context
|
||||
|
||||
.. autoclass:: redbot.core.commands.Group
|
||||
:members:
|
||||
@ -22,5 +23,14 @@ extend functionlities used throughout the bot, as outlined below.
|
||||
.. autoclass:: redbot.core.commands.Context
|
||||
:members:
|
||||
|
||||
.. autoclass:: redbot.core.commands.GuildContext
|
||||
|
||||
.. autoclass:: redbot.core.commands.DMContext
|
||||
|
||||
.. automodule:: redbot.core.commands.requires
|
||||
:members: PrivilegeLevel, PermState, Requires
|
||||
|
||||
.. automodule:: redbot.core.commands.converter
|
||||
:members:
|
||||
:exclude-members: convert
|
||||
:no-undoc-members:
|
||||
|
||||
@ -25,7 +25,7 @@ Basic Usage
|
||||
async def ban(self, ctx, user: discord.Member, reason: str = None):
|
||||
await ctx.guild.ban(user)
|
||||
case = await modlog.create_case(
|
||||
ctx.bot, ctx.guild, ctx.message.created_at, action="ban",
|
||||
ctx.bot, ctx.guild, ctx.message.created_at, action_type="ban",
|
||||
user=user, moderator=ctx.author, reason=reason
|
||||
)
|
||||
await ctx.send("Done. It was about time.")
|
||||
|
||||
@ -81,5 +81,5 @@ Keys specific to the cog info.json (case sensitive)
|
||||
``SHARED_LIBRARY``. If ``SHARED_LIBRARY`` then ``hidden`` will be ``True``.
|
||||
|
||||
.. warning::
|
||||
Shared libraries are deprecated since version 3.2 and are marked for removal in version 3.3.
|
||||
Shared libraries are deprecated since version 3.2 and are marked for removal in version 3.4.
|
||||
|
||||
|
||||
@ -57,6 +57,7 @@ Welcome to Red - Discord Bot's documentation!
|
||||
:maxdepth: 2
|
||||
:caption: Changelogs:
|
||||
|
||||
changelog_3_3_0
|
||||
release_notes_3_2_0
|
||||
changelog_3_2_0
|
||||
changelog_3_1_0
|
||||
|
||||
@ -19,12 +19,22 @@ Please install the pre-requirements using the commands listed for your operating
|
||||
The pre-requirements are:
|
||||
- Python 3.8.1 or greater
|
||||
- Pip 18.1 or greater
|
||||
- Git
|
||||
- Git 2.11+
|
||||
- Java Runtime Environment 11 or later (for audio support)
|
||||
|
||||
We also recommend installing some basic compiler tools, in case our dependencies don't provide
|
||||
pre-built "wheels" for your architecture.
|
||||
|
||||
|
||||
*****************
|
||||
Operating systems
|
||||
*****************
|
||||
|
||||
.. contents::
|
||||
:local:
|
||||
|
||||
----
|
||||
|
||||
.. _install-arch:
|
||||
|
||||
~~~~~~~~~~
|
||||
@ -35,6 +45,10 @@ Arch Linux
|
||||
|
||||
sudo pacman -Syu python python-pip git jre-openjdk-headless base-devel
|
||||
|
||||
Continue by `creating-venv-linux`.
|
||||
|
||||
----
|
||||
|
||||
.. _install-centos:
|
||||
.. _install-rhel:
|
||||
|
||||
@ -51,15 +65,63 @@ CentOS and RHEL 7
|
||||
|
||||
Complete the rest of the installation by `installing Python 3.8 with pyenv <install-python-pyenv>`.
|
||||
|
||||
----
|
||||
|
||||
.. _install-centos8:
|
||||
.. _install-rhel8:
|
||||
|
||||
~~~~~~~~~~~~~~~~~
|
||||
CentOS and RHEL 8
|
||||
~~~~~~~~~~~~~~~~~
|
||||
|
||||
.. code-block:: none
|
||||
|
||||
yum -y install epel-release
|
||||
yum update -y
|
||||
yum -y groupinstall development
|
||||
yum -y install git zlib-devel bzip2 bzip2-devel readline-devel sqlite \
|
||||
sqlite-devel openssl-devel xz xz-devel libffi-devel findutils java-11-openjdk
|
||||
|
||||
Complete the rest of the installation by `installing Python 3.8 with pyenv <install-python-pyenv>`.
|
||||
|
||||
----
|
||||
|
||||
.. _install-debian-stretch:
|
||||
|
||||
~~~~~~~~~~~~~~
|
||||
Debian Stretch
|
||||
~~~~~~~~~~~~~~
|
||||
|
||||
.. note::
|
||||
|
||||
This guide is only for Debian Stretch users, these instructions won't work with
|
||||
Raspbian Stretch. Raspbian Buster is the only version of Raspbian supported by Red.
|
||||
|
||||
We recommend installing pyenv as a method of installing non-native versions of python on
|
||||
Debian Stretch. This guide will tell you how. First, run the following commands:
|
||||
|
||||
.. code-block:: none
|
||||
|
||||
sudo echo "deb http://deb.debian.org/debian stretch-backports main" >> /etc/apt/sources.list.d/red-sources.list
|
||||
sudo apt update
|
||||
sudo apt -y install make build-essential libssl-dev zlib1g-dev libbz2-dev libreadline-dev \
|
||||
libsqlite3-dev wget curl llvm libncurses5-dev xz-utils tk-dev libxml2-dev \
|
||||
libxmlsec1-dev libffi-dev liblzma-dev libgdbm-dev uuid-dev python3-openssl git openjdk-11-jre
|
||||
CXX=/usr/bin/g++
|
||||
|
||||
Complete the rest of the installation by `installing Python 3.8 with pyenv <install-python-pyenv>`.
|
||||
|
||||
----
|
||||
|
||||
.. _install-debian:
|
||||
.. _install-raspbian:
|
||||
|
||||
~~~~~~~~~~~~~~~~~~~
|
||||
Debian and Raspbian
|
||||
~~~~~~~~~~~~~~~~~~~
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
Debian and Raspbian Buster
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
We recommend installing pyenv as a method of installing non-native versions of python on
|
||||
Debian/Raspbian. This guide will tell you how. First, run the following commands:
|
||||
Debian/Raspbian Buster. This guide will tell you how. First, run the following commands:
|
||||
|
||||
.. code-block:: none
|
||||
|
||||
@ -71,6 +133,8 @@ Debian/Raspbian. This guide will tell you how. First, run the following commands
|
||||
|
||||
Complete the rest of the installation by `installing Python 3.8 with pyenv <install-python-pyenv>`.
|
||||
|
||||
----
|
||||
|
||||
.. _install-fedora:
|
||||
|
||||
~~~~~~~~~~~~
|
||||
@ -84,6 +148,10 @@ them with dnf:
|
||||
|
||||
sudo dnf -y install python38 git java-latest-openjdk-headless @development-tools
|
||||
|
||||
Continue by `creating-venv-linux`.
|
||||
|
||||
----
|
||||
|
||||
.. _install-mac:
|
||||
|
||||
~~~
|
||||
@ -110,6 +178,10 @@ one-by-one:
|
||||
It's possible you will have network issues. If so, go in your Applications folder, inside it, go in
|
||||
the Python 3.8 folder then double click ``Install certificates.command``.
|
||||
|
||||
Continue by `creating-venv-linux`.
|
||||
|
||||
----
|
||||
|
||||
.. _install-opensuse:
|
||||
|
||||
~~~~~~~~
|
||||
@ -150,6 +222,8 @@ Now, install pip with easy_install:
|
||||
|
||||
sudo /opt/python/bin/easy_install-3.8 pip
|
||||
|
||||
Continue by `creating-venv-linux`.
|
||||
|
||||
openSUSE Tumbleweed
|
||||
*******************
|
||||
|
||||
@ -161,35 +235,74 @@ with zypper:
|
||||
sudo zypper install python3-base python3-pip git-core java-12-openjdk-headless
|
||||
sudo zypper install -t pattern devel_basis
|
||||
|
||||
Continue by `creating-venv-linux`.
|
||||
|
||||
----
|
||||
|
||||
.. _install-ubuntu:
|
||||
|
||||
~~~~~~
|
||||
Ubuntu
|
||||
~~~~~~
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
Ubuntu LTS versions (18.04 and 16.04)
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
.. note:: **Ubuntu Python Availability**
|
||||
|
||||
We recommend using the deadsnakes ppa to ensure up to date python availability.
|
||||
|
||||
.. code-block:: none
|
||||
|
||||
sudo apt update
|
||||
sudo apt install software-properties-common
|
||||
sudo add-apt-repository ppa:deadsnakes/ppa
|
||||
|
||||
Install the pre-requirements with apt:
|
||||
We recommend adding the ``git-core`` ppa to install Git 2.11 or greater:
|
||||
|
||||
.. code-block:: none
|
||||
|
||||
sudo apt update
|
||||
sudo apt -y install software-properties-common
|
||||
sudo add-apt-repository -yu ppa:git-core/ppa
|
||||
|
||||
We recommend adding the ``deadsnakes`` ppa to install Python 3.8.1 or greater:
|
||||
|
||||
.. code-block:: none
|
||||
|
||||
sudo add-apt-repository -yu ppa:deadsnakes/ppa
|
||||
|
||||
Now install the pre-requirements with apt:
|
||||
|
||||
.. code-block:: none
|
||||
|
||||
sudo apt -y install python3.8 python3.8-dev python3.8-venv python3-pip git default-jre-headless \
|
||||
build-essential
|
||||
|
||||
Continue by `creating-venv-linux`.
|
||||
|
||||
----
|
||||
|
||||
.. _install-ubuntu-non-lts:
|
||||
|
||||
~~~~~~~~~~~~~~~~~~~~~~~
|
||||
Ubuntu non-LTS versions
|
||||
~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
We recommend adding the ``git-core`` ppa to install Git 2.11 or greater:
|
||||
|
||||
.. code-block:: none
|
||||
|
||||
sudo apt update
|
||||
sudo apt -y install software-properties-common
|
||||
sudo add-apt-repository -yu ppa:git-core/ppa
|
||||
|
||||
Now, to install non-native version of python on non-LTS versions of Ubuntu, we recommend
|
||||
installing pyenv. To do this, first run the following commands:
|
||||
|
||||
.. code-block:: none
|
||||
|
||||
sudo apt -y install make build-essential libssl-dev zlib1g-dev libbz2-dev libreadline-dev \
|
||||
libsqlite3-dev wget curl llvm libncurses5-dev xz-utils tk-dev libxml2-dev \
|
||||
libxmlsec1-dev libffi-dev liblzma-dev libgdbm-dev uuid-dev python3-openssl git openjdk-11-jre
|
||||
CXX=/usr/bin/g++
|
||||
|
||||
And then complete the rest of the installation by `installing Python 3.8 with pyenv <install-python-pyenv>`.
|
||||
|
||||
----
|
||||
|
||||
.. _install-python-pyenv:
|
||||
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
****************************
|
||||
Installing Python with pyenv
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
****************************
|
||||
|
||||
.. note::
|
||||
|
||||
@ -227,11 +340,15 @@ After that is finished, run:
|
||||
|
||||
Pyenv is now installed and your system should be configured to run Python 3.8.
|
||||
|
||||
Continue by `creating-venv-linux`.
|
||||
|
||||
.. _creating-venv-linux:
|
||||
|
||||
------------------------------
|
||||
Creating a Virtual Environment
|
||||
------------------------------
|
||||
|
||||
We **strongly** recommend installing Red into a virtual environment. Don't be scared, it's very
|
||||
We require installing Red into a virtual environment. Don't be scared, it's very
|
||||
straightforward. See the section `installing-in-virtual-environment`.
|
||||
|
||||
.. _installing-red-linux-mac:
|
||||
@ -242,31 +359,25 @@ Installing Red
|
||||
|
||||
Choose one of the following commands to install Red.
|
||||
|
||||
.. note::
|
||||
|
||||
If you're not inside an activated virtual environment, include the ``--user`` flag with all
|
||||
``python3.8 -m pip install`` commands, like this:
|
||||
|
||||
.. code-block:: none
|
||||
|
||||
python3.8 -m pip install --user -U setuptools wheel
|
||||
python3.8 -m pip install --user -U Red-DiscordBot
|
||||
|
||||
To install without additional config backend support:
|
||||
|
||||
.. code-block:: none
|
||||
|
||||
python3.8 -m pip install -U setuptools wheel
|
||||
python3.8 -m pip install -U Red-DiscordBot
|
||||
python -m pip install -U pip setuptools wheel
|
||||
python -m pip install -U Red-DiscordBot
|
||||
|
||||
Or, to install with PostgreSQL support:
|
||||
|
||||
.. code-block:: none
|
||||
|
||||
python3.8 -m pip install -U setuptools wheel
|
||||
python3.8 -m pip install -U Red-DiscordBot[postgres]
|
||||
python -m pip install -U pip setuptools wheel
|
||||
python -m pip install -U Red-DiscordBot[postgres]
|
||||
|
||||
|
||||
.. note::
|
||||
|
||||
These commands are also used for updating Red
|
||||
|
||||
--------------------------
|
||||
Setting Up and Running Red
|
||||
--------------------------
|
||||
|
||||
@ -64,6 +64,13 @@ Manually installing dependencies
|
||||
|
||||
.. _installing-red-windows:
|
||||
|
||||
------------------------------
|
||||
Creating a Virtual Environment
|
||||
------------------------------
|
||||
|
||||
We require installing Red into a virtual environment. Don't be scared, it's very
|
||||
straightforward. See the section `installing-in-virtual-environment`.
|
||||
|
||||
--------------
|
||||
Installing Red
|
||||
--------------
|
||||
@ -72,34 +79,27 @@ Installing Red
|
||||
for the PATH changes to take effect.
|
||||
|
||||
1. Open a command prompt (open Start, search for "command prompt", then click it)
|
||||
2. Create and activate a virtual environment (strongly recommended), see the section `using-venv`
|
||||
3. Run **one** of the following commands, depending on what extras you want installed
|
||||
|
||||
.. note::
|
||||
|
||||
If you're not inside an activated virtual environment, use ``py -3.8`` in place of
|
||||
``python``, and include the ``--user`` flag with all ``pip install`` commands, like this:
|
||||
|
||||
.. code-block:: none
|
||||
|
||||
py -3.8 -m pip install --user -U setuptools wheel
|
||||
py -3.8 -m pip install --user -U Red-DiscordBot
|
||||
2. Run **one** of the following set of commands, depending on what extras you want installed
|
||||
|
||||
* Normal installation:
|
||||
|
||||
.. code-block:: none
|
||||
|
||||
python -m pip install -U setuptools wheel
|
||||
python -m pip install -U pip setuptools wheel
|
||||
python -m pip install -U Red-DiscordBot
|
||||
|
||||
* With PostgreSQL support:
|
||||
|
||||
.. code-block:: none
|
||||
|
||||
python -m pip install -U setuptools wheel
|
||||
python -m pip install -U pip setuptools wheel
|
||||
python -m pip install -U Red-DiscordBot[postgres]
|
||||
|
||||
|
||||
.. note::
|
||||
|
||||
These commands are also used for updating Red
|
||||
|
||||
--------------------------
|
||||
Setting Up and Running Red
|
||||
--------------------------
|
||||
|
||||
@ -9,14 +9,9 @@ problems. Firstly, simply choose how you'd like to create your virtual environme
|
||||
* :ref:`using-venv` (quick and easy, involves two commands)
|
||||
* :ref:`using-pyenv-virtualenv` (recommended if you installed Python with pyenv)
|
||||
|
||||
**Why Should I Use a Virtual Environment?**
|
||||
|
||||
90% of the installation and setup issues raised in our support channels are resolved when the user
|
||||
creates a virtual environment.
|
||||
|
||||
**What Are Virtual Environments For?**
|
||||
|
||||
Virtual environments allow you to isolate red's library dependencies, cog dependencies and python
|
||||
Virtual environments allow you to isolate Red's library dependencies, cog dependencies and python
|
||||
binaries from the rest of your system. It also makes sure Red and its dependencies are installed to
|
||||
a predictable location. It makes uninstalling Red as simple as removing a single folder, without
|
||||
worrying about losing your data or other things on your system becoming broken.
|
||||
@ -31,18 +26,18 @@ python.
|
||||
|
||||
First, choose a directory where you would like to create your virtual environment. It's a good idea
|
||||
to keep it in a location which is easy to type out the path to. From now, we'll call it
|
||||
``redenv``.
|
||||
``redenv`` and it will be located in your home directory.
|
||||
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
``venv`` on Linux or Mac
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
Create your virtual environment with the following command::
|
||||
|
||||
python3.8 -m venv redenv
|
||||
python3.8 -m venv ~/redenv
|
||||
|
||||
And activate it with the following command::
|
||||
|
||||
source redenv/bin/activate
|
||||
source ~/redenv/bin/activate
|
||||
|
||||
.. important::
|
||||
|
||||
@ -56,11 +51,11 @@ Continue reading `below <after-activating-virtual-environment>`.
|
||||
~~~~~~~~~~~~~~~~~~~
|
||||
Create your virtual environment with the following command::
|
||||
|
||||
py -3.8 -m venv redenv
|
||||
py -3.8 -m venv %userprofile%\redenv
|
||||
|
||||
And activate it with the following command::
|
||||
|
||||
redenv\Scripts\activate.bat
|
||||
%userprofile%\redenv\Scripts\activate.bat
|
||||
|
||||
.. important::
|
||||
|
||||
|
||||
@ -181,9 +181,7 @@ class VersionInfo:
|
||||
|
||||
|
||||
def _update_event_loop_policy():
|
||||
if _sys.platform == "win32":
|
||||
_asyncio.set_event_loop_policy(_asyncio.WindowsProactorEventLoopPolicy())
|
||||
elif _sys.implementation.name == "cpython":
|
||||
if _sys.implementation.name == "cpython":
|
||||
# Let's not force this dependency, uvloop is much faster on cpython
|
||||
try:
|
||||
import uvloop as _uvloop
|
||||
@ -193,7 +191,7 @@ def _update_event_loop_policy():
|
||||
_asyncio.set_event_loop_policy(_uvloop.EventLoopPolicy())
|
||||
|
||||
|
||||
__version__ = "3.2.3.dev1"
|
||||
__version__ = "3.3.2.dev1"
|
||||
version_info = VersionInfo.from_str(__version__)
|
||||
|
||||
# Filter fuzzywuzzy slow sequence matcher warning
|
||||
|
||||
@ -1,7 +1,5 @@
|
||||
#!/usr/bin/env python
|
||||
|
||||
# Discord Version check
|
||||
|
||||
import asyncio
|
||||
import functools
|
||||
import getpass
|
||||
@ -16,10 +14,11 @@ import sys
|
||||
from argparse import Namespace
|
||||
from copy import deepcopy
|
||||
from pathlib import Path
|
||||
from typing import NoReturn
|
||||
|
||||
import discord
|
||||
|
||||
# Set the event loop policies here so any subsequent `get_event_loop()`
|
||||
# Set the event loop policies here so any subsequent `new_event_loop()`
|
||||
# calls, in particular those as a result of the following imports,
|
||||
# return the correct loop object.
|
||||
from redbot import _update_event_loop_policy, __version__
|
||||
@ -106,6 +105,7 @@ async def edit_instance(red, cli_flags):
|
||||
no_prompt = cli_flags.no_prompt
|
||||
token = cli_flags.token
|
||||
owner = cli_flags.owner
|
||||
prefix = cli_flags.prefix
|
||||
old_name = cli_flags.instance_name
|
||||
new_name = cli_flags.edit_instance_name
|
||||
data_path = cli_flags.edit_data_path
|
||||
@ -118,14 +118,20 @@ async def edit_instance(red, cli_flags):
|
||||
if new_name is None and confirm_overwrite:
|
||||
print("--overwrite-existing-instance can't be used without --edit-instance-name argument")
|
||||
sys.exit(1)
|
||||
if no_prompt and all(to_change is None for to_change in (token, owner, new_name, data_path)):
|
||||
if (
|
||||
no_prompt
|
||||
and all(to_change is None for to_change in (token, owner, new_name, data_path))
|
||||
and not prefix
|
||||
):
|
||||
print(
|
||||
"No arguments to edit were provided. Available arguments (check help for more "
|
||||
"information): --edit-instance-name, --edit-data-path, --copy-data, --owner, --token"
|
||||
"No arguments to edit were provided."
|
||||
" Available arguments (check help for more information):"
|
||||
" --edit-instance-name, --edit-data-path, --copy-data, --owner, --token, --prefix"
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
await _edit_token(red, token, no_prompt)
|
||||
await _edit_prefix(red, prefix, no_prompt)
|
||||
await _edit_owner(red, owner, no_prompt)
|
||||
|
||||
data = deepcopy(data_manager.basic_config)
|
||||
@ -151,6 +157,26 @@ async def _edit_token(red, token, no_prompt):
|
||||
print("Token updated.\n")
|
||||
|
||||
|
||||
async def _edit_prefix(red, prefix, no_prompt):
|
||||
if prefix:
|
||||
prefixes = sorted(prefix, reverse=True)
|
||||
await red._config.prefix.set(prefixes)
|
||||
elif not no_prompt and confirm("Would you like to change instance's prefixes?", default=False):
|
||||
print(
|
||||
"Enter the prefixes, separated by a space (please note "
|
||||
"that prefixes containing a space will need to be added with [p]set prefix)"
|
||||
)
|
||||
while True:
|
||||
prefixes = input("> ").strip().split()
|
||||
if not prefixes:
|
||||
print("You need to pass at least one prefix!")
|
||||
continue
|
||||
prefixes = sorted(prefixes, reverse=True)
|
||||
await red._config.prefix.set(prefixes)
|
||||
print("Prefixes updated.\n")
|
||||
break
|
||||
|
||||
|
||||
async def _edit_owner(red, owner, no_prompt):
|
||||
if owner:
|
||||
if not (15 <= len(str(owner)) <= 21):
|
||||
@ -270,7 +296,8 @@ def handle_edit(cli_flags: Namespace):
|
||||
"""
|
||||
This one exists to not log all the things like it's a full run of the bot.
|
||||
"""
|
||||
loop = asyncio.get_event_loop()
|
||||
loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(loop)
|
||||
data_manager.load_basic_configuration(cli_flags.instance_name)
|
||||
red = Red(cli_flags=cli_flags, description="Red V3", dm_help=None, fetch_offline_members=True)
|
||||
try:
|
||||
@ -282,12 +309,24 @@ def handle_edit(cli_flags: Namespace):
|
||||
print("Aborted!")
|
||||
finally:
|
||||
loop.run_until_complete(asyncio.sleep(1))
|
||||
asyncio.set_event_loop(None)
|
||||
loop.stop()
|
||||
loop.close()
|
||||
sys.exit(0)
|
||||
|
||||
|
||||
async def run_bot(red: Red, cli_flags: Namespace):
|
||||
async def run_bot(red: Red, cli_flags: Namespace) -> None:
|
||||
"""
|
||||
This runs the bot.
|
||||
|
||||
Any shutdown which is a result of not being able to log in needs to raise
|
||||
a SystemExit exception.
|
||||
|
||||
If the bot starts normally, the bot should be left to handle the exit case.
|
||||
It will raise SystemExit in a task, which will reach the event loop and
|
||||
interrupt running forever, then trigger our cleanup process, and does not
|
||||
need additional handling in this function.
|
||||
"""
|
||||
|
||||
driver_cls = drivers.get_driver_class()
|
||||
|
||||
@ -341,6 +380,10 @@ async def run_bot(red: Red, cli_flags: Namespace):
|
||||
if confirm("\nDo you want to reset the token?"):
|
||||
await red._config.token.set("")
|
||||
print("Token has been reset.")
|
||||
sys.exit(0)
|
||||
sys.exit(1)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def handle_early_exit_flags(cli_flags: Namespace):
|
||||
@ -417,7 +460,8 @@ def main():
|
||||
handle_edit(cli_flags)
|
||||
return
|
||||
try:
|
||||
loop = asyncio.get_event_loop()
|
||||
loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(loop)
|
||||
|
||||
if cli_flags.no_instance:
|
||||
print(
|
||||
@ -474,14 +518,14 @@ def main():
|
||||
# Allows transports to close properly, and prevent new ones from being opened.
|
||||
# Transports may still not be closed correcly on windows, see below
|
||||
loop.run_until_complete(loop.shutdown_asyncgens())
|
||||
if os.name == "nt":
|
||||
# *we* aren't cleaning up more here, but it prevents
|
||||
# a runtime error at the event loop on windows
|
||||
# with resources which require longer to clean up.
|
||||
# With other event loops, a failure to cleanup prior to here
|
||||
# results in a resource warning instead and does not break us.
|
||||
# results in a resource warning instead
|
||||
log.info("Please wait, cleaning up a bit more")
|
||||
loop.run_until_complete(asyncio.sleep(1))
|
||||
loop.run_until_complete(asyncio.sleep(2))
|
||||
asyncio.set_event_loop(None)
|
||||
loop.stop()
|
||||
loop.close()
|
||||
exit_code = red._shutdown_mode if red is not None else 1
|
||||
|
||||
@ -116,12 +116,19 @@ class Admin(commands.Cog):
|
||||
:param role:
|
||||
:return:
|
||||
"""
|
||||
return ctx.author.top_role > role
|
||||
return ctx.author.top_role > role or ctx.author == ctx.guild.owner
|
||||
|
||||
async def _addrole(self, ctx: commands.Context, member: discord.Member, role: discord.Role):
|
||||
if member is None:
|
||||
member = ctx.author
|
||||
if not self.pass_user_hierarchy_check(ctx, role):
|
||||
async def _addrole(
|
||||
self, ctx: commands.Context, member: discord.Member, role: discord.Role, *, check_user=True
|
||||
):
|
||||
if role in member.roles:
|
||||
await ctx.send(
|
||||
_("{member.display_name} already has the role {role.name}.").format(
|
||||
role=role, member=member
|
||||
)
|
||||
)
|
||||
return
|
||||
if check_user and not self.pass_user_hierarchy_check(ctx, role):
|
||||
await ctx.send(_(USER_HIERARCHY_ISSUE_ADD).format(role=role, member=member))
|
||||
return
|
||||
if not self.pass_hierarchy_check(ctx, role):
|
||||
@ -141,10 +148,17 @@ class Admin(commands.Cog):
|
||||
)
|
||||
)
|
||||
|
||||
async def _removerole(self, ctx: commands.Context, member: discord.Member, role: discord.Role):
|
||||
if member is None:
|
||||
member = ctx.author
|
||||
if not self.pass_user_hierarchy_check(ctx, role):
|
||||
async def _removerole(
|
||||
self, ctx: commands.Context, member: discord.Member, role: discord.Role, *, check_user=True
|
||||
):
|
||||
if role not in member.roles:
|
||||
await ctx.send(
|
||||
_("{member.display_name} does not have the role {role.name}.").format(
|
||||
role=role, member=member
|
||||
)
|
||||
)
|
||||
return
|
||||
if check_user and not self.pass_user_hierarchy_check(ctx, role):
|
||||
await ctx.send(_(USER_HIERARCHY_ISSUE_REMOVE).format(role=role, member=member))
|
||||
return
|
||||
if not self.pass_hierarchy_check(ctx, role):
|
||||
@ -365,7 +379,7 @@ class Admin(commands.Cog):
|
||||
NOTE: The role is case sensitive!
|
||||
"""
|
||||
# noinspection PyTypeChecker
|
||||
await self._addrole(ctx, ctx.author, selfrole)
|
||||
await self._addrole(ctx, ctx.author, selfrole, check_user=False)
|
||||
|
||||
@selfrole.command(name="remove")
|
||||
async def selfrole_remove(self, ctx: commands.Context, *, selfrole: SelfRole):
|
||||
@ -376,7 +390,7 @@ class Admin(commands.Cog):
|
||||
NOTE: The role is case sensitive!
|
||||
"""
|
||||
# noinspection PyTypeChecker
|
||||
await self._removerole(ctx, ctx.author, selfrole)
|
||||
await self._removerole(ctx, ctx.author, selfrole, check_user=False)
|
||||
|
||||
@selfrole.command(name="list")
|
||||
async def selfrole_list(self, ctx: commands.Context):
|
||||
@ -406,6 +420,13 @@ class Admin(commands.Cog):
|
||||
|
||||
NOTE: The role is case sensitive!
|
||||
"""
|
||||
if not self.pass_user_hierarchy_check(ctx, role):
|
||||
await ctx.send(
|
||||
_(
|
||||
"I cannot let you add {role.name} as a selfrole because that role is higher than or equal to your highest role in the Discord hierarchy."
|
||||
).format(role=role)
|
||||
)
|
||||
return
|
||||
async with self.conf.guild(ctx.guild).selfroles() as curr_selfroles:
|
||||
if role.id not in curr_selfroles:
|
||||
curr_selfroles.append(role.id)
|
||||
@ -421,6 +442,13 @@ class Admin(commands.Cog):
|
||||
|
||||
NOTE: The role is case sensitive!
|
||||
"""
|
||||
if not self.pass_user_hierarchy_check(ctx, role):
|
||||
await ctx.send(
|
||||
_(
|
||||
"I cannot let you remove {role.name} from being a selfrole because that role is higher than or equal to your highest role in the Discord hierarchy."
|
||||
).format(role=role)
|
||||
)
|
||||
return
|
||||
async with self.conf.guild(ctx.guild).selfroles() as curr_selfroles:
|
||||
curr_selfroles.remove(role.id)
|
||||
|
||||
|
||||
@ -70,12 +70,12 @@ class Announcer:
|
||||
failed.append(str(g.id))
|
||||
await asyncio.sleep(0.5)
|
||||
|
||||
if failed:
|
||||
msg = (
|
||||
_("I could not announce to the following server: ")
|
||||
if len(failed) == 1
|
||||
else _("I could not announce to the following servers: ")
|
||||
)
|
||||
if failed:
|
||||
msg += humanize_list(tuple(map(inline, failed)))
|
||||
await self.ctx.bot.send_to_owners(msg)
|
||||
self.active = False
|
||||
|
||||
@ -90,7 +90,7 @@ class Alias(commands.Cog):
|
||||
|
||||
def is_command(self, alias_name: str) -> bool:
|
||||
"""
|
||||
The logic here is that if this returns true, the name shouldnt be used for an alias
|
||||
The logic here is that if this returns true, the name should not be used for an alias
|
||||
The function name can be changed when alias is reworked
|
||||
"""
|
||||
command = self.bot.get_command(alias_name)
|
||||
|
||||
@ -746,13 +746,17 @@ class MusicCache:
|
||||
(val, update) = await self.database.fetch_one("lavalink", "data", {"query": query})
|
||||
if update:
|
||||
val = None
|
||||
if val and not isinstance(val, str):
|
||||
if val and isinstance(val, dict):
|
||||
log.debug(f"Querying Local Database for {query}")
|
||||
task = ("update", ("lavalink", {"query": query}))
|
||||
self.append_task(ctx, *task)
|
||||
if val and not forced:
|
||||
else:
|
||||
val = None
|
||||
if val and not forced and isinstance(val, dict):
|
||||
data = val
|
||||
data["query"] = query
|
||||
if data.get("loadType") == "V2_COMPACT":
|
||||
data["loadType"] = "V2_COMPAT"
|
||||
results = LoadResult(data)
|
||||
called_api = False
|
||||
if results.has_error:
|
||||
@ -778,6 +782,10 @@ class MusicCache:
|
||||
):
|
||||
with contextlib.suppress(SQLError):
|
||||
time_now = int(datetime.datetime.now(datetime.timezone.utc).timestamp())
|
||||
data = json.dumps(results._raw)
|
||||
if all(
|
||||
k in data for k in ["loadType", "playlistInfo", "isSeekable", "isStream"]
|
||||
):
|
||||
task = (
|
||||
"insert",
|
||||
(
|
||||
@ -785,7 +793,7 @@ class MusicCache:
|
||||
[
|
||||
{
|
||||
"query": query,
|
||||
"data": json.dumps(results._raw),
|
||||
"data": data,
|
||||
"last_updated": time_now,
|
||||
"last_fetched": time_now,
|
||||
}
|
||||
@ -853,10 +861,12 @@ class MusicCache:
|
||||
query_data["maxage"] = maxage_int
|
||||
|
||||
vals = await self.database.fetch_all("lavalink", "data", query_data)
|
||||
recently_played = [r.tracks for r in vals if r]
|
||||
recently_played = [r.tracks for r in vals if r if isinstance(tracks, dict)]
|
||||
|
||||
if recently_played:
|
||||
track = random.choice(recently_played)
|
||||
if track.get("loadType") == "V2_COMPACT":
|
||||
track["loadType"] = "V2_COMPAT"
|
||||
results = LoadResult(track)
|
||||
tracks = list(results.tracks)
|
||||
except Exception:
|
||||
|
||||
@ -67,7 +67,7 @@ from .utils import *
|
||||
|
||||
_ = Translator("Audio", __file__)
|
||||
|
||||
__version__ = "1.1.0"
|
||||
__version__ = "1.1.1"
|
||||
__author__ = ["aikaterna", "Draper"]
|
||||
|
||||
log = logging.getLogger("red.audio")
|
||||
@ -245,11 +245,16 @@ class Audio(commands.Cog):
|
||||
for t in tracks_in_playlist:
|
||||
uri = t.get("info", {}).get("uri")
|
||||
if uri:
|
||||
t = {"loadType": "V2_COMPACT", "tracks": [t], "query": uri}
|
||||
t = {"loadType": "V2_COMPAT", "tracks": [t], "query": uri}
|
||||
data = json.dumps(t)
|
||||
if all(
|
||||
k in data
|
||||
for k in ["loadType", "playlistInfo", "isSeekable", "isStream"]
|
||||
):
|
||||
database_entries.append(
|
||||
{
|
||||
"query": uri,
|
||||
"data": json.dumps(t),
|
||||
"data": data,
|
||||
"last_updated": time_now,
|
||||
"last_fetched": time_now,
|
||||
}
|
||||
@ -530,15 +535,16 @@ class Audio(commands.Cog):
|
||||
player_check = await self._players_check()
|
||||
await self._status_check(*player_check)
|
||||
|
||||
if not autoplay and event_type == lavalink.LavalinkEvents.QUEUE_END and notify:
|
||||
if event_type == lavalink.LavalinkEvents.QUEUE_END:
|
||||
if not autoplay:
|
||||
notify_channel = player.fetch("channel")
|
||||
if notify_channel:
|
||||
if notify_channel and notify:
|
||||
notify_channel = self.bot.get_channel(notify_channel)
|
||||
await self._embed_msg(notify_channel, title=_("Queue Ended."))
|
||||
elif not autoplay and event_type == lavalink.LavalinkEvents.QUEUE_END and disconnect:
|
||||
if disconnect:
|
||||
self.bot.dispatch("red_audio_audio_disconnect", guild)
|
||||
await player.disconnect()
|
||||
if event_type == lavalink.LavalinkEvents.QUEUE_END and status:
|
||||
if status:
|
||||
player_check = await self._players_check()
|
||||
await self._status_check(*player_check)
|
||||
|
||||
@ -690,7 +696,7 @@ class Audio(commands.Cog):
|
||||
async def dc(self, ctx: commands.Context):
|
||||
"""Toggle the bot auto-disconnecting when done playing.
|
||||
|
||||
This setting takes precedence over [p]audioset emptydisconnect.
|
||||
This setting takes precedence over `[p]audioset emptydisconnect`.
|
||||
"""
|
||||
|
||||
disconnect = await self.config.guild(ctx.guild).disconnect()
|
||||
@ -699,7 +705,6 @@ class Audio(commands.Cog):
|
||||
msg += _("Auto-disconnection at queue end: {true_or_false}.").format(
|
||||
true_or_false=_("Enabled") if not disconnect else _("Disabled")
|
||||
)
|
||||
await self.config.guild(ctx.guild).repeat.set(not disconnect)
|
||||
if disconnect is not True and autoplay is True:
|
||||
msg += _("\nAuto-play has been disabled.")
|
||||
await self.config.guild(ctx.guild).auto_play.set(False)
|
||||
@ -1117,7 +1122,7 @@ class Audio(commands.Cog):
|
||||
"""Set a playlist to auto-play songs from.
|
||||
|
||||
**Usage**:
|
||||
[p]audioset autoplay playlist_name_OR_id args
|
||||
`[p]audioset autoplay playlist_name_OR_id [args]`
|
||||
|
||||
**Args**:
|
||||
The following are all optional:
|
||||
@ -1140,16 +1145,16 @@ class Audio(commands.Cog):
|
||||
Exact guild name
|
||||
|
||||
Example use:
|
||||
[p]audioset autoplay MyGuildPlaylist
|
||||
[p]audioset autoplay MyGlobalPlaylist --scope Global
|
||||
[p]audioset autoplay PersonalPlaylist --scope User --author Draper
|
||||
`[p]audioset autoplay MyGuildPlaylist`
|
||||
`[p]audioset autoplay MyGlobalPlaylist --scope Global`
|
||||
`[p]audioset autoplay PersonalPlaylist --scope User --author Draper`
|
||||
"""
|
||||
if scope_data is None:
|
||||
scope_data = [PlaylistScope.GUILD.value, ctx.author, ctx.guild, False]
|
||||
scope_data = [None, ctx.author, ctx.guild, False]
|
||||
|
||||
scope, author, guild, specified_user = scope_data
|
||||
try:
|
||||
playlist_id, playlist_arg = await self._get_correct_playlist_id(
|
||||
playlist_id, playlist_arg, scope = await self._get_correct_playlist_id(
|
||||
ctx, playlist_matches, scope, author, guild, specified_user
|
||||
)
|
||||
except TooManyMatches as e:
|
||||
@ -1253,7 +1258,10 @@ class Audio(commands.Cog):
|
||||
@audioset.command()
|
||||
@checks.mod_or_permissions(administrator=True)
|
||||
async def emptydisconnect(self, ctx: commands.Context, seconds: int):
|
||||
"""Auto-disconnect from channel when bot is alone in it for x seconds, 0 to disable."""
|
||||
"""Auto-disconnect from channel when bot is alone in it for x seconds, 0 to disable.
|
||||
|
||||
`[p]audioset dc` takes precedence over this setting.
|
||||
"""
|
||||
if seconds < 0:
|
||||
return await self._embed_msg(
|
||||
ctx, title=_("Invalid Time"), description=_("Seconds can't be less than zero.")
|
||||
@ -2443,7 +2451,11 @@ class Audio(commands.Cog):
|
||||
if not await self._localtracks_check(ctx):
|
||||
return
|
||||
|
||||
return audio_data.subfolders_in_tree() if search_subfolders else audio_data.subfolders()
|
||||
return (
|
||||
await audio_data.subfolders_in_tree()
|
||||
if search_subfolders
|
||||
else await audio_data.subfolders()
|
||||
)
|
||||
|
||||
async def _folder_list(
|
||||
self, ctx: commands.Context, query: audio_dataclasses.Query
|
||||
@ -2454,9 +2466,9 @@ class Audio(commands.Cog):
|
||||
if not query.track.exists():
|
||||
return
|
||||
return (
|
||||
query.track.tracks_in_tree()
|
||||
await query.track.tracks_in_tree()
|
||||
if query.search_subfolders
|
||||
else query.track.tracks_in_folder()
|
||||
else await query.track.tracks_in_folder()
|
||||
)
|
||||
|
||||
async def _folder_tracks(
|
||||
@ -2495,9 +2507,9 @@ class Audio(commands.Cog):
|
||||
return
|
||||
|
||||
return (
|
||||
query.track.tracks_in_tree()
|
||||
await query.track.tracks_in_tree()
|
||||
if query.search_subfolders
|
||||
else query.track.tracks_in_folder()
|
||||
else await query.track.tracks_in_folder()
|
||||
)
|
||||
|
||||
async def _localtracks_check(self, ctx: commands.Context) -> bool:
|
||||
@ -2948,8 +2960,7 @@ class Audio(commands.Cog):
|
||||
return await self._embed_msg(ctx, embed=embed)
|
||||
elif isinstance(tracks, discord.Message):
|
||||
return
|
||||
queue_dur = await queue_duration(ctx)
|
||||
lavalink.utils.format_time(queue_dur)
|
||||
queue_dur = await track_remaining_duration(ctx)
|
||||
index = query.track_index
|
||||
seek = 0
|
||||
if query.start_time:
|
||||
@ -3822,7 +3833,7 @@ class Audio(commands.Cog):
|
||||
author: discord.User,
|
||||
guild: discord.Guild,
|
||||
specified_user: bool = False,
|
||||
) -> Tuple[Optional[int], str]:
|
||||
) -> Tuple[Optional[int], str, str]:
|
||||
"""
|
||||
Parameters
|
||||
----------
|
||||
@ -3851,34 +3862,57 @@ class Audio(commands.Cog):
|
||||
"""
|
||||
correct_scope_matches: List[Playlist]
|
||||
original_input = matches.get("arg")
|
||||
lazy_match = False
|
||||
if scope is None:
|
||||
correct_scope_matches_temp: MutableMapping = matches.get("all")
|
||||
lazy_match = True
|
||||
else:
|
||||
correct_scope_matches_temp: MutableMapping = matches.get(scope)
|
||||
|
||||
guild_to_query = guild.id
|
||||
user_to_query = author.id
|
||||
correct_scope_matches_user = []
|
||||
correct_scope_matches_guild = []
|
||||
correct_scope_matches_global = []
|
||||
|
||||
if not correct_scope_matches_temp:
|
||||
return None, original_input
|
||||
if scope == PlaylistScope.USER.value:
|
||||
correct_scope_matches = [
|
||||
p for p in correct_scope_matches_temp if user_to_query == p.scope_id
|
||||
return None, original_input, scope or PlaylistScope.GUILD.value
|
||||
if lazy_match or (scope == PlaylistScope.USER.value):
|
||||
correct_scope_matches_user = [
|
||||
p for p in matches.get(PlaylistScope.USER.value) if user_to_query == p.scope_id
|
||||
]
|
||||
elif scope == PlaylistScope.GUILD.value:
|
||||
if lazy_match or (scope == PlaylistScope.GUILD.value and not correct_scope_matches_user):
|
||||
if specified_user:
|
||||
correct_scope_matches = [
|
||||
correct_scope_matches_guild = [
|
||||
p
|
||||
for p in correct_scope_matches_temp
|
||||
for p in matches.get(PlaylistScope.GUILD.value)
|
||||
if guild_to_query == p.scope_id and p.author == user_to_query
|
||||
]
|
||||
else:
|
||||
correct_scope_matches = [
|
||||
p for p in correct_scope_matches_temp if guild_to_query == p.scope_id
|
||||
correct_scope_matches_guild = [
|
||||
p
|
||||
for p in matches.get(PlaylistScope.GUILD.value)
|
||||
if guild_to_query == p.scope_id
|
||||
]
|
||||
else:
|
||||
if lazy_match or (
|
||||
scope == PlaylistScope.GLOBAL.value
|
||||
and not correct_scope_matches_user
|
||||
and not correct_scope_matches_guild
|
||||
):
|
||||
if specified_user:
|
||||
correct_scope_matches = [
|
||||
p for p in correct_scope_matches_temp if p.author == user_to_query
|
||||
correct_scope_matches_global = [
|
||||
p
|
||||
for p in matches.get(PlaylistScope.USGLOBALER.value)
|
||||
if p.author == user_to_query
|
||||
]
|
||||
else:
|
||||
correct_scope_matches = [p for p in correct_scope_matches_temp]
|
||||
correct_scope_matches_global = [p for p in matches.get(PlaylistScope.GLOBAL.value)]
|
||||
|
||||
correct_scope_matches = [
|
||||
*correct_scope_matches_global,
|
||||
*correct_scope_matches_guild,
|
||||
*correct_scope_matches_user,
|
||||
]
|
||||
match_count = len(correct_scope_matches)
|
||||
if match_count > 1:
|
||||
correct_scope_matches2 = [
|
||||
@ -3905,14 +3939,15 @@ class Audio(commands.Cog):
|
||||
).format(match_count=match_count, original_input=original_input)
|
||||
)
|
||||
elif match_count == 1:
|
||||
return correct_scope_matches[0].id, original_input
|
||||
return correct_scope_matches[0].id, original_input, correct_scope_matches[0].scope
|
||||
elif match_count == 0:
|
||||
return None, original_input
|
||||
return None, original_input, scope
|
||||
|
||||
# TODO : Convert this section to a new paged reaction menu when Toby Menus are Merged
|
||||
pos_len = 3
|
||||
playlists = f"{'#':{pos_len}}\n"
|
||||
number = 0
|
||||
correct_scope_matches = sorted(correct_scope_matches, key=lambda x: x.name.lower())
|
||||
for number, playlist in enumerate(correct_scope_matches, 1):
|
||||
author = self.bot.get_user(playlist.author) or playlist.author or _("Unknown")
|
||||
line = _(
|
||||
@ -3925,7 +3960,7 @@ class Audio(commands.Cog):
|
||||
).format(
|
||||
number=number,
|
||||
playlist=playlist,
|
||||
scope=humanize_scope(scope),
|
||||
scope=humanize_scope(playlist.scope),
|
||||
tracks=len(playlist.tracks),
|
||||
author=author,
|
||||
)
|
||||
@ -3961,7 +3996,11 @@ class Audio(commands.Cog):
|
||||
)
|
||||
with contextlib.suppress(discord.HTTPException):
|
||||
await msg.delete()
|
||||
return correct_scope_matches[pred.result].id, original_input
|
||||
return (
|
||||
correct_scope_matches[pred.result].id,
|
||||
original_input,
|
||||
correct_scope_matches[pred.result].scope,
|
||||
)
|
||||
|
||||
@commands.group()
|
||||
@commands.guild_only()
|
||||
@ -3996,7 +4035,7 @@ class Audio(commands.Cog):
|
||||
The track(s) will be appended to the end of the playlist.
|
||||
|
||||
**Usage**:
|
||||
[p]playlist append playlist_name_OR_id track_name_OR_url args
|
||||
`[p]playlist append playlist_name_OR_id track_name_OR_url [args]`
|
||||
|
||||
**Args**:
|
||||
The following are all optional:
|
||||
@ -4019,18 +4058,17 @@ class Audio(commands.Cog):
|
||||
Exact guild name
|
||||
|
||||
Example use:
|
||||
[p]playlist append MyGuildPlaylist Hello by Adele
|
||||
[p]playlist append MyGlobalPlaylist Hello by Adele --scope Global
|
||||
[p]playlist append MyGlobalPlaylist Hello by Adele --scope Global
|
||||
--Author Draper#6666
|
||||
`[p]playlist append MyGuildPlaylist Hello by Adele`
|
||||
`[p]playlist append MyGlobalPlaylist Hello by Adele --scope Global`
|
||||
`[p]playlist append MyGlobalPlaylist Hello by Adele --scope Global --Author Draper#6666`
|
||||
"""
|
||||
if scope_data is None:
|
||||
scope_data = [PlaylistScope.GUILD.value, ctx.author, ctx.guild, False]
|
||||
scope_data = [None, ctx.author, ctx.guild, False]
|
||||
(scope, author, guild, specified_user) = scope_data
|
||||
if not await self._playlist_check(ctx):
|
||||
return
|
||||
try:
|
||||
(playlist_id, playlist_arg) = await self._get_correct_playlist_id(
|
||||
(playlist_id, playlist_arg, scope) = await self._get_correct_playlist_id(
|
||||
ctx, playlist_matches, scope, author, guild, specified_user
|
||||
)
|
||||
except TooManyMatches as e:
|
||||
@ -4144,8 +4182,8 @@ class Audio(commands.Cog):
|
||||
else None,
|
||||
)
|
||||
|
||||
@commands.cooldown(1, 300, commands.BucketType.member)
|
||||
@playlist.command(name="copy", usage="<id_or_name> [args]")
|
||||
@commands.cooldown(1, 150, commands.BucketType.member)
|
||||
@playlist.command(name="copy", usage="<id_or_name> [args]", cooldown_after_parsing=True)
|
||||
async def _playlist_copy(
|
||||
self,
|
||||
ctx: commands.Context,
|
||||
@ -4157,7 +4195,7 @@ class Audio(commands.Cog):
|
||||
"""Copy a playlist from one scope to another.
|
||||
|
||||
**Usage**:
|
||||
[p]playlist copy playlist_name_OR_id args
|
||||
`[p]playlist copy playlist_name_OR_id [args]`
|
||||
|
||||
**Args**:
|
||||
The following are all optional:
|
||||
@ -4184,11 +4222,9 @@ class Audio(commands.Cog):
|
||||
Exact guild name
|
||||
|
||||
Example use:
|
||||
[p]playlist copy MyGuildPlaylist --from-scope Guild --to-scope Global
|
||||
[p]playlist copy MyGlobalPlaylist --from-scope Global --to-author Draper#6666
|
||||
--to-scope User
|
||||
[p]playlist copy MyPersonalPlaylist --from-scope user --to-author Draper#6666
|
||||
--to-scope Guild --to-guild Red - Discord Bot
|
||||
`[p]playlist copy MyGuildPlaylist --from-scope Guild --to-scope Global`
|
||||
`[p]playlist copy MyGlobalPlaylist --from-scope Global --to-author Draper#6666 --to-scope User`
|
||||
`[p]playlist copy MyPersonalPlaylist --from-scope user --to-author Draper#6666 --to-scope Guild --to-guild Red - Discord Bot`
|
||||
"""
|
||||
|
||||
if scope_data is None:
|
||||
@ -4214,7 +4250,7 @@ class Audio(commands.Cog):
|
||||
) = scope_data
|
||||
|
||||
try:
|
||||
playlist_id, playlist_arg = await self._get_correct_playlist_id(
|
||||
playlist_id, playlist_arg, scope = await self._get_correct_playlist_id(
|
||||
ctx, playlist_matches, from_scope, from_author, from_guild, specified_from_user
|
||||
)
|
||||
except TooManyMatches as e:
|
||||
@ -4284,8 +4320,8 @@ class Audio(commands.Cog):
|
||||
).format(
|
||||
name=from_playlist.name,
|
||||
from_id=from_playlist.id,
|
||||
from_scope=humanize_scope(from_scope, ctx=from_scope_name, the=True),
|
||||
to_scope=humanize_scope(to_scope, ctx=to_scope_name, the=True),
|
||||
from_scope=humanize_scope(from_scope, ctx=from_scope_name),
|
||||
to_scope=humanize_scope(to_scope, ctx=to_scope_name),
|
||||
to_id=to_playlist.id,
|
||||
),
|
||||
)
|
||||
@ -4297,7 +4333,7 @@ class Audio(commands.Cog):
|
||||
"""Create an empty playlist.
|
||||
|
||||
**Usage**:
|
||||
[p]playlist create playlist_name args
|
||||
`[p]playlist create playlist_name [args]`
|
||||
|
||||
**Args**:
|
||||
The following are all optional:
|
||||
@ -4320,9 +4356,9 @@ class Audio(commands.Cog):
|
||||
Exact guild name
|
||||
|
||||
Example use:
|
||||
[p]playlist create MyGuildPlaylist
|
||||
[p]playlist create MyGlobalPlaylist --scope Global
|
||||
[p]playlist create MyPersonalPlaylist --scope User
|
||||
`[p]playlist create MyGuildPlaylist`
|
||||
`[p]playlist create MyGlobalPlaylist --scope Global`
|
||||
`[p]playlist create MyPersonalPlaylist --scope User`
|
||||
"""
|
||||
if scope_data is None:
|
||||
scope_data = [PlaylistScope.GUILD.value, ctx.author, ctx.guild, False]
|
||||
@ -4364,7 +4400,7 @@ class Audio(commands.Cog):
|
||||
"""Delete a saved playlist.
|
||||
|
||||
**Usage**:
|
||||
[p]playlist delete playlist_name_OR_id args
|
||||
`[p]playlist delete playlist_name_OR_id [args]`
|
||||
|
||||
**Args**:
|
||||
The following are all optional:
|
||||
@ -4387,16 +4423,16 @@ class Audio(commands.Cog):
|
||||
Exact guild name
|
||||
|
||||
Example use:
|
||||
[p]playlist delete MyGuildPlaylist
|
||||
[p]playlist delete MyGlobalPlaylist --scope Global
|
||||
[p]playlist delete MyPersonalPlaylist --scope User
|
||||
`[p]playlist delete MyGuildPlaylist`
|
||||
`[p]playlist delete MyGlobalPlaylist --scope Global`
|
||||
`[p]playlist delete MyPersonalPlaylist --scope User`
|
||||
"""
|
||||
if scope_data is None:
|
||||
scope_data = [PlaylistScope.GUILD.value, ctx.author, ctx.guild, False]
|
||||
scope_data = [None, ctx.author, ctx.guild, False]
|
||||
scope, author, guild, specified_user = scope_data
|
||||
|
||||
try:
|
||||
playlist_id, playlist_arg = await self._get_correct_playlist_id(
|
||||
playlist_id, playlist_arg, scope = await self._get_correct_playlist_id(
|
||||
ctx, playlist_matches, scope, author, guild, specified_user
|
||||
)
|
||||
except TooManyMatches as e:
|
||||
@ -4438,7 +4474,9 @@ class Audio(commands.Cog):
|
||||
)
|
||||
|
||||
@commands.cooldown(1, 30, commands.BucketType.member)
|
||||
@playlist.command(name="dedupe", usage="<playlist_name_OR_id> [args]")
|
||||
@playlist.command(
|
||||
name="dedupe", usage="<playlist_name_OR_id> [args]", cooldown_after_parsing=True
|
||||
)
|
||||
async def _playlist_remdupe(
|
||||
self,
|
||||
ctx: commands.Context,
|
||||
@ -4449,7 +4487,7 @@ class Audio(commands.Cog):
|
||||
"""Remove duplicate tracks from a saved playlist.
|
||||
|
||||
**Usage**:
|
||||
[p]playlist dedupe playlist_name_OR_id args
|
||||
`[p]playlist dedupe playlist_name_OR_id [args]`
|
||||
|
||||
**Args**:
|
||||
The following are all optional:
|
||||
@ -4472,25 +4510,24 @@ class Audio(commands.Cog):
|
||||
Exact guild name
|
||||
|
||||
Example use:
|
||||
[p]playlist dedupe MyGuildPlaylist
|
||||
[p]playlist dedupe MyGlobalPlaylist --scope Global
|
||||
[p]playlist dedupe MyPersonalPlaylist --scope User
|
||||
`[p]playlist dedupe MyGuildPlaylist`
|
||||
`[p]playlist dedupe MyGlobalPlaylist --scope Global`
|
||||
`[p]playlist dedupe MyPersonalPlaylist --scope User`
|
||||
"""
|
||||
async with ctx.typing():
|
||||
if scope_data is None:
|
||||
scope_data = [PlaylistScope.GUILD.value, ctx.author, ctx.guild, False]
|
||||
scope_data = [None, ctx.author, ctx.guild, False]
|
||||
scope, author, guild, specified_user = scope_data
|
||||
scope_name = humanize_scope(
|
||||
scope, ctx=guild if scope == PlaylistScope.GUILD.value else author
|
||||
)
|
||||
|
||||
try:
|
||||
playlist_id, playlist_arg = await self._get_correct_playlist_id(
|
||||
playlist_id, playlist_arg, scope = await self._get_correct_playlist_id(
|
||||
ctx, playlist_matches, scope, author, guild, specified_user
|
||||
)
|
||||
except TooManyMatches as e:
|
||||
ctx.command.reset_cooldown(ctx)
|
||||
return await self._embed_msg(ctx, title=str(e))
|
||||
scope_name = humanize_scope(
|
||||
scope, ctx=guild if scope == PlaylistScope.GUILD.value else author
|
||||
)
|
||||
if playlist_id is None:
|
||||
ctx.command.reset_cooldown(ctx)
|
||||
return await self._embed_msg(
|
||||
@ -4571,9 +4608,13 @@ class Audio(commands.Cog):
|
||||
)
|
||||
|
||||
@checks.is_owner()
|
||||
@playlist.command(name="download", usage="<playlist_name_OR_id> [v2=False] [args]")
|
||||
@playlist.command(
|
||||
name="download",
|
||||
usage="<playlist_name_OR_id> [v2=False] [args]",
|
||||
cooldown_after_parsing=True,
|
||||
)
|
||||
@commands.bot_has_permissions(attach_files=True)
|
||||
@commands.cooldown(1, 60, commands.BucketType.guild)
|
||||
@commands.cooldown(1, 30, commands.BucketType.guild)
|
||||
async def _playlist_download(
|
||||
self,
|
||||
ctx: commands.Context,
|
||||
@ -4584,12 +4625,12 @@ class Audio(commands.Cog):
|
||||
):
|
||||
"""Download a copy of a playlist.
|
||||
|
||||
These files can be used with the [p]playlist upload command.
|
||||
These files can be used with the `[p]playlist upload` command.
|
||||
Red v2-compatible playlists can be generated by passing True
|
||||
for the v2 variable.
|
||||
|
||||
**Usage**:
|
||||
[p]playlist download playlist_name_OR_id [v2=True_OR_False] args
|
||||
`[p]playlist download playlist_name_OR_id [v2=True_OR_False] [args]`
|
||||
|
||||
**Args**:
|
||||
The following are all optional:
|
||||
@ -4612,16 +4653,16 @@ class Audio(commands.Cog):
|
||||
Exact guild name
|
||||
|
||||
Example use:
|
||||
[p]playlist download MyGuildPlaylist True
|
||||
[p]playlist download MyGlobalPlaylist False --scope Global
|
||||
[p]playlist download MyPersonalPlaylist --scope User
|
||||
`[p]playlist download MyGuildPlaylist True`
|
||||
`[p]playlist download MyGlobalPlaylist False --scope Global`
|
||||
`[p]playlist download MyPersonalPlaylist --scope User`
|
||||
"""
|
||||
if scope_data is None:
|
||||
scope_data = [PlaylistScope.GUILD.value, ctx.author, ctx.guild, False]
|
||||
scope_data = [None, ctx.author, ctx.guild, False]
|
||||
scope, author, guild, specified_user = scope_data
|
||||
|
||||
try:
|
||||
playlist_id, playlist_arg = await self._get_correct_playlist_id(
|
||||
playlist_id, playlist_arg, scope = await self._get_correct_playlist_id(
|
||||
ctx, playlist_matches, scope, author, guild, specified_user
|
||||
)
|
||||
except TooManyMatches as e:
|
||||
@ -4715,8 +4756,10 @@ class Audio(commands.Cog):
|
||||
await ctx.send(file=discord.File(to_write, filename=f"{file_name}.txt"))
|
||||
to_write.close()
|
||||
|
||||
@commands.cooldown(1, 20, commands.BucketType.member)
|
||||
@playlist.command(name="info", usage="<playlist_name_OR_id> [args]")
|
||||
@commands.cooldown(1, 10, commands.BucketType.member)
|
||||
@playlist.command(
|
||||
name="info", usage="<playlist_name_OR_id> [args]", cooldown_after_parsing=True
|
||||
)
|
||||
async def _playlist_info(
|
||||
self,
|
||||
ctx: commands.Context,
|
||||
@ -4727,7 +4770,7 @@ class Audio(commands.Cog):
|
||||
"""Retrieve information from a saved playlist.
|
||||
|
||||
**Usage**:
|
||||
[p]playlist info playlist_name_OR_id args
|
||||
`[p]playlist info playlist_name_OR_id [args]`
|
||||
|
||||
**Args**:
|
||||
The following are all optional:
|
||||
@ -4750,24 +4793,24 @@ class Audio(commands.Cog):
|
||||
Exact guild name
|
||||
|
||||
Example use:
|
||||
[p]playlist info MyGuildPlaylist
|
||||
[p]playlist info MyGlobalPlaylist --scope Global
|
||||
[p]playlist info MyPersonalPlaylist --scope User
|
||||
`[p]playlist info MyGuildPlaylist`
|
||||
`[p]playlist info MyGlobalPlaylist --scope Global`
|
||||
`[p]playlist info MyPersonalPlaylist --scope User`
|
||||
"""
|
||||
if scope_data is None:
|
||||
scope_data = [PlaylistScope.GUILD.value, ctx.author, ctx.guild, False]
|
||||
scope_data = [None, ctx.author, ctx.guild, False]
|
||||
scope, author, guild, specified_user = scope_data
|
||||
scope_name = humanize_scope(
|
||||
scope, ctx=guild if scope == PlaylistScope.GUILD.value else author
|
||||
)
|
||||
|
||||
try:
|
||||
playlist_id, playlist_arg = await self._get_correct_playlist_id(
|
||||
playlist_id, playlist_arg, scope = await self._get_correct_playlist_id(
|
||||
ctx, playlist_matches, scope, author, guild, specified_user
|
||||
)
|
||||
except TooManyMatches as e:
|
||||
ctx.command.reset_cooldown(ctx)
|
||||
return await self._embed_msg(ctx, title=str(e))
|
||||
scope_name = humanize_scope(
|
||||
scope, ctx=guild if scope == PlaylistScope.GUILD.value else author
|
||||
)
|
||||
|
||||
if playlist_id is None:
|
||||
ctx.command.reset_cooldown(ctx)
|
||||
return await self._embed_msg(
|
||||
@ -4852,14 +4895,14 @@ class Audio(commands.Cog):
|
||||
page_list.append(embed)
|
||||
await menu(ctx, page_list, DEFAULT_CONTROLS)
|
||||
|
||||
@commands.cooldown(1, 30, commands.BucketType.guild)
|
||||
@playlist.command(name="list", usage="[args]")
|
||||
@commands.cooldown(1, 15, commands.BucketType.guild)
|
||||
@playlist.command(name="list", usage="[args]", cooldown_after_parsing=True)
|
||||
@commands.bot_has_permissions(add_reactions=True)
|
||||
async def _playlist_list(self, ctx: commands.Context, *, scope_data: ScopeParser = None):
|
||||
"""List saved playlists.
|
||||
|
||||
**Usage**:
|
||||
[p]playlist list args
|
||||
`[p]playlist list [args]`
|
||||
|
||||
**Args**:
|
||||
The following are all optional:
|
||||
@ -4882,9 +4925,9 @@ class Audio(commands.Cog):
|
||||
Exact guild name
|
||||
|
||||
Example use:
|
||||
[p]playlist list
|
||||
[p]playlist list --scope Global
|
||||
[p]playlist list --scope User
|
||||
`[p]playlist list`
|
||||
`[p]playlist list --scope Global`
|
||||
`[p]playlist list --scope User`
|
||||
"""
|
||||
if scope_data is None:
|
||||
scope_data = [PlaylistScope.GUILD.value, ctx.author, ctx.guild, False]
|
||||
@ -4976,15 +5019,15 @@ class Audio(commands.Cog):
|
||||
)
|
||||
return embed
|
||||
|
||||
@playlist.command(name="queue", usage="<name> [args]")
|
||||
@commands.cooldown(1, 600, commands.BucketType.member)
|
||||
@playlist.command(name="queue", usage="<name> [args]", cooldown_after_parsing=True)
|
||||
@commands.cooldown(1, 300, commands.BucketType.member)
|
||||
async def _playlist_queue(
|
||||
self, ctx: commands.Context, playlist_name: str, *, scope_data: ScopeParser = None
|
||||
):
|
||||
"""Save the queue to a playlist.
|
||||
|
||||
**Usage**:
|
||||
[p]playlist queue playlist_name
|
||||
`[p]playlist queue playlist_name [args]`
|
||||
|
||||
**Args**:
|
||||
The following are all optional:
|
||||
@ -5007,9 +5050,9 @@ class Audio(commands.Cog):
|
||||
Exact guild name
|
||||
|
||||
Example use:
|
||||
[p]playlist queue MyGuildPlaylist
|
||||
[p]playlist queue MyGlobalPlaylist --scope Global
|
||||
[p]playlist queue MyPersonalPlaylist --scope User
|
||||
`[p]playlist queue MyGuildPlaylist`
|
||||
`[p]playlist queue MyGlobalPlaylist --scope Global`
|
||||
`[p]playlist queue MyPersonalPlaylist --scope User`
|
||||
"""
|
||||
async with ctx.typing():
|
||||
if scope_data is None:
|
||||
@ -5087,7 +5130,7 @@ class Audio(commands.Cog):
|
||||
"""Remove a track from a playlist by url.
|
||||
|
||||
**Usage**:
|
||||
[p]playlist remove playlist_name_OR_id url args
|
||||
`[p]playlist remove playlist_name_OR_id url [args]`
|
||||
|
||||
**Args**:
|
||||
The following are all optional:
|
||||
@ -5110,25 +5153,23 @@ class Audio(commands.Cog):
|
||||
Exact guild name
|
||||
|
||||
Example use:
|
||||
[p]playlist remove MyGuildPlaylist https://www.youtube.com/watch?v=MN3x-kAbgFU
|
||||
[p]playlist remove MyGlobalPlaylist https://www.youtube.com/watch?v=MN3x-kAbgFU
|
||||
--scope Global
|
||||
[p]playlist remove MyPersonalPlaylist https://www.youtube.com/watch?v=MN3x-kAbgFU
|
||||
--scope User
|
||||
`[p]playlist remove MyGuildPlaylist https://www.youtube.com/watch?v=MN3x-kAbgFU`
|
||||
`[p]playlist remove MyGlobalPlaylist https://www.youtube.com/watch?v=MN3x-kAbgFU --scope Global`
|
||||
`[p]playlist remove MyPersonalPlaylist https://www.youtube.com/watch?v=MN3x-kAbgFU --scope User`
|
||||
"""
|
||||
if scope_data is None:
|
||||
scope_data = [PlaylistScope.GUILD.value, ctx.author, ctx.guild, False]
|
||||
scope_data = [None, ctx.author, ctx.guild, False]
|
||||
scope, author, guild, specified_user = scope_data
|
||||
scope_name = humanize_scope(
|
||||
scope, ctx=guild if scope == PlaylistScope.GUILD.value else author
|
||||
)
|
||||
|
||||
try:
|
||||
playlist_id, playlist_arg = await self._get_correct_playlist_id(
|
||||
playlist_id, playlist_arg, scope = await self._get_correct_playlist_id(
|
||||
ctx, playlist_matches, scope, author, guild, specified_user
|
||||
)
|
||||
except TooManyMatches as e:
|
||||
return await self._embed_msg(ctx, title=str(e))
|
||||
scope_name = humanize_scope(
|
||||
scope, ctx=guild if scope == PlaylistScope.GUILD.value else author
|
||||
)
|
||||
if playlist_id is None:
|
||||
return await self._embed_msg(
|
||||
ctx,
|
||||
@ -5188,8 +5229,8 @@ class Audio(commands.Cog):
|
||||
).format(playlist_name=playlist.name, id=playlist.id, scope=scope_name),
|
||||
)
|
||||
|
||||
@playlist.command(name="save", usage="<name> <url> [args]")
|
||||
@commands.cooldown(1, 120, commands.BucketType.member)
|
||||
@playlist.command(name="save", usage="<name> <url> [args]", cooldown_after_parsing=True)
|
||||
@commands.cooldown(1, 60, commands.BucketType.member)
|
||||
async def _playlist_save(
|
||||
self,
|
||||
ctx: commands.Context,
|
||||
@ -5201,7 +5242,7 @@ class Audio(commands.Cog):
|
||||
"""Save a playlist from a url.
|
||||
|
||||
**Usage**:
|
||||
[p]playlist save name url args
|
||||
`[p]playlist save name url [args]`
|
||||
|
||||
**Args**:
|
||||
The following are all optional:
|
||||
@ -5224,12 +5265,9 @@ class Audio(commands.Cog):
|
||||
Exact guild name
|
||||
|
||||
Example use:
|
||||
[p]playlist save MyGuildPlaylist
|
||||
https://www.youtube.com/playlist?list=PLx0sYbCqOb8Q_CLZC2BdBSKEEB59BOPUM
|
||||
[p]playlist save MyGlobalPlaylist
|
||||
https://www.youtube.com/playlist?list=PLx0sYbCqOb8Q_CLZC2BdBSKEEB59BOPUM --scope Global
|
||||
[p]playlist save MyPersonalPlaylist
|
||||
https://open.spotify.com/playlist/1RyeIbyFeIJVnNzlGr5KkR --scope User
|
||||
`[p]playlist save MyGuildPlaylist https://www.youtube.com/playlist?list=PLx0sYbCqOb8Q_CLZC2BdBSKEEB59BOPUM`
|
||||
`[p]playlist save MyGlobalPlaylist https://www.youtube.com/playlist?list=PLx0sYbCqOb8Q_CLZC2BdBSKEEB59BOPUM --scope Global`
|
||||
`[p]playlist save MyPersonalPlaylist https://open.spotify.com/playlist/1RyeIbyFeIJVnNzlGr5KkR --scope User`
|
||||
"""
|
||||
if scope_data is None:
|
||||
scope_data = [PlaylistScope.GUILD.value, ctx.author, ctx.guild, False]
|
||||
@ -5282,8 +5320,13 @@ class Audio(commands.Cog):
|
||||
else None,
|
||||
)
|
||||
|
||||
@commands.cooldown(1, 60, commands.BucketType.member)
|
||||
@playlist.command(name="start", aliases=["play"], usage="<playlist_name_OR_id> [args]")
|
||||
@commands.cooldown(1, 30, commands.BucketType.member)
|
||||
@playlist.command(
|
||||
name="start",
|
||||
aliases=["play"],
|
||||
usage="<playlist_name_OR_id> [args]",
|
||||
cooldown_after_parsing=True,
|
||||
)
|
||||
async def _playlist_start(
|
||||
self,
|
||||
ctx: commands.Context,
|
||||
@ -5294,7 +5337,7 @@ class Audio(commands.Cog):
|
||||
"""Load a playlist into the queue.
|
||||
|
||||
**Usage**:
|
||||
[p]playlist start playlist_name_OR_id args
|
||||
` [p]playlist start playlist_name_OR_id [args]`
|
||||
|
||||
**Args**:
|
||||
The following are all optional:
|
||||
@ -5317,12 +5360,12 @@ class Audio(commands.Cog):
|
||||
Exact guild name
|
||||
|
||||
Example use:
|
||||
[p]playlist start MyGuildPlaylist
|
||||
[p]playlist start MyGlobalPlaylist --scope Global
|
||||
[p]playlist start MyPersonalPlaylist --scope User
|
||||
`[p]playlist start MyGuildPlaylist`
|
||||
`[p]playlist start MyGlobalPlaylist --scope Global`
|
||||
`[p]playlist start MyPersonalPlaylist --scope User`
|
||||
"""
|
||||
if scope_data is None:
|
||||
scope_data = [PlaylistScope.GUILD.value, ctx.author, ctx.guild, False]
|
||||
scope_data = [None, ctx.author, ctx.guild, False]
|
||||
scope, author, guild, specified_user = scope_data
|
||||
dj_enabled = self._dj_status_cache.setdefault(
|
||||
ctx.guild.id, await self.config.guild(ctx.guild).dj_enabled()
|
||||
@ -5338,7 +5381,7 @@ class Audio(commands.Cog):
|
||||
return False
|
||||
|
||||
try:
|
||||
playlist_id, playlist_arg = await self._get_correct_playlist_id(
|
||||
playlist_id, playlist_arg, scope = await self._get_correct_playlist_id(
|
||||
ctx, playlist_matches, scope, author, guild, specified_user
|
||||
)
|
||||
except TooManyMatches as e:
|
||||
@ -5451,7 +5494,9 @@ class Audio(commands.Cog):
|
||||
return await ctx.invoke(self.play, query=playlist.url)
|
||||
|
||||
@commands.cooldown(1, 60, commands.BucketType.member)
|
||||
@playlist.command(name="update", usage="<playlist_name_OR_id> [args]")
|
||||
@playlist.command(
|
||||
name="update", usage="<playlist_name_OR_id> [args]", cooldown_after_parsing=True
|
||||
)
|
||||
async def _playlist_update(
|
||||
self,
|
||||
ctx: commands.Context,
|
||||
@ -5462,7 +5507,7 @@ class Audio(commands.Cog):
|
||||
"""Updates all tracks in a playlist.
|
||||
|
||||
**Usage**:
|
||||
[p]playlist update playlist_name_OR_id args
|
||||
`[p]playlist update playlist_name_OR_id [args]`
|
||||
|
||||
**Args**:
|
||||
The following are all optional:
|
||||
@ -5485,16 +5530,16 @@ class Audio(commands.Cog):
|
||||
Exact guild name
|
||||
|
||||
Example use:
|
||||
[p]playlist update MyGuildPlaylist
|
||||
[p]playlist update MyGlobalPlaylist --scope Global
|
||||
[p]playlist update MyPersonalPlaylist --scope User
|
||||
`[p]playlist update MyGuildPlaylist`
|
||||
`[p]playlist update MyGlobalPlaylist --scope Global`
|
||||
`[p]playlist update MyPersonalPlaylist --scope User`
|
||||
"""
|
||||
|
||||
if scope_data is None:
|
||||
scope_data = [PlaylistScope.GUILD.value, ctx.author, ctx.guild, False]
|
||||
scope_data = [None, ctx.author, ctx.guild, False]
|
||||
scope, author, guild, specified_user = scope_data
|
||||
try:
|
||||
playlist_id, playlist_arg = await self._get_correct_playlist_id(
|
||||
playlist_id, playlist_arg, scope = await self._get_correct_playlist_id(
|
||||
ctx, playlist_matches, scope, author, guild, specified_user
|
||||
)
|
||||
except TooManyMatches as e:
|
||||
@ -5610,10 +5655,10 @@ class Audio(commands.Cog):
|
||||
"""Uploads a playlist file as a playlist for the bot.
|
||||
|
||||
V2 and old V3 playlist will be slow.
|
||||
V3 Playlist made with [p]playlist download will load a lot faster.
|
||||
V3 Playlist made with `[p]playlist download` will load a lot faster.
|
||||
|
||||
**Usage**:
|
||||
[p]playlist upload args
|
||||
`[p]playlist upload [args]`
|
||||
|
||||
**Args**:
|
||||
The following are all optional:
|
||||
@ -5636,9 +5681,9 @@ class Audio(commands.Cog):
|
||||
Exact guild name
|
||||
|
||||
Example use:
|
||||
[p]playlist upload
|
||||
[p]playlist upload --scope Global
|
||||
[p]playlist upload --scope User
|
||||
`[p]playlist upload`
|
||||
`[p]playlist upload --scope Global`
|
||||
`[p]playlist upload --scope User`
|
||||
"""
|
||||
if scope_data is None:
|
||||
scope_data = [PlaylistScope.GUILD.value, ctx.author, ctx.guild, False]
|
||||
@ -5728,7 +5773,9 @@ class Audio(commands.Cog):
|
||||
)
|
||||
|
||||
@commands.cooldown(1, 60, commands.BucketType.member)
|
||||
@playlist.command(name="rename", usage="<playlist_name_OR_id> <new_name> [args]")
|
||||
@playlist.command(
|
||||
name="rename", usage="<playlist_name_OR_id> <new_name> [args]", cooldown_after_parsing=True
|
||||
)
|
||||
async def _playlist_rename(
|
||||
self,
|
||||
ctx: commands.Context,
|
||||
@ -5740,7 +5787,7 @@ class Audio(commands.Cog):
|
||||
"""Rename an existing playlist.
|
||||
|
||||
**Usage**:
|
||||
[p]playlist rename playlist_name_OR_id new_name args
|
||||
`[p]playlist rename playlist_name_OR_id new_name [args]`
|
||||
|
||||
**Args**:
|
||||
The following are all optional:
|
||||
@ -5763,12 +5810,12 @@ class Audio(commands.Cog):
|
||||
Exact guild name
|
||||
|
||||
Example use:
|
||||
[p]playlist rename MyGuildPlaylist RenamedGuildPlaylist
|
||||
[p]playlist rename MyGlobalPlaylist RenamedGlobalPlaylist --scope Global
|
||||
[p]playlist rename MyPersonalPlaylist RenamedPersonalPlaylist --scope User
|
||||
`[p]playlist rename MyGuildPlaylist RenamedGuildPlaylist`
|
||||
`[p]playlist rename MyGlobalPlaylist RenamedGlobalPlaylist --scope Global`
|
||||
`[p]playlist rename MyPersonalPlaylist RenamedPersonalPlaylist --scope User`
|
||||
"""
|
||||
if scope_data is None:
|
||||
scope_data = [PlaylistScope.GUILD.value, ctx.author, ctx.guild, False]
|
||||
scope_data = [None, ctx.author, ctx.guild, False]
|
||||
scope, author, guild, specified_user = scope_data
|
||||
|
||||
new_name = new_name.split(" ")[0].strip('"')[:32]
|
||||
@ -5784,7 +5831,7 @@ class Audio(commands.Cog):
|
||||
)
|
||||
|
||||
try:
|
||||
playlist_id, playlist_arg = await self._get_correct_playlist_id(
|
||||
playlist_id, playlist_arg, scope = await self._get_correct_playlist_id(
|
||||
ctx, playlist_matches, scope, author, guild, specified_user
|
||||
)
|
||||
except TooManyMatches as e:
|
||||
@ -5882,11 +5929,13 @@ class Audio(commands.Cog):
|
||||
for t in track_list:
|
||||
uri = t.get("info", {}).get("uri")
|
||||
if uri:
|
||||
t = {"loadType": "V2_COMPACT", "tracks": [t], "query": uri}
|
||||
t = {"loadType": "V2_COMPAT", "tracks": [t], "query": uri}
|
||||
data = json.dumps(t)
|
||||
if all(k in data for k in ["loadType", "playlistInfo", "isSeekable", "isStream"]):
|
||||
database_entries.append(
|
||||
{
|
||||
"query": uri,
|
||||
"data": json.dumps(t),
|
||||
"data": data,
|
||||
"last_updated": time_now,
|
||||
"last_fetched": time_now,
|
||||
}
|
||||
@ -6793,8 +6842,8 @@ class Audio(commands.Cog):
|
||||
async def search(self, ctx: commands.Context, *, query: str):
|
||||
"""Pick a track with a search.
|
||||
|
||||
Use `[p]search list <search term>` to queue all tracks found on YouTube. `[p]search sc
|
||||
<search term>` will search SoundCloud instead of YouTube.
|
||||
Use `[p]search list <search term>` to queue all tracks found on YouTube.
|
||||
`[p]search sc<search term>` will search SoundCloud instead of YouTube.
|
||||
"""
|
||||
|
||||
async def _search_menu(
|
||||
@ -7357,8 +7406,8 @@ class Audio(commands.Cog):
|
||||
async def _shuffle_bumpped(self, ctx: commands.Context):
|
||||
"""Toggle bumped track shuffle.
|
||||
|
||||
Set this to disabled if you wish to avoid bumped songs being shuffled. This takes priority
|
||||
over `[p]shuffle`.
|
||||
Set this to disabled if you wish to avoid bumped songs being shuffled.
|
||||
This takes priority over `[p]shuffle`.
|
||||
"""
|
||||
dj_enabled = self._dj_status_cache.setdefault(
|
||||
ctx.guild.id, await self.config.guild(ctx.guild).dj_enabled()
|
||||
|
||||
@ -1,9 +1,12 @@
|
||||
import asyncio
|
||||
import contextlib
|
||||
import glob
|
||||
import ntpath
|
||||
import os
|
||||
import posixpath
|
||||
import re
|
||||
from pathlib import Path, PosixPath, WindowsPath
|
||||
from typing import List, Optional, Union, MutableMapping
|
||||
from typing import List, Optional, Union, MutableMapping, Iterator, AsyncIterator
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import lavalink
|
||||
@ -167,29 +170,48 @@ class LocalPath:
|
||||
modified.path = modified.path.joinpath(*args)
|
||||
return modified
|
||||
|
||||
def multiglob(self, *patterns):
|
||||
paths = []
|
||||
def rglob(self, pattern, folder=False) -> Iterator[str]:
|
||||
if folder:
|
||||
return glob.iglob(f"{self.path}{os.sep}**{os.sep}", recursive=True)
|
||||
else:
|
||||
return glob.iglob(f"{self.path}{os.sep}**{os.sep}{pattern}", recursive=True)
|
||||
|
||||
def glob(self, pattern, folder=False) -> Iterator[str]:
|
||||
if folder:
|
||||
return glob.iglob(f"{self.path}{os.sep}*{os.sep}", recursive=False)
|
||||
else:
|
||||
return glob.iglob(f"{self.path}{os.sep}*{pattern}", recursive=False)
|
||||
|
||||
async def multiglob(self, *patterns, folder=False) -> AsyncIterator["LocalPath"]:
|
||||
for p in patterns:
|
||||
paths.extend(list(self.path.glob(p)))
|
||||
for p in self._filtered(paths):
|
||||
yield p
|
||||
for rp in self.glob(p):
|
||||
rp = LocalPath(rp)
|
||||
if folder and rp.is_dir() and rp.exists():
|
||||
yield rp
|
||||
await asyncio.sleep(0)
|
||||
else:
|
||||
if rp.suffix in self._all_music_ext and rp.is_file() and rp.exists():
|
||||
yield rp
|
||||
await asyncio.sleep(0)
|
||||
|
||||
def multirglob(self, *patterns):
|
||||
paths = []
|
||||
async def multirglob(self, *patterns, folder=False) -> AsyncIterator["LocalPath"]:
|
||||
for p in patterns:
|
||||
paths.extend(list(self.path.rglob(p)))
|
||||
|
||||
for p in self._filtered(paths):
|
||||
yield p
|
||||
|
||||
def _filtered(self, paths: List[Path]):
|
||||
for p in paths:
|
||||
if p.suffix in self._all_music_ext:
|
||||
yield p
|
||||
for rp in self.rglob(p):
|
||||
rp = LocalPath(rp)
|
||||
if folder and rp.is_dir() and rp.exists():
|
||||
yield rp
|
||||
await asyncio.sleep(0)
|
||||
else:
|
||||
if rp.suffix in self._all_music_ext and rp.is_file() and rp.exists():
|
||||
yield rp
|
||||
await asyncio.sleep(0)
|
||||
|
||||
def __str__(self):
|
||||
return self.to_string()
|
||||
|
||||
def __repr__(self):
|
||||
return str(self)
|
||||
|
||||
def to_string(self):
|
||||
try:
|
||||
return str(self.path.absolute())
|
||||
@ -209,48 +231,56 @@ class LocalPath:
|
||||
string = f"...{os.sep}{string}"
|
||||
return string
|
||||
|
||||
def tracks_in_tree(self):
|
||||
async def tracks_in_tree(self):
|
||||
tracks = []
|
||||
for track in self.multirglob(*[f"*{ext}" for ext in self._all_music_ext]):
|
||||
if track.exists() and track.is_file() and track.parent != self.localtrack_folder:
|
||||
tracks.append(Query.process_input(LocalPath(str(track.absolute()))))
|
||||
async for track in self.multirglob(*[f"{ext}" for ext in self._all_music_ext]):
|
||||
with contextlib.suppress(ValueError):
|
||||
if track.path.parent != self.localtrack_folder and track.path.relative_to(
|
||||
self.path
|
||||
):
|
||||
tracks.append(Query.process_input(track))
|
||||
return sorted(tracks, key=lambda x: x.to_string_user().lower())
|
||||
|
||||
def subfolders_in_tree(self):
|
||||
files = list(self.multirglob(*[f"*{ext}" for ext in self._all_music_ext]))
|
||||
folders = []
|
||||
for f in files:
|
||||
if f.exists() and f.parent not in folders and f.parent != self.localtrack_folder:
|
||||
folders.append(f.parent)
|
||||
async def subfolders_in_tree(self):
|
||||
return_folders = []
|
||||
for folder in folders:
|
||||
if folder.exists() and folder.is_dir():
|
||||
return_folders.append(LocalPath(str(folder.absolute())))
|
||||
async for f in self.multirglob("", folder=True):
|
||||
with contextlib.suppress(ValueError):
|
||||
if (
|
||||
f not in return_folders
|
||||
and f.path != self.localtrack_folder
|
||||
and f.path.relative_to(self.path)
|
||||
):
|
||||
return_folders.append(f)
|
||||
return sorted(return_folders, key=lambda x: x.to_string_user().lower())
|
||||
|
||||
def tracks_in_folder(self):
|
||||
async def tracks_in_folder(self):
|
||||
tracks = []
|
||||
for track in self.multiglob(*[f"*{ext}" for ext in self._all_music_ext]):
|
||||
if track.exists() and track.is_file() and track.parent != self.localtrack_folder:
|
||||
tracks.append(Query.process_input(LocalPath(str(track.absolute()))))
|
||||
async for track in self.multiglob(*[f"{ext}" for ext in self._all_music_ext]):
|
||||
with contextlib.suppress(ValueError):
|
||||
if track.path.parent != self.localtrack_folder and track.path.relative_to(
|
||||
self.path
|
||||
):
|
||||
tracks.append(Query.process_input(track))
|
||||
return sorted(tracks, key=lambda x: x.to_string_user().lower())
|
||||
|
||||
def subfolders(self):
|
||||
files = list(self.multiglob(*[f"*{ext}" for ext in self._all_music_ext]))
|
||||
folders = []
|
||||
for f in files:
|
||||
if f.exists() and f.parent not in folders and f.parent != self.localtrack_folder:
|
||||
folders.append(f.parent)
|
||||
async def subfolders(self):
|
||||
return_folders = []
|
||||
for folder in folders:
|
||||
if folder.exists() and folder.is_dir():
|
||||
return_folders.append(LocalPath(str(folder.absolute())))
|
||||
async for f in self.multiglob("", folder=True):
|
||||
with contextlib.suppress(ValueError):
|
||||
if (
|
||||
f not in return_folders
|
||||
and f.path != self.localtrack_folder
|
||||
and f.path.relative_to(self.path)
|
||||
):
|
||||
return_folders.append(f)
|
||||
return sorted(return_folders, key=lambda x: x.to_string_user().lower())
|
||||
|
||||
def __eq__(self, other):
|
||||
if not isinstance(other, LocalPath):
|
||||
return NotImplemented
|
||||
if isinstance(other, LocalPath):
|
||||
return self.path._cparts == other.path._cparts
|
||||
elif isinstance(other, Path):
|
||||
return self.path._cparts == other._cpart
|
||||
return NotImplemented
|
||||
|
||||
def __hash__(self):
|
||||
try:
|
||||
@ -260,24 +290,32 @@ class LocalPath:
|
||||
return self._hash
|
||||
|
||||
def __lt__(self, other):
|
||||
if not isinstance(other, LocalPath):
|
||||
return NotImplemented
|
||||
if isinstance(other, LocalPath):
|
||||
return self.path._cparts < other.path._cparts
|
||||
elif isinstance(other, Path):
|
||||
return self.path._cparts < other._cpart
|
||||
return NotImplemented
|
||||
|
||||
def __le__(self, other):
|
||||
if not isinstance(other, LocalPath):
|
||||
return NotImplemented
|
||||
if isinstance(other, LocalPath):
|
||||
return self.path._cparts <= other.path._cparts
|
||||
elif isinstance(other, Path):
|
||||
return self.path._cparts <= other._cpart
|
||||
return NotImplemented
|
||||
|
||||
def __gt__(self, other):
|
||||
if not isinstance(other, LocalPath):
|
||||
return NotImplemented
|
||||
if isinstance(other, LocalPath):
|
||||
return self.path._cparts > other.path._cparts
|
||||
elif isinstance(other, Path):
|
||||
return self.path._cparts > other._cpart
|
||||
return NotImplemented
|
||||
|
||||
def __ge__(self, other):
|
||||
if not isinstance(other, LocalPath):
|
||||
return NotImplemented
|
||||
if isinstance(other, LocalPath):
|
||||
return self.path._cparts >= other.path._cparts
|
||||
elif isinstance(other, Path):
|
||||
return self.path._cparts >= other._cpart
|
||||
return NotImplemented
|
||||
|
||||
|
||||
class Query:
|
||||
@ -378,6 +416,10 @@ class Query:
|
||||
|
||||
if isinstance(query, str):
|
||||
query = query.strip("<>")
|
||||
while "ytsearch:" in query:
|
||||
query = query.replace("ytsearch:", "")
|
||||
while "scsearch:" in query:
|
||||
query = query.replace("scsearch:", "")
|
||||
|
||||
elif isinstance(query, Query):
|
||||
for key, val in kwargs.items():
|
||||
|
||||
@ -158,6 +158,7 @@ class PlaylistConverter(commands.Converter):
|
||||
PlaylistScope.GLOBAL.value: global_matches,
|
||||
PlaylistScope.GUILD.value: guild_matches,
|
||||
PlaylistScope.USER.value: user_matches,
|
||||
"all": [*global_matches, *guild_matches, *user_matches],
|
||||
"arg": arg,
|
||||
}
|
||||
|
||||
@ -170,7 +171,7 @@ class NoExitParser(argparse.ArgumentParser):
|
||||
class ScopeParser(commands.Converter):
|
||||
async def convert(
|
||||
self, ctx: commands.Context, argument: str
|
||||
) -> Tuple[str, discord.User, Optional[discord.Guild], bool]:
|
||||
) -> Tuple[Optional[str], discord.User, Optional[discord.Guild], bool]:
|
||||
|
||||
target_scope: Optional[str] = None
|
||||
target_user: Optional[Union[discord.Member, discord.User]] = None
|
||||
@ -261,7 +262,7 @@ class ScopeParser(commands.Converter):
|
||||
elif any(x in argument for x in ["--author", "--user", "--member"]):
|
||||
raise commands.ArgParserFailure("--scope", "Nothing", custom_help=_USER_HELP)
|
||||
|
||||
target_scope: str = target_scope or PlaylistScope.GUILD.value
|
||||
target_scope: str = target_scope or None
|
||||
target_user: Union[discord.Member, discord.User] = target_user or ctx.author
|
||||
target_guild: discord.Guild = target_guild or ctx.guild
|
||||
|
||||
|
||||
@ -43,6 +43,7 @@ __all__ = [
|
||||
"CacheLevel",
|
||||
"format_playlist_picker_data",
|
||||
"get_track_description_unformatted",
|
||||
"track_remaining_duration",
|
||||
"Notifier",
|
||||
"PlaylistScope",
|
||||
]
|
||||
@ -126,6 +127,20 @@ async def queue_duration(ctx) -> int:
|
||||
return queue_total_duration
|
||||
|
||||
|
||||
async def track_remaining_duration(ctx) -> int:
|
||||
player = lavalink.get_player(ctx.guild.id)
|
||||
if not player.current:
|
||||
return 0
|
||||
try:
|
||||
if not player.current.is_stream:
|
||||
remain = player.current.length - player.position
|
||||
else:
|
||||
remain = 0
|
||||
except AttributeError:
|
||||
remain = 0
|
||||
return remain
|
||||
|
||||
|
||||
async def draw_time(ctx) -> str:
|
||||
player = lavalink.get_player(ctx.guild.id)
|
||||
paused = player.paused
|
||||
@ -213,7 +228,7 @@ async def clear_react(bot: Red, message: discord.Message, emoji: MutableMapping
|
||||
def get_track_description(track) -> Optional[str]:
|
||||
if track and getattr(track, "uri", None):
|
||||
query = Query.process_input(track.uri)
|
||||
if query.is_local:
|
||||
if query.is_local or "localtracks/" in track.uri:
|
||||
if track.title != "Unknown title":
|
||||
return f'**{escape(f"{track.author} - {track.title}")}**' + escape(
|
||||
f"\n{query.to_string_user()} "
|
||||
@ -229,7 +244,7 @@ def get_track_description(track) -> Optional[str]:
|
||||
def get_track_description_unformatted(track) -> Optional[str]:
|
||||
if track and hasattr(track, "uri"):
|
||||
query = Query.process_input(track.uri)
|
||||
if query.is_local:
|
||||
if query.is_local or "localtracks/" in track.uri:
|
||||
if track.title != "Unknown title":
|
||||
return escape(f"{track.author} - {track.title}")
|
||||
else:
|
||||
@ -521,8 +536,8 @@ class PlaylistScope(Enum):
|
||||
def humanize_scope(scope, ctx=None, the=None):
|
||||
|
||||
if scope == PlaylistScope.GLOBAL.value:
|
||||
return _("the ") if the else "" + _("Global")
|
||||
return (_("the ") if the else "") + _("Global")
|
||||
elif scope == PlaylistScope.GUILD.value:
|
||||
return ctx.name if ctx else _("the ") if the else "" + _("Server")
|
||||
return ctx.name if ctx else (_("the ") if the else "") + _("Server")
|
||||
elif scope == PlaylistScope.USER.value:
|
||||
return str(ctx) if ctx else _("the ") if the else "" + _("User")
|
||||
return str(ctx) if ctx else (_("the ") if the else "") + _("User")
|
||||
|
||||
@ -227,6 +227,9 @@ class CustomCommands(commands.Cog):
|
||||
await ctx.send(_("There already exists a bot command with the same name."))
|
||||
return
|
||||
responses = await self.commandobj.get_responses(ctx=ctx)
|
||||
if not responses:
|
||||
await ctx.send(_("Custom command process cancelled."))
|
||||
return
|
||||
try:
|
||||
await self.commandobj.create(ctx=ctx, command=command, response=responses)
|
||||
await ctx.send(_("Custom command successfully added."))
|
||||
|
||||
@ -3,5 +3,5 @@ from .downloader import Downloader
|
||||
|
||||
async def setup(bot):
|
||||
cog = Downloader(bot)
|
||||
await cog.initialize()
|
||||
bot.add_cog(cog)
|
||||
cog.create_init_task()
|
||||
|
||||
@ -15,6 +15,8 @@ class InstalledCog(InstalledModule):
|
||||
|
||||
cog = discord.utils.get(await downloader.installed_cogs(), name=arg)
|
||||
if cog is None:
|
||||
raise commands.BadArgument(_("That cog is not installed"))
|
||||
raise commands.BadArgument(
|
||||
_("Cog `{cog_name}` is not installed.").format(cog_name=arg)
|
||||
)
|
||||
|
||||
return cog
|
||||
|
||||
@ -29,7 +29,7 @@ _ = Translator("Downloader", __file__)
|
||||
|
||||
DEPRECATION_NOTICE = _(
|
||||
"\n**WARNING:** The following repos are using shared libraries"
|
||||
" which are marked for removal in Red 3.3: {repo_list}.\n"
|
||||
" which are marked for removal in Red 3.4: {repo_list}.\n"
|
||||
" You should inform maintainers of these repos about this message."
|
||||
)
|
||||
|
||||
@ -53,6 +53,9 @@ class Downloader(commands.Cog):
|
||||
self._create_lib_folder()
|
||||
|
||||
self._repo_manager = RepoManager()
|
||||
self._ready = asyncio.Event()
|
||||
self._init_task = None
|
||||
self._ready_raised = False
|
||||
|
||||
def _create_lib_folder(self, *, remove_first: bool = False) -> None:
|
||||
if remove_first:
|
||||
@ -62,9 +65,38 @@ class Downloader(commands.Cog):
|
||||
with self.SHAREDLIB_INIT.open(mode="w", encoding="utf-8") as _:
|
||||
pass
|
||||
|
||||
async def cog_before_invoke(self, ctx: commands.Context) -> None:
|
||||
async with ctx.typing():
|
||||
await self._ready.wait()
|
||||
if self._ready_raised:
|
||||
await ctx.send(
|
||||
"There was an error during Downloader's initialization."
|
||||
" Check logs for more information."
|
||||
)
|
||||
raise commands.CheckFailure()
|
||||
|
||||
def cog_unload(self):
|
||||
if self._init_task is not None:
|
||||
self._init_task.cancel()
|
||||
|
||||
def create_init_task(self):
|
||||
def _done_callback(task: asyncio.Task) -> None:
|
||||
exc = task.exception()
|
||||
if exc is not None:
|
||||
log.error(
|
||||
"An unexpected error occurred during Downloader's initialization.",
|
||||
exc_info=exc,
|
||||
)
|
||||
self._ready_raised = True
|
||||
self._ready.set()
|
||||
|
||||
self._init_task = asyncio.create_task(self.initialize())
|
||||
self._init_task.add_done_callback(_done_callback)
|
||||
|
||||
async def initialize(self) -> None:
|
||||
await self._repo_manager.initialize()
|
||||
await self._maybe_update_config()
|
||||
self._ready.set()
|
||||
|
||||
async def _maybe_update_config(self) -> None:
|
||||
schema_version = await self.conf.schema_version()
|
||||
@ -205,7 +237,7 @@ class Downloader(commands.Cog):
|
||||
await self.conf.installed_libraries.set(installed_libraries)
|
||||
|
||||
async def _shared_lib_load_check(self, cog_name: str) -> Optional[Repo]:
|
||||
# remove in Red 3.3
|
||||
# remove in Red 3.4
|
||||
is_installed, cog = await self.is_installed(cog_name)
|
||||
# it's not gonna be None when `is_installed` is True
|
||||
# if we'll use typing_extensions in future, `Literal` can solve this
|
||||
@ -418,6 +450,11 @@ class Downloader(commands.Cog):
|
||||
elif target.is_file():
|
||||
os.remove(str(target))
|
||||
|
||||
@staticmethod
|
||||
async def send_pagified(target: discord.abc.Messageable, content: str) -> None:
|
||||
for page in pagify(content):
|
||||
await target.send(page)
|
||||
|
||||
@commands.command()
|
||||
@checks.is_owner()
|
||||
async def pipinstall(self, ctx: commands.Context, *deps: str) -> None:
|
||||
@ -425,7 +462,7 @@ class Downloader(commands.Cog):
|
||||
if not deps:
|
||||
await ctx.send_help()
|
||||
return
|
||||
repo = Repo("", "", "", "", Path.cwd(), loop=ctx.bot.loop)
|
||||
repo = Repo("", "", "", "", Path.cwd())
|
||||
async with ctx.typing():
|
||||
success = await repo.install_raw_requirements(deps, self.LIB_PATH)
|
||||
|
||||
@ -550,7 +587,7 @@ class Downloader(commands.Cog):
|
||||
if failed:
|
||||
message += "\n" + self.format_failed_repos(failed)
|
||||
|
||||
await ctx.send(message)
|
||||
await self.send_pagified(ctx, message)
|
||||
|
||||
@commands.group()
|
||||
@checks.is_owner()
|
||||
@ -596,12 +633,13 @@ class Downloader(commands.Cog):
|
||||
tuple(map(inline, libnames))
|
||||
)
|
||||
if message:
|
||||
await ctx.send(
|
||||
await self.send_pagified(
|
||||
ctx,
|
||||
_(
|
||||
"Cog requirements and shared libraries for all installed cogs"
|
||||
" have been reinstalled but there were some errors:\n"
|
||||
)
|
||||
+ message
|
||||
+ message,
|
||||
)
|
||||
else:
|
||||
await ctx.send(
|
||||
@ -643,8 +681,7 @@ class Downloader(commands.Cog):
|
||||
f"**{candidate.object_type} {candidate.rev}**"
|
||||
f" - {candidate.description}\n"
|
||||
)
|
||||
for page in pagify(msg):
|
||||
await ctx.send(msg)
|
||||
await self.send_pagified(ctx, msg)
|
||||
return
|
||||
except errors.UnknownRevision:
|
||||
await ctx.send(
|
||||
@ -658,14 +695,14 @@ class Downloader(commands.Cog):
|
||||
async with repo.checkout(commit, exit_to_rev=repo.branch):
|
||||
cogs, message = await self._filter_incorrect_cogs_by_names(repo, cog_names)
|
||||
if not cogs:
|
||||
await ctx.send(message)
|
||||
await self.send_pagified(ctx, message)
|
||||
return
|
||||
failed_reqs = await self._install_requirements(cogs)
|
||||
if failed_reqs:
|
||||
message += _("\nFailed to install requirements: ") + humanize_list(
|
||||
tuple(map(inline, failed_reqs))
|
||||
)
|
||||
await ctx.send(message)
|
||||
await self.send_pagified(ctx, message)
|
||||
return
|
||||
|
||||
installed_cogs, failed_cogs = await self._install_cogs(cogs)
|
||||
@ -711,7 +748,7 @@ class Downloader(commands.Cog):
|
||||
+ message
|
||||
)
|
||||
# "---" added to separate cog install messages from Downloader's message
|
||||
await ctx.send(f"{message}{deprecation_notice}\n---")
|
||||
await self.send_pagified(ctx, f"{message}{deprecation_notice}\n---")
|
||||
for cog in installed_cogs:
|
||||
if cog.install_msg:
|
||||
await ctx.send(cog.install_msg.replace("[p]", ctx.prefix))
|
||||
@ -748,14 +785,18 @@ class Downloader(commands.Cog):
|
||||
message += _("Successfully uninstalled cogs: ") + humanize_list(uninstalled_cogs)
|
||||
if failed_cogs:
|
||||
message += (
|
||||
_("\nThese cog were installed but can no longer be located: ")
|
||||
_(
|
||||
"\nDownloader has removed these cogs from the installed cogs list"
|
||||
" but it wasn't able to find their files: "
|
||||
)
|
||||
+ humanize_list(tuple(map(inline, failed_cogs)))
|
||||
+ _(
|
||||
"\nYou may need to remove their files manually if they are still usable."
|
||||
" Also make sure you've unloaded those cogs with `{prefix}unload {cogs}`."
|
||||
"\nThey were most likely removed without using `{prefix}cog uninstall`.\n"
|
||||
"You may need to remove those files manually if the cogs are still usable."
|
||||
" If so, ensure the cogs have been unloaded with `{prefix}unload {cogs}`."
|
||||
).format(prefix=ctx.prefix, cogs=" ".join(failed_cogs))
|
||||
)
|
||||
await ctx.send(message)
|
||||
await self.send_pagified(ctx, message)
|
||||
|
||||
@cog.command(name="pin", usage="<cogs>")
|
||||
async def _cog_pin(self, ctx: commands.Context, *cogs: InstalledCog) -> None:
|
||||
@ -778,7 +819,7 @@ class Downloader(commands.Cog):
|
||||
message += _("Pinned cogs: ") + humanize_list(cognames)
|
||||
if already_pinned:
|
||||
message += _("\nThese cogs were already pinned: ") + humanize_list(already_pinned)
|
||||
await ctx.send(message)
|
||||
await self.send_pagified(ctx, message)
|
||||
|
||||
@cog.command(name="unpin", usage="<cogs>")
|
||||
async def _cog_unpin(self, ctx: commands.Context, *cogs: InstalledCog) -> None:
|
||||
@ -801,7 +842,7 @@ class Downloader(commands.Cog):
|
||||
message += _("Unpinned cogs: ") + humanize_list(cognames)
|
||||
if not_pinned:
|
||||
message += _("\nThese cogs weren't pinned: ") + humanize_list(not_pinned)
|
||||
await ctx.send(message)
|
||||
await self.send_pagified(ctx, message)
|
||||
|
||||
@cog.command(name="checkforupdates")
|
||||
async def _cog_checkforupdates(self, ctx: commands.Context) -> None:
|
||||
@ -833,7 +874,7 @@ class Downloader(commands.Cog):
|
||||
if failed:
|
||||
message += "\n" + self.format_failed_repos(failed)
|
||||
|
||||
await ctx.send(message)
|
||||
await self.send_pagified(ctx, message)
|
||||
|
||||
@cog.command(name="update")
|
||||
async def _cog_update(self, ctx: commands.Context, *cogs: InstalledCog) -> None:
|
||||
@ -869,7 +910,6 @@ class Downloader(commands.Cog):
|
||||
rev: Optional[str] = None,
|
||||
cogs: Optional[List[InstalledModule]] = None,
|
||||
) -> None:
|
||||
message = ""
|
||||
failed_repos = set()
|
||||
updates_available = set()
|
||||
|
||||
@ -882,7 +922,7 @@ class Downloader(commands.Cog):
|
||||
await repo.update()
|
||||
except errors.UpdateError:
|
||||
message = self.format_failed_repos([repo.name])
|
||||
await ctx.send(message)
|
||||
await self.send_pagified(ctx, message)
|
||||
return
|
||||
|
||||
try:
|
||||
@ -896,11 +936,10 @@ class Downloader(commands.Cog):
|
||||
f"**{candidate.object_type} {candidate.rev}**"
|
||||
f" - {candidate.description}\n"
|
||||
)
|
||||
for page in pagify(msg):
|
||||
await ctx.send(msg)
|
||||
await self.send_pagified(ctx, msg)
|
||||
return
|
||||
except errors.UnknownRevision:
|
||||
message += _(
|
||||
message = _(
|
||||
"Error: there is no revision `{rev}` in repo `{repo.name}`"
|
||||
).format(rev=rev, repo=repo)
|
||||
await ctx.send(message)
|
||||
@ -917,6 +956,8 @@ class Downloader(commands.Cog):
|
||||
|
||||
pinned_cogs = {cog for cog in cogs_to_check if cog.pinned}
|
||||
cogs_to_check -= pinned_cogs
|
||||
|
||||
message = ""
|
||||
if not cogs_to_check:
|
||||
cogs_to_update = libs_to_update = ()
|
||||
message += _("There were no cogs to check.")
|
||||
@ -972,7 +1013,7 @@ class Downloader(commands.Cog):
|
||||
if repos_with_libs:
|
||||
message += DEPRECATION_NOTICE.format(repo_list=humanize_list(list(repos_with_libs)))
|
||||
|
||||
await ctx.send(message)
|
||||
await self.send_pagified(ctx, message)
|
||||
|
||||
if updates_available and updated_cognames:
|
||||
await self._ask_for_cog_reload(ctx, updated_cognames)
|
||||
|
||||
@ -38,6 +38,10 @@ class GitException(DownloaderException):
|
||||
Generic class for git exceptions.
|
||||
"""
|
||||
|
||||
def __init__(self, message: str, git_command: str) -> None:
|
||||
self.git_command = git_command
|
||||
super().__init__(f"Git command failed: {git_command}\nError message: {message}")
|
||||
|
||||
|
||||
class InvalidRepoName(DownloaderException):
|
||||
"""
|
||||
@ -138,8 +142,8 @@ class AmbiguousRevision(GitException):
|
||||
Thrown when specified revision is ambiguous.
|
||||
"""
|
||||
|
||||
def __init__(self, message: str, candidates: List[Candidate]) -> None:
|
||||
super().__init__(message)
|
||||
def __init__(self, message: str, git_command: str, candidates: List[Candidate]) -> None:
|
||||
super().__init__(message, git_command)
|
||||
self.candidates = candidates
|
||||
|
||||
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import distutils.dir_util
|
||||
import functools
|
||||
import shutil
|
||||
from enum import IntEnum
|
||||
from pathlib import Path
|
||||
@ -127,15 +127,13 @@ class Installable(RepoJSONMixin):
|
||||
if self._location.is_file():
|
||||
copy_func = shutil.copy2
|
||||
else:
|
||||
# clear copy_tree's cache to make sure missing directories are created (GH-2690)
|
||||
distutils.dir_util._path_created = {}
|
||||
copy_func = distutils.dir_util.copy_tree
|
||||
copy_func = functools.partial(shutil.copytree, dirs_exist_ok=True)
|
||||
|
||||
# noinspection PyBroadException
|
||||
try:
|
||||
copy_func(src=str(self._location), dst=str(target_dir / self._location.stem))
|
||||
except: # noqa: E722
|
||||
log.exception("Error occurred when copying path: {}".format(self._location))
|
||||
log.exception("Error occurred when copying path: %s", self._location)
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
@ -135,7 +135,6 @@ class Repo(RepoJSONMixin):
|
||||
commit: str,
|
||||
folder_path: Path,
|
||||
available_modules: Tuple[Installable, ...] = (),
|
||||
loop: Optional[asyncio.AbstractEventLoop] = None,
|
||||
):
|
||||
self.url = url
|
||||
self.branch = branch
|
||||
@ -154,8 +153,6 @@ class Repo(RepoJSONMixin):
|
||||
|
||||
self._repo_lock = asyncio.Lock()
|
||||
|
||||
self._loop = loop if loop is not None else asyncio.get_event_loop()
|
||||
|
||||
@property
|
||||
def clean_url(self) -> str:
|
||||
"""Sanitized repo URL (with removed HTTP Basic Auth)"""
|
||||
@ -203,21 +200,20 @@ class Repo(RepoJSONMixin):
|
||||
|
||||
"""
|
||||
valid_exit_codes = (0, 1)
|
||||
p = await self._run(
|
||||
ProcessFormatter().format(
|
||||
git_command = ProcessFormatter().format(
|
||||
self.GIT_IS_ANCESTOR,
|
||||
path=self.folder_path,
|
||||
maybe_ancestor_rev=maybe_ancestor_rev,
|
||||
descendant_rev=descendant_rev,
|
||||
),
|
||||
valid_exit_codes=valid_exit_codes,
|
||||
)
|
||||
p = await self._run(git_command, valid_exit_codes=valid_exit_codes)
|
||||
|
||||
if p.returncode in valid_exit_codes:
|
||||
return not bool(p.returncode)
|
||||
raise errors.GitException(
|
||||
f"Git failed to determine if commit {maybe_ancestor_rev}"
|
||||
f" is ancestor of {descendant_rev} for repo at path: {self.folder_path}"
|
||||
f" is ancestor of {descendant_rev} for repo at path: {self.folder_path}",
|
||||
git_command,
|
||||
)
|
||||
|
||||
async def is_on_branch(self) -> bool:
|
||||
@ -253,15 +249,14 @@ class Repo(RepoJSONMixin):
|
||||
"""
|
||||
if new_rev is None:
|
||||
new_rev = self.branch
|
||||
p = await self._run(
|
||||
ProcessFormatter().format(
|
||||
git_command = ProcessFormatter().format(
|
||||
self.GIT_DIFF_FILE_STATUS, path=self.folder_path, old_rev=old_rev, new_rev=new_rev
|
||||
)
|
||||
)
|
||||
p = await self._run(git_command)
|
||||
|
||||
if p.returncode != 0:
|
||||
raise errors.GitDiffError(
|
||||
"Git diff failed for repo at path: {}".format(self.folder_path)
|
||||
f"Git diff failed for repo at path: {self.folder_path}", git_command
|
||||
)
|
||||
|
||||
stdout = p.stdout.strip(b"\t\n\x00 ").decode().split("\x00\t")
|
||||
@ -310,18 +305,17 @@ class Repo(RepoJSONMixin):
|
||||
async with self.checkout(descendant_rev):
|
||||
return discord.utils.get(self.available_modules, name=module_name)
|
||||
|
||||
p = await self._run(
|
||||
ProcessFormatter().format(
|
||||
git_command = ProcessFormatter().format(
|
||||
self.GIT_GET_LAST_MODULE_OCCURRENCE_COMMIT,
|
||||
path=self.folder_path,
|
||||
descendant_rev=descendant_rev,
|
||||
module_name=module_name,
|
||||
)
|
||||
)
|
||||
p = await self._run(git_command)
|
||||
|
||||
if p.returncode != 0:
|
||||
raise errors.GitException(
|
||||
"Git log failed for repo at path: {}".format(self.folder_path)
|
||||
f"Git log failed for repo at path: {self.folder_path}", git_command
|
||||
)
|
||||
|
||||
commit = p.stdout.decode().strip()
|
||||
@ -418,19 +412,18 @@ class Repo(RepoJSONMixin):
|
||||
to get messages for.
|
||||
:return: Git commit note log
|
||||
"""
|
||||
p = await self._run(
|
||||
ProcessFormatter().format(
|
||||
git_command = ProcessFormatter().format(
|
||||
self.GIT_LOG,
|
||||
path=self.folder_path,
|
||||
old_rev=old_rev,
|
||||
relative_file_path=relative_file_path,
|
||||
)
|
||||
)
|
||||
p = await self._run(git_command)
|
||||
|
||||
if p.returncode != 0:
|
||||
raise errors.GitException(
|
||||
"An exception occurred while executing git log on"
|
||||
" this repo: {}".format(self.folder_path)
|
||||
f"An exception occurred while executing git log on this repo: {self.folder_path}",
|
||||
git_command,
|
||||
)
|
||||
|
||||
return p.stdout.decode().strip()
|
||||
@ -457,21 +450,24 @@ class Repo(RepoJSONMixin):
|
||||
Full sha1 object name for provided revision.
|
||||
|
||||
"""
|
||||
p = await self._run(
|
||||
ProcessFormatter().format(self.GIT_GET_FULL_SHA1, path=self.folder_path, rev=rev)
|
||||
git_command = ProcessFormatter().format(
|
||||
self.GIT_GET_FULL_SHA1, path=self.folder_path, rev=rev
|
||||
)
|
||||
p = await self._run(git_command)
|
||||
|
||||
if p.returncode != 0:
|
||||
stderr = p.stderr.decode().strip()
|
||||
ambiguous_error = f"error: short SHA1 {rev} is ambiguous\nhint: The candidates are:\n"
|
||||
if not stderr.startswith(ambiguous_error):
|
||||
raise errors.UnknownRevision(f"Revision {rev} cannot be found.")
|
||||
raise errors.UnknownRevision(f"Revision {rev} cannot be found.", git_command)
|
||||
candidates = []
|
||||
for match in self.AMBIGUOUS_ERROR_REGEX.finditer(stderr, len(ambiguous_error)):
|
||||
candidates.append(Candidate(match["rev"], match["type"], match["desc"]))
|
||||
if candidates:
|
||||
raise errors.AmbiguousRevision(f"Short SHA1 {rev} is ambiguous.", candidates)
|
||||
raise errors.UnknownRevision(f"Revision {rev} cannot be found.")
|
||||
raise errors.AmbiguousRevision(
|
||||
f"Short SHA1 {rev} is ambiguous.", git_command, candidates
|
||||
)
|
||||
raise errors.UnknownRevision(f"Revision {rev} cannot be found.", git_command)
|
||||
|
||||
return p.stdout.decode().strip()
|
||||
|
||||
@ -530,7 +526,7 @@ class Repo(RepoJSONMixin):
|
||||
env["LANGUAGE"] = "C"
|
||||
kwargs["env"] = env
|
||||
async with self._repo_lock:
|
||||
p: CompletedProcess = await self._loop.run_in_executor(
|
||||
p: CompletedProcess = await asyncio.get_running_loop().run_in_executor(
|
||||
self._executor,
|
||||
functools.partial(sp_run, *args, stdout=PIPE, stderr=PIPE, **kwargs),
|
||||
)
|
||||
@ -554,17 +550,14 @@ class Repo(RepoJSONMixin):
|
||||
return
|
||||
exists, __ = self._existing_git_repo()
|
||||
if not exists:
|
||||
raise errors.MissingGitRepo(
|
||||
"A git repo does not exist at path: {}".format(self.folder_path)
|
||||
)
|
||||
raise errors.MissingGitRepo(f"A git repo does not exist at path: {self.folder_path}")
|
||||
|
||||
p = await self._run(
|
||||
ProcessFormatter().format(self.GIT_CHECKOUT, path=self.folder_path, rev=rev)
|
||||
)
|
||||
git_command = ProcessFormatter().format(self.GIT_CHECKOUT, path=self.folder_path, rev=rev)
|
||||
p = await self._run(git_command)
|
||||
|
||||
if p.returncode != 0:
|
||||
raise errors.UnknownRevision(
|
||||
"Could not checkout to {}. This revision may not exist".format(rev)
|
||||
f"Could not checkout to {rev}. This revision may not exist", git_command
|
||||
)
|
||||
|
||||
await self._setup_repo()
|
||||
@ -619,25 +612,22 @@ class Repo(RepoJSONMixin):
|
||||
"""
|
||||
exists, path = self._existing_git_repo()
|
||||
if exists:
|
||||
raise errors.ExistingGitRepo("A git repo already exists at path: {}".format(path))
|
||||
raise errors.ExistingGitRepo(f"A git repo already exists at path: {path}")
|
||||
|
||||
if self.branch is not None:
|
||||
p = await self._run(
|
||||
ProcessFormatter().format(
|
||||
git_command = ProcessFormatter().format(
|
||||
self.GIT_CLONE, branch=self.branch, url=self.url, folder=self.folder_path
|
||||
)
|
||||
)
|
||||
else:
|
||||
p = await self._run(
|
||||
ProcessFormatter().format(
|
||||
git_command = ProcessFormatter().format(
|
||||
self.GIT_CLONE_NO_BRANCH, url=self.url, folder=self.folder_path
|
||||
)
|
||||
)
|
||||
p = await self._run(git_command)
|
||||
|
||||
if p.returncode:
|
||||
# Try cleaning up folder
|
||||
shutil.rmtree(str(self.folder_path), ignore_errors=True)
|
||||
raise errors.CloningError("Error when running git clone.")
|
||||
raise errors.CloningError("Error when running git clone.", git_command)
|
||||
|
||||
if self.branch is None:
|
||||
self.branch = await self.current_branch()
|
||||
@ -657,17 +647,14 @@ class Repo(RepoJSONMixin):
|
||||
"""
|
||||
exists, __ = self._existing_git_repo()
|
||||
if not exists:
|
||||
raise errors.MissingGitRepo(
|
||||
"A git repo does not exist at path: {}".format(self.folder_path)
|
||||
)
|
||||
raise errors.MissingGitRepo(f"A git repo does not exist at path: {self.folder_path}")
|
||||
|
||||
p = await self._run(
|
||||
ProcessFormatter().format(self.GIT_CURRENT_BRANCH, path=self.folder_path)
|
||||
)
|
||||
git_command = ProcessFormatter().format(self.GIT_CURRENT_BRANCH, path=self.folder_path)
|
||||
p = await self._run(git_command)
|
||||
|
||||
if p.returncode != 0:
|
||||
raise errors.GitException(
|
||||
"Could not determine current branch at path: {}".format(self.folder_path)
|
||||
f"Could not determine current branch at path: {self.folder_path}", git_command
|
||||
)
|
||||
|
||||
return p.stdout.decode().strip()
|
||||
@ -683,16 +670,13 @@ class Repo(RepoJSONMixin):
|
||||
"""
|
||||
exists, __ = self._existing_git_repo()
|
||||
if not exists:
|
||||
raise errors.MissingGitRepo(
|
||||
"A git repo does not exist at path: {}".format(self.folder_path)
|
||||
)
|
||||
raise errors.MissingGitRepo(f"A git repo does not exist at path: {self.folder_path}")
|
||||
|
||||
p = await self._run(
|
||||
ProcessFormatter().format(self.GIT_CURRENT_COMMIT, path=self.folder_path)
|
||||
)
|
||||
git_command = ProcessFormatter().format(self.GIT_CURRENT_COMMIT, path=self.folder_path)
|
||||
p = await self._run(git_command)
|
||||
|
||||
if p.returncode != 0:
|
||||
raise errors.CurrentHashError("Unable to determine commit hash.")
|
||||
raise errors.CurrentHashError("Unable to determine commit hash.", git_command)
|
||||
|
||||
return p.stdout.decode().strip()
|
||||
|
||||
@ -715,16 +699,15 @@ class Repo(RepoJSONMixin):
|
||||
|
||||
exists, __ = self._existing_git_repo()
|
||||
if not exists:
|
||||
raise errors.MissingGitRepo(
|
||||
"A git repo does not exist at path: {}".format(self.folder_path)
|
||||
)
|
||||
raise errors.MissingGitRepo(f"A git repo does not exist at path: {self.folder_path}")
|
||||
|
||||
p = await self._run(
|
||||
ProcessFormatter().format(self.GIT_LATEST_COMMIT, path=self.folder_path, branch=branch)
|
||||
git_command = ProcessFormatter().format(
|
||||
self.GIT_LATEST_COMMIT, path=self.folder_path, branch=branch
|
||||
)
|
||||
p = await self._run(git_command)
|
||||
|
||||
if p.returncode != 0:
|
||||
raise errors.CurrentHashError("Unable to determine latest commit hash.")
|
||||
raise errors.CurrentHashError("Unable to determine latest commit hash.", git_command)
|
||||
|
||||
return p.stdout.decode().strip()
|
||||
|
||||
@ -751,10 +734,11 @@ class Repo(RepoJSONMixin):
|
||||
if folder is None:
|
||||
folder = self.folder_path
|
||||
|
||||
p = await self._run(ProcessFormatter().format(Repo.GIT_DISCOVER_REMOTE_URL, path=folder))
|
||||
git_command = ProcessFormatter().format(Repo.GIT_DISCOVER_REMOTE_URL, path=folder)
|
||||
p = await self._run(git_command)
|
||||
|
||||
if p.returncode != 0:
|
||||
raise errors.NoRemoteURL("Unable to discover a repo URL.")
|
||||
raise errors.NoRemoteURL("Unable to discover a repo URL.", git_command)
|
||||
|
||||
return p.stdout.decode().strip()
|
||||
|
||||
@ -773,19 +757,18 @@ class Repo(RepoJSONMixin):
|
||||
await self.checkout(branch)
|
||||
exists, __ = self._existing_git_repo()
|
||||
if not exists:
|
||||
raise errors.MissingGitRepo(
|
||||
"A git repo does not exist at path: {}".format(self.folder_path)
|
||||
)
|
||||
raise errors.MissingGitRepo(f"A git repo does not exist at path: {self.folder_path}")
|
||||
|
||||
p = await self._run(
|
||||
ProcessFormatter().format(self.GIT_HARD_RESET, path=self.folder_path, branch=branch)
|
||||
git_command = ProcessFormatter().format(
|
||||
self.GIT_HARD_RESET, path=self.folder_path, branch=branch
|
||||
)
|
||||
p = await self._run(git_command)
|
||||
|
||||
if p.returncode != 0:
|
||||
raise errors.HardResetError(
|
||||
"Some error occurred when trying to"
|
||||
" execute a hard reset on the repo at"
|
||||
" the following path: {}".format(self.folder_path)
|
||||
"Some error occurred when trying to execute a hard reset on the repo at"
|
||||
f" the following path: {self.folder_path}",
|
||||
git_command,
|
||||
)
|
||||
|
||||
async def update(self) -> Tuple[str, str]:
|
||||
@ -804,12 +787,14 @@ class Repo(RepoJSONMixin):
|
||||
|
||||
await self.hard_reset()
|
||||
|
||||
p = await self._run(ProcessFormatter().format(self.GIT_PULL, path=self.folder_path))
|
||||
git_command = ProcessFormatter().format(self.GIT_PULL, path=self.folder_path)
|
||||
p = await self._run(git_command)
|
||||
|
||||
if p.returncode != 0:
|
||||
raise errors.UpdateError(
|
||||
"Git pull returned a non zero exit code"
|
||||
" for the repo located at path: {}".format(self.folder_path)
|
||||
f" for the repo located at path: {self.folder_path}",
|
||||
git_command,
|
||||
)
|
||||
|
||||
await self._setup_repo()
|
||||
@ -1114,7 +1099,7 @@ class RepoManager:
|
||||
"""
|
||||
repo = self.get_repo(name)
|
||||
if repo is None:
|
||||
raise errors.MissingGitRepo("There is no repo with the name {}".format(name))
|
||||
raise errors.MissingGitRepo(f"There is no repo with the name {name}")
|
||||
|
||||
safe_delete(repo.folder_path)
|
||||
|
||||
|
||||
@ -2,6 +2,7 @@ import datetime
|
||||
import time
|
||||
from enum import Enum
|
||||
from random import randint, choice
|
||||
from typing import Final
|
||||
import aiohttp
|
||||
import discord
|
||||
from redbot.core import commands
|
||||
@ -31,6 +32,9 @@ class RPSParser:
|
||||
self.choice = None
|
||||
|
||||
|
||||
MAX_ROLL: Final[int] = 2 ** 64 - 1
|
||||
|
||||
|
||||
@cog_i18n(_)
|
||||
class General(commands.Cog):
|
||||
"""General commands."""
|
||||
@ -87,15 +91,21 @@ class General(commands.Cog):
|
||||
`<number>` defaults to 100.
|
||||
"""
|
||||
author = ctx.author
|
||||
if number > 1:
|
||||
if 1 < number <= MAX_ROLL:
|
||||
n = randint(1, number)
|
||||
await ctx.send(
|
||||
"{author.mention} :game_die: {n} :game_die:".format(
|
||||
author=author, n=humanize_number(n)
|
||||
)
|
||||
)
|
||||
else:
|
||||
elif number <= 1:
|
||||
await ctx.send(_("{author.mention} Maybe higher than 1? ;P").format(author=author))
|
||||
else:
|
||||
await ctx.send(
|
||||
_("{author.mention} Max allowed number is {maxamount}.").format(
|
||||
author=author, maxamount=humanize_number(MAX_ROLL)
|
||||
)
|
||||
)
|
||||
|
||||
@commands.command()
|
||||
async def flip(self, ctx, user: discord.Member = None):
|
||||
|
||||
@ -101,7 +101,7 @@ class Events(MixinMeta):
|
||||
while None in name_list: # clean out null entries from a bug
|
||||
name_list.remove(None)
|
||||
if after.name in name_list:
|
||||
# Ensure order is maintained without duplicates occuring
|
||||
# Ensure order is maintained without duplicates occurring
|
||||
name_list.remove(after.name)
|
||||
name_list.append(after.name)
|
||||
while len(name_list) > 20:
|
||||
|
||||
@ -7,7 +7,7 @@ from typing import cast, Optional, Union
|
||||
|
||||
import discord
|
||||
from redbot.core import commands, i18n, checks, modlog
|
||||
from redbot.core.utils.chat_formatting import pagify, humanize_number, format_perms_list
|
||||
from redbot.core.utils.chat_formatting import pagify, humanize_number, bold, format_perms_list
|
||||
from redbot.core.utils.mod import is_allowed_by_hierarchy, get_audit_reason
|
||||
from .abc import MixinMeta
|
||||
from .converters import RawUserIds
|
||||
@ -124,6 +124,19 @@ class KickBanMixin(MixinMeta):
|
||||
elif not (0 <= days <= 7):
|
||||
return _("Invalid days. Must be between 0 and 7.")
|
||||
|
||||
toggle = await self.settings.guild(guild).dm_on_kickban()
|
||||
if toggle:
|
||||
with contextlib.suppress(discord.HTTPException):
|
||||
em = discord.Embed(
|
||||
title=bold(_("You have been banned from {guild}.").format(guild=guild))
|
||||
)
|
||||
em.add_field(
|
||||
name=_("**Reason**"),
|
||||
value=reason if reason is not None else _("No reason was given."),
|
||||
inline=False,
|
||||
)
|
||||
await user.send(embed=em)
|
||||
|
||||
audit_reason = get_audit_reason(author, reason)
|
||||
|
||||
queue_entry = (guild.id, user.id)
|
||||
@ -137,7 +150,7 @@ class KickBanMixin(MixinMeta):
|
||||
except discord.Forbidden:
|
||||
return _("I'm not allowed to do that.")
|
||||
except Exception as e:
|
||||
return e # TODO: impproper return type? Is this intended to be re-raised?
|
||||
return e # TODO: improper return type? Is this intended to be re-raised?
|
||||
|
||||
if create_modlog_case:
|
||||
try:
|
||||
@ -228,6 +241,18 @@ class KickBanMixin(MixinMeta):
|
||||
await ctx.send(_("I cannot do that due to discord hierarchy rules"))
|
||||
return
|
||||
audit_reason = get_audit_reason(author, reason)
|
||||
toggle = await self.settings.guild(guild).dm_on_kickban()
|
||||
if toggle:
|
||||
with contextlib.suppress(discord.HTTPException):
|
||||
em = discord.Embed(
|
||||
title=bold(_("You have been kicked from {guild}.").format(guild=guild))
|
||||
)
|
||||
em.add_field(
|
||||
name=_("**Reason**"),
|
||||
value=reason if reason is not None else _("No reason was given."),
|
||||
inline=False,
|
||||
)
|
||||
await user.send(embed=em)
|
||||
try:
|
||||
await guild.kick(user, reason=audit_reason)
|
||||
log.info("{}({}) kicked {}({})".format(author.name, author.id, user.name, user.id))
|
||||
@ -260,14 +285,19 @@ class KickBanMixin(MixinMeta):
|
||||
self,
|
||||
ctx: commands.Context,
|
||||
user: discord.Member,
|
||||
days: Optional[int] = 0,
|
||||
days: Optional[int] = None,
|
||||
*,
|
||||
reason: str = None,
|
||||
):
|
||||
"""Ban a user from this server and optionally delete days of messages.
|
||||
|
||||
If days is not a number, it's treated as the first word of the reason.
|
||||
Minimum 0 days, maximum 7. Defaults to 0."""
|
||||
|
||||
Minimum 0 days, maximum 7. If not specified, defaultdays setting will be used instead."""
|
||||
author = ctx.author
|
||||
guild = ctx.guild
|
||||
if days is None:
|
||||
days = await self.settings.guild(guild).default_days()
|
||||
|
||||
result = await self.ban_user(
|
||||
user=user, ctx=ctx, days=days, reason=reason, create_modlog_case=True
|
||||
@ -286,7 +316,7 @@ class KickBanMixin(MixinMeta):
|
||||
self,
|
||||
ctx: commands.Context,
|
||||
user_ids: commands.Greedy[RawUserIds],
|
||||
days: Optional[int] = 0,
|
||||
days: Optional[int] = None,
|
||||
*,
|
||||
reason: str = None,
|
||||
):
|
||||
@ -294,7 +324,6 @@ class KickBanMixin(MixinMeta):
|
||||
|
||||
User IDs need to be provided in order to ban
|
||||
using this command"""
|
||||
days = cast(int, days)
|
||||
banned = []
|
||||
errors = {}
|
||||
|
||||
@ -321,6 +350,9 @@ class KickBanMixin(MixinMeta):
|
||||
await ctx.send_help()
|
||||
return
|
||||
|
||||
if days is None:
|
||||
days = await self.settings.guild(guild).default_days()
|
||||
|
||||
if not (0 <= days <= 7):
|
||||
await ctx.send(_("Invalid days. Must be between 0 and 7."))
|
||||
return
|
||||
|
||||
@ -51,6 +51,8 @@ class Mod(
|
||||
"delete_delay": -1,
|
||||
"reinvite_on_unban": False,
|
||||
"current_tempbans": [],
|
||||
"dm_on_kickban": False,
|
||||
"default_days": 0,
|
||||
}
|
||||
|
||||
default_channel_settings = {"ignored": False}
|
||||
|
||||
@ -21,11 +21,14 @@ class ModSettings(MixinMeta):
|
||||
if ctx.invoked_subcommand is None:
|
||||
guild = ctx.guild
|
||||
# Display current settings
|
||||
delete_repeats = await self.settings.guild(guild).delete_repeats()
|
||||
ban_mention_spam = await self.settings.guild(guild).ban_mention_spam()
|
||||
respect_hierarchy = await self.settings.guild(guild).respect_hierarchy()
|
||||
delete_delay = await self.settings.guild(guild).delete_delay()
|
||||
reinvite_on_unban = await self.settings.guild(guild).reinvite_on_unban()
|
||||
data = await self.settings.guild(guild).all()
|
||||
delete_repeats = data["delete_repeats"]
|
||||
ban_mention_spam = data["ban_mention_spam"]
|
||||
respect_hierarchy = data["respect_hierarchy"]
|
||||
delete_delay = data["delete_delay"]
|
||||
reinvite_on_unban = data["reinvite_on_unban"]
|
||||
dm_on_kickban = data["dm_on_kickban"]
|
||||
default_days = data["default_days"]
|
||||
msg = ""
|
||||
msg += _("Delete repeats: {num_repeats}\n").format(
|
||||
num_repeats=_("after {num} repeats").format(num=delete_repeats)
|
||||
@ -48,6 +51,15 @@ class ModSettings(MixinMeta):
|
||||
msg += _("Reinvite on unban: {yes_or_no}\n").format(
|
||||
yes_or_no=_("Yes") if reinvite_on_unban else _("No")
|
||||
)
|
||||
msg += _("Send message to users on kick/ban: {yes_or_no}\n").format(
|
||||
yes_or_no=_("Yes") if dm_on_kickban else _("No")
|
||||
)
|
||||
if default_days:
|
||||
msg += _(
|
||||
"Default message history delete on ban: Previous {num_days} days\n"
|
||||
).format(num_days=default_days)
|
||||
else:
|
||||
msg += _("Default message history delete on ban: Don't delete any\n")
|
||||
await ctx.send(box(msg))
|
||||
|
||||
@modset.command()
|
||||
@ -199,3 +211,43 @@ class ModSettings(MixinMeta):
|
||||
command=f"{ctx.prefix}unban"
|
||||
)
|
||||
)
|
||||
|
||||
@modset.command()
|
||||
@commands.guild_only()
|
||||
async def dm(self, ctx: commands.Context, enabled: bool = None):
|
||||
"""Toggle whether a message should be sent to a user when they are kicked/banned.
|
||||
|
||||
If this option is enabled, the bot will attempt to DM the user with the guild name
|
||||
and reason as to why they were kicked/banned.
|
||||
"""
|
||||
guild = ctx.guild
|
||||
if enabled is None:
|
||||
setting = await self.settings.guild(guild).dm_on_kickban()
|
||||
await ctx.send(
|
||||
_("DM when kicked/banned is currently set to: {setting}").format(setting=setting)
|
||||
)
|
||||
return
|
||||
await self.settings.guild(guild).dm_on_kickban.set(enabled)
|
||||
if enabled:
|
||||
await ctx.send(_("Bot will now attempt to send a DM to user before kick and ban."))
|
||||
else:
|
||||
await ctx.send(
|
||||
_("Bot will no longer attempt to send a DM to user before kick and ban.")
|
||||
)
|
||||
|
||||
@modset.command()
|
||||
@commands.guild_only()
|
||||
async def defaultdays(self, ctx: commands.Context, days: int = 0):
|
||||
"""Set the default number of days worth of messages to be deleted when a user is banned.
|
||||
|
||||
The number of days must be between 0 and 7.
|
||||
"""
|
||||
guild = ctx.guild
|
||||
if not (0 <= days <= 7):
|
||||
return await ctx.send(_("Invalid number of days. Must be between 0 and 7."))
|
||||
await self.settings.guild(guild).default_days.set(days)
|
||||
await ctx.send(
|
||||
_("{days} days worth of messages will be deleted when a user is banned.").format(
|
||||
days=days
|
||||
)
|
||||
)
|
||||
|
||||
@ -26,6 +26,13 @@ class ModLog(commands.Cog):
|
||||
"""Manage modlog settings."""
|
||||
pass
|
||||
|
||||
@checks.is_owner()
|
||||
@modlogset.command(hidden=True, name="fixcasetypes")
|
||||
async def reapply_audittype_migration(self, ctx: commands.Context):
|
||||
"""Command to fix misbehaving casetypes."""
|
||||
await modlog.handle_auditype_key()
|
||||
await ctx.tick()
|
||||
|
||||
@modlogset.command()
|
||||
@commands.guild_only()
|
||||
async def modlog(self, ctx: commands.Context, channel: discord.TextChannel = None):
|
||||
|
||||
@ -61,7 +61,7 @@ At which Arena can you unlock X-Bow?:
|
||||
- 6
|
||||
- Builder's Workshop
|
||||
At which Arena do you get a chance for Legendary cards to appear in the shop?:
|
||||
- Hog Mountian
|
||||
- Hog Mountain
|
||||
- A10
|
||||
- 10
|
||||
- Arena 10
|
||||
|
||||
@ -375,7 +375,7 @@ Porky Pig had a girlfriend named ________?:
|
||||
Randy Travis said his love was 'deeper than the ______'?:
|
||||
- Holler
|
||||
Richard Strauss' majestic overture "Also Sprach Zarathustra" was the theme music for which Stanley Kubrick film?:
|
||||
- "2001: A Space Odyessy"
|
||||
- "2001: A Space Odyssey"
|
||||
Rolling Stones first hit was written by what group?:
|
||||
- The Beatles
|
||||
Russian modernist Igor _________?:
|
||||
|
||||
@ -12,7 +12,6 @@ from redbot.cogs.warnings.helpers import (
|
||||
from redbot.core import Config, checks, commands, modlog
|
||||
from redbot.core.bot import Red
|
||||
from redbot.core.i18n import Translator, cog_i18n
|
||||
from redbot.core.utils.mod import is_admin_or_superior
|
||||
from redbot.core.utils.chat_formatting import warning, pagify
|
||||
from redbot.core.utils.menus import menu, DEFAULT_CONTROLS
|
||||
|
||||
@ -342,23 +341,9 @@ class Warnings(commands.Cog):
|
||||
|
||||
@commands.command()
|
||||
@commands.guild_only()
|
||||
async def warnings(
|
||||
self, ctx: commands.Context, user: Optional[Union[discord.Member, int]] = None
|
||||
):
|
||||
"""List the warnings for the specified user.
|
||||
|
||||
Omit `<user>` to see your own warnings.
|
||||
|
||||
Note that showing warnings for users other than yourself requires
|
||||
appropriate permissions.
|
||||
"""
|
||||
if user is None:
|
||||
user = ctx.author
|
||||
else:
|
||||
if not await is_admin_or_superior(self.bot, ctx.author):
|
||||
return await ctx.send(
|
||||
warning(_("You are not allowed to check warnings for other users!"))
|
||||
)
|
||||
@checks.admin()
|
||||
async def warnings(self, ctx: commands.Context, user: Union[discord.Member, int]):
|
||||
"""List the warnings for the specified user."""
|
||||
|
||||
try:
|
||||
userid: int = user.id
|
||||
@ -389,6 +374,35 @@ class Warnings(commands.Cog):
|
||||
pagify(msg, shorten_by=58), box_lang=_("Warnings for {user}").format(user=user)
|
||||
)
|
||||
|
||||
@commands.command()
|
||||
@commands.guild_only()
|
||||
async def mywarnings(self, ctx: commands.Context):
|
||||
"""List warnings for yourself."""
|
||||
|
||||
user = ctx.author
|
||||
|
||||
msg = ""
|
||||
member_settings = self.config.member(user)
|
||||
async with member_settings.warnings() as user_warnings:
|
||||
if not user_warnings.keys(): # no warnings for the user
|
||||
await ctx.send(_("You have no warnings!"))
|
||||
else:
|
||||
for key in user_warnings.keys():
|
||||
mod_id = user_warnings[key]["mod"]
|
||||
mod = ctx.bot.get_user(mod_id) or _("Unknown Moderator ({})").format(mod_id)
|
||||
msg += _(
|
||||
"{num_points} point warning {reason_name} issued by {user} for "
|
||||
"{description}\n"
|
||||
).format(
|
||||
num_points=user_warnings[key]["points"],
|
||||
reason_name=key,
|
||||
user=mod,
|
||||
description=user_warnings[key]["description"],
|
||||
)
|
||||
await ctx.send_interactive(
|
||||
pagify(msg, shorten_by=58), box_lang=_("Warnings for {user}").format(user=user)
|
||||
)
|
||||
|
||||
@commands.command()
|
||||
@commands.guild_only()
|
||||
@checks.admin_or_permissions(ban_members=True)
|
||||
|
||||
@ -22,7 +22,7 @@ class SharedLibImportWarner(MetaPathFinder):
|
||||
return None
|
||||
msg = (
|
||||
"One of cogs uses shared libraries which are"
|
||||
" deprecated and scheduled for removal in Red 3.3.\n"
|
||||
" deprecated and scheduled for removal in Red 3.4.\n"
|
||||
"You should inform author of the cog about this message."
|
||||
)
|
||||
warnings.warn(msg, SharedLibDeprecationWarning, stacklevel=2)
|
||||
|
||||
@ -838,9 +838,9 @@ async def set_default_balance(amount: int, guild: discord.Guild = None) -> int:
|
||||
amount = int(amount)
|
||||
max_bal = await get_max_balance(guild)
|
||||
|
||||
if not (0 < amount <= max_bal):
|
||||
if not (0 <= amount <= max_bal):
|
||||
raise ValueError(
|
||||
"Amount must be greater than zero and less than {max}.".format(
|
||||
"Amount must be greater than or equal zero and less than or equal {max}.".format(
|
||||
max=humanize_number(max_bal, override_locale="en_US")
|
||||
)
|
||||
)
|
||||
|
||||
@ -10,11 +10,25 @@ from datetime import datetime
|
||||
from enum import IntEnum
|
||||
from importlib.machinery import ModuleSpec
|
||||
from pathlib import Path
|
||||
from typing import Optional, Union, List, Dict, NoReturn
|
||||
from typing import (
|
||||
Optional,
|
||||
Union,
|
||||
List,
|
||||
Dict,
|
||||
NoReturn,
|
||||
Set,
|
||||
Coroutine,
|
||||
TypeVar,
|
||||
Callable,
|
||||
Awaitable,
|
||||
Any,
|
||||
)
|
||||
from types import MappingProxyType
|
||||
|
||||
import discord
|
||||
from discord.ext import commands as dpy_commands
|
||||
from discord.ext.commands import when_mentioned_or
|
||||
from discord.ext.commands.bot import BotBase
|
||||
|
||||
from . import Config, i18n, commands, errors, drivers, modlog, bank
|
||||
from .cog_manager import CogManager, CogManagerUI
|
||||
@ -24,6 +38,8 @@ from .dev_commands import Dev
|
||||
from .events import init_events
|
||||
from .global_checks import init_global_checks
|
||||
|
||||
from .settings_caches import PrefixManager
|
||||
|
||||
from .rpc import RPCMixin
|
||||
from .utils import common_filters
|
||||
|
||||
@ -36,13 +52,18 @@ __all__ = ["RedBase", "Red", "ExitCodes"]
|
||||
|
||||
NotMessage = namedtuple("NotMessage", "guild")
|
||||
|
||||
PreInvokeCoroutine = Callable[[commands.Context], Awaitable[Any]]
|
||||
T_BIC = TypeVar("T_BIC", bound=PreInvokeCoroutine)
|
||||
|
||||
|
||||
def _is_submodule(parent, child):
|
||||
return parent == child or child.startswith(parent + ".")
|
||||
|
||||
|
||||
# barely spurious warning caused by our intentional shadowing
|
||||
class RedBase(commands.GroupMixin, commands.bot.BotBase, RPCMixin): # pylint: disable=no-member
|
||||
class RedBase(
|
||||
commands.GroupMixin, dpy_commands.bot.BotBase, RPCMixin
|
||||
): # pylint: disable=no-member
|
||||
"""Mixin for the main bot class.
|
||||
|
||||
This exists because `Red` inherits from `discord.AutoShardedClient`, which
|
||||
@ -71,11 +92,13 @@ class RedBase(commands.GroupMixin, commands.bot.BotBase, RPCMixin): # pylint: d
|
||||
custom_info=None,
|
||||
help__page_char_limit=1000,
|
||||
help__max_pages_in_guild=2,
|
||||
help__delete_delay=0,
|
||||
help__use_menus=False,
|
||||
help__show_hidden=False,
|
||||
help__verify_checks=True,
|
||||
help__verify_exists=False,
|
||||
help__tagline="",
|
||||
description="Red V3",
|
||||
invite_public=False,
|
||||
invite_perm=0,
|
||||
disabled_commands=[],
|
||||
@ -101,6 +124,7 @@ class RedBase(commands.GroupMixin, commands.bot.BotBase, RPCMixin): # pylint: d
|
||||
autoimmune_ids=[],
|
||||
)
|
||||
|
||||
self._config.register_channel(embeds=None)
|
||||
self._config.register_user(embeds=None)
|
||||
|
||||
self._config.init_custom(CUSTOM_GROUPS, 2)
|
||||
@ -108,23 +132,13 @@ class RedBase(commands.GroupMixin, commands.bot.BotBase, RPCMixin): # pylint: d
|
||||
|
||||
self._config.init_custom(SHARED_API_TOKENS, 2)
|
||||
self._config.register_custom(SHARED_API_TOKENS)
|
||||
self._prefix_cache = PrefixManager(self._config, cli_flags)
|
||||
|
||||
async def prefix_manager(bot, message):
|
||||
if not cli_flags.prefix:
|
||||
global_prefix = await bot._config.prefix()
|
||||
else:
|
||||
global_prefix = cli_flags.prefix
|
||||
if message.guild is None:
|
||||
return global_prefix
|
||||
server_prefix = await bot._config.guild(message.guild).prefix()
|
||||
async def prefix_manager(bot, message) -> List[str]:
|
||||
prefixes = await self._prefix_cache.get_prefixes(message.guild)
|
||||
if cli_flags.mentionable:
|
||||
return (
|
||||
when_mentioned_or(*server_prefix)(bot, message)
|
||||
if server_prefix
|
||||
else when_mentioned_or(*global_prefix)(bot, message)
|
||||
)
|
||||
else:
|
||||
return server_prefix if server_prefix else global_prefix
|
||||
return when_mentioned_or(*prefixes)(bot, message)
|
||||
return prefixes
|
||||
|
||||
if "command_prefix" not in kwargs:
|
||||
kwargs["command_prefix"] = prefix_manager
|
||||
@ -135,12 +149,19 @@ class RedBase(commands.GroupMixin, commands.bot.BotBase, RPCMixin): # pylint: d
|
||||
if "command_not_found" not in kwargs:
|
||||
kwargs["command_not_found"] = "Command {} not found.\n{}"
|
||||
|
||||
message_cache_size = cli_flags.message_cache_size
|
||||
if cli_flags.no_message_cache:
|
||||
message_cache_size = None
|
||||
kwargs["max_messages"] = message_cache_size
|
||||
self._max_messages = message_cache_size
|
||||
|
||||
self._uptime = None
|
||||
self._checked_time_accuracy = None
|
||||
self._color = discord.Embed.Empty # This is needed or color ends up 0x000000
|
||||
|
||||
self._main_dir = bot_dir
|
||||
self._cog_mgr = CogManager()
|
||||
self._use_team_features = cli_flags.use_team_features
|
||||
super().__init__(*args, help_command=None, **kwargs)
|
||||
# Do not manually use the help formatter attribute here, see `send_help_for`,
|
||||
# for a documented API. The internals of this object are still subject to change.
|
||||
@ -149,6 +170,74 @@ class RedBase(commands.GroupMixin, commands.bot.BotBase, RPCMixin): # pylint: d
|
||||
|
||||
self._permissions_hooks: List[commands.CheckPredicate] = []
|
||||
self._red_ready = asyncio.Event()
|
||||
self._red_before_invoke_objs: Set[PreInvokeCoroutine] = set()
|
||||
|
||||
def get_command(self, name: str) -> Optional[commands.Command]:
|
||||
com = super().get_command(name)
|
||||
assert com is None or isinstance(com, commands.Command)
|
||||
return com
|
||||
|
||||
def get_cog(self, name: str) -> Optional[commands.Cog]:
|
||||
cog = super().get_cog(name)
|
||||
assert cog is None or isinstance(cog, commands.Cog)
|
||||
return cog
|
||||
|
||||
@property
|
||||
def _before_invoke(self): # DEP-WARN
|
||||
return self._red_before_invoke_method
|
||||
|
||||
@_before_invoke.setter
|
||||
def _before_invoke(self, val): # DEP-WARN
|
||||
"""Prevent this from being overwritten in super().__init__"""
|
||||
pass
|
||||
|
||||
async def _red_before_invoke_method(self, ctx):
|
||||
await self.wait_until_red_ready()
|
||||
return_exceptions = isinstance(ctx.command, commands.commands._AlwaysAvailableCommand)
|
||||
if self._red_before_invoke_objs:
|
||||
await asyncio.gather(
|
||||
*(coro(ctx) for coro in self._red_before_invoke_objs),
|
||||
return_exceptions=return_exceptions,
|
||||
)
|
||||
|
||||
def remove_before_invoke_hook(self, coro: PreInvokeCoroutine) -> None:
|
||||
"""
|
||||
Functional method to remove a `before_invoke` hook.
|
||||
"""
|
||||
self._red_before_invoke_objs.discard(coro)
|
||||
|
||||
def before_invoke(self, coro: T_BIC) -> T_BIC:
|
||||
"""
|
||||
Overridden decorator method for Red's ``before_invoke`` behavior.
|
||||
|
||||
This can safely be used purely functionally as well.
|
||||
|
||||
3rd party cogs should remove any hooks which they register at unload
|
||||
using `remove_before_invoke_hook`
|
||||
|
||||
Below behavior shared with discord.py:
|
||||
|
||||
.. note::
|
||||
The ``before_invoke`` hooks are
|
||||
only called if all checks and argument parsing procedures pass
|
||||
without error. If any check or argument parsing procedures fail
|
||||
then the hooks are not called.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
coro: Callable[[commands.Context], Awaitable[Any]]
|
||||
The coroutine to register as the pre-invoke hook.
|
||||
|
||||
Raises
|
||||
------
|
||||
TypeError
|
||||
The coroutine passed is not actually a coroutine.
|
||||
"""
|
||||
if not asyncio.iscoroutinefunction(coro):
|
||||
raise TypeError("The pre-invoke hook must be a coroutine.")
|
||||
|
||||
self._red_before_invoke_objs.add(coro)
|
||||
return coro
|
||||
|
||||
@property
|
||||
def cog_mgr(self) -> NoReturn:
|
||||
@ -188,6 +277,10 @@ class RedBase(commands.GroupMixin, commands.bot.BotBase, RPCMixin): # pylint: d
|
||||
def colour(self) -> NoReturn:
|
||||
raise AttributeError("Please fetch the embed colour with `get_embed_colour`")
|
||||
|
||||
@property
|
||||
def max_messages(self) -> Optional[int]:
|
||||
return self._max_messages
|
||||
|
||||
async def allowed_by_whitelist_blacklist(
|
||||
self,
|
||||
who: Optional[Union[discord.Member, discord.User]] = None,
|
||||
@ -400,6 +493,7 @@ class RedBase(commands.GroupMixin, commands.bot.BotBase, RPCMixin): # pylint: d
|
||||
This should only be run once, prior to connecting to discord.
|
||||
"""
|
||||
await self._maybe_update_config()
|
||||
self.description = await self._config.description()
|
||||
|
||||
init_global_checks(self)
|
||||
init_events(self, cli_flags)
|
||||
@ -547,23 +641,57 @@ class RedBase(commands.GroupMixin, commands.bot.BotBase, RPCMixin): # pylint: d
|
||||
bool
|
||||
:code:`True` if an embed is requested
|
||||
"""
|
||||
if isinstance(channel, discord.abc.PrivateChannel) or (
|
||||
command and command == self.get_command("help")
|
||||
):
|
||||
if isinstance(channel, discord.abc.PrivateChannel):
|
||||
user_setting = await self._config.user(user).embeds()
|
||||
if user_setting is not None:
|
||||
return user_setting
|
||||
else:
|
||||
channel_setting = await self._config.channel(channel).embeds()
|
||||
if channel_setting is not None:
|
||||
return channel_setting
|
||||
guild_setting = await self._config.guild(channel.guild).embeds()
|
||||
if guild_setting is not None:
|
||||
return guild_setting
|
||||
|
||||
global_setting = await self._config.embeds()
|
||||
return global_setting
|
||||
|
||||
async def is_owner(self, user) -> bool:
|
||||
async def is_owner(self, user: Union[discord.User, discord.Member]) -> bool:
|
||||
"""
|
||||
Determines if the user should be considered a bot owner.
|
||||
|
||||
This takes into account CLI flags and application ownership.
|
||||
|
||||
By default,
|
||||
application team members are not considered owners,
|
||||
while individual application owners are.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
user: Union[discord.User, discord.Member]
|
||||
|
||||
Returns
|
||||
-------
|
||||
bool
|
||||
"""
|
||||
if user.id in self._co_owners:
|
||||
return True
|
||||
return await super().is_owner(user)
|
||||
|
||||
if self.owner_id:
|
||||
return self.owner_id == user.id
|
||||
elif self.owner_ids:
|
||||
return user.id in self.owner_ids
|
||||
else:
|
||||
app = await self.application_info()
|
||||
if app.team:
|
||||
if self._use_team_features:
|
||||
self.owner_ids = ids = {m.id for m in app.team.members}
|
||||
return user.id in ids
|
||||
else:
|
||||
self.owner_id = owner_id = app.owner.id
|
||||
return user.id == owner_id
|
||||
|
||||
return False
|
||||
|
||||
async def is_admin(self, member: discord.Member) -> bool:
|
||||
"""Checks if a member is an admin of their guild."""
|
||||
@ -1002,10 +1130,11 @@ class RedBase(commands.GroupMixin, commands.bot.BotBase, RPCMixin): # pylint: d
|
||||
await self.wait_until_red_ready()
|
||||
destinations = []
|
||||
opt_outs = await self._config.owner_opt_out_list()
|
||||
for user_id in (self.owner_id, *self._co_owners):
|
||||
team_ids = () if not self._use_team_features else self.owner_ids
|
||||
for user_id in set((self.owner_id, *self._co_owners, *team_ids)):
|
||||
if user_id not in opt_outs:
|
||||
user = self.get_user(user_id)
|
||||
if user:
|
||||
if user and not user.bot: # user.bot is possible with flags and teams
|
||||
destinations.append(user)
|
||||
else:
|
||||
log.warning(
|
||||
|
||||
@ -74,6 +74,22 @@ async def interactive_config(red, token_set, prefix_set, *, print_header=True):
|
||||
return token
|
||||
|
||||
|
||||
def positive_int(arg: str) -> int:
|
||||
try:
|
||||
x = int(arg)
|
||||
except ValueError:
|
||||
raise argparse.ArgumentTypeError("Message cache size has to be a number.")
|
||||
if x < 1000:
|
||||
raise argparse.ArgumentTypeError(
|
||||
"Message cache size has to be greater than or equal to 1000."
|
||||
)
|
||||
if x > sys.maxsize:
|
||||
raise argparse.ArgumentTypeError(
|
||||
f"Message cache size has to be lower than or equal to {sys.maxsize}."
|
||||
)
|
||||
return x
|
||||
|
||||
|
||||
def parse_cli_flags(args):
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Red - Discord Bot", usage="redbot <instance_name> [arguments]"
|
||||
@ -90,7 +106,7 @@ def parse_cli_flags(args):
|
||||
action="store_true",
|
||||
help="Edit the instance. This can be done without console interaction "
|
||||
"by passing --no-prompt and arguments that you want to change (available arguments: "
|
||||
"--edit-instance-name, --edit-data-path, --copy-data, --owner, --token).",
|
||||
"--edit-instance-name, --edit-data-path, --copy-data, --owner, --token, --prefix).",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--edit-instance-name",
|
||||
@ -135,7 +151,9 @@ def parse_cli_flags(args):
|
||||
"security implications if misused. Can be "
|
||||
"multiple.",
|
||||
)
|
||||
parser.add_argument("--prefix", "-p", action="append", help="Global prefix. Can be multiple")
|
||||
parser.add_argument(
|
||||
"--prefix", "-p", action="append", help="Global prefix. Can be multiple", default=[]
|
||||
)
|
||||
parser.add_argument(
|
||||
"--no-prompt",
|
||||
action="store_true",
|
||||
@ -198,6 +216,27 @@ def parse_cli_flags(args):
|
||||
parser.add_argument(
|
||||
"instance_name", nargs="?", help="Name of the bot instance created during `redbot-setup`."
|
||||
)
|
||||
parser.add_argument(
|
||||
"--team-members-are-owners",
|
||||
action="store_true",
|
||||
dest="use_team_features",
|
||||
default=False,
|
||||
help=(
|
||||
"Treat application team members as owners. "
|
||||
"This is off by default. Owners can load and run arbitrary code. "
|
||||
"Do not enable if you would not trust all of your team members with "
|
||||
"all of the data on the host machine."
|
||||
),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--message-cache-size",
|
||||
type=positive_int,
|
||||
default=1000,
|
||||
help="Set the maximum number of messages to store in the internal message cache.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--no-message-cache", action="store_true", help="Disable the internal message cache.",
|
||||
)
|
||||
|
||||
args = parser.parse_args(args)
|
||||
|
||||
|
||||
@ -1,7 +1,145 @@
|
||||
from discord.ext.commands import *
|
||||
from .commands import *
|
||||
from .context import *
|
||||
from .converter import *
|
||||
from .errors import *
|
||||
from .requires import *
|
||||
from .help import *
|
||||
########## SENSITIVE SECTION WARNING ###########
|
||||
################################################
|
||||
# Any edits of any of the exported names #
|
||||
# may result in a breaking change. #
|
||||
# Ensure no names are removed without warning. #
|
||||
################################################
|
||||
|
||||
from .commands import (
|
||||
Cog as Cog,
|
||||
CogMixin as CogMixin,
|
||||
CogCommandMixin as CogCommandMixin,
|
||||
CogGroupMixin as CogGroupMixin,
|
||||
Command as Command,
|
||||
Group as Group,
|
||||
GroupMixin as GroupMixin,
|
||||
command as command,
|
||||
group as group,
|
||||
RESERVED_COMMAND_NAMES as RESERVED_COMMAND_NAMES,
|
||||
)
|
||||
from .context import Context as Context, GuildContext as GuildContext, DMContext as DMContext
|
||||
from .converter import (
|
||||
APIToken as APIToken,
|
||||
DictConverter as DictConverter,
|
||||
GuildConverter as GuildConverter,
|
||||
TimedeltaConverter as TimedeltaConverter,
|
||||
get_dict_converter as get_dict_converter,
|
||||
get_timedelta_converter as get_timedelta_converter,
|
||||
parse_timedelta as parse_timedelta,
|
||||
NoParseOptional as NoParseOptional,
|
||||
UserInputOptional as UserInputOptional,
|
||||
Literal as Literal,
|
||||
)
|
||||
from .errors import (
|
||||
ConversionFailure as ConversionFailure,
|
||||
BotMissingPermissions as BotMissingPermissions,
|
||||
UserFeedbackCheckFailure as UserFeedbackCheckFailure,
|
||||
ArgParserFailure as ArgParserFailure,
|
||||
)
|
||||
from .help import (
|
||||
red_help as red_help,
|
||||
RedHelpFormatter as RedHelpFormatter,
|
||||
HelpSettings as HelpSettings,
|
||||
)
|
||||
from .requires import (
|
||||
CheckPredicate as CheckPredicate,
|
||||
DM_PERMS as DM_PERMS,
|
||||
GlobalPermissionModel as GlobalPermissionModel,
|
||||
GuildPermissionModel as GuildPermissionModel,
|
||||
PermissionModel as PermissionModel,
|
||||
PrivilegeLevel as PrivilegeLevel,
|
||||
PermState as PermState,
|
||||
Requires as Requires,
|
||||
permissions_check as permissions_check,
|
||||
bot_has_permissions as bot_has_permissions,
|
||||
has_permissions as has_permissions,
|
||||
has_guild_permissions as has_guild_permissions,
|
||||
is_owner as is_owner,
|
||||
guildowner as guildowner,
|
||||
guildowner_or_permissions as guildowner_or_permissions,
|
||||
admin as admin,
|
||||
admin_or_permissions as admin_or_permissions,
|
||||
mod as mod,
|
||||
mod_or_permissions as mod_or_permissions,
|
||||
)
|
||||
|
||||
from ._dpy_reimplements import (
|
||||
check as check,
|
||||
guild_only as guild_only,
|
||||
cooldown as cooldown,
|
||||
dm_only as dm_only,
|
||||
is_nsfw as is_nsfw,
|
||||
has_role as has_role,
|
||||
has_any_role as has_any_role,
|
||||
bot_has_role as bot_has_role,
|
||||
when_mentioned_or as when_mentioned_or,
|
||||
when_mentioned as when_mentioned,
|
||||
bot_has_any_role as bot_has_any_role,
|
||||
)
|
||||
|
||||
### DEP-WARN: Check this *every* discord.py update
|
||||
from discord.ext.commands import (
|
||||
BadArgument as BadArgument,
|
||||
EmojiConverter as EmojiConverter,
|
||||
InvalidEndOfQuotedStringError as InvalidEndOfQuotedStringError,
|
||||
MemberConverter as MemberConverter,
|
||||
BotMissingRole as BotMissingRole,
|
||||
PrivateMessageOnly as PrivateMessageOnly,
|
||||
HelpCommand as HelpCommand,
|
||||
MinimalHelpCommand as MinimalHelpCommand,
|
||||
DisabledCommand as DisabledCommand,
|
||||
ExtensionFailed as ExtensionFailed,
|
||||
Bot as Bot,
|
||||
NotOwner as NotOwner,
|
||||
CategoryChannelConverter as CategoryChannelConverter,
|
||||
CogMeta as CogMeta,
|
||||
ConversionError as ConversionError,
|
||||
UserInputError as UserInputError,
|
||||
Converter as Converter,
|
||||
InviteConverter as InviteConverter,
|
||||
ExtensionError as ExtensionError,
|
||||
Cooldown as Cooldown,
|
||||
CheckFailure as CheckFailure,
|
||||
MessageConverter as MessageConverter,
|
||||
MissingPermissions as MissingPermissions,
|
||||
BadUnionArgument as BadUnionArgument,
|
||||
DefaultHelpCommand as DefaultHelpCommand,
|
||||
ExtensionNotFound as ExtensionNotFound,
|
||||
UserConverter as UserConverter,
|
||||
MissingRole as MissingRole,
|
||||
CommandOnCooldown as CommandOnCooldown,
|
||||
MissingAnyRole as MissingAnyRole,
|
||||
ExtensionNotLoaded as ExtensionNotLoaded,
|
||||
clean_content as clean_content,
|
||||
CooldownMapping as CooldownMapping,
|
||||
ArgumentParsingError as ArgumentParsingError,
|
||||
RoleConverter as RoleConverter,
|
||||
CommandError as CommandError,
|
||||
TextChannelConverter as TextChannelConverter,
|
||||
UnexpectedQuoteError as UnexpectedQuoteError,
|
||||
Paginator as Paginator,
|
||||
BucketType as BucketType,
|
||||
NoEntryPointError as NoEntryPointError,
|
||||
CommandInvokeError as CommandInvokeError,
|
||||
TooManyArguments as TooManyArguments,
|
||||
Greedy as Greedy,
|
||||
ExpectedClosingQuoteError as ExpectedClosingQuoteError,
|
||||
ColourConverter as ColourConverter,
|
||||
VoiceChannelConverter as VoiceChannelConverter,
|
||||
NSFWChannelRequired as NSFWChannelRequired,
|
||||
IDConverter as IDConverter,
|
||||
MissingRequiredArgument as MissingRequiredArgument,
|
||||
GameConverter as GameConverter,
|
||||
CommandNotFound as CommandNotFound,
|
||||
BotMissingAnyRole as BotMissingAnyRole,
|
||||
NoPrivateMessage as NoPrivateMessage,
|
||||
AutoShardedBot as AutoShardedBot,
|
||||
ExtensionAlreadyLoaded as ExtensionAlreadyLoaded,
|
||||
PartialEmojiConverter as PartialEmojiConverter,
|
||||
check_any as check_any,
|
||||
max_concurrency as max_concurrency,
|
||||
CheckAnyFailure as CheckAnyFailure,
|
||||
MaxConcurrency as MaxConcurrency,
|
||||
MaxConcurrencyReached as MaxConcurrencyReached,
|
||||
bot_has_guild_permissions as bot_has_guild_permissions,
|
||||
)
|
||||
|
||||
126
redbot/core/commands/_dpy_reimplements.py
Normal file
126
redbot/core/commands/_dpy_reimplements.py
Normal file
@ -0,0 +1,126 @@
|
||||
from __future__ import annotations
|
||||
import inspect
|
||||
import functools
|
||||
from typing import (
|
||||
TypeVar,
|
||||
Callable,
|
||||
Awaitable,
|
||||
Coroutine,
|
||||
Union,
|
||||
Type,
|
||||
TYPE_CHECKING,
|
||||
List,
|
||||
Any,
|
||||
Generator,
|
||||
Protocol,
|
||||
overload,
|
||||
)
|
||||
|
||||
import discord
|
||||
from discord.ext import commands as dpy_commands
|
||||
|
||||
# So much of this can be stripped right back out with proper stubs.
|
||||
if not TYPE_CHECKING:
|
||||
from discord.ext.commands import (
|
||||
check as check,
|
||||
guild_only as guild_only,
|
||||
dm_only as dm_only,
|
||||
is_nsfw as is_nsfw,
|
||||
has_role as has_role,
|
||||
has_any_role as has_any_role,
|
||||
bot_has_role as bot_has_role,
|
||||
bot_has_any_role as bot_has_any_role,
|
||||
cooldown as cooldown,
|
||||
)
|
||||
|
||||
from ..i18n import Translator
|
||||
from .context import Context
|
||||
from .commands import Command
|
||||
|
||||
|
||||
_ = Translator("nah", __file__)
|
||||
|
||||
|
||||
"""
|
||||
Anything here is either a reimplementation or re-export
|
||||
of a discord.py funtion or class with more lies for mypy
|
||||
"""
|
||||
|
||||
__all__ = [
|
||||
"check",
|
||||
# "check_any", # discord.py 1.3
|
||||
"guild_only",
|
||||
"dm_only",
|
||||
"is_nsfw",
|
||||
"has_role",
|
||||
"has_any_role",
|
||||
"bot_has_role",
|
||||
"bot_has_any_role",
|
||||
"when_mentioned_or",
|
||||
"cooldown",
|
||||
"when_mentioned",
|
||||
]
|
||||
|
||||
_CT = TypeVar("_CT", bound=Context)
|
||||
_T = TypeVar("_T")
|
||||
_F = TypeVar("_F")
|
||||
CheckType = Union[Callable[[_CT], bool], Callable[[_CT], Coroutine[Any, Any, bool]]]
|
||||
CoroLike = Callable[..., Union[Awaitable[_T], Generator[Any, None, _T]]]
|
||||
|
||||
|
||||
class CheckDecorator(Protocol):
|
||||
predicate: Coroutine[Any, Any, bool]
|
||||
|
||||
@overload
|
||||
def __call__(self, func: _CT) -> _CT:
|
||||
...
|
||||
|
||||
@overload
|
||||
def __call__(self, func: CoroLike) -> CoroLike:
|
||||
...
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
|
||||
def check(predicate: CheckType) -> CheckDecorator:
|
||||
...
|
||||
|
||||
def guild_only() -> CheckDecorator:
|
||||
...
|
||||
|
||||
def dm_only() -> CheckDecorator:
|
||||
...
|
||||
|
||||
def is_nsfw() -> CheckDecorator:
|
||||
...
|
||||
|
||||
def has_role() -> CheckDecorator:
|
||||
...
|
||||
|
||||
def has_any_role() -> CheckDecorator:
|
||||
...
|
||||
|
||||
def bot_has_role() -> CheckDecorator:
|
||||
...
|
||||
|
||||
def bot_has_any_role() -> CheckDecorator:
|
||||
...
|
||||
|
||||
def cooldown(rate: int, per: float, type: dpy_commands.BucketType = ...) -> Callable[[_F], _F]:
|
||||
...
|
||||
|
||||
|
||||
PrefixCallable = Callable[[dpy_commands.bot.BotBase, discord.Message], List[str]]
|
||||
|
||||
|
||||
def when_mentioned(bot: dpy_commands.bot.BotBase, msg: discord.Message) -> List[str]:
|
||||
return [f"<@{bot.user.id}> ", f"<@!{bot.user.id}> "]
|
||||
|
||||
|
||||
def when_mentioned_or(*prefixes) -> PrefixCallable:
|
||||
def inner(bot: dpy_commands.bot.BotBase, msg: discord.Message) -> List[str]:
|
||||
r = list(prefixes)
|
||||
r = when_mentioned(bot, msg) + r
|
||||
return r
|
||||
|
||||
return inner
|
||||
@ -1,23 +1,53 @@
|
||||
"""Module for command helpers and classes.
|
||||
|
||||
This module contains extended classes and functions which are intended to
|
||||
replace those from the `discord.ext.commands` module.
|
||||
be used instead of those from the `discord.ext.commands` module.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import inspect
|
||||
import re
|
||||
import weakref
|
||||
from typing import Awaitable, Callable, Dict, List, Optional, Tuple, Union, TYPE_CHECKING
|
||||
from typing import (
|
||||
Awaitable,
|
||||
Callable,
|
||||
Coroutine,
|
||||
TypeVar,
|
||||
Type,
|
||||
Dict,
|
||||
List,
|
||||
Optional,
|
||||
Tuple,
|
||||
Union,
|
||||
MutableMapping,
|
||||
TYPE_CHECKING,
|
||||
cast,
|
||||
)
|
||||
|
||||
import discord
|
||||
from discord.ext import commands
|
||||
from discord.ext.commands import (
|
||||
BadArgument,
|
||||
CommandError,
|
||||
CheckFailure,
|
||||
DisabledCommand,
|
||||
command as dpy_command_deco,
|
||||
Command as DPYCommand,
|
||||
Cog as DPYCog,
|
||||
CogMeta as DPYCogMeta,
|
||||
Group as DPYGroup,
|
||||
Greedy,
|
||||
)
|
||||
|
||||
from . import converter as converters
|
||||
from .errors import ConversionFailure
|
||||
from .requires import PermState, PrivilegeLevel, Requires
|
||||
from .requires import PermState, PrivilegeLevel, Requires, PermStateAllowedStates
|
||||
from ..i18n import Translator
|
||||
|
||||
if TYPE_CHECKING:
|
||||
# circular import avoidance
|
||||
from .context import Context
|
||||
|
||||
|
||||
__all__ = [
|
||||
"Cog",
|
||||
"CogMixin",
|
||||
@ -37,11 +67,17 @@ RESERVED_COMMAND_NAMES = (
|
||||
)
|
||||
|
||||
_ = Translator("commands.commands", __file__)
|
||||
DisablerDictType = MutableMapping[discord.Guild, Callable[["Context"], Awaitable[bool]]]
|
||||
|
||||
|
||||
class CogCommandMixin:
|
||||
"""A mixin for cogs and commands."""
|
||||
|
||||
@property
|
||||
def help(self) -> str:
|
||||
"""To be defined by subclasses"""
|
||||
...
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
if isinstance(self, Command):
|
||||
@ -57,6 +93,77 @@ class CogCommandMixin:
|
||||
checks=getattr(decorated, "__requires_checks__", []),
|
||||
)
|
||||
|
||||
def format_text_for_context(self, ctx: "Context", text: str) -> str:
|
||||
"""
|
||||
This formats text based on values in context
|
||||
|
||||
The steps are (currently, roughly) the following:
|
||||
|
||||
- substitute ``[p]`` with ``ctx.clean_prefix``
|
||||
- substitute ``[botname]`` with ``ctx.me.display_name``
|
||||
|
||||
More steps may be added at a later time.
|
||||
|
||||
Cog creators should only override this if they want
|
||||
help text to be modified, and may also want to
|
||||
look at `format_help_for_context` and (for commands only)
|
||||
``format_shortdoc_for_context``
|
||||
|
||||
Parameters
|
||||
----------
|
||||
ctx: Context
|
||||
text: str
|
||||
|
||||
Returns
|
||||
-------
|
||||
str
|
||||
text which has had some portions replaced based on context
|
||||
"""
|
||||
formatting_pattern = re.compile(r"\[p\]|\[botname\]")
|
||||
|
||||
def replacement(m: re.Match) -> str:
|
||||
s = m.group(0)
|
||||
if s == "[p]":
|
||||
return ctx.clean_prefix
|
||||
if s == "[botname]":
|
||||
return ctx.me.display_name
|
||||
# We shouldnt get here:
|
||||
return s
|
||||
|
||||
return formatting_pattern.sub(replacement, text)
|
||||
|
||||
def format_help_for_context(self, ctx: "Context") -> str:
|
||||
"""
|
||||
This formats the help string based on values in context
|
||||
|
||||
The steps are (currently, roughly) the following:
|
||||
|
||||
- get the localized help
|
||||
- substitute ``[p]`` with ``ctx.clean_prefix``
|
||||
- substitute ``[botname]`` with ``ctx.me.display_name``
|
||||
|
||||
More steps may be added at a later time.
|
||||
|
||||
Cog creators may override this in their own command classes
|
||||
as long as the method signature stays the same.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
ctx: Context
|
||||
|
||||
Returns
|
||||
-------
|
||||
str
|
||||
Localized help with some formatting
|
||||
"""
|
||||
|
||||
help_str = self.help
|
||||
if not help_str:
|
||||
# Short circuit out on an empty help string
|
||||
return help_str
|
||||
|
||||
return self.format_text_for_context(ctx, help_str)
|
||||
|
||||
def allow_for(self, model_id: Union[int, str], guild_id: int) -> None:
|
||||
"""Actively allow this command for the given model.
|
||||
|
||||
@ -138,7 +245,7 @@ class CogCommandMixin:
|
||||
self.deny_to(Requires.DEFAULT, guild_id=guild_id)
|
||||
|
||||
|
||||
class Command(CogCommandMixin, commands.Command):
|
||||
class Command(CogCommandMixin, DPYCommand):
|
||||
"""Command class for Red.
|
||||
|
||||
This should not be created directly, and instead via the decorator.
|
||||
@ -154,10 +261,21 @@ class Command(CogCommandMixin, commands.Command):
|
||||
`Requires.checks`.
|
||||
translator : Translator
|
||||
A translator for this command's help docstring.
|
||||
|
||||
ignore_optional_for_conversion : bool
|
||||
A value which can be set to not have discord.py's
|
||||
argument parsing behavior for ``typing.Optional``
|
||||
(type used will be of the inner type instead)
|
||||
"""
|
||||
|
||||
def __call__(self, *args, **kwargs):
|
||||
if self.cog:
|
||||
# We need to inject cog as self here
|
||||
return self.callback(self.cog, *args, **kwargs)
|
||||
else:
|
||||
return self.callback(*args, **kwargs)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
self.ignore_optional_for_conversion = kwargs.pop("ignore_optional_for_conversion", False)
|
||||
super().__init__(*args, **kwargs)
|
||||
self._help_override = kwargs.pop("help_override", None)
|
||||
self.translator = kwargs.pop("i18n", None)
|
||||
@ -178,8 +296,62 @@ class Command(CogCommandMixin, commands.Command):
|
||||
|
||||
# Red specific
|
||||
other.requires = self.requires
|
||||
other.ignore_optional_for_conversion = self.ignore_optional_for_conversion
|
||||
return other
|
||||
|
||||
@property
|
||||
def callback(self):
|
||||
return self._callback
|
||||
|
||||
@callback.setter
|
||||
def callback(self, function):
|
||||
"""
|
||||
Below should be mostly the same as discord.py
|
||||
The only (current) change is to filter out typing.Optional
|
||||
if a user has specified the desire for this behavior
|
||||
"""
|
||||
self._callback = function
|
||||
self.module = function.__module__
|
||||
|
||||
signature = inspect.signature(function)
|
||||
self.params = signature.parameters.copy()
|
||||
|
||||
# PEP-563 allows postponing evaluation of annotations with a __future__
|
||||
# import. When postponed, Parameter.annotation will be a string and must
|
||||
# be replaced with the real value for the converters to work later on
|
||||
for key, value in self.params.items():
|
||||
if isinstance(value.annotation, str):
|
||||
self.params[key] = value = value.replace(
|
||||
annotation=eval(value.annotation, function.__globals__)
|
||||
)
|
||||
|
||||
# fail early for when someone passes an unparameterized Greedy type
|
||||
if value.annotation is Greedy:
|
||||
raise TypeError("Unparameterized Greedy[...] is disallowed in signature.")
|
||||
|
||||
if not self.ignore_optional_for_conversion:
|
||||
continue # reduces indentation compared to alternative
|
||||
|
||||
try:
|
||||
vtype = value.annotation.__origin__
|
||||
if vtype is Union:
|
||||
_NoneType = type if TYPE_CHECKING else type(None)
|
||||
args = value.annotation.__args__
|
||||
if _NoneType in args:
|
||||
args = tuple(a for a in args if a is not _NoneType)
|
||||
if len(args) == 1:
|
||||
# can't have a union of 1 or 0 items
|
||||
# 1 prevents this from becoming 0
|
||||
# we need to prevent 2 become 1
|
||||
# (Don't change that to becoming, it's intentional :musical_note:)
|
||||
self.params[key] = value = value.replace(annotation=args[0])
|
||||
else:
|
||||
# and mypy wretches at the correct Union[args]
|
||||
temp_type = type if TYPE_CHECKING else Union[args]
|
||||
self.params[key] = value = value.replace(annotation=temp_type)
|
||||
except AttributeError:
|
||||
continue
|
||||
|
||||
@property
|
||||
def help(self):
|
||||
"""Help string for this command.
|
||||
@ -260,7 +432,7 @@ class Command(CogCommandMixin, commands.Command):
|
||||
for parent in reversed(self.parents):
|
||||
try:
|
||||
result = await parent.can_run(ctx, change_permission_state=True)
|
||||
except commands.CommandError:
|
||||
except CommandError:
|
||||
result = False
|
||||
|
||||
if result is False:
|
||||
@ -279,14 +451,24 @@ class Command(CogCommandMixin, commands.Command):
|
||||
if not change_permission_state:
|
||||
ctx.permission_state = original_state
|
||||
|
||||
async def _verify_checks(self, ctx):
|
||||
if not self.enabled:
|
||||
raise commands.DisabledCommand(f"{self.name} command is disabled")
|
||||
async def prepare(self, ctx):
|
||||
ctx.command = self
|
||||
|
||||
if not (await self.can_run(ctx, change_permission_state=True)):
|
||||
raise commands.CheckFailure(
|
||||
f"The check functions for command {self.qualified_name} failed."
|
||||
)
|
||||
if not self.enabled:
|
||||
raise DisabledCommand(f"{self.name} command is disabled")
|
||||
|
||||
if not await self.can_run(ctx, change_permission_state=True):
|
||||
raise CheckFailure(f"The check functions for command {self.qualified_name} failed.")
|
||||
|
||||
if self.cooldown_after_parsing:
|
||||
await self._parse_arguments(ctx)
|
||||
self._prepare_cooldowns(ctx)
|
||||
else:
|
||||
self._prepare_cooldowns(ctx)
|
||||
await self._parse_arguments(ctx)
|
||||
if self._max_concurrency is not None:
|
||||
await self._max_concurrency.acquire(ctx)
|
||||
await self.call_before_hooks(ctx)
|
||||
|
||||
async def do_conversion(
|
||||
self, ctx: "Context", converter, argument: str, param: inspect.Parameter
|
||||
@ -310,7 +492,7 @@ class Command(CogCommandMixin, commands.Command):
|
||||
|
||||
try:
|
||||
return await super().do_conversion(ctx, converter, argument, param)
|
||||
except commands.BadArgument as exc:
|
||||
except BadArgument as exc:
|
||||
raise ConversionFailure(converter, argument, param, *exc.args) from exc
|
||||
except ValueError as exc:
|
||||
# Some common converters need special treatment...
|
||||
@ -345,7 +527,7 @@ class Command(CogCommandMixin, commands.Command):
|
||||
can_run = await self.can_run(
|
||||
ctx, check_all_parents=True, change_permission_state=False
|
||||
)
|
||||
except (commands.CheckFailure, commands.errors.DisabledCommand):
|
||||
except (CheckFailure, DisabledCommand):
|
||||
return False
|
||||
else:
|
||||
if can_run is False:
|
||||
@ -465,6 +647,28 @@ class Command(CogCommandMixin, commands.Command):
|
||||
"""
|
||||
return super().error(coro)
|
||||
|
||||
def format_shortdoc_for_context(self, ctx: "Context") -> str:
|
||||
"""
|
||||
This formats the short version of the help
|
||||
tring based on values in context
|
||||
|
||||
See ``format_text_for_context`` for the actual implementation details
|
||||
|
||||
Cog creators may override this in their own command classes
|
||||
as long as the method signature stays the same.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
ctx: Context
|
||||
|
||||
Returns
|
||||
-------
|
||||
str
|
||||
Localized help with some formatting
|
||||
"""
|
||||
sh = self.short_doc
|
||||
return self.format_text_for_context(ctx, sh) if sh else sh
|
||||
|
||||
|
||||
class GroupMixin(discord.ext.commands.GroupMixin):
|
||||
"""Mixin for `Group` and `Red` classes.
|
||||
@ -501,10 +705,9 @@ class GroupMixin(discord.ext.commands.GroupMixin):
|
||||
|
||||
class CogGroupMixin:
|
||||
requires: Requires
|
||||
all_commands: Dict[str, Command]
|
||||
|
||||
def reevaluate_rules_for(
|
||||
self, model_id: Union[str, int], guild_id: Optional[int]
|
||||
self, model_id: Union[str, int], guild_id: int = 0
|
||||
) -> Tuple[PermState, bool]:
|
||||
"""Re-evaluate a rule by checking subcommand rules.
|
||||
|
||||
@ -527,15 +730,16 @@ class CogGroupMixin:
|
||||
|
||||
"""
|
||||
cur_rule = self.requires.get_rule(model_id, guild_id=guild_id)
|
||||
if cur_rule in (PermState.NORMAL, PermState.ACTIVE_ALLOW, PermState.ACTIVE_DENY):
|
||||
# These three states are unaffected by subcommand rules
|
||||
return cur_rule, False
|
||||
else:
|
||||
if cur_rule not in (PermState.NORMAL, PermState.ACTIVE_ALLOW, PermState.ACTIVE_DENY):
|
||||
# The above three states are unaffected by subcommand rules
|
||||
# Remaining states can be changed if there exists no actively-allowed
|
||||
# subcommand (this includes subcommands multiple levels below)
|
||||
|
||||
all_commands: Dict[str, Command] = getattr(self, "all_commands", {})
|
||||
|
||||
if any(
|
||||
cmd.requires.get_rule(model_id, guild_id=guild_id) in PermState.ALLOWED_STATES
|
||||
for cmd in self.all_commands.values()
|
||||
cmd.requires.get_rule(model_id, guild_id=guild_id) in PermStateAllowedStates
|
||||
for cmd in all_commands.values()
|
||||
):
|
||||
return cur_rule, False
|
||||
elif cur_rule is PermState.PASSIVE_ALLOW:
|
||||
@ -545,8 +749,11 @@ class CogGroupMixin:
|
||||
self.requires.set_rule(model_id, PermState.ACTIVE_DENY, guild_id=guild_id)
|
||||
return PermState.ACTIVE_DENY, True
|
||||
|
||||
# Default return value
|
||||
return cur_rule, False
|
||||
|
||||
class Group(GroupMixin, Command, CogGroupMixin, commands.Group):
|
||||
|
||||
class Group(GroupMixin, Command, CogGroupMixin, DPYGroup):
|
||||
"""Group command class for Red.
|
||||
|
||||
This class inherits from `Command`, with :class:`GroupMixin` and
|
||||
@ -574,14 +781,14 @@ class Group(GroupMixin, Command, CogGroupMixin, commands.Group):
|
||||
|
||||
if ctx.invoked_subcommand is None or self == ctx.invoked_subcommand:
|
||||
if self.autohelp and not self.invoke_without_command:
|
||||
await self._verify_checks(ctx)
|
||||
await self.can_run(ctx, change_permission_state=True)
|
||||
await ctx.send_help()
|
||||
elif self.invoke_without_command:
|
||||
# So invoke_without_command when a subcommand of this group is invoked
|
||||
# will skip the the invokation of *this* command. However, because of
|
||||
# how our permissions system works, we don't want it to skip the checks
|
||||
# as well.
|
||||
await self._verify_checks(ctx)
|
||||
await self.can_run(ctx, change_permission_state=True)
|
||||
# this is actually why we don't prepare earlier.
|
||||
|
||||
await super().invoke(ctx)
|
||||
@ -590,14 +797,6 @@ class Group(GroupMixin, Command, CogGroupMixin, commands.Group):
|
||||
class CogMixin(CogGroupMixin, CogCommandMixin):
|
||||
"""Mixin class for a cog, intended for use with discord.py's cog class"""
|
||||
|
||||
@property
|
||||
def all_commands(self) -> Dict[str, Command]:
|
||||
"""
|
||||
This does not have identical behavior to
|
||||
Group.all_commands but should return what you expect
|
||||
"""
|
||||
return {cmd.name: cmd for cmd in self.__cog_commands__}
|
||||
|
||||
@property
|
||||
def help(self):
|
||||
doc = self.__doc__
|
||||
@ -626,7 +825,7 @@ class CogMixin(CogGroupMixin, CogCommandMixin):
|
||||
|
||||
try:
|
||||
can_run = await self.requires.verify(ctx)
|
||||
except commands.CommandError:
|
||||
except CommandError:
|
||||
return False
|
||||
|
||||
return can_run
|
||||
@ -655,16 +854,22 @@ class CogMixin(CogGroupMixin, CogCommandMixin):
|
||||
return await self.can_run(ctx)
|
||||
|
||||
|
||||
class Cog(CogMixin, commands.Cog):
|
||||
class Cog(CogMixin, DPYCog, metaclass=DPYCogMeta):
|
||||
"""
|
||||
Red's Cog base class
|
||||
|
||||
This includes a metaclass from discord.py
|
||||
"""
|
||||
|
||||
# NB: Do not move the inheritcance of this. Keeping the mix of that metaclass
|
||||
# seperate gives us more freedoms in several places.
|
||||
pass
|
||||
__cog_commands__: Tuple[Command]
|
||||
|
||||
@property
|
||||
def all_commands(self) -> Dict[str, Command]:
|
||||
"""
|
||||
This does not have identical behavior to
|
||||
Group.all_commands but should return what you expect
|
||||
"""
|
||||
return {cmd.name: cmd for cmd in self.__cog_commands__}
|
||||
|
||||
|
||||
def command(name=None, cls=Command, **attrs):
|
||||
@ -673,7 +878,8 @@ def command(name=None, cls=Command, **attrs):
|
||||
Same interface as `discord.ext.commands.command`.
|
||||
"""
|
||||
attrs["help_override"] = attrs.pop("help", None)
|
||||
return commands.command(name, cls, **attrs)
|
||||
|
||||
return dpy_command_deco(name, cls, **attrs)
|
||||
|
||||
|
||||
def group(name=None, cls=Group, **attrs):
|
||||
@ -681,10 +887,10 @@ def group(name=None, cls=Group, **attrs):
|
||||
|
||||
Same interface as `discord.ext.commands.group`.
|
||||
"""
|
||||
return command(name, cls, **attrs)
|
||||
return dpy_command_deco(name, cls, **attrs)
|
||||
|
||||
|
||||
__command_disablers = weakref.WeakValueDictionary()
|
||||
__command_disablers: DisablerDictType = weakref.WeakValueDictionary()
|
||||
|
||||
|
||||
def get_command_disabler(guild: discord.Guild) -> Callable[["Context"], Awaitable[bool]]:
|
||||
@ -699,7 +905,7 @@ def get_command_disabler(guild: discord.Guild) -> Callable[["Context"], Awaitabl
|
||||
|
||||
async def disabler(ctx: "Context") -> bool:
|
||||
if ctx.guild == guild:
|
||||
raise commands.DisabledCommand()
|
||||
raise DisabledCommand()
|
||||
return True
|
||||
|
||||
__command_disablers[guild] = disabler
|
||||
@ -727,6 +933,3 @@ class _AlwaysAvailableCommand(Command):
|
||||
|
||||
async def can_run(self, ctx, *args, **kwargs) -> bool:
|
||||
return not ctx.author.bot
|
||||
|
||||
async def _verify_checks(self, ctx) -> bool:
|
||||
return not ctx.author.bot
|
||||
|
||||
@ -1,21 +1,28 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
import os
|
||||
import re
|
||||
from typing import Iterable, List, Union
|
||||
from typing import Iterable, List, Union, Optional, TYPE_CHECKING
|
||||
import discord
|
||||
from discord.ext import commands
|
||||
from discord.ext.commands import Context as DPYContext
|
||||
|
||||
from .requires import PermState
|
||||
from ..utils.chat_formatting import box
|
||||
from ..utils.predicates import MessagePredicate
|
||||
from ..utils import common_filters
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .commands import Command
|
||||
from ..bot import Red
|
||||
|
||||
TICK = "\N{WHITE HEAVY CHECK MARK}"
|
||||
|
||||
__all__ = ["Context"]
|
||||
__all__ = ["Context", "GuildContext", "DMContext"]
|
||||
|
||||
|
||||
class Context(commands.Context):
|
||||
class Context(DPYContext):
|
||||
"""Command invocation context for Red.
|
||||
|
||||
All context passed into commands will be of this type.
|
||||
@ -40,6 +47,10 @@ class Context(commands.Context):
|
||||
The permission state the current context is in.
|
||||
"""
|
||||
|
||||
command: "Command"
|
||||
invoked_subcommand: "Optional[Command]"
|
||||
bot: "Red"
|
||||
|
||||
def __init__(self, **attrs):
|
||||
self.assume_yes = attrs.pop("assume_yes", False)
|
||||
super().__init__(**attrs)
|
||||
@ -254,7 +265,7 @@ class Context(commands.Context):
|
||||
return pattern.sub(f"@{me.display_name}", self.prefix)
|
||||
|
||||
@property
|
||||
def me(self) -> discord.abc.User:
|
||||
def me(self) -> Union[discord.ClientUser, discord.Member]:
|
||||
"""discord.abc.User: The bot member or user object.
|
||||
|
||||
If the context is DM, this will be a `discord.User` object.
|
||||
@ -263,3 +274,63 @@ class Context(commands.Context):
|
||||
return self.guild.me
|
||||
else:
|
||||
return self.bot.user
|
||||
|
||||
|
||||
if TYPE_CHECKING or os.getenv("BUILDING_DOCS", False):
|
||||
|
||||
class DMContext(Context):
|
||||
"""
|
||||
At runtime, this will still be a normal context object.
|
||||
|
||||
This lies about some type narrowing for type analysis in commands
|
||||
using a dm_only decorator.
|
||||
|
||||
It is only correct to use when those types are already narrowed
|
||||
"""
|
||||
|
||||
@property
|
||||
def author(self) -> discord.User:
|
||||
...
|
||||
|
||||
@property
|
||||
def channel(self) -> discord.DMChannel:
|
||||
...
|
||||
|
||||
@property
|
||||
def guild(self) -> None:
|
||||
...
|
||||
|
||||
@property
|
||||
def me(self) -> discord.ClientUser:
|
||||
...
|
||||
|
||||
class GuildContext(Context):
|
||||
"""
|
||||
At runtime, this will still be a normal context object.
|
||||
|
||||
This lies about some type narrowing for type analysis in commands
|
||||
using a guild_only decorator.
|
||||
|
||||
It is only correct to use when those types are already narrowed
|
||||
"""
|
||||
|
||||
@property
|
||||
def author(self) -> discord.Member:
|
||||
...
|
||||
|
||||
@property
|
||||
def channel(self) -> discord.TextChannel:
|
||||
...
|
||||
|
||||
@property
|
||||
def guild(self) -> discord.Guild:
|
||||
...
|
||||
|
||||
@property
|
||||
def me(self) -> discord.Member:
|
||||
...
|
||||
|
||||
|
||||
else:
|
||||
GuildContext = Context
|
||||
DMContext = Context
|
||||
|
||||
@ -1,14 +1,33 @@
|
||||
"""
|
||||
commands.converter
|
||||
==================
|
||||
This module contains useful functions and classes for command argument conversion.
|
||||
|
||||
Some of the converters within are included provisionaly and are marked as such.
|
||||
"""
|
||||
import os
|
||||
import re
|
||||
import functools
|
||||
from datetime import timedelta
|
||||
from typing import TYPE_CHECKING, Optional, List, Dict
|
||||
from typing import (
|
||||
TYPE_CHECKING,
|
||||
Generic,
|
||||
Optional,
|
||||
Optional as NoParseOptional,
|
||||
Tuple,
|
||||
List,
|
||||
Dict,
|
||||
Type,
|
||||
TypeVar,
|
||||
Literal as Literal,
|
||||
)
|
||||
|
||||
import discord
|
||||
from discord.ext import commands as dpy_commands
|
||||
from discord.ext.commands import BadArgument
|
||||
|
||||
from . import BadArgument
|
||||
from ..i18n import Translator
|
||||
from ..utils.chat_formatting import humanize_timedelta
|
||||
from ..utils.chat_formatting import humanize_timedelta, humanize_list
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .context import Context
|
||||
@ -17,10 +36,13 @@ __all__ = [
|
||||
"APIToken",
|
||||
"DictConverter",
|
||||
"GuildConverter",
|
||||
"UserInputOptional",
|
||||
"NoParseOptional",
|
||||
"TimedeltaConverter",
|
||||
"get_dict_converter",
|
||||
"get_timedelta_converter",
|
||||
"parse_timedelta",
|
||||
"Literal",
|
||||
]
|
||||
|
||||
_ = Translator("commands.converter", __file__)
|
||||
@ -67,7 +89,7 @@ def parse_timedelta(
|
||||
allowed_units : Optional[List[str]]
|
||||
If provided, you can constrain a user to expressing the amount of time
|
||||
in specific units. The units you can chose to provide are the same as the
|
||||
parser understands. `weeks` `days` `hours` `minutes` `seconds`
|
||||
parser understands. (``weeks``, ``days``, ``hours``, ``minutes``, ``seconds``)
|
||||
|
||||
Returns
|
||||
-------
|
||||
@ -138,17 +160,18 @@ class APIToken(discord.ext.commands.Converter):
|
||||
This will parse the input argument separating the key value pairs into a
|
||||
format to be used for the core bots API token storage.
|
||||
|
||||
This will split the argument by either `;` ` `, or `,` and return a dict
|
||||
This will split the argument by a space, comma, or semicolon and return a dict
|
||||
to be stored. Since all API's are different and have different naming convention,
|
||||
this leaves the onus on the cog creator to clearly define how to setup the correct
|
||||
credential names for their cogs.
|
||||
|
||||
Note: Core usage of this has been replaced with DictConverter use instead.
|
||||
Note: Core usage of this has been replaced with `DictConverter` use instead.
|
||||
|
||||
This may be removed at a later date (with warning)
|
||||
.. warning::
|
||||
This will be removed in version 3.4.
|
||||
"""
|
||||
|
||||
async def convert(self, ctx, argument) -> dict:
|
||||
async def convert(self, ctx: "Context", argument) -> dict:
|
||||
bot = ctx.bot
|
||||
result = {}
|
||||
match = re.split(r";|,| ", argument)
|
||||
@ -162,7 +185,16 @@ class APIToken(discord.ext.commands.Converter):
|
||||
return result
|
||||
|
||||
|
||||
class DictConverter(dpy_commands.Converter):
|
||||
# Below this line are a lot of lies for mypy about things that *end up* correct when
|
||||
# These are used for command conversion purposes. Please refer to the portion
|
||||
# which is *not* for type checking for the actual implementation
|
||||
# and ensure the lies stay correct for how the object should look as a typehint
|
||||
|
||||
if TYPE_CHECKING:
|
||||
DictConverter = Dict[str, str]
|
||||
else:
|
||||
|
||||
class DictConverter(dpy_commands.Converter):
|
||||
"""
|
||||
Converts pairs of space seperated values to a dict
|
||||
"""
|
||||
@ -173,7 +205,6 @@ class DictConverter(dpy_commands.Converter):
|
||||
self.pattern = re.compile(r"|".join(re.escape(d) for d in self.delims))
|
||||
|
||||
async def convert(self, ctx: "Context", argument: str) -> Dict[str, str]:
|
||||
|
||||
ret: Dict[str, str] = {}
|
||||
args = self.pattern.split(argument)
|
||||
|
||||
@ -191,12 +222,20 @@ class DictConverter(dpy_commands.Converter):
|
||||
return ret
|
||||
|
||||
|
||||
def get_dict_converter(*expected_keys: str, delims: Optional[List[str]] = None) -> type:
|
||||
if TYPE_CHECKING:
|
||||
|
||||
def get_dict_converter(*expected_keys: str, delims: Optional[List[str]] = None) -> Type[dict]:
|
||||
...
|
||||
|
||||
|
||||
else:
|
||||
|
||||
def get_dict_converter(*expected_keys: str, delims: Optional[List[str]] = None) -> Type[dict]:
|
||||
"""
|
||||
Returns a typechecking safe `DictConverter` suitable for use with discord.py
|
||||
"""
|
||||
|
||||
class PartialMeta(type(DictConverter)):
|
||||
class PartialMeta(type):
|
||||
__call__ = functools.partialmethod(
|
||||
type(DictConverter).__call__, *expected_keys, delims=delims
|
||||
)
|
||||
@ -207,7 +246,11 @@ def get_dict_converter(*expected_keys: str, delims: Optional[List[str]] = None)
|
||||
return ValidatedConverter
|
||||
|
||||
|
||||
class TimedeltaConverter(dpy_commands.Converter):
|
||||
if TYPE_CHECKING:
|
||||
TimedeltaConverter = timedelta
|
||||
else:
|
||||
|
||||
class TimedeltaConverter(dpy_commands.Converter):
|
||||
"""
|
||||
This is a converter for timedeltas.
|
||||
The units should be in order from largest to smallest.
|
||||
@ -223,11 +266,11 @@ class TimedeltaConverter(dpy_commands.Converter):
|
||||
If provided, any parsed value lower than this will raise an exception
|
||||
allowed_units : Optional[List[str]]
|
||||
If provided, you can constrain a user to expressing the amount of time
|
||||
in specific units. The units you can chose to provide are the same as the
|
||||
parser understands: `weeks` `days` `hours` `minutes` `seconds`
|
||||
in specific units. The units you can choose to provide are the same as the
|
||||
parser understands: (``weeks``, ``days``, ``hours``, ``minutes``, ``seconds``)
|
||||
default_unit : Optional[str]
|
||||
If provided, it will additionally try to match integer-only input into
|
||||
a timedelta, using the unit specified. Same units as in `allowed_units`
|
||||
a timedelta, using the unit specified. Same units as in ``allowed_units``
|
||||
apply.
|
||||
"""
|
||||
|
||||
@ -239,26 +282,41 @@ class TimedeltaConverter(dpy_commands.Converter):
|
||||
|
||||
async def convert(self, ctx: "Context", argument: str) -> timedelta:
|
||||
if self.default_unit and argument.isdecimal():
|
||||
delta = timedelta(**{self.default_unit: int(argument)})
|
||||
else:
|
||||
argument = argument + self.default_unit
|
||||
|
||||
delta = parse_timedelta(
|
||||
argument,
|
||||
minimum=self.minimum,
|
||||
maximum=self.maximum,
|
||||
allowed_units=self.allowed_units,
|
||||
)
|
||||
|
||||
if delta is not None:
|
||||
return delta
|
||||
raise BadArgument() # This allows this to be a required argument.
|
||||
|
||||
|
||||
def get_timedelta_converter(
|
||||
if TYPE_CHECKING:
|
||||
|
||||
def get_timedelta_converter(
|
||||
*,
|
||||
default_unit: Optional[str] = None,
|
||||
maximum: Optional[timedelta] = None,
|
||||
minimum: Optional[timedelta] = None,
|
||||
allowed_units: Optional[List[str]] = None,
|
||||
) -> type:
|
||||
) -> Type[timedelta]:
|
||||
...
|
||||
|
||||
|
||||
else:
|
||||
|
||||
def get_timedelta_converter(
|
||||
*,
|
||||
default_unit: Optional[str] = None,
|
||||
maximum: Optional[timedelta] = None,
|
||||
minimum: Optional[timedelta] = None,
|
||||
allowed_units: Optional[List[str]] = None,
|
||||
) -> Type[timedelta]:
|
||||
"""
|
||||
This creates a type suitable for typechecking which works with discord.py's
|
||||
commands.
|
||||
@ -273,11 +331,11 @@ def get_timedelta_converter(
|
||||
If provided, any parsed value lower than this will raise an exception
|
||||
allowed_units : Optional[List[str]]
|
||||
If provided, you can constrain a user to expressing the amount of time
|
||||
in specific units. The units you can chose to provide are the same as the
|
||||
parser understands: `weeks` `days` `hours` `minutes` `seconds`
|
||||
in specific units. The units you can choose to provide are the same as the
|
||||
parser understands: (``weeks``, ``days``, ``hours``, ``minutes``, ``seconds``)
|
||||
default_unit : Optional[str]
|
||||
If provided, it will additionally try to match integer-only input into
|
||||
a timedelta, using the unit specified. Same units as in `allowed_units`
|
||||
a timedelta, using the unit specified. Same units as in ``allowed_units``
|
||||
apply.
|
||||
|
||||
Returns
|
||||
@ -286,7 +344,7 @@ def get_timedelta_converter(
|
||||
The converter class, which will be a subclass of `TimedeltaConverter`
|
||||
"""
|
||||
|
||||
class PartialMeta(type(TimedeltaConverter)):
|
||||
class PartialMeta(type):
|
||||
__call__ = functools.partialmethod(
|
||||
type(DictConverter).__call__,
|
||||
allowed_units=allowed_units,
|
||||
@ -299,3 +357,91 @@ def get_timedelta_converter(
|
||||
pass
|
||||
|
||||
return ValidatedConverter
|
||||
|
||||
|
||||
if not TYPE_CHECKING:
|
||||
|
||||
class NoParseOptional:
|
||||
"""
|
||||
This can be used instead of `typing.Optional`
|
||||
to avoid discord.py special casing the conversion behavior.
|
||||
|
||||
.. warning::
|
||||
This converter class is still provisional.
|
||||
|
||||
.. seealso::
|
||||
The `ignore_optional_for_conversion` option of commands.
|
||||
"""
|
||||
|
||||
def __class_getitem__(cls, key):
|
||||
if isinstance(key, tuple):
|
||||
raise TypeError("Must only provide a single type to Optional")
|
||||
return key
|
||||
|
||||
|
||||
_T_OPT = TypeVar("_T_OPT", bound=Type)
|
||||
|
||||
if TYPE_CHECKING or os.getenv("BUILDING_DOCS", False):
|
||||
|
||||
class UserInputOptional(Generic[_T_OPT]):
|
||||
"""
|
||||
This can be used when user input should be converted as discord.py
|
||||
treats `typing.Optional`, but the type should not be equivalent to
|
||||
``typing.Union[DesiredType, None]`` for type checking.
|
||||
|
||||
|
||||
.. warning::
|
||||
This converter class is still provisional.
|
||||
|
||||
This class may not play well with mypy yet
|
||||
and may still require you guard this in a
|
||||
type checking conditional import vs the desired types
|
||||
|
||||
We're aware and looking into improving this.
|
||||
"""
|
||||
|
||||
def __class_getitem__(cls, key: _T_OPT) -> _T_OPT:
|
||||
if isinstance(key, tuple):
|
||||
raise TypeError("Must only provide a single type to Optional")
|
||||
return key
|
||||
|
||||
|
||||
else:
|
||||
UserInputOptional = Optional
|
||||
|
||||
|
||||
if not TYPE_CHECKING:
|
||||
|
||||
class Literal(dpy_commands.Converter):
|
||||
"""
|
||||
This can be used as a converter for `typing.Literal`.
|
||||
|
||||
In a type checking context it is `typing.Literal`.
|
||||
In a runtime context, it's a converter which only matches the literals it was given.
|
||||
|
||||
|
||||
.. warning::
|
||||
This converter class is still provisional.
|
||||
"""
|
||||
|
||||
def __init__(self, valid_names: Tuple[str]):
|
||||
self.valid_names = valid_names
|
||||
|
||||
def __call__(self, ctx, arg):
|
||||
# Callable's are treated as valid types:
|
||||
# https://github.com/python/cpython/blob/3.8/Lib/typing.py#L148
|
||||
# Without this, ``typing.Union[Literal["clear"], bool]`` would fail
|
||||
return self.convert(ctx, arg)
|
||||
|
||||
async def convert(self, ctx, arg):
|
||||
if arg in self.valid_names:
|
||||
return arg
|
||||
raise BadArgument(_("Expected one of: {}").format(humanize_list(self.valid_names)))
|
||||
|
||||
def __class_getitem__(cls, k):
|
||||
if not k:
|
||||
raise ValueError("Need at least one value for Literal")
|
||||
if isinstance(k, tuple):
|
||||
return cls(k)
|
||||
else:
|
||||
return cls((k,))
|
||||
|
||||
@ -44,6 +44,7 @@ from . import commands
|
||||
from .context import Context
|
||||
from ..i18n import Translator
|
||||
from ..utils import menus
|
||||
from ..utils.mod import mass_purge
|
||||
from ..utils._internal_utils import fuzzy_command_search, format_fuzzy_results
|
||||
from ..utils.chat_formatting import box, pagify
|
||||
|
||||
@ -162,10 +163,10 @@ class RedHelpFormatter:
|
||||
|
||||
@staticmethod
|
||||
def get_default_tagline(ctx: Context):
|
||||
return (
|
||||
f"Type {ctx.clean_prefix}help <command> for more info on a command. "
|
||||
f"You can also type {ctx.clean_prefix}help <category> for more info on a category."
|
||||
)
|
||||
return T_(
|
||||
"Type {ctx.clean_prefix}help <command> for more info on a command. "
|
||||
"You can also type {ctx.clean_prefix}help <category> for more info on a category."
|
||||
).format(ctx=ctx)
|
||||
|
||||
async def format_command_help(self, ctx: Context, obj: commands.Command):
|
||||
|
||||
@ -187,7 +188,9 @@ class RedHelpFormatter:
|
||||
|
||||
description = command.description or ""
|
||||
tagline = (await ctx.bot._config.help.tagline()) or self.get_default_tagline(ctx)
|
||||
signature = f"`Syntax: {ctx.clean_prefix}{command.qualified_name} {command.signature}`"
|
||||
signature = (
|
||||
f"`{T_('Syntax')}: {ctx.clean_prefix}{command.qualified_name} {command.signature}`"
|
||||
)
|
||||
subcommands = None
|
||||
|
||||
if hasattr(command, "all_commands"):
|
||||
@ -198,18 +201,19 @@ class RedHelpFormatter:
|
||||
emb = {"embed": {"title": "", "description": ""}, "footer": {"text": ""}, "fields": []}
|
||||
|
||||
if description:
|
||||
emb["embed"]["title"] = f"*{description[:2044]}*"
|
||||
emb["embed"]["title"] = f"*{description[:250]}*"
|
||||
|
||||
emb["footer"]["text"] = tagline
|
||||
emb["embed"]["description"] = signature
|
||||
|
||||
if command.help:
|
||||
splitted = command.help.split("\n\n")
|
||||
command_help = command.format_help_for_context(ctx)
|
||||
if command_help:
|
||||
splitted = command_help.split("\n\n")
|
||||
name = splitted[0]
|
||||
value = "\n\n".join(splitted[1:]).replace("[p]", ctx.clean_prefix)
|
||||
value = "\n\n".join(splitted[1:])
|
||||
if not value:
|
||||
value = EMPTY_STRING
|
||||
field = EmbedField(name[:252], value[:1024], False)
|
||||
field = EmbedField(name[:250], value[:1024], False)
|
||||
emb["fields"].append(field)
|
||||
|
||||
if subcommands:
|
||||
@ -220,14 +224,14 @@ class RedHelpFormatter:
|
||||
return a_line[:67] + "..."
|
||||
|
||||
subtext = "\n".join(
|
||||
shorten_line(f"**{name}** {command.short_doc}")
|
||||
shorten_line(f"**{name}** {command.format_shortdoc_for_context(ctx)}")
|
||||
for name, command in sorted(subcommands.items())
|
||||
)
|
||||
for i, page in enumerate(pagify(subtext, page_length=500, shorten_by=0)):
|
||||
if i == 0:
|
||||
title = "**__Subcommands:__**"
|
||||
title = T_("**__Subcommands:__**")
|
||||
else:
|
||||
title = "**__Subcommands:__** (continued)"
|
||||
title = T_("**__Subcommands:__** (continued)")
|
||||
field = EmbedField(title, page, False)
|
||||
emb["fields"].append(field)
|
||||
|
||||
@ -238,14 +242,14 @@ class RedHelpFormatter:
|
||||
subtext = None
|
||||
subtext_header = None
|
||||
if subcommands:
|
||||
subtext_header = "Subcommands:"
|
||||
subtext_header = T_("Subcommands:")
|
||||
max_width = max(discord.utils._string_width(name) for name in subcommands.keys())
|
||||
|
||||
def width_maker(cmds):
|
||||
doc_max_width = 80 - max_width
|
||||
for nm, com in sorted(cmds):
|
||||
width_gap = discord.utils._string_width(nm) - len(nm)
|
||||
doc = com.short_doc
|
||||
doc = com.format_shortdoc_for_context(ctx)
|
||||
if len(doc) > doc_max_width:
|
||||
doc = doc[: doc_max_width - 3] + "..."
|
||||
yield nm, doc, max_width - width_gap
|
||||
@ -261,7 +265,7 @@ class RedHelpFormatter:
|
||||
(
|
||||
description,
|
||||
signature[1:-1],
|
||||
command.help.replace("[p]", ctx.clean_prefix),
|
||||
command.format_help_for_context(ctx),
|
||||
subtext_header,
|
||||
subtext,
|
||||
),
|
||||
@ -301,7 +305,10 @@ class RedHelpFormatter:
|
||||
page_char_limit = await ctx.bot._config.help.page_char_limit()
|
||||
page_char_limit = min(page_char_limit, 5500) # Just in case someone was manually...
|
||||
|
||||
author_info = {"name": f"{ctx.me.display_name} Help Menu", "icon_url": ctx.me.avatar_url}
|
||||
author_info = {
|
||||
"name": f"{ctx.me.display_name} {T_('Help Menu')}",
|
||||
"icon_url": ctx.me.avatar_url,
|
||||
}
|
||||
|
||||
# Offset calculation here is for total embed size limit
|
||||
# 20 accounts for# *Page {i} of {page_count}*
|
||||
@ -346,7 +353,9 @@ class RedHelpFormatter:
|
||||
embed = discord.Embed(color=color, **embed_dict["embed"])
|
||||
|
||||
if page_count > 1:
|
||||
description = f"*Page {i} of {page_count}*\n{embed.description}"
|
||||
description = T_(
|
||||
"*Page {page_num} of {page_count}*\n{content_description}"
|
||||
).format(content_description=embed.description, page_num=i, page_count=page_count)
|
||||
embed.description = description
|
||||
|
||||
embed.set_author(**author_info)
|
||||
@ -366,7 +375,7 @@ class RedHelpFormatter:
|
||||
if not (coms or await ctx.bot._config.help.verify_exists()):
|
||||
return
|
||||
|
||||
description = obj.help
|
||||
description = obj.format_help_for_context(ctx)
|
||||
tagline = (await ctx.bot._config.help.tagline()) or self.get_default_tagline(ctx)
|
||||
|
||||
if await ctx.embed_requested():
|
||||
@ -376,7 +385,7 @@ class RedHelpFormatter:
|
||||
if description:
|
||||
splitted = description.split("\n\n")
|
||||
name = splitted[0]
|
||||
value = "\n\n".join(splitted[1:]).replace("[p]", ctx.clean_prefix)
|
||||
value = "\n\n".join(splitted[1:])
|
||||
if not value:
|
||||
value = EMPTY_STRING
|
||||
field = EmbedField(name[:252], value[:1024], False)
|
||||
@ -390,14 +399,14 @@ class RedHelpFormatter:
|
||||
return a_line[:67] + "..."
|
||||
|
||||
command_text = "\n".join(
|
||||
shorten_line(f"**{name}** {command.short_doc}")
|
||||
shorten_line(f"**{name}** {command.format_shortdoc_for_context(ctx)}")
|
||||
for name, command in sorted(coms.items())
|
||||
)
|
||||
for i, page in enumerate(pagify(command_text, page_length=500, shorten_by=0)):
|
||||
if i == 0:
|
||||
title = "**__Commands:__**"
|
||||
title = T_("**__Commands:__**")
|
||||
else:
|
||||
title = "**__Commands:__** (continued)"
|
||||
title = T_("**__Commands:__** (continued)")
|
||||
field = EmbedField(title, page, False)
|
||||
emb["fields"].append(field)
|
||||
|
||||
@ -407,14 +416,14 @@ class RedHelpFormatter:
|
||||
subtext = None
|
||||
subtext_header = None
|
||||
if coms:
|
||||
subtext_header = "Commands:"
|
||||
subtext_header = T_("Commands:")
|
||||
max_width = max(discord.utils._string_width(name) for name in coms.keys())
|
||||
|
||||
def width_maker(cmds):
|
||||
doc_max_width = 80 - max_width
|
||||
for nm, com in sorted(cmds):
|
||||
width_gap = discord.utils._string_width(nm) - len(nm)
|
||||
doc = com.short_doc
|
||||
doc = com.format_shortdoc_for_context(ctx)
|
||||
if len(doc) > doc_max_width:
|
||||
doc = doc[: doc_max_width - 3] + "..."
|
||||
yield nm, doc, max_width - width_gap
|
||||
@ -442,14 +451,14 @@ class RedHelpFormatter:
|
||||
|
||||
emb["footer"]["text"] = tagline
|
||||
if description:
|
||||
emb["embed"]["title"] = f"*{description[:2044]}*"
|
||||
emb["embed"]["title"] = f"*{description[:250]}*"
|
||||
|
||||
for cog_name, data in coms:
|
||||
|
||||
if cog_name:
|
||||
title = f"**__{cog_name}:__**"
|
||||
else:
|
||||
title = f"**__No Category:__**"
|
||||
title = f"**__{T_('No Category')}:__**"
|
||||
|
||||
def shorten_line(a_line: str) -> str:
|
||||
if len(a_line) < 70: # embed max width needs to be lower
|
||||
@ -457,12 +466,12 @@ class RedHelpFormatter:
|
||||
return a_line[:67] + "..."
|
||||
|
||||
cog_text = "\n".join(
|
||||
shorten_line(f"**{name}** {command.short_doc}")
|
||||
shorten_line(f"**{name}** {command.format_shortdoc_for_context(ctx)}")
|
||||
for name, command in sorted(data.items())
|
||||
)
|
||||
|
||||
for i, page in enumerate(pagify(cog_text, page_length=1000, shorten_by=0)):
|
||||
title = title if i < 1 else f"{title} (continued)"
|
||||
title = title if i < 1 else f"{title} {T_('(continued)')}"
|
||||
field = EmbedField(title, page, False)
|
||||
emb["fields"].append(field)
|
||||
|
||||
@ -478,21 +487,21 @@ class RedHelpFormatter:
|
||||
names.extend(list(v.name for v in v.values()))
|
||||
|
||||
max_width = max(
|
||||
discord.utils._string_width((name or "No Category:")) for name in names
|
||||
discord.utils._string_width((name or T_("No Category:"))) for name in names
|
||||
)
|
||||
|
||||
def width_maker(cmds):
|
||||
doc_max_width = 80 - max_width
|
||||
for nm, com in cmds:
|
||||
width_gap = discord.utils._string_width(nm) - len(nm)
|
||||
doc = com.short_doc
|
||||
doc = com.format_shortdoc_for_context(ctx)
|
||||
if len(doc) > doc_max_width:
|
||||
doc = doc[: doc_max_width - 3] + "..."
|
||||
yield nm, doc, max_width - width_gap
|
||||
|
||||
for cog_name, data in coms:
|
||||
|
||||
title = f"{cog_name}:" if cog_name else "No Category:"
|
||||
title = f"{cog_name}:" if cog_name else T_("No Category:")
|
||||
to_join.append(title)
|
||||
|
||||
for name, doc, width in width_maker(sorted(data.items())):
|
||||
@ -543,7 +552,9 @@ class RedHelpFormatter:
|
||||
if fuzzy_commands:
|
||||
ret = await format_fuzzy_results(ctx, fuzzy_commands, embed=use_embeds)
|
||||
if use_embeds:
|
||||
ret.set_author(name=f"{ctx.me.display_name} Help Menu", icon_url=ctx.me.avatar_url)
|
||||
ret.set_author(
|
||||
name=f"{ctx.me.display_name} {T_('Help Menu')}", icon_url=ctx.me.avatar_url
|
||||
)
|
||||
tagline = (await ctx.bot._config.help.tagline()) or self.get_default_tagline(ctx)
|
||||
ret.set_footer(text=tagline)
|
||||
await ctx.send(embed=ret)
|
||||
@ -553,7 +564,9 @@ class RedHelpFormatter:
|
||||
ret = T_("Help topic for *{command_name}* not found.").format(command_name=help_for)
|
||||
if use_embeds:
|
||||
ret = discord.Embed(color=(await ctx.embed_color()), description=ret)
|
||||
ret.set_author(name=f"{ctx.me.display_name} Help Menu", icon_url=ctx.me.avatar_url)
|
||||
ret.set_author(
|
||||
name=f"{ctx.me.display_name} {T_('Help Menu')}", icon_url=ctx.me.avatar_url
|
||||
)
|
||||
tagline = (await ctx.bot._config.help.tagline()) or self.get_default_tagline(ctx)
|
||||
ret.set_footer(text=tagline)
|
||||
await ctx.send(embed=ret)
|
||||
@ -569,7 +582,9 @@ class RedHelpFormatter:
|
||||
)
|
||||
if await ctx.embed_requested():
|
||||
ret = discord.Embed(color=(await ctx.embed_color()), description=ret)
|
||||
ret.set_author(name=f"{ctx.me.display_name} Help Menu", icon_url=ctx.me.avatar_url)
|
||||
ret.set_author(
|
||||
name=f"{ctx.me.display_name} {T_('Help Menu')}", icon_url=ctx.me.avatar_url
|
||||
)
|
||||
tagline = (await ctx.bot._config.help.tagline()) or self.get_default_tagline(ctx)
|
||||
ret.set_footer(text=tagline)
|
||||
await ctx.send(embed=ret)
|
||||
@ -613,18 +628,24 @@ class RedHelpFormatter:
|
||||
Sends pages based on settings.
|
||||
"""
|
||||
|
||||
if not (
|
||||
ctx.channel.permissions_for(ctx.me).add_reactions
|
||||
and await ctx.bot._config.help.use_menus()
|
||||
):
|
||||
# save on config calls
|
||||
config_help = await ctx.bot._config.help()
|
||||
channel_permissions = ctx.channel.permissions_for(ctx.me)
|
||||
|
||||
max_pages_in_guild = await ctx.bot._config.help.max_pages_in_guild()
|
||||
destination = ctx.author if len(pages) > max_pages_in_guild else ctx
|
||||
if not (channel_permissions.add_reactions and config_help["use_menus"]):
|
||||
|
||||
if embed:
|
||||
max_pages_in_guild = config_help["max_pages_in_guild"]
|
||||
use_DMs = len(pages) > max_pages_in_guild
|
||||
destination = ctx.author if use_DMs else ctx.channel
|
||||
delete_delay = config_help["delete_delay"]
|
||||
|
||||
messages: List[discord.Message] = []
|
||||
for page in pages:
|
||||
try:
|
||||
await destination.send(embed=page)
|
||||
if embed:
|
||||
msg = await destination.send(embed=page)
|
||||
else:
|
||||
msg = await destination.send(page)
|
||||
except discord.Forbidden:
|
||||
return await ctx.send(
|
||||
T_(
|
||||
@ -633,16 +654,26 @@ class RedHelpFormatter:
|
||||
)
|
||||
)
|
||||
else:
|
||||
for page in pages:
|
||||
try:
|
||||
await destination.send(page)
|
||||
except discord.Forbidden:
|
||||
return await ctx.send(
|
||||
T_(
|
||||
"I couldn't send the help message to you in DM. "
|
||||
"Either you blocked me or you disabled DMs in this server."
|
||||
)
|
||||
)
|
||||
messages.append(msg)
|
||||
|
||||
# The if statement takes into account that 'destination' will be
|
||||
# the context channel in non-DM context, reusing 'channel_permissions' to avoid
|
||||
# computing the permissions twice.
|
||||
if (
|
||||
not use_DMs # we're not in DMs
|
||||
and delete_delay > 0 # delete delay is enabled
|
||||
and channel_permissions.manage_messages # we can manage messages here
|
||||
):
|
||||
|
||||
# We need to wrap this in a task to not block after-sending-help interactions.
|
||||
# The channel has to be TextChannel as we can't bulk-delete from DMs
|
||||
async def _delete_delay_help(
|
||||
channel: discord.TextChannel, messages: List[discord.Message], delay: int
|
||||
):
|
||||
await asyncio.sleep(delay)
|
||||
await mass_purge(messages, channel)
|
||||
|
||||
asyncio.create_task(_delete_delay_help(destination, messages, delete_delay))
|
||||
else:
|
||||
# Specifically ensuring the menu's message is sent prior to returning
|
||||
m = await (ctx.send(embed=pages[0]) if embed else ctx.send(pages[0]))
|
||||
|
||||
@ -8,6 +8,8 @@ checks like bot permissions checks.
|
||||
"""
|
||||
import asyncio
|
||||
import enum
|
||||
import inspect
|
||||
from collections import ChainMap
|
||||
from typing import (
|
||||
Union,
|
||||
Optional,
|
||||
@ -20,6 +22,7 @@ from typing import (
|
||||
TypeVar,
|
||||
Tuple,
|
||||
ClassVar,
|
||||
Mapping,
|
||||
)
|
||||
|
||||
import discord
|
||||
@ -45,6 +48,7 @@ __all__ = [
|
||||
"permissions_check",
|
||||
"bot_has_permissions",
|
||||
"has_permissions",
|
||||
"has_guild_permissions",
|
||||
"is_owner",
|
||||
"guildowner",
|
||||
"guildowner_or_permissions",
|
||||
@ -52,6 +56,9 @@ __all__ = [
|
||||
"admin_or_permissions",
|
||||
"mod",
|
||||
"mod_or_permissions",
|
||||
"transition_permstate_to",
|
||||
"PermStateTransitions",
|
||||
"PermStateAllowedStates",
|
||||
]
|
||||
|
||||
_T = TypeVar("_T")
|
||||
@ -95,8 +102,8 @@ class PrivilegeLevel(enum.IntEnum):
|
||||
"""Enumeration for special privileges."""
|
||||
|
||||
# Maintainer Note: do NOT re-order these.
|
||||
# Each privelege level also implies access to the ones before it.
|
||||
# Inserting new privelege levels at a later point is fine if that is considered.
|
||||
# Each privilege level also implies access to the ones before it.
|
||||
# Inserting new privilege levels at a later point is fine if that is considered.
|
||||
|
||||
NONE = enum.auto()
|
||||
"""No special privilege level."""
|
||||
@ -182,11 +189,6 @@ class PermState(enum.Enum):
|
||||
"""This command has been actively denied by a permission hook
|
||||
check validation doesn't need this, but is useful to developers"""
|
||||
|
||||
def transition_to(
|
||||
self, next_state: "PermState"
|
||||
) -> Tuple[Optional[bool], Union["PermState", Dict[bool, "PermState"]]]:
|
||||
return self.TRANSITIONS[self][next_state]
|
||||
|
||||
@classmethod
|
||||
def from_bool(cls, value: Optional[bool]) -> "PermState":
|
||||
"""Get a PermState from a bool or ``NoneType``."""
|
||||
@ -211,7 +213,11 @@ class PermState(enum.Enum):
|
||||
# result of the default permission checks - the transition from NORMAL
|
||||
# to PASSIVE_ALLOW. In this case "next state" is a dict mapping the
|
||||
# permission check results to the actual next state.
|
||||
PermState.TRANSITIONS = {
|
||||
|
||||
TransitionResult = Tuple[Optional[bool], Union[PermState, Dict[bool, PermState]]]
|
||||
TransitionDict = Dict[PermState, Dict[PermState, TransitionResult]]
|
||||
|
||||
PermStateTransitions: TransitionDict = {
|
||||
PermState.ACTIVE_ALLOW: {
|
||||
PermState.ACTIVE_ALLOW: (True, PermState.ACTIVE_ALLOW),
|
||||
PermState.NORMAL: (True, PermState.ACTIVE_ALLOW),
|
||||
@ -248,13 +254,18 @@ PermState.TRANSITIONS = {
|
||||
PermState.ACTIVE_DENY: (False, PermState.ACTIVE_DENY),
|
||||
},
|
||||
}
|
||||
PermState.ALLOWED_STATES = (
|
||||
|
||||
PermStateAllowedStates = (
|
||||
PermState.ACTIVE_ALLOW,
|
||||
PermState.PASSIVE_ALLOW,
|
||||
PermState.CAUTIOUS_ALLOW,
|
||||
)
|
||||
|
||||
|
||||
def transition_permstate_to(prev: PermState, next_state: PermState) -> TransitionResult:
|
||||
return PermStateTransitions[prev][next_state]
|
||||
|
||||
|
||||
class Requires:
|
||||
"""This class describes the requirements for executing a specific command.
|
||||
|
||||
@ -326,13 +337,13 @@ class Requires:
|
||||
|
||||
@staticmethod
|
||||
def get_decorator(
|
||||
privilege_level: Optional[PrivilegeLevel], user_perms: Dict[str, bool]
|
||||
privilege_level: Optional[PrivilegeLevel], user_perms: Optional[Dict[str, bool]]
|
||||
) -> Callable[["_CommandOrCoro"], "_CommandOrCoro"]:
|
||||
if not user_perms:
|
||||
user_perms = None
|
||||
|
||||
def decorator(func: "_CommandOrCoro") -> "_CommandOrCoro":
|
||||
if asyncio.iscoroutinefunction(func):
|
||||
if inspect.iscoroutinefunction(func):
|
||||
func.__requires_privilege_level__ = privilege_level
|
||||
func.__requires_user_perms__ = user_perms
|
||||
else:
|
||||
@ -341,6 +352,7 @@ class Requires:
|
||||
func.requires.user_perms = None
|
||||
else:
|
||||
_validate_perms_dict(user_perms)
|
||||
assert func.requires.user_perms is not None
|
||||
func.requires.user_perms.update(**user_perms)
|
||||
return func
|
||||
|
||||
@ -357,6 +369,8 @@ class Requires:
|
||||
guild_id : int
|
||||
The ID of the guild for the rule's scope. Set to
|
||||
`Requires.GLOBAL` for a global rule.
|
||||
If a global rule is set for a model,
|
||||
it will be prefered over the guild rule.
|
||||
|
||||
Returns
|
||||
-------
|
||||
@ -367,8 +381,9 @@ class Requires:
|
||||
"""
|
||||
if not isinstance(model, (str, int)):
|
||||
model = model.id
|
||||
rules: Mapping[Union[int, str], PermState]
|
||||
if guild_id:
|
||||
rules = self._guild_rules.get(guild_id, _RulesDict())
|
||||
rules = ChainMap(self._global_rules, self._guild_rules.get(guild_id, _RulesDict()))
|
||||
else:
|
||||
rules = self._global_rules
|
||||
return rules.get(model, PermState.NORMAL)
|
||||
@ -488,7 +503,7 @@ class Requires:
|
||||
async def _transition_state(self, ctx: "Context") -> bool:
|
||||
prev_state = ctx.permission_state
|
||||
cur_state = self._get_rule_from_ctx(ctx)
|
||||
should_invoke, next_state = prev_state.transition_to(cur_state)
|
||||
should_invoke, next_state = transition_permstate_to(prev_state, cur_state)
|
||||
if should_invoke is None:
|
||||
# NORMAL invokation, we simply follow standard procedure
|
||||
should_invoke = await self._verify_user(ctx)
|
||||
@ -509,6 +524,7 @@ class Requires:
|
||||
would_invoke = await self._verify_user(ctx)
|
||||
next_state = next_state[would_invoke]
|
||||
|
||||
assert isinstance(next_state, PermState)
|
||||
ctx.permission_state = next_state
|
||||
return should_invoke
|
||||
|
||||
@ -635,6 +651,20 @@ def permissions_check(predicate: CheckPredicate):
|
||||
return decorator
|
||||
|
||||
|
||||
def has_guild_permissions(**perms):
|
||||
"""Restrict the command to users with these guild permissions.
|
||||
|
||||
This check can be overridden by rules.
|
||||
"""
|
||||
|
||||
_validate_perms_dict(perms)
|
||||
|
||||
def predicate(ctx):
|
||||
return ctx.guild and ctx.author.guild_permissions >= discord.Permissions(**perms)
|
||||
|
||||
return permissions_check(predicate)
|
||||
|
||||
|
||||
def bot_has_permissions(**perms: bool):
|
||||
"""Complain if the bot is missing permissions.
|
||||
|
||||
@ -757,16 +787,10 @@ class _RulesDict(Dict[Union[int, str], PermState]):
|
||||
|
||||
|
||||
def _validate_perms_dict(perms: Dict[str, bool]) -> None:
|
||||
invalid_keys = set(perms.keys()) - set(discord.Permissions.VALID_FLAGS)
|
||||
if invalid_keys:
|
||||
raise TypeError(f"Invalid perm name(s): {', '.join(invalid_keys)}")
|
||||
for perm, value in perms.items():
|
||||
try:
|
||||
attr = getattr(discord.Permissions, perm)
|
||||
except AttributeError:
|
||||
attr = None
|
||||
|
||||
if attr is None or not isinstance(attr, property):
|
||||
# We reject invalid permissions
|
||||
raise TypeError(f"Unknown permission name '{perm}'")
|
||||
|
||||
if value is not True:
|
||||
# We reject any permission not specified as 'True', since this is the only value which
|
||||
# makes practical sense.
|
||||
|
||||
@ -979,7 +979,7 @@ class Config:
|
||||
"""
|
||||
return self._get_base_group(self.CHANNEL, str(channel_id))
|
||||
|
||||
def channel(self, channel: discord.TextChannel) -> Group:
|
||||
def channel(self, channel: discord.abc.GuildChannel) -> Group:
|
||||
"""Returns a `Group` for the given channel.
|
||||
|
||||
This does not discriminate between text and voice channels.
|
||||
|
||||
@ -126,7 +126,7 @@ class CoreLogic:
|
||||
else:
|
||||
await bot.add_loaded_package(name)
|
||||
loaded_packages.append(name)
|
||||
# remove in Red 3.3
|
||||
# remove in Red 3.4
|
||||
downloader = bot.get_cog("Downloader")
|
||||
if downloader is None:
|
||||
continue
|
||||
@ -257,10 +257,9 @@ class CoreLogic:
|
||||
The current (or new) list of prefixes.
|
||||
"""
|
||||
if prefixes:
|
||||
prefixes = sorted(prefixes, reverse=True)
|
||||
await self.bot._config.prefix.set(prefixes)
|
||||
await self.bot._prefix_cache.set_prefixes(guild=None, prefixes=prefixes)
|
||||
return prefixes
|
||||
return await self.bot._config.prefix()
|
||||
return await self.bot._prefix_cache.get_prefixes(guild=None)
|
||||
|
||||
@classmethod
|
||||
async def _version_info(cls) -> Dict[str, str]:
|
||||
@ -320,6 +319,9 @@ class Core(commands.Cog, CoreLogic):
|
||||
python_version = "[{}.{}.{}]({})".format(*sys.version_info[:3], python_url)
|
||||
red_version = "[{}]({})".format(__version__, red_pypi)
|
||||
app_info = await self.bot.application_info()
|
||||
if app_info.team:
|
||||
owner = app_info.team.name
|
||||
else:
|
||||
owner = app_info.owner
|
||||
custom_info = await self.bot._config.custom_info()
|
||||
|
||||
@ -359,7 +361,7 @@ class Core(commands.Cog, CoreLogic):
|
||||
|
||||
@commands.command()
|
||||
async def uptime(self, ctx: commands.Context):
|
||||
"""Shows Red's uptime"""
|
||||
"""Shows [botname]'s uptime"""
|
||||
since = ctx.bot.uptime.strftime("%Y-%m-%d %H:%M:%S")
|
||||
delta = datetime.datetime.utcnow() - self.bot.uptime
|
||||
uptime_str = humanize_timedelta(timedelta=delta) or _("Less than one second")
|
||||
@ -386,6 +388,9 @@ class Core(commands.Cog, CoreLogic):
|
||||
if ctx.guild:
|
||||
guild_setting = await self.bot._config.guild(ctx.guild).embeds()
|
||||
text += _("Guild setting: {}\n").format(guild_setting)
|
||||
if ctx.channel:
|
||||
channel_setting = await self.bot._config.channel(ctx.channel).embeds()
|
||||
text += _("Channel setting: {}\n").format(channel_setting)
|
||||
user_setting = await self.bot._config.user(ctx.author).embeds()
|
||||
text += _("User setting: {}").format(user_setting)
|
||||
await ctx.send(box(text))
|
||||
@ -431,6 +436,31 @@ class Core(commands.Cog, CoreLogic):
|
||||
)
|
||||
)
|
||||
|
||||
@embedset.command(name="channel")
|
||||
@checks.guildowner_or_permissions(administrator=True)
|
||||
@commands.guild_only()
|
||||
async def embedset_channel(self, ctx: commands.Context, enabled: bool = None):
|
||||
"""
|
||||
Toggle the channel's embed setting.
|
||||
|
||||
If enabled is None, the setting will be unset and
|
||||
the guild default will be used instead.
|
||||
|
||||
If set, this is used instead of the guild default
|
||||
to determine whether or not to use embeds. This is
|
||||
used for all commands done in a channel except
|
||||
for help commands.
|
||||
"""
|
||||
await self.bot._config.channel(ctx.channel).embeds.set(enabled)
|
||||
if enabled is None:
|
||||
await ctx.send(_("Embeds will now fall back to the global setting."))
|
||||
else:
|
||||
await ctx.send(
|
||||
_("Embeds are now {} for this channel.").format(
|
||||
_("enabled") if enabled else _("disabled")
|
||||
)
|
||||
)
|
||||
|
||||
@embedset.command(name="user")
|
||||
async def embedset_user(self, ctx: commands.Context, enabled: bool = None):
|
||||
"""
|
||||
@ -472,7 +502,7 @@ class Core(commands.Cog, CoreLogic):
|
||||
@commands.command()
|
||||
@commands.check(CoreLogic._can_get_invite_url)
|
||||
async def invite(self, ctx):
|
||||
"""Show's Red's invite url"""
|
||||
"""Show's [botname]'s invite url"""
|
||||
try:
|
||||
await ctx.author.send(await self._invite_url())
|
||||
except discord.errors.Forbidden:
|
||||
@ -563,7 +593,7 @@ class Core(commands.Cog, CoreLogic):
|
||||
msg = ""
|
||||
responses = []
|
||||
for i, server in enumerate(guilds, 1):
|
||||
msg += "{}: {}\n".format(i, server.name)
|
||||
msg += "{}: {} (`{}`)\n".format(i, server.name, server.id)
|
||||
responses.append(str(i))
|
||||
|
||||
for page in pagify(msg, ["\n"]):
|
||||
@ -675,13 +705,13 @@ class Core(commands.Cog, CoreLogic):
|
||||
if len(repos_with_shared_libs) == 1:
|
||||
formed = _(
|
||||
"**WARNING**: The following repo is using shared libs"
|
||||
" which are marked for removal in Red 3.3: {repo}.\n"
|
||||
" which are marked for removal in Red 3.4: {repo}.\n"
|
||||
"You should inform maintainer of the repo about this message."
|
||||
).format(repo=inline(repos_with_shared_libs.pop()))
|
||||
else:
|
||||
formed = _(
|
||||
"**WARNING**: The following repos are using shared libs"
|
||||
" which are marked for removal in Red 3.3: {repos}.\n"
|
||||
" which are marked for removal in Red 3.4: {repos}.\n"
|
||||
"You should inform maintainers of these repos about this message."
|
||||
).format(repos=humanize_list([inline(repo) for repo in repos_with_shared_libs]))
|
||||
output.append(formed)
|
||||
@ -793,13 +823,13 @@ class Core(commands.Cog, CoreLogic):
|
||||
if len(repos_with_shared_libs) == 1:
|
||||
formed = _(
|
||||
"**WARNING**: The following repo is using shared libs"
|
||||
" which are marked for removal in Red 3.3: {repo}.\n"
|
||||
" which are marked for removal in Red 3.4: {repo}.\n"
|
||||
"You should inform maintainers of these repos about this message."
|
||||
).format(repo=inline(repos_with_shared_libs.pop()))
|
||||
else:
|
||||
formed = _(
|
||||
"**WARNING**: The following repos are using shared libs"
|
||||
" which are marked for removal in Red 3.3: {repos}.\n"
|
||||
" which are marked for removal in Red 3.4: {repos}.\n"
|
||||
"You should inform maintainers of these repos about this message."
|
||||
).format(repos=humanize_list([inline(repo) for repo in repos_with_shared_libs]))
|
||||
output.append(formed)
|
||||
@ -835,7 +865,7 @@ class Core(commands.Cog, CoreLogic):
|
||||
|
||||
@commands.group(name="set")
|
||||
async def _set(self, ctx: commands.Context):
|
||||
"""Changes Red's settings"""
|
||||
"""Changes [botname]'s settings"""
|
||||
if ctx.invoked_subcommand is None:
|
||||
if ctx.guild:
|
||||
guild = ctx.guild
|
||||
@ -847,15 +877,13 @@ class Core(commands.Cog, CoreLogic):
|
||||
mod_role_ids = await ctx.bot._config.guild(ctx.guild).mod_role()
|
||||
mod_role_names = [r.name for r in guild.roles if r.id in mod_role_ids]
|
||||
mod_roles_str = humanize_list(mod_role_names) if mod_role_names else "Not Set."
|
||||
prefixes = await ctx.bot._config.guild(ctx.guild).prefix()
|
||||
guild_settings = _("Admin roles: {admin}\nMod roles: {mod}\n").format(
|
||||
admin=admin_roles_str, mod=mod_roles_str
|
||||
)
|
||||
else:
|
||||
guild_settings = ""
|
||||
prefixes = None # This is correct. The below can happen in a guild.
|
||||
if not prefixes:
|
||||
prefixes = await ctx.bot._config.prefix()
|
||||
|
||||
prefixes = await ctx.bot._prefix_cache.get_prefixes(ctx.guild)
|
||||
locale = await ctx.bot._config.locale()
|
||||
|
||||
prefix_string = " ".join(prefixes)
|
||||
@ -873,6 +901,32 @@ class Core(commands.Cog, CoreLogic):
|
||||
for page in pagify(settings):
|
||||
await ctx.send(box(page))
|
||||
|
||||
@checks.is_owner()
|
||||
@_set.command(name="description")
|
||||
async def setdescription(self, ctx: commands.Context, *, description: str = ""):
|
||||
"""
|
||||
Sets the bot's description.
|
||||
Use without a description to reset.
|
||||
This is shown in a few locations, including the help menu.
|
||||
|
||||
The default is "Red V3"
|
||||
"""
|
||||
if not description:
|
||||
await ctx.bot._config.description.clear()
|
||||
ctx.bot.description = "Red V3"
|
||||
await ctx.send(_("Description reset."))
|
||||
elif len(description) > 250: # While the limit is 256, we bold it adding characters.
|
||||
await ctx.send(
|
||||
_(
|
||||
"This description is too long to properly display. "
|
||||
"Please try again with below 250 characters"
|
||||
)
|
||||
)
|
||||
else:
|
||||
await ctx.bot._config.description.set(description)
|
||||
ctx.bot.description = description
|
||||
await ctx.tick()
|
||||
|
||||
@_set.command()
|
||||
@checks.guildowner()
|
||||
@commands.guild_only()
|
||||
@ -997,7 +1051,7 @@ class Core(commands.Cog, CoreLogic):
|
||||
@_set.command()
|
||||
@checks.is_owner()
|
||||
async def avatar(self, ctx: commands.Context, url: str):
|
||||
"""Sets Red's avatar"""
|
||||
"""Sets [botname]'s avatar"""
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.get(url) as r:
|
||||
data = await r.read()
|
||||
@ -1021,7 +1075,7 @@ class Core(commands.Cog, CoreLogic):
|
||||
@checks.bot_in_a_guild()
|
||||
@checks.is_owner()
|
||||
async def _game(self, ctx: commands.Context, *, game: str = None):
|
||||
"""Sets Red's playing status"""
|
||||
"""Sets [botname]'s playing status"""
|
||||
|
||||
if game:
|
||||
game = discord.Game(name=game)
|
||||
@ -1035,7 +1089,7 @@ class Core(commands.Cog, CoreLogic):
|
||||
@checks.bot_in_a_guild()
|
||||
@checks.is_owner()
|
||||
async def _listening(self, ctx: commands.Context, *, listening: str = None):
|
||||
"""Sets Red's listening status"""
|
||||
"""Sets [botname]'s listening status"""
|
||||
|
||||
status = ctx.bot.guilds[0].me.status if len(ctx.bot.guilds) > 0 else discord.Status.online
|
||||
if listening:
|
||||
@ -1049,7 +1103,7 @@ class Core(commands.Cog, CoreLogic):
|
||||
@checks.bot_in_a_guild()
|
||||
@checks.is_owner()
|
||||
async def _watching(self, ctx: commands.Context, *, watching: str = None):
|
||||
"""Sets Red's watching status"""
|
||||
"""Sets [botname]'s watching status"""
|
||||
|
||||
status = ctx.bot.guilds[0].me.status if len(ctx.bot.guilds) > 0 else discord.Status.online
|
||||
if watching:
|
||||
@ -1063,7 +1117,7 @@ class Core(commands.Cog, CoreLogic):
|
||||
@checks.bot_in_a_guild()
|
||||
@checks.is_owner()
|
||||
async def status(self, ctx: commands.Context, *, status: str):
|
||||
"""Sets Red's status
|
||||
"""Sets [botname]'s status
|
||||
|
||||
Available statuses:
|
||||
online
|
||||
@ -1092,7 +1146,7 @@ class Core(commands.Cog, CoreLogic):
|
||||
@checks.bot_in_a_guild()
|
||||
@checks.is_owner()
|
||||
async def stream(self, ctx: commands.Context, streamer=None, *, stream_title=None):
|
||||
"""Sets Red's streaming status
|
||||
"""Sets [botname]'s streaming status
|
||||
Leaving both streamer and stream_title empty will clear it."""
|
||||
|
||||
status = ctx.bot.guilds[0].me.status if len(ctx.bot.guilds) > 0 else None
|
||||
@ -1113,7 +1167,7 @@ class Core(commands.Cog, CoreLogic):
|
||||
@_set.command(name="username", aliases=["name"])
|
||||
@checks.is_owner()
|
||||
async def _username(self, ctx: commands.Context, *, username: str):
|
||||
"""Sets Red's username"""
|
||||
"""Sets [botname]'s username"""
|
||||
try:
|
||||
await self._name(name=username)
|
||||
except discord.HTTPException:
|
||||
@ -1132,7 +1186,7 @@ class Core(commands.Cog, CoreLogic):
|
||||
@checks.admin()
|
||||
@commands.guild_only()
|
||||
async def _nickname(self, ctx: commands.Context, *, nickname: str = None):
|
||||
"""Sets Red's nickname"""
|
||||
"""Sets [botname]'s nickname"""
|
||||
try:
|
||||
await ctx.guild.me.edit(nick=nickname)
|
||||
except discord.Forbidden:
|
||||
@ -1143,7 +1197,7 @@ class Core(commands.Cog, CoreLogic):
|
||||
@_set.command(aliases=["prefixes"])
|
||||
@checks.is_owner()
|
||||
async def prefix(self, ctx: commands.Context, *prefixes: str):
|
||||
"""Sets Red's global prefix(es)"""
|
||||
"""Sets [botname]'s global prefix(es)"""
|
||||
if not prefixes:
|
||||
await ctx.send_help()
|
||||
return
|
||||
@ -1154,13 +1208,13 @@ class Core(commands.Cog, CoreLogic):
|
||||
@checks.admin()
|
||||
@commands.guild_only()
|
||||
async def serverprefix(self, ctx: commands.Context, *prefixes: str):
|
||||
"""Sets Red's server prefix(es)"""
|
||||
"""Sets [botname]'s server prefix(es)"""
|
||||
if not prefixes:
|
||||
await ctx.bot._config.guild(ctx.guild).prefix.set([])
|
||||
await ctx.bot._prefix_cache.set_prefixes(guild=ctx.guild, prefixes=[])
|
||||
await ctx.send(_("Guild prefixes have been reset."))
|
||||
return
|
||||
prefixes = sorted(prefixes, reverse=True)
|
||||
await ctx.bot._config.guild(ctx.guild).prefix.set(prefixes)
|
||||
await ctx.bot._prefix_cache.set_prefixes(guild=ctx.guild, prefixes=prefixes)
|
||||
await ctx.send(_("Prefix set."))
|
||||
|
||||
@_set.command()
|
||||
@ -1345,6 +1399,30 @@ class Core(commands.Cog, CoreLogic):
|
||||
await ctx.bot._config.help.max_pages_in_guild.set(pages)
|
||||
await ctx.send(_("Done. The page limit has been set to {}.").format(pages))
|
||||
|
||||
@helpset.command(name="deletedelay")
|
||||
@commands.bot_has_permissions(manage_messages=True)
|
||||
async def helpset_deletedelay(self, ctx: commands.Context, seconds: int):
|
||||
"""Set the delay after which help pages will be deleted.
|
||||
|
||||
The setting is disabled by default, and only applies to non-menu help,
|
||||
sent in server text channels.
|
||||
Setting the delay to 0 disables this feature.
|
||||
|
||||
The bot has to have MANAGE_MESSAGES permission for this to work.
|
||||
"""
|
||||
if seconds < 0:
|
||||
await ctx.send(_("You must give a value of zero or greater!"))
|
||||
return
|
||||
if seconds > 60 * 60 * 24 * 14: # 14 days
|
||||
await ctx.send(_("The delay cannot be longer than 14 days!"))
|
||||
return
|
||||
|
||||
await ctx.bot._config.help.delete_delay.set(seconds)
|
||||
if seconds == 0:
|
||||
await ctx.send(_("Done. Help messages will not be deleted now."))
|
||||
else:
|
||||
await ctx.send(_("Done. The delete delay has been set to {} seconds.").format(seconds))
|
||||
|
||||
@helpset.command(name="tagline")
|
||||
async def helpset_tagline(self, ctx: commands.Context, *, tagline: str = None):
|
||||
"""
|
||||
@ -1434,6 +1512,8 @@ class Core(commands.Cog, CoreLogic):
|
||||
if not destination.permissions_for(destination.guild.me).send_messages:
|
||||
continue
|
||||
if destination.permissions_for(destination.guild.me).embed_links:
|
||||
send_embed = await ctx.bot._config.channel(destination).embeds()
|
||||
if send_embed is None:
|
||||
send_embed = await ctx.bot._config.guild(destination.guild).embeds()
|
||||
else:
|
||||
send_embed = False
|
||||
@ -1501,12 +1581,12 @@ class Core(commands.Cog, CoreLogic):
|
||||
settings, 'appearance' tab. Then right click a user
|
||||
and copy their id"""
|
||||
destination = discord.utils.get(ctx.bot.get_all_members(), id=user_id)
|
||||
if destination is None:
|
||||
if destination is None or destination.bot:
|
||||
await ctx.send(
|
||||
_(
|
||||
"Invalid ID or user not found. You can only "
|
||||
"send messages to people I share a server "
|
||||
"with."
|
||||
"Invalid ID, user not found, or user is a bot. "
|
||||
"You can only send messages to people I share "
|
||||
"a server with."
|
||||
)
|
||||
)
|
||||
return
|
||||
|
||||
@ -271,7 +271,7 @@ class BaseDriver(abc.ABC):
|
||||
|
||||
The driver must be initialized before this operation.
|
||||
|
||||
The BaseDriver provides a generic method which may be overriden
|
||||
The BaseDriver provides a generic method which may be overridden
|
||||
by subclasses.
|
||||
|
||||
Parameters
|
||||
|
||||
@ -217,7 +217,7 @@ class JsonDriver(BaseDriver):
|
||||
|
||||
def _save_json(path: Path, data: Dict[str, Any]) -> None:
|
||||
"""
|
||||
This fsync stuff here is entirely neccessary.
|
||||
This fsync stuff here is entirely necessary.
|
||||
|
||||
On windows, it is not available in entirety.
|
||||
If a windows user ends up with tons of temp files, they should consider hosting on
|
||||
|
||||
@ -49,6 +49,11 @@ def init_events(bot, cli_flags):
|
||||
users = len(set([m for m in bot.get_all_members()]))
|
||||
|
||||
app_info = await bot.application_info()
|
||||
|
||||
if app_info.team:
|
||||
if bot._use_team_features:
|
||||
bot.owner_ids = {m.id for m in app_info.team.members}
|
||||
else:
|
||||
if bot.owner_id is None:
|
||||
bot.owner_id = app_info.owner.id
|
||||
|
||||
@ -213,6 +218,12 @@ def init_events(bot, cli_flags):
|
||||
),
|
||||
delete_after=error.retry_after,
|
||||
)
|
||||
elif isinstance(error, commands.MaxConcurrencyReached):
|
||||
await ctx.send(
|
||||
"Too many people using this command. It can only be used {} time(s) per {} concurrently.".format(
|
||||
error.number, error.per.name
|
||||
)
|
||||
)
|
||||
else:
|
||||
log.exception(type(error).__name__, exc_info=error)
|
||||
|
||||
|
||||
@ -4,18 +4,20 @@ from . import commands
|
||||
|
||||
def init_global_checks(bot):
|
||||
@bot.check_once
|
||||
def actually_up(ctx):
|
||||
def minimum_bot_perms(ctx) -> bool:
|
||||
"""
|
||||
Uptime is set during the initial startup process.
|
||||
If this hasn't been set, we should assume the bot isn't ready yet.
|
||||
Too many 403, 401, and 429 Errors can cause bots to get global'd
|
||||
|
||||
It's reasonable to assume the below as a minimum amount of perms for
|
||||
commands.
|
||||
"""
|
||||
return ctx.bot.uptime is not None
|
||||
return ctx.channel.permissions_for(ctx.me).send_messages
|
||||
|
||||
@bot.check_once
|
||||
async def whiteblacklist_checks(ctx):
|
||||
async def whiteblacklist_checks(ctx) -> bool:
|
||||
return await ctx.bot.allowed_by_whitelist_blacklist(ctx.author)
|
||||
|
||||
@bot.check_once
|
||||
def bots(ctx):
|
||||
def bots(ctx) -> bool:
|
||||
"""Check the user is not another bot."""
|
||||
return not ctx.author.bot
|
||||
|
||||
@ -142,6 +142,18 @@ async def _init(bot: Red):
|
||||
bot.add_listener(on_member_unban)
|
||||
|
||||
|
||||
async def handle_auditype_key():
|
||||
all_casetypes = {
|
||||
casetype_name: {
|
||||
inner_key: inner_value
|
||||
for inner_key, inner_value in casetype_data.items()
|
||||
if inner_key != "audit_type"
|
||||
}
|
||||
for casetype_name, casetype_data in (await _conf.custom(_CASETYPES).all()).items()
|
||||
}
|
||||
await _conf.custom(_CASETYPES).set(all_casetypes)
|
||||
|
||||
|
||||
async def _migrate_config(from_version: int, to_version: int):
|
||||
if from_version == to_version:
|
||||
return
|
||||
@ -170,16 +182,7 @@ async def _migrate_config(from_version: int, to_version: int):
|
||||
await _conf.guild(cast(discord.Guild, discord.Object(id=guild_id))).clear_raw("cases")
|
||||
|
||||
if from_version < 3 <= to_version:
|
||||
all_casetypes = {
|
||||
casetype_name: {
|
||||
inner_key: inner_value
|
||||
for inner_key, inner_value in casetype_data.items()
|
||||
if inner_key != "audit_type"
|
||||
}
|
||||
for casetype_name, casetype_data in (await _conf.custom(_CASETYPES).all()).items()
|
||||
}
|
||||
|
||||
await _conf.custom(_CASETYPES).set(all_casetypes)
|
||||
await handle_auditype_key()
|
||||
await _conf.schema_version.set(3)
|
||||
|
||||
if from_version < 4 <= to_version:
|
||||
@ -321,9 +324,7 @@ class Case:
|
||||
|
||||
if embed:
|
||||
emb = discord.Embed(title=title, description=reason)
|
||||
|
||||
if avatar_url is not None:
|
||||
emb.set_author(name=user, icon_url=avatar_url)
|
||||
emb.set_author(name=user)
|
||||
emb.add_field(name=_("Moderator"), value=moderator, inline=False)
|
||||
if until and duration:
|
||||
emb.add_field(name=_("Until"), value=until)
|
||||
@ -507,8 +508,15 @@ class CaseType:
|
||||
self.image = image
|
||||
self.case_str = case_str
|
||||
self.guild = guild
|
||||
|
||||
if "audit_type" in kwargs:
|
||||
kwargs.pop("audit_type", None)
|
||||
log.warning(
|
||||
"Fix this using the hidden command: `modlogset fixcasetypes` in Discord: "
|
||||
"Got outdated key in casetype: audit_type"
|
||||
)
|
||||
if kwargs:
|
||||
log.warning("Got unexpected keys in case %s", ",".join(kwargs.keys()))
|
||||
log.warning("Got unexpected key(s) in casetype: %s", ",".join(kwargs.keys()))
|
||||
|
||||
async def to_json(self):
|
||||
"""Transforms the case type into a dict and saves it"""
|
||||
|
||||
53
redbot/core/settings_caches.py
Normal file
53
redbot/core/settings_caches.py
Normal file
@ -0,0 +1,53 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Dict, List, Optional
|
||||
from argparse import Namespace
|
||||
|
||||
import discord
|
||||
|
||||
from .config import Config
|
||||
|
||||
|
||||
class PrefixManager:
|
||||
def __init__(self, config: Config, cli_flags: Namespace):
|
||||
self._config: Config = config
|
||||
self._global_prefix_overide: Optional[List[str]] = sorted(
|
||||
cli_flags.prefix, reverse=True
|
||||
) or None
|
||||
self._cached: Dict[Optional[int], List[str]] = {}
|
||||
|
||||
async def get_prefixes(self, guild: Optional[discord.Guild] = None) -> List[str]:
|
||||
ret: List[str]
|
||||
|
||||
gid: Optional[int] = guild.id if guild else None
|
||||
|
||||
if gid in self._cached:
|
||||
ret = self._cached[gid].copy()
|
||||
else:
|
||||
if gid is not None:
|
||||
ret = await self._config.guild_from_id(gid).prefix()
|
||||
if not ret:
|
||||
ret = await self.get_prefixes(None)
|
||||
else:
|
||||
ret = self._global_prefix_overide or (await self._config.prefix())
|
||||
|
||||
self._cached[gid] = ret.copy()
|
||||
|
||||
return ret
|
||||
|
||||
async def set_prefixes(
|
||||
self, guild: Optional[discord.Guild] = None, prefixes: Optional[List[str]] = None
|
||||
):
|
||||
gid: Optional[int] = guild.id if guild else None
|
||||
prefixes = prefixes or []
|
||||
if not isinstance(prefixes, list) and not all(isinstance(pfx, str) for pfx in prefixes):
|
||||
raise TypeError("Prefixes must be a list of strings")
|
||||
prefixes = sorted(prefixes, reverse=True)
|
||||
if gid is None:
|
||||
if not prefixes:
|
||||
raise ValueError("You must have at least one prefix.")
|
||||
self._cached.clear()
|
||||
await self._config.prefix.set(prefixes)
|
||||
else:
|
||||
del self._cached[gid]
|
||||
await self._config.guild_from_id(gid).prefix.set(prefixes)
|
||||
@ -1,4 +1,5 @@
|
||||
import asyncio
|
||||
import warnings
|
||||
from asyncio import AbstractEventLoop, as_completed, Semaphore
|
||||
from asyncio.futures import isfuture
|
||||
from itertools import chain
|
||||
@ -177,14 +178,20 @@ def bounded_gather_iter(
|
||||
TypeError
|
||||
When invalid parameters are passed
|
||||
"""
|
||||
if loop is None:
|
||||
loop = asyncio.get_event_loop()
|
||||
if loop is not None:
|
||||
warnings.warn(
|
||||
"Explicitly passing the loop will not work in Red 3.4+ and is currently ignored."
|
||||
"Call this from the related event loop.",
|
||||
DeprecationWarning,
|
||||
)
|
||||
|
||||
loop = asyncio.get_running_loop()
|
||||
|
||||
if semaphore is None:
|
||||
if not isinstance(limit, int) or limit <= 0:
|
||||
raise TypeError("limit must be an int > 0")
|
||||
|
||||
semaphore = Semaphore(limit, loop=loop)
|
||||
semaphore = Semaphore(limit)
|
||||
|
||||
pending = []
|
||||
|
||||
@ -195,7 +202,7 @@ def bounded_gather_iter(
|
||||
cof = _sem_wrapper(semaphore, cof)
|
||||
pending.append(cof)
|
||||
|
||||
return as_completed(pending, loop=loop)
|
||||
return as_completed(pending)
|
||||
|
||||
|
||||
def bounded_gather(
|
||||
@ -228,15 +235,21 @@ def bounded_gather(
|
||||
TypeError
|
||||
When invalid parameters are passed
|
||||
"""
|
||||
if loop is None:
|
||||
loop = asyncio.get_event_loop()
|
||||
if loop is not None:
|
||||
warnings.warn(
|
||||
"Explicitly passing the loop will not work in Red 3.4+ and is currently ignored."
|
||||
"Call this from the related event loop.",
|
||||
DeprecationWarning,
|
||||
)
|
||||
|
||||
loop = asyncio.get_running_loop()
|
||||
|
||||
if semaphore is None:
|
||||
if not isinstance(limit, int) or limit <= 0:
|
||||
raise TypeError("limit must be an int > 0")
|
||||
|
||||
semaphore = Semaphore(limit, loop=loop)
|
||||
semaphore = Semaphore(limit)
|
||||
|
||||
tasks = (_sem_wrapper(semaphore, task) for task in coros_or_futures)
|
||||
|
||||
return asyncio.gather(*tasks, loop=loop, return_exceptions=return_exceptions)
|
||||
return asyncio.gather(*tasks, return_exceptions=return_exceptions)
|
||||
|
||||
@ -21,7 +21,7 @@ class AntiSpam:
|
||||
|
||||
# TODO : Decorator interface for command check using `spammy`
|
||||
# with insertion of the antispam element into context
|
||||
# for manual stamping on succesful command completion
|
||||
# for manual stamping on successful command completion
|
||||
|
||||
default_intervals = [
|
||||
(timedelta(seconds=5), 3),
|
||||
|
||||
@ -5,6 +5,7 @@
|
||||
import asyncio
|
||||
import contextlib
|
||||
import functools
|
||||
import warnings
|
||||
from typing import Union, Iterable, Optional
|
||||
import discord
|
||||
|
||||
@ -200,7 +201,9 @@ def start_adding_reactions(
|
||||
await message.add_reaction(emoji)
|
||||
|
||||
if loop is None:
|
||||
loop = asyncio.get_event_loop()
|
||||
loop = asyncio.get_running_loop()
|
||||
else:
|
||||
warnings.warn("Explicitly passing the loop will not work in Red 3.4+", DeprecationWarning)
|
||||
|
||||
return loop.create_task(task())
|
||||
|
||||
|
||||
@ -38,12 +38,13 @@ async def mass_purge(messages: List[discord.Message], channel: discord.TextChann
|
||||
|
||||
"""
|
||||
while messages:
|
||||
if len(messages) > 1:
|
||||
# discord.NotFound can be raised when `len(messages) == 1` and the message does not exist.
|
||||
# As a result of this obscure behavior, this error needs to be caught just in case.
|
||||
try:
|
||||
await channel.delete_messages(messages[:100])
|
||||
except discord.errors.HTTPException:
|
||||
pass
|
||||
messages = messages[100:]
|
||||
else:
|
||||
await messages[0].delete()
|
||||
messages = []
|
||||
await asyncio.sleep(1.5)
|
||||
|
||||
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Callable, ClassVar, List, Optional, Pattern, Sequence, Tuple, Union, cast
|
||||
|
||||
|
||||
0
redbot/py.typed
Normal file
0
redbot/py.typed
Normal file
@ -76,7 +76,6 @@ def bot_repo(event_loop):
|
||||
commit="",
|
||||
url="https://empty.com/something.git",
|
||||
folder_path=cwd,
|
||||
loop=event_loop,
|
||||
)
|
||||
|
||||
|
||||
@ -163,14 +162,7 @@ def _init_test_repo(destination: Path):
|
||||
async def _session_git_repo(tmp_path_factory, event_loop):
|
||||
# we will import repo only once once per session and duplicate the repo folder
|
||||
repo_path = tmp_path_factory.mktemp("session_git_repo")
|
||||
repo = Repo(
|
||||
name="redbot-testrepo",
|
||||
url="",
|
||||
branch="master",
|
||||
commit="",
|
||||
folder_path=repo_path,
|
||||
loop=event_loop,
|
||||
)
|
||||
repo = Repo(name="redbot-testrepo", url="", branch="master", commit="", folder_path=repo_path)
|
||||
git_dirparams = _init_test_repo(repo_path)
|
||||
fast_import = sp.Popen((*git_dirparams, "fast-import", "--quiet"), stdin=sp.PIPE)
|
||||
with TEST_REPO_EXPORT_PTH.open(mode="rb") as f:
|
||||
@ -193,7 +185,6 @@ async def git_repo(_session_git_repo, tmp_path, event_loop):
|
||||
branch=_session_git_repo.branch,
|
||||
commit=_session_git_repo.commit,
|
||||
folder_path=repo_path,
|
||||
loop=event_loop,
|
||||
)
|
||||
return repo
|
||||
|
||||
@ -208,7 +199,6 @@ async def cloned_git_repo(_session_git_repo, tmp_path, event_loop):
|
||||
branch=_session_git_repo.branch,
|
||||
commit=_session_git_repo.commit,
|
||||
folder_path=repo_path,
|
||||
loop=event_loop,
|
||||
)
|
||||
sp.run(("git", "clone", str(_session_git_repo.folder_path), str(repo_path)), check=True)
|
||||
return repo
|
||||
@ -224,7 +214,6 @@ async def git_repo_with_remote(git_repo, tmp_path, event_loop):
|
||||
branch=git_repo.branch,
|
||||
commit=git_repo.commit,
|
||||
folder_path=repo_path,
|
||||
loop=event_loop,
|
||||
)
|
||||
sp.run(("git", "clone", str(git_repo.folder_path), str(repo_path)), check=True)
|
||||
return repo
|
||||
|
||||
@ -253,7 +253,8 @@ async def remove_instance(
|
||||
|
||||
backend = get_current_backend(instance)
|
||||
driver_cls = drivers.get_driver_class(backend)
|
||||
|
||||
await driver_cls.initialize(**data_manager.storage_details())
|
||||
try:
|
||||
if delete_data is True:
|
||||
await driver_cls.delete_all_data(interactive=interactive, drop_db=drop_db)
|
||||
|
||||
@ -267,6 +268,8 @@ async def remove_instance(
|
||||
safe_delete(data_path)
|
||||
|
||||
save_config(instance, {}, remove=True)
|
||||
finally:
|
||||
await driver_cls.teardown()
|
||||
print("The instance {} has been removed\n".format(instance))
|
||||
|
||||
|
||||
@ -368,8 +371,7 @@ def delete(
|
||||
remove_datapath: Optional[bool],
|
||||
):
|
||||
"""Removes an instance."""
|
||||
loop = asyncio.get_event_loop()
|
||||
loop.run_until_complete(
|
||||
asyncio.run(
|
||||
remove_instance(
|
||||
instance, interactive, delete_data, _create_backup, drop_db, remove_datapath
|
||||
)
|
||||
@ -388,14 +390,12 @@ def convert(instance, backend):
|
||||
default_dirs = deepcopy(data_manager.basic_config_default)
|
||||
default_dirs["DATA_PATH"] = str(Path(instance_data[instance]["DATA_PATH"]))
|
||||
|
||||
loop = asyncio.get_event_loop()
|
||||
|
||||
if current_backend == BackendType.MONGOV1:
|
||||
raise RuntimeError("Please see the 3.2 release notes for upgrading a bot using mongo.")
|
||||
elif current_backend == BackendType.POSTGRES: # TODO: GH-3115
|
||||
raise RuntimeError("Converting away from postgres isn't currently supported")
|
||||
else:
|
||||
new_storage_details = loop.run_until_complete(do_migration(current_backend, target))
|
||||
new_storage_details = asyncio.run(do_migration(current_backend, target))
|
||||
|
||||
if new_storage_details is not None:
|
||||
default_dirs["STORAGE_TYPE"] = target.value
|
||||
@ -419,8 +419,7 @@ def convert(instance, backend):
|
||||
)
|
||||
def backup(instance: str, destination_folder: Union[str, Path]) -> None:
|
||||
"""Backup instance's data."""
|
||||
loop = asyncio.get_event_loop()
|
||||
loop.run_until_complete(create_backup(instance, Path(destination_folder)))
|
||||
asyncio.run(create_backup(instance, Path(destination_folder)))
|
||||
|
||||
|
||||
def run_cli():
|
||||
|
||||
@ -27,7 +27,7 @@ packages = find_namespace:
|
||||
python_requires = >=3.8.1
|
||||
install_requires =
|
||||
aiohttp==3.6.2
|
||||
aiohttp-json-rpc==0.12.1
|
||||
aiohttp-json-rpc==0.12.2
|
||||
aiosqlite==0.11.0
|
||||
appdirs==1.4.3
|
||||
apsw-wheels==3.30.1.post3
|
||||
@ -38,7 +38,7 @@ install_requires =
|
||||
Click==7.0
|
||||
colorama==0.4.3
|
||||
contextlib2==0.5.5
|
||||
discord.py==1.2.5
|
||||
discord.py==1.3.1
|
||||
distro==1.4.0; sys_platform == "linux"
|
||||
fuzzywuzzy==0.17.0
|
||||
idna==2.8
|
||||
@ -46,7 +46,7 @@ install_requires =
|
||||
python-Levenshtein-wheels==0.13.1
|
||||
pytz==2019.3
|
||||
PyYAML==5.3
|
||||
Red-Lavalink==0.4.1
|
||||
Red-Lavalink==0.4.2
|
||||
schema==0.7.1
|
||||
tqdm==4.41.1
|
||||
uvloop==0.14.0; sys_platform != "win32" and platform_python_implementation == "CPython"
|
||||
@ -127,5 +127,6 @@ include =
|
||||
data/*
|
||||
data/**/*
|
||||
*.export
|
||||
py.typed
|
||||
redbot.core.drivers.postgres =
|
||||
*.sql
|
||||
|
||||
@ -12,8 +12,10 @@ _update_event_loop_policy()
|
||||
@pytest.fixture(scope="session")
|
||||
def event_loop(request):
|
||||
"""Create an instance of the default event loop for entire session."""
|
||||
loop = asyncio.get_event_loop_policy().new_event_loop()
|
||||
loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(loop)
|
||||
yield loop
|
||||
asyncio.set_event_loop(None)
|
||||
loop.close()
|
||||
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user