Merge branch 'V3/develop' into V3/feature/mutes

This commit is contained in:
Michael H 2020-01-17 20:25:45 -05:00
commit 677d700363
75 changed files with 2811 additions and 964 deletions

1
.github/CODEOWNERS vendored
View File

@ -62,3 +62,4 @@ redbot/setup.py @tekulvw
# Others # Others
.travis.yml @Kowlin .travis.yml @Kowlin
crowdin.yml @Kowlin crowdin.yml @Kowlin
.github/workflows/* @Kowlin

View File

@ -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. 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 # 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. 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: 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. 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. 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 ### 4.1 Setting up your development environment
The following requirements must be installed prior to setting up: The following requirements must be installed prior to setting up:
- Python 3.7.0 or greater - Python 3.8.1 or greater
- git - git
- pip - 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. 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: 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 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`) - 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. 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 ### 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. 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.

View File

@ -13,9 +13,14 @@ jobs:
with: with:
github-token: ${{secrets.GITHUB_TOKEN}} github-token: ${{secrets.GITHUB_TOKEN}}
script: | 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({ github.issues.addLabels({
issue_number: context.issue.number, issue_number: context.issue.number,
owner: context.repo.owner, owner: context.repo.owner,
repo: context.repo.repo, repo: context.repo.repo,
labels: ['Status: Needs Triage'] labels: ['Status: Needs Triage']
}) });

28
.github/workflows/publish_crowdin.yml vendored Normal file
View 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
View 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
View 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

View File

@ -16,7 +16,7 @@
<img src="https://img.shields.io/badge/Support-Red!-yellow.svg" alt="Support Red on Patreon!"> <img src="https://img.shields.io/badge/Support-Red!-yellow.svg" alt="Support Red on Patreon!">
</a> </a>
<a href="https://www.python.org/downloads/"> <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>
<a href="https://crowdin.com/project/red-discordbot"> <a href="https://crowdin.com/project/red-discordbot">
<img src="https://d322cqt584bo4o.cloudfront.net/red-discordbot/localized.svg" alt="Localized with Crowdin"> <img src="https://d322cqt584bo4o.cloudfront.net/red-discordbot/localized.svg" alt="Localized with Crowdin">
@ -26,8 +26,8 @@
</a> </a>
</p> </p>
<p align="center"> <p align="center">
<a href="https://travis-ci.com/Cog-Creators/Red-DiscordBot"> <a href="https://github.com/Cog-Creators/Red-DiscordBot/actions">
<img src="https://api.travis-ci.com/Cog-Creators/Red-DiscordBot.svg?branch=V3/develop" alt="Travis CI"> <img src="https://github.com/Cog-Creators/Red-DiscordBot/workflows/Tests/badge.svg" alt="GitHub Actions">
</a> </a>
<a href="http://red-discordbot.readthedocs.io/en/stable/?badge=stable"> <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"> <img src="https://readthedocs.org/projects/red-discordbot/badge/?version=stable" alt="Red on readthedocs.org">

9
docs/_templates/layout.html vendored Normal file
View 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 %}

View File

@ -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. 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`. 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 .. code-block:: none
Arguments to replace. Arguments to replace.
--name "" <Insert a name here>
A name to identify the bot within pm2, this is not your Red instance. A name to identify the bot within pm2, this is not your Red instance.
--interpreter "" <Location to your Python Interpreter>
The location of your Python interpreter, to find out where that is use the following command: The location of your Python interpreter, to find out where that is use the following command inside activated venv:
which python3.6 which python
<Red Instance> <Red Instance>
The name of your Red instance. The name of your Red instance.

View File

@ -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 # If redbot is installed in a virtualenv
source redenv/bin/activate source redenv/bin/activate
which python
# If you are using pyenv # If you are using pyenv
pyenv shell <name> pyenv shell <name>
pyenv which python
which redbot
Then create the new service file: 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 After=multi-user.target
[Service] [Service]
ExecStart=path %I --no-prompt ExecStart=path -O -m redbot %I --no-prompt
User=username User=username
Group=username Group=username
Type=idle Type=idle
@ -71,4 +71,4 @@ type the following command in the terminal, still by adding the instance name af
To view Reds log, you can acccess through journalctl: To view Reds log, you can acccess through journalctl:
:code:`sudo journalctl -u red@instancename` :code:`sudo journalctl -eu red@instancename`

View File

@ -1,5 +1,68 @@
.. 3.2.x Changelogs .. 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) 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>`_) - ``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>`_) - 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>`_) - 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>`_) - 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>`_) - ``[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 ``[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 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>`_) - 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
View 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.

View File

@ -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. 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: Example:
@ -60,3 +60,16 @@ Event Reference
:type service_name: :class:`str` :type service_name: :class:`str`
:param api_tokens: New Mapping of token names to tokens. This contains api tokens that weren't changed too. :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`] :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

View File

@ -7,7 +7,7 @@ Commands Package
This package acts almost identically to :doc:`discord.ext.commands <dpy:ext/commands/api>`; i.e. 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. 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 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 .. autofunction:: redbot.core.commands.command
@ -15,6 +15,7 @@ extend functionlities used throughout the bot, as outlined below.
.. autoclass:: redbot.core.commands.Command .. autoclass:: redbot.core.commands.Command
:members: :members:
:inherited-members: format_help_for_context
.. autoclass:: redbot.core.commands.Group .. autoclass:: redbot.core.commands.Group
:members: :members:
@ -22,5 +23,14 @@ extend functionlities used throughout the bot, as outlined below.
.. autoclass:: redbot.core.commands.Context .. autoclass:: redbot.core.commands.Context
:members: :members:
.. autoclass:: redbot.core.commands.GuildContext
.. autoclass:: redbot.core.commands.DMContext
.. automodule:: redbot.core.commands.requires .. automodule:: redbot.core.commands.requires
:members: PrivilegeLevel, PermState, Requires :members: PrivilegeLevel, PermState, Requires
.. automodule:: redbot.core.commands.converter
:members:
:exclude-members: convert
:no-undoc-members:

View File

@ -25,7 +25,7 @@ Basic Usage
async def ban(self, ctx, user: discord.Member, reason: str = None): async def ban(self, ctx, user: discord.Member, reason: str = None):
await ctx.guild.ban(user) await ctx.guild.ban(user)
case = await modlog.create_case( 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 user=user, moderator=ctx.author, reason=reason
) )
await ctx.send("Done. It was about time.") await ctx.send("Done. It was about time.")

View File

@ -81,5 +81,5 @@ Keys specific to the cog info.json (case sensitive)
``SHARED_LIBRARY``. If ``SHARED_LIBRARY`` then ``hidden`` will be ``True``. ``SHARED_LIBRARY``. If ``SHARED_LIBRARY`` then ``hidden`` will be ``True``.
.. warning:: .. 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.

View File

@ -57,6 +57,7 @@ Welcome to Red - Discord Bot's documentation!
:maxdepth: 2 :maxdepth: 2
:caption: Changelogs: :caption: Changelogs:
changelog_3_3_0
release_notes_3_2_0 release_notes_3_2_0
changelog_3_2_0 changelog_3_2_0
changelog_3_1_0 changelog_3_1_0

View File

@ -19,12 +19,22 @@ Please install the pre-requirements using the commands listed for your operating
The pre-requirements are: The pre-requirements are:
- Python 3.8.1 or greater - Python 3.8.1 or greater
- Pip 18.1 or greater - Pip 18.1 or greater
- Git - Git 2.11+
- Java Runtime Environment 11 or later (for audio support) - Java Runtime Environment 11 or later (for audio support)
We also recommend installing some basic compiler tools, in case our dependencies don't provide We also recommend installing some basic compiler tools, in case our dependencies don't provide
pre-built "wheels" for your architecture. pre-built "wheels" for your architecture.
*****************
Operating systems
*****************
.. contents::
:local:
----
.. _install-arch: .. _install-arch:
~~~~~~~~~~ ~~~~~~~~~~
@ -35,6 +45,10 @@ Arch Linux
sudo pacman -Syu python python-pip git jre-openjdk-headless base-devel sudo pacman -Syu python python-pip git jre-openjdk-headless base-devel
Continue by `creating-venv-linux`.
----
.. _install-centos: .. _install-centos:
.. _install-rhel: .. _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>`. 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-debian:
.. _install-raspbian: .. _install-raspbian:
~~~~~~~~~~~~~~~~~~~ ~~~~~~~~~~~~~~~~~~~~~~~~~~
Debian and Raspbian Debian and Raspbian Buster
~~~~~~~~~~~~~~~~~~~ ~~~~~~~~~~~~~~~~~~~~~~~~~~
We recommend installing pyenv as a method of installing non-native versions of python on 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 .. 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>`. Complete the rest of the installation by `installing Python 3.8 with pyenv <install-python-pyenv>`.
----
.. _install-fedora: .. _install-fedora:
~~~~~~~~~~~~ ~~~~~~~~~~~~
@ -84,6 +148,10 @@ them with dnf:
sudo dnf -y install python38 git java-latest-openjdk-headless @development-tools sudo dnf -y install python38 git java-latest-openjdk-headless @development-tools
Continue by `creating-venv-linux`.
----
.. _install-mac: .. _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 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``. the Python 3.8 folder then double click ``Install certificates.command``.
Continue by `creating-venv-linux`.
----
.. _install-opensuse: .. _install-opensuse:
~~~~~~~~ ~~~~~~~~
@ -150,6 +222,8 @@ Now, install pip with easy_install:
sudo /opt/python/bin/easy_install-3.8 pip sudo /opt/python/bin/easy_install-3.8 pip
Continue by `creating-venv-linux`.
openSUSE Tumbleweed openSUSE Tumbleweed
******************* *******************
@ -161,35 +235,74 @@ with zypper:
sudo zypper install python3-base python3-pip git-core java-12-openjdk-headless sudo zypper install python3-base python3-pip git-core java-12-openjdk-headless
sudo zypper install -t pattern devel_basis sudo zypper install -t pattern devel_basis
Continue by `creating-venv-linux`.
----
.. _install-ubuntu: .. _install-ubuntu:
~~~~~~ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
Ubuntu Ubuntu LTS versions (18.04 and 16.04)
~~~~~~ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
.. note:: **Ubuntu Python Availability** We recommend adding the ``git-core`` ppa to install Git 2.11 or greater:
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:
.. code-block:: none .. code-block:: none
sudo apt update 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 \ sudo apt -y install python3.8 python3.8-dev python3.8-venv python3-pip git default-jre-headless \
build-essential 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: .. _install-python-pyenv:
~~~~~~~~~~~~~~~~~~~~~~~~~~~~ ****************************
Installing Python with pyenv Installing Python with pyenv
~~~~~~~~~~~~~~~~~~~~~~~~~~~~ ****************************
.. note:: .. 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. 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 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`. straightforward. See the section `installing-in-virtual-environment`.
.. _installing-red-linux-mac: .. _installing-red-linux-mac:
@ -242,31 +359,25 @@ Installing Red
Choose one of the following commands to install 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: To install without additional config backend support:
.. code-block:: none .. code-block:: none
python3.8 -m pip install -U setuptools wheel python -m pip install -U pip setuptools wheel
python3.8 -m pip install -U Red-DiscordBot python -m pip install -U Red-DiscordBot
Or, to install with PostgreSQL support: Or, to install with PostgreSQL support:
.. code-block:: none .. code-block:: none
python3.8 -m pip install -U setuptools wheel python -m pip install -U pip setuptools wheel
python3.8 -m pip install -U Red-DiscordBot[postgres] python -m pip install -U Red-DiscordBot[postgres]
.. note::
These commands are also used for updating Red
-------------------------- --------------------------
Setting Up and Running Red Setting Up and Running Red
-------------------------- --------------------------

View File

@ -64,6 +64,13 @@ Manually installing dependencies
.. _installing-red-windows: .. _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 Installing Red
-------------- --------------
@ -72,34 +79,27 @@ Installing Red
for the PATH changes to take effect. for the PATH changes to take effect.
1. Open a command prompt (open Start, search for "command prompt", then click it) 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` 2. Run **one** of the following set of commands, depending on what extras you want installed
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
* Normal installation: * Normal installation:
.. code-block:: none .. 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 python -m pip install -U Red-DiscordBot
* With PostgreSQL support: * With PostgreSQL support:
.. code-block:: none .. 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] python -m pip install -U Red-DiscordBot[postgres]
.. note::
These commands are also used for updating Red
-------------------------- --------------------------
Setting Up and Running Red Setting Up and Running Red
-------------------------- --------------------------

View File

@ -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-venv` (quick and easy, involves two commands)
* :ref:`using-pyenv-virtualenv` (recommended if you installed Python with pyenv) * :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?** **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 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 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. 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 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 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 ``venv`` on Linux or Mac
~~~~~~~~~~~~~~~~~~~~~~~~ ~~~~~~~~~~~~~~~~~~~~~~~~
Create your virtual environment with the following command:: 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:: And activate it with the following command::
source redenv/bin/activate source ~/redenv/bin/activate
.. important:: .. important::
@ -56,11 +51,11 @@ Continue reading `below <after-activating-virtual-environment>`.
~~~~~~~~~~~~~~~~~~~ ~~~~~~~~~~~~~~~~~~~
Create your virtual environment with the following command:: 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:: And activate it with the following command::
redenv\Scripts\activate.bat %userprofile%\redenv\Scripts\activate.bat
.. important:: .. important::

View File

@ -181,9 +181,7 @@ class VersionInfo:
def _update_event_loop_policy(): def _update_event_loop_policy():
if _sys.platform == "win32": if _sys.implementation.name == "cpython":
_asyncio.set_event_loop_policy(_asyncio.WindowsProactorEventLoopPolicy())
elif _sys.implementation.name == "cpython":
# Let's not force this dependency, uvloop is much faster on cpython # Let's not force this dependency, uvloop is much faster on cpython
try: try:
import uvloop as _uvloop import uvloop as _uvloop
@ -193,7 +191,7 @@ def _update_event_loop_policy():
_asyncio.set_event_loop_policy(_uvloop.EventLoopPolicy()) _asyncio.set_event_loop_policy(_uvloop.EventLoopPolicy())
__version__ = "3.2.3.dev1" __version__ = "3.3.2.dev1"
version_info = VersionInfo.from_str(__version__) version_info = VersionInfo.from_str(__version__)
# Filter fuzzywuzzy slow sequence matcher warning # Filter fuzzywuzzy slow sequence matcher warning

View File

@ -1,7 +1,5 @@
#!/usr/bin/env python #!/usr/bin/env python
# Discord Version check
import asyncio import asyncio
import functools import functools
import getpass import getpass
@ -16,10 +14,11 @@ import sys
from argparse import Namespace from argparse import Namespace
from copy import deepcopy from copy import deepcopy
from pathlib import Path from pathlib import Path
from typing import NoReturn
import discord 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, # calls, in particular those as a result of the following imports,
# return the correct loop object. # return the correct loop object.
from redbot import _update_event_loop_policy, __version__ 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 no_prompt = cli_flags.no_prompt
token = cli_flags.token token = cli_flags.token
owner = cli_flags.owner owner = cli_flags.owner
prefix = cli_flags.prefix
old_name = cli_flags.instance_name old_name = cli_flags.instance_name
new_name = cli_flags.edit_instance_name new_name = cli_flags.edit_instance_name
data_path = cli_flags.edit_data_path 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: if new_name is None and confirm_overwrite:
print("--overwrite-existing-instance can't be used without --edit-instance-name argument") print("--overwrite-existing-instance can't be used without --edit-instance-name argument")
sys.exit(1) 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( print(
"No arguments to edit were provided. Available arguments (check help for more " "No arguments to edit were provided."
"information): --edit-instance-name, --edit-data-path, --copy-data, --owner, --token" " Available arguments (check help for more information):"
" --edit-instance-name, --edit-data-path, --copy-data, --owner, --token, --prefix"
) )
sys.exit(1) sys.exit(1)
await _edit_token(red, token, no_prompt) await _edit_token(red, token, no_prompt)
await _edit_prefix(red, prefix, no_prompt)
await _edit_owner(red, owner, no_prompt) await _edit_owner(red, owner, no_prompt)
data = deepcopy(data_manager.basic_config) data = deepcopy(data_manager.basic_config)
@ -151,6 +157,26 @@ async def _edit_token(red, token, no_prompt):
print("Token updated.\n") 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): async def _edit_owner(red, owner, no_prompt):
if owner: if owner:
if not (15 <= len(str(owner)) <= 21): 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. 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) 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) red = Red(cli_flags=cli_flags, description="Red V3", dm_help=None, fetch_offline_members=True)
try: try:
@ -282,12 +309,24 @@ def handle_edit(cli_flags: Namespace):
print("Aborted!") print("Aborted!")
finally: finally:
loop.run_until_complete(asyncio.sleep(1)) loop.run_until_complete(asyncio.sleep(1))
asyncio.set_event_loop(None)
loop.stop() loop.stop()
loop.close() loop.close()
sys.exit(0) 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() 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?"): if confirm("\nDo you want to reset the token?"):
await red._config.token.set("") await red._config.token.set("")
print("Token has been reset.") print("Token has been reset.")
sys.exit(0)
sys.exit(1)
return None
def handle_early_exit_flags(cli_flags: Namespace): def handle_early_exit_flags(cli_flags: Namespace):
@ -417,7 +460,8 @@ def main():
handle_edit(cli_flags) handle_edit(cli_flags)
return return
try: try:
loop = asyncio.get_event_loop() loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
if cli_flags.no_instance: if cli_flags.no_instance:
print( print(
@ -474,14 +518,14 @@ def main():
# Allows transports to close properly, and prevent new ones from being opened. # Allows transports to close properly, and prevent new ones from being opened.
# Transports may still not be closed correcly on windows, see below # Transports may still not be closed correcly on windows, see below
loop.run_until_complete(loop.shutdown_asyncgens()) loop.run_until_complete(loop.shutdown_asyncgens())
if os.name == "nt":
# *we* aren't cleaning up more here, but it prevents # *we* aren't cleaning up more here, but it prevents
# a runtime error at the event loop on windows # a runtime error at the event loop on windows
# with resources which require longer to clean up. # with resources which require longer to clean up.
# With other event loops, a failure to cleanup prior to here # 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") 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.stop()
loop.close() loop.close()
exit_code = red._shutdown_mode if red is not None else 1 exit_code = red._shutdown_mode if red is not None else 1

View File

@ -116,12 +116,19 @@ class Admin(commands.Cog):
:param role: :param role:
:return: :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): async def _addrole(
if member is None: self, ctx: commands.Context, member: discord.Member, role: discord.Role, *, check_user=True
member = ctx.author ):
if not self.pass_user_hierarchy_check(ctx, role): 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)) await ctx.send(_(USER_HIERARCHY_ISSUE_ADD).format(role=role, member=member))
return return
if not self.pass_hierarchy_check(ctx, role): 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): async def _removerole(
if member is None: self, ctx: commands.Context, member: discord.Member, role: discord.Role, *, check_user=True
member = ctx.author ):
if not self.pass_user_hierarchy_check(ctx, role): 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)) await ctx.send(_(USER_HIERARCHY_ISSUE_REMOVE).format(role=role, member=member))
return return
if not self.pass_hierarchy_check(ctx, role): if not self.pass_hierarchy_check(ctx, role):
@ -365,7 +379,7 @@ class Admin(commands.Cog):
NOTE: The role is case sensitive! NOTE: The role is case sensitive!
""" """
# noinspection PyTypeChecker # noinspection PyTypeChecker
await self._addrole(ctx, ctx.author, selfrole) await self._addrole(ctx, ctx.author, selfrole, check_user=False)
@selfrole.command(name="remove") @selfrole.command(name="remove")
async def selfrole_remove(self, ctx: commands.Context, *, selfrole: SelfRole): async def selfrole_remove(self, ctx: commands.Context, *, selfrole: SelfRole):
@ -376,7 +390,7 @@ class Admin(commands.Cog):
NOTE: The role is case sensitive! NOTE: The role is case sensitive!
""" """
# noinspection PyTypeChecker # noinspection PyTypeChecker
await self._removerole(ctx, ctx.author, selfrole) await self._removerole(ctx, ctx.author, selfrole, check_user=False)
@selfrole.command(name="list") @selfrole.command(name="list")
async def selfrole_list(self, ctx: commands.Context): async def selfrole_list(self, ctx: commands.Context):
@ -406,6 +420,13 @@ class Admin(commands.Cog):
NOTE: The role is case sensitive! 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: async with self.conf.guild(ctx.guild).selfroles() as curr_selfroles:
if role.id not in curr_selfroles: if role.id not in curr_selfroles:
curr_selfroles.append(role.id) curr_selfroles.append(role.id)
@ -421,6 +442,13 @@ class Admin(commands.Cog):
NOTE: The role is case sensitive! 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: async with self.conf.guild(ctx.guild).selfroles() as curr_selfroles:
curr_selfroles.remove(role.id) curr_selfroles.remove(role.id)

View File

@ -70,12 +70,12 @@ class Announcer:
failed.append(str(g.id)) failed.append(str(g.id))
await asyncio.sleep(0.5) await asyncio.sleep(0.5)
if failed:
msg = ( msg = (
_("I could not announce to the following server: ") _("I could not announce to the following server: ")
if len(failed) == 1 if len(failed) == 1
else _("I could not announce to the following servers: ") else _("I could not announce to the following servers: ")
) )
if failed:
msg += humanize_list(tuple(map(inline, failed))) msg += humanize_list(tuple(map(inline, failed)))
await self.ctx.bot.send_to_owners(msg) await self.ctx.bot.send_to_owners(msg)
self.active = False self.active = False

View File

@ -90,7 +90,7 @@ class Alias(commands.Cog):
def is_command(self, alias_name: str) -> bool: 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 The function name can be changed when alias is reworked
""" """
command = self.bot.get_command(alias_name) command = self.bot.get_command(alias_name)

View File

@ -746,13 +746,17 @@ class MusicCache:
(val, update) = await self.database.fetch_one("lavalink", "data", {"query": query}) (val, update) = await self.database.fetch_one("lavalink", "data", {"query": query})
if update: if update:
val = None val = None
if val and not isinstance(val, str): if val and isinstance(val, dict):
log.debug(f"Querying Local Database for {query}") log.debug(f"Querying Local Database for {query}")
task = ("update", ("lavalink", {"query": query})) task = ("update", ("lavalink", {"query": query}))
self.append_task(ctx, *task) 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 = val
data["query"] = query data["query"] = query
if data.get("loadType") == "V2_COMPACT":
data["loadType"] = "V2_COMPAT"
results = LoadResult(data) results = LoadResult(data)
called_api = False called_api = False
if results.has_error: if results.has_error:
@ -778,6 +782,10 @@ class MusicCache:
): ):
with contextlib.suppress(SQLError): with contextlib.suppress(SQLError):
time_now = int(datetime.datetime.now(datetime.timezone.utc).timestamp()) 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 = ( task = (
"insert", "insert",
( (
@ -785,7 +793,7 @@ class MusicCache:
[ [
{ {
"query": query, "query": query,
"data": json.dumps(results._raw), "data": data,
"last_updated": time_now, "last_updated": time_now,
"last_fetched": time_now, "last_fetched": time_now,
} }
@ -853,10 +861,12 @@ class MusicCache:
query_data["maxage"] = maxage_int query_data["maxage"] = maxage_int
vals = await self.database.fetch_all("lavalink", "data", query_data) 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: if recently_played:
track = random.choice(recently_played) track = random.choice(recently_played)
if track.get("loadType") == "V2_COMPACT":
track["loadType"] = "V2_COMPAT"
results = LoadResult(track) results = LoadResult(track)
tracks = list(results.tracks) tracks = list(results.tracks)
except Exception: except Exception:

View File

@ -67,7 +67,7 @@ from .utils import *
_ = Translator("Audio", __file__) _ = Translator("Audio", __file__)
__version__ = "1.1.0" __version__ = "1.1.1"
__author__ = ["aikaterna", "Draper"] __author__ = ["aikaterna", "Draper"]
log = logging.getLogger("red.audio") log = logging.getLogger("red.audio")
@ -245,11 +245,16 @@ class Audio(commands.Cog):
for t in tracks_in_playlist: for t in tracks_in_playlist:
uri = t.get("info", {}).get("uri") uri = t.get("info", {}).get("uri")
if 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( database_entries.append(
{ {
"query": uri, "query": uri,
"data": json.dumps(t), "data": data,
"last_updated": time_now, "last_updated": time_now,
"last_fetched": time_now, "last_fetched": time_now,
} }
@ -530,15 +535,16 @@ class Audio(commands.Cog):
player_check = await self._players_check() player_check = await self._players_check()
await self._status_check(*player_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") notify_channel = player.fetch("channel")
if notify_channel: if notify_channel and notify:
notify_channel = self.bot.get_channel(notify_channel) notify_channel = self.bot.get_channel(notify_channel)
await self._embed_msg(notify_channel, title=_("Queue Ended.")) 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) self.bot.dispatch("red_audio_audio_disconnect", guild)
await player.disconnect() await player.disconnect()
if event_type == lavalink.LavalinkEvents.QUEUE_END and status: if status:
player_check = await self._players_check() player_check = await self._players_check()
await self._status_check(*player_check) await self._status_check(*player_check)
@ -690,7 +696,7 @@ class Audio(commands.Cog):
async def dc(self, ctx: commands.Context): async def dc(self, ctx: commands.Context):
"""Toggle the bot auto-disconnecting when done playing. """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() 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( msg += _("Auto-disconnection at queue end: {true_or_false}.").format(
true_or_false=_("Enabled") if not disconnect else _("Disabled") 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: if disconnect is not True and autoplay is True:
msg += _("\nAuto-play has been disabled.") msg += _("\nAuto-play has been disabled.")
await self.config.guild(ctx.guild).auto_play.set(False) 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. """Set a playlist to auto-play songs from.
**Usage**: **Usage**:
[p]audioset autoplay playlist_name_OR_id args `[p]audioset autoplay playlist_name_OR_id [args]`
**Args**: **Args**:
The following are all optional: The following are all optional:
@ -1140,16 +1145,16 @@ class Audio(commands.Cog):
Exact guild name Exact guild name
Example use: Example use:
[p]audioset autoplay MyGuildPlaylist `[p]audioset autoplay MyGuildPlaylist`
[p]audioset autoplay MyGlobalPlaylist --scope Global `[p]audioset autoplay MyGlobalPlaylist --scope Global`
[p]audioset autoplay PersonalPlaylist --scope User --author Draper `[p]audioset autoplay PersonalPlaylist --scope User --author Draper`
""" """
if scope_data is None: 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, author, guild, specified_user = scope_data
try: 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 ctx, playlist_matches, scope, author, guild, specified_user
) )
except TooManyMatches as e: except TooManyMatches as e:
@ -1253,7 +1258,10 @@ class Audio(commands.Cog):
@audioset.command() @audioset.command()
@checks.mod_or_permissions(administrator=True) @checks.mod_or_permissions(administrator=True)
async def emptydisconnect(self, ctx: commands.Context, seconds: int): 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: if seconds < 0:
return await self._embed_msg( return await self._embed_msg(
ctx, title=_("Invalid Time"), description=_("Seconds can't be less than zero.") 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): if not await self._localtracks_check(ctx):
return 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( async def _folder_list(
self, ctx: commands.Context, query: audio_dataclasses.Query self, ctx: commands.Context, query: audio_dataclasses.Query
@ -2454,9 +2466,9 @@ class Audio(commands.Cog):
if not query.track.exists(): if not query.track.exists():
return return
return ( return (
query.track.tracks_in_tree() await query.track.tracks_in_tree()
if query.search_subfolders if query.search_subfolders
else query.track.tracks_in_folder() else await query.track.tracks_in_folder()
) )
async def _folder_tracks( async def _folder_tracks(
@ -2495,9 +2507,9 @@ class Audio(commands.Cog):
return return
return ( return (
query.track.tracks_in_tree() await query.track.tracks_in_tree()
if query.search_subfolders 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: 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) return await self._embed_msg(ctx, embed=embed)
elif isinstance(tracks, discord.Message): elif isinstance(tracks, discord.Message):
return return
queue_dur = await queue_duration(ctx) queue_dur = await track_remaining_duration(ctx)
lavalink.utils.format_time(queue_dur)
index = query.track_index index = query.track_index
seek = 0 seek = 0
if query.start_time: if query.start_time:
@ -3822,7 +3833,7 @@ class Audio(commands.Cog):
author: discord.User, author: discord.User,
guild: discord.Guild, guild: discord.Guild,
specified_user: bool = False, specified_user: bool = False,
) -> Tuple[Optional[int], str]: ) -> Tuple[Optional[int], str, str]:
""" """
Parameters Parameters
---------- ----------
@ -3851,34 +3862,57 @@ class Audio(commands.Cog):
""" """
correct_scope_matches: List[Playlist] correct_scope_matches: List[Playlist]
original_input = matches.get("arg") 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) correct_scope_matches_temp: MutableMapping = matches.get(scope)
guild_to_query = guild.id guild_to_query = guild.id
user_to_query = author.id user_to_query = author.id
correct_scope_matches_user = []
correct_scope_matches_guild = []
correct_scope_matches_global = []
if not correct_scope_matches_temp: if not correct_scope_matches_temp:
return None, original_input return None, original_input, scope or PlaylistScope.GUILD.value
if scope == PlaylistScope.USER.value: if lazy_match or (scope == PlaylistScope.USER.value):
correct_scope_matches = [ correct_scope_matches_user = [
p for p in correct_scope_matches_temp if user_to_query == p.scope_id 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: if specified_user:
correct_scope_matches = [ correct_scope_matches_guild = [
p 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 if guild_to_query == p.scope_id and p.author == user_to_query
] ]
else: else:
correct_scope_matches = [ correct_scope_matches_guild = [
p for p in correct_scope_matches_temp if guild_to_query == p.scope_id 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: if specified_user:
correct_scope_matches = [ correct_scope_matches_global = [
p for p in correct_scope_matches_temp if p.author == user_to_query p
for p in matches.get(PlaylistScope.USGLOBALER.value)
if p.author == user_to_query
] ]
else: 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) match_count = len(correct_scope_matches)
if match_count > 1: if match_count > 1:
correct_scope_matches2 = [ correct_scope_matches2 = [
@ -3905,14 +3939,15 @@ class Audio(commands.Cog):
).format(match_count=match_count, original_input=original_input) ).format(match_count=match_count, original_input=original_input)
) )
elif match_count == 1: 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: 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 # TODO : Convert this section to a new paged reaction menu when Toby Menus are Merged
pos_len = 3 pos_len = 3
playlists = f"{'#':{pos_len}}\n" playlists = f"{'#':{pos_len}}\n"
number = 0 number = 0
correct_scope_matches = sorted(correct_scope_matches, key=lambda x: x.name.lower())
for number, playlist in enumerate(correct_scope_matches, 1): for number, playlist in enumerate(correct_scope_matches, 1):
author = self.bot.get_user(playlist.author) or playlist.author or _("Unknown") author = self.bot.get_user(playlist.author) or playlist.author or _("Unknown")
line = _( line = _(
@ -3925,7 +3960,7 @@ class Audio(commands.Cog):
).format( ).format(
number=number, number=number,
playlist=playlist, playlist=playlist,
scope=humanize_scope(scope), scope=humanize_scope(playlist.scope),
tracks=len(playlist.tracks), tracks=len(playlist.tracks),
author=author, author=author,
) )
@ -3961,7 +3996,11 @@ class Audio(commands.Cog):
) )
with contextlib.suppress(discord.HTTPException): with contextlib.suppress(discord.HTTPException):
await msg.delete() 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.group()
@commands.guild_only() @commands.guild_only()
@ -3996,7 +4035,7 @@ class Audio(commands.Cog):
The track(s) will be appended to the end of the playlist. The track(s) will be appended to the end of the playlist.
**Usage**: **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**: **Args**:
The following are all optional: The following are all optional:
@ -4019,18 +4058,17 @@ class Audio(commands.Cog):
Exact guild name Exact guild name
Example use: Example use:
[p]playlist append MyGuildPlaylist Hello by Adele `[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`
[p]playlist append MyGlobalPlaylist Hello by Adele --scope Global `[p]playlist append MyGlobalPlaylist Hello by Adele --scope Global --Author Draper#6666`
--Author Draper#6666
""" """
if scope_data is None: 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, author, guild, specified_user) = scope_data
if not await self._playlist_check(ctx): if not await self._playlist_check(ctx):
return return
try: 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 ctx, playlist_matches, scope, author, guild, specified_user
) )
except TooManyMatches as e: except TooManyMatches as e:
@ -4144,8 +4182,8 @@ class Audio(commands.Cog):
else None, else None,
) )
@commands.cooldown(1, 300, commands.BucketType.member) @commands.cooldown(1, 150, commands.BucketType.member)
@playlist.command(name="copy", usage="<id_or_name> [args]") @playlist.command(name="copy", usage="<id_or_name> [args]", cooldown_after_parsing=True)
async def _playlist_copy( async def _playlist_copy(
self, self,
ctx: commands.Context, ctx: commands.Context,
@ -4157,7 +4195,7 @@ class Audio(commands.Cog):
"""Copy a playlist from one scope to another. """Copy a playlist from one scope to another.
**Usage**: **Usage**:
[p]playlist copy playlist_name_OR_id args `[p]playlist copy playlist_name_OR_id [args]`
**Args**: **Args**:
The following are all optional: The following are all optional:
@ -4184,11 +4222,9 @@ class Audio(commands.Cog):
Exact guild name Exact guild name
Example use: Example use:
[p]playlist copy MyGuildPlaylist --from-scope Guild --to-scope Global `[p]playlist copy MyGuildPlaylist --from-scope Guild --to-scope Global`
[p]playlist copy MyGlobalPlaylist --from-scope Global --to-author Draper#6666 `[p]playlist copy MyGlobalPlaylist --from-scope Global --to-author Draper#6666 --to-scope User`
--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 MyPersonalPlaylist --from-scope user --to-author Draper#6666
--to-scope Guild --to-guild Red - Discord Bot
""" """
if scope_data is None: if scope_data is None:
@ -4214,7 +4250,7 @@ class Audio(commands.Cog):
) = scope_data ) = scope_data
try: 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 ctx, playlist_matches, from_scope, from_author, from_guild, specified_from_user
) )
except TooManyMatches as e: except TooManyMatches as e:
@ -4284,8 +4320,8 @@ class Audio(commands.Cog):
).format( ).format(
name=from_playlist.name, name=from_playlist.name,
from_id=from_playlist.id, from_id=from_playlist.id,
from_scope=humanize_scope(from_scope, ctx=from_scope_name, the=True), from_scope=humanize_scope(from_scope, ctx=from_scope_name),
to_scope=humanize_scope(to_scope, ctx=to_scope_name, the=True), to_scope=humanize_scope(to_scope, ctx=to_scope_name),
to_id=to_playlist.id, to_id=to_playlist.id,
), ),
) )
@ -4297,7 +4333,7 @@ class Audio(commands.Cog):
"""Create an empty playlist. """Create an empty playlist.
**Usage**: **Usage**:
[p]playlist create playlist_name args `[p]playlist create playlist_name [args]`
**Args**: **Args**:
The following are all optional: The following are all optional:
@ -4320,9 +4356,9 @@ class Audio(commands.Cog):
Exact guild name Exact guild name
Example use: Example use:
[p]playlist create MyGuildPlaylist `[p]playlist create MyGuildPlaylist`
[p]playlist create MyGlobalPlaylist --scope Global `[p]playlist create MyGlobalPlaylist --scope Global`
[p]playlist create MyPersonalPlaylist --scope User `[p]playlist create MyPersonalPlaylist --scope User`
""" """
if scope_data is None: if scope_data is None:
scope_data = [PlaylistScope.GUILD.value, ctx.author, ctx.guild, False] scope_data = [PlaylistScope.GUILD.value, ctx.author, ctx.guild, False]
@ -4364,7 +4400,7 @@ class Audio(commands.Cog):
"""Delete a saved playlist. """Delete a saved playlist.
**Usage**: **Usage**:
[p]playlist delete playlist_name_OR_id args `[p]playlist delete playlist_name_OR_id [args]`
**Args**: **Args**:
The following are all optional: The following are all optional:
@ -4387,16 +4423,16 @@ class Audio(commands.Cog):
Exact guild name Exact guild name
Example use: Example use:
[p]playlist delete MyGuildPlaylist `[p]playlist delete MyGuildPlaylist`
[p]playlist delete MyGlobalPlaylist --scope Global `[p]playlist delete MyGlobalPlaylist --scope Global`
[p]playlist delete MyPersonalPlaylist --scope User `[p]playlist delete MyPersonalPlaylist --scope User`
""" """
if scope_data is None: 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, author, guild, specified_user = scope_data
try: 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 ctx, playlist_matches, scope, author, guild, specified_user
) )
except TooManyMatches as e: except TooManyMatches as e:
@ -4438,7 +4474,9 @@ class Audio(commands.Cog):
) )
@commands.cooldown(1, 30, commands.BucketType.member) @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( async def _playlist_remdupe(
self, self,
ctx: commands.Context, ctx: commands.Context,
@ -4449,7 +4487,7 @@ class Audio(commands.Cog):
"""Remove duplicate tracks from a saved playlist. """Remove duplicate tracks from a saved playlist.
**Usage**: **Usage**:
[p]playlist dedupe playlist_name_OR_id args `[p]playlist dedupe playlist_name_OR_id [args]`
**Args**: **Args**:
The following are all optional: The following are all optional:
@ -4472,25 +4510,24 @@ class Audio(commands.Cog):
Exact guild name Exact guild name
Example use: Example use:
[p]playlist dedupe MyGuildPlaylist `[p]playlist dedupe MyGuildPlaylist`
[p]playlist dedupe MyGlobalPlaylist --scope Global `[p]playlist dedupe MyGlobalPlaylist --scope Global`
[p]playlist dedupe MyPersonalPlaylist --scope User `[p]playlist dedupe MyPersonalPlaylist --scope User`
""" """
async with ctx.typing(): async with ctx.typing():
if scope_data is None: 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, author, guild, specified_user = scope_data
scope_name = humanize_scope(
scope, ctx=guild if scope == PlaylistScope.GUILD.value else author
)
try: 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 ctx, playlist_matches, scope, author, guild, specified_user
) )
except TooManyMatches as e: except TooManyMatches as e:
ctx.command.reset_cooldown(ctx) ctx.command.reset_cooldown(ctx)
return await self._embed_msg(ctx, title=str(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: if playlist_id is None:
ctx.command.reset_cooldown(ctx) ctx.command.reset_cooldown(ctx)
return await self._embed_msg( return await self._embed_msg(
@ -4571,9 +4608,13 @@ class Audio(commands.Cog):
) )
@checks.is_owner() @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.bot_has_permissions(attach_files=True)
@commands.cooldown(1, 60, commands.BucketType.guild) @commands.cooldown(1, 30, commands.BucketType.guild)
async def _playlist_download( async def _playlist_download(
self, self,
ctx: commands.Context, ctx: commands.Context,
@ -4584,12 +4625,12 @@ class Audio(commands.Cog):
): ):
"""Download a copy of a playlist. """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 Red v2-compatible playlists can be generated by passing True
for the v2 variable. for the v2 variable.
**Usage**: **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**: **Args**:
The following are all optional: The following are all optional:
@ -4612,16 +4653,16 @@ class Audio(commands.Cog):
Exact guild name Exact guild name
Example use: Example use:
[p]playlist download MyGuildPlaylist True `[p]playlist download MyGuildPlaylist True`
[p]playlist download MyGlobalPlaylist False --scope Global `[p]playlist download MyGlobalPlaylist False --scope Global`
[p]playlist download MyPersonalPlaylist --scope User `[p]playlist download MyPersonalPlaylist --scope User`
""" """
if scope_data is None: 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, author, guild, specified_user = scope_data
try: 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 ctx, playlist_matches, scope, author, guild, specified_user
) )
except TooManyMatches as e: 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")) await ctx.send(file=discord.File(to_write, filename=f"{file_name}.txt"))
to_write.close() to_write.close()
@commands.cooldown(1, 20, commands.BucketType.member) @commands.cooldown(1, 10, commands.BucketType.member)
@playlist.command(name="info", usage="<playlist_name_OR_id> [args]") @playlist.command(
name="info", usage="<playlist_name_OR_id> [args]", cooldown_after_parsing=True
)
async def _playlist_info( async def _playlist_info(
self, self,
ctx: commands.Context, ctx: commands.Context,
@ -4727,7 +4770,7 @@ class Audio(commands.Cog):
"""Retrieve information from a saved playlist. """Retrieve information from a saved playlist.
**Usage**: **Usage**:
[p]playlist info playlist_name_OR_id args `[p]playlist info playlist_name_OR_id [args]`
**Args**: **Args**:
The following are all optional: The following are all optional:
@ -4750,24 +4793,24 @@ class Audio(commands.Cog):
Exact guild name Exact guild name
Example use: Example use:
[p]playlist info MyGuildPlaylist `[p]playlist info MyGuildPlaylist`
[p]playlist info MyGlobalPlaylist --scope Global `[p]playlist info MyGlobalPlaylist --scope Global`
[p]playlist info MyPersonalPlaylist --scope User `[p]playlist info MyPersonalPlaylist --scope User`
""" """
if scope_data is None: 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, author, guild, specified_user = scope_data
scope_name = humanize_scope(
scope, ctx=guild if scope == PlaylistScope.GUILD.value else author
)
try: 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 ctx, playlist_matches, scope, author, guild, specified_user
) )
except TooManyMatches as e: except TooManyMatches as e:
ctx.command.reset_cooldown(ctx) ctx.command.reset_cooldown(ctx)
return await self._embed_msg(ctx, title=str(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: if playlist_id is None:
ctx.command.reset_cooldown(ctx) ctx.command.reset_cooldown(ctx)
return await self._embed_msg( return await self._embed_msg(
@ -4852,14 +4895,14 @@ class Audio(commands.Cog):
page_list.append(embed) page_list.append(embed)
await menu(ctx, page_list, DEFAULT_CONTROLS) await menu(ctx, page_list, DEFAULT_CONTROLS)
@commands.cooldown(1, 30, commands.BucketType.guild) @commands.cooldown(1, 15, commands.BucketType.guild)
@playlist.command(name="list", usage="[args]") @playlist.command(name="list", usage="[args]", cooldown_after_parsing=True)
@commands.bot_has_permissions(add_reactions=True) @commands.bot_has_permissions(add_reactions=True)
async def _playlist_list(self, ctx: commands.Context, *, scope_data: ScopeParser = None): async def _playlist_list(self, ctx: commands.Context, *, scope_data: ScopeParser = None):
"""List saved playlists. """List saved playlists.
**Usage**: **Usage**:
[p]playlist list args `[p]playlist list [args]`
**Args**: **Args**:
The following are all optional: The following are all optional:
@ -4882,9 +4925,9 @@ class Audio(commands.Cog):
Exact guild name Exact guild name
Example use: Example use:
[p]playlist list `[p]playlist list`
[p]playlist list --scope Global `[p]playlist list --scope Global`
[p]playlist list --scope User `[p]playlist list --scope User`
""" """
if scope_data is None: if scope_data is None:
scope_data = [PlaylistScope.GUILD.value, ctx.author, ctx.guild, False] scope_data = [PlaylistScope.GUILD.value, ctx.author, ctx.guild, False]
@ -4976,15 +5019,15 @@ class Audio(commands.Cog):
) )
return embed return embed
@playlist.command(name="queue", usage="<name> [args]") @playlist.command(name="queue", usage="<name> [args]", cooldown_after_parsing=True)
@commands.cooldown(1, 600, commands.BucketType.member) @commands.cooldown(1, 300, commands.BucketType.member)
async def _playlist_queue( async def _playlist_queue(
self, ctx: commands.Context, playlist_name: str, *, scope_data: ScopeParser = None self, ctx: commands.Context, playlist_name: str, *, scope_data: ScopeParser = None
): ):
"""Save the queue to a playlist. """Save the queue to a playlist.
**Usage**: **Usage**:
[p]playlist queue playlist_name `[p]playlist queue playlist_name [args]`
**Args**: **Args**:
The following are all optional: The following are all optional:
@ -5007,9 +5050,9 @@ class Audio(commands.Cog):
Exact guild name Exact guild name
Example use: Example use:
[p]playlist queue MyGuildPlaylist `[p]playlist queue MyGuildPlaylist`
[p]playlist queue MyGlobalPlaylist --scope Global `[p]playlist queue MyGlobalPlaylist --scope Global`
[p]playlist queue MyPersonalPlaylist --scope User `[p]playlist queue MyPersonalPlaylist --scope User`
""" """
async with ctx.typing(): async with ctx.typing():
if scope_data is None: if scope_data is None:
@ -5087,7 +5130,7 @@ class Audio(commands.Cog):
"""Remove a track from a playlist by url. """Remove a track from a playlist by url.
**Usage**: **Usage**:
[p]playlist remove playlist_name_OR_id url args `[p]playlist remove playlist_name_OR_id url [args]`
**Args**: **Args**:
The following are all optional: The following are all optional:
@ -5110,25 +5153,23 @@ class Audio(commands.Cog):
Exact guild name Exact guild name
Example use: Example use:
[p]playlist remove MyGuildPlaylist https://www.youtube.com/watch?v=MN3x-kAbgFU `[p]playlist remove MyGuildPlaylist https://www.youtube.com/watch?v=MN3x-kAbgFU`
[p]playlist remove MyGlobalPlaylist https://www.youtube.com/watch?v=MN3x-kAbgFU `[p]playlist remove MyGlobalPlaylist https://www.youtube.com/watch?v=MN3x-kAbgFU --scope Global`
--scope Global `[p]playlist remove MyPersonalPlaylist https://www.youtube.com/watch?v=MN3x-kAbgFU --scope User`
[p]playlist remove MyPersonalPlaylist https://www.youtube.com/watch?v=MN3x-kAbgFU
--scope User
""" """
if scope_data is None: 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, author, guild, specified_user = scope_data
scope_name = humanize_scope(
scope, ctx=guild if scope == PlaylistScope.GUILD.value else author
)
try: 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 ctx, playlist_matches, scope, author, guild, specified_user
) )
except TooManyMatches as e: except TooManyMatches as e:
return await self._embed_msg(ctx, title=str(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: if playlist_id is None:
return await self._embed_msg( return await self._embed_msg(
ctx, ctx,
@ -5188,8 +5229,8 @@ class Audio(commands.Cog):
).format(playlist_name=playlist.name, id=playlist.id, scope=scope_name), ).format(playlist_name=playlist.name, id=playlist.id, scope=scope_name),
) )
@playlist.command(name="save", usage="<name> <url> [args]") @playlist.command(name="save", usage="<name> <url> [args]", cooldown_after_parsing=True)
@commands.cooldown(1, 120, commands.BucketType.member) @commands.cooldown(1, 60, commands.BucketType.member)
async def _playlist_save( async def _playlist_save(
self, self,
ctx: commands.Context, ctx: commands.Context,
@ -5201,7 +5242,7 @@ class Audio(commands.Cog):
"""Save a playlist from a url. """Save a playlist from a url.
**Usage**: **Usage**:
[p]playlist save name url args `[p]playlist save name url [args]`
**Args**: **Args**:
The following are all optional: The following are all optional:
@ -5224,12 +5265,9 @@ class Audio(commands.Cog):
Exact guild name Exact guild name
Example use: Example use:
[p]playlist save MyGuildPlaylist `[p]playlist save MyGuildPlaylist https://www.youtube.com/playlist?list=PLx0sYbCqOb8Q_CLZC2BdBSKEEB59BOPUM`
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 MyGlobalPlaylist `[p]playlist save MyPersonalPlaylist https://open.spotify.com/playlist/1RyeIbyFeIJVnNzlGr5KkR --scope User`
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: if scope_data is None:
scope_data = [PlaylistScope.GUILD.value, ctx.author, ctx.guild, False] scope_data = [PlaylistScope.GUILD.value, ctx.author, ctx.guild, False]
@ -5282,8 +5320,13 @@ class Audio(commands.Cog):
else None, else None,
) )
@commands.cooldown(1, 60, commands.BucketType.member) @commands.cooldown(1, 30, commands.BucketType.member)
@playlist.command(name="start", aliases=["play"], usage="<playlist_name_OR_id> [args]") @playlist.command(
name="start",
aliases=["play"],
usage="<playlist_name_OR_id> [args]",
cooldown_after_parsing=True,
)
async def _playlist_start( async def _playlist_start(
self, self,
ctx: commands.Context, ctx: commands.Context,
@ -5294,7 +5337,7 @@ class Audio(commands.Cog):
"""Load a playlist into the queue. """Load a playlist into the queue.
**Usage**: **Usage**:
[p]playlist start playlist_name_OR_id args ` [p]playlist start playlist_name_OR_id [args]`
**Args**: **Args**:
The following are all optional: The following are all optional:
@ -5317,12 +5360,12 @@ class Audio(commands.Cog):
Exact guild name Exact guild name
Example use: Example use:
[p]playlist start MyGuildPlaylist `[p]playlist start MyGuildPlaylist`
[p]playlist start MyGlobalPlaylist --scope Global `[p]playlist start MyGlobalPlaylist --scope Global`
[p]playlist start MyPersonalPlaylist --scope User `[p]playlist start MyPersonalPlaylist --scope User`
""" """
if scope_data is None: 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, author, guild, specified_user = scope_data
dj_enabled = self._dj_status_cache.setdefault( dj_enabled = self._dj_status_cache.setdefault(
ctx.guild.id, await self.config.guild(ctx.guild).dj_enabled() ctx.guild.id, await self.config.guild(ctx.guild).dj_enabled()
@ -5338,7 +5381,7 @@ class Audio(commands.Cog):
return False return False
try: 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 ctx, playlist_matches, scope, author, guild, specified_user
) )
except TooManyMatches as e: except TooManyMatches as e:
@ -5451,7 +5494,9 @@ class Audio(commands.Cog):
return await ctx.invoke(self.play, query=playlist.url) return await ctx.invoke(self.play, query=playlist.url)
@commands.cooldown(1, 60, commands.BucketType.member) @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( async def _playlist_update(
self, self,
ctx: commands.Context, ctx: commands.Context,
@ -5462,7 +5507,7 @@ class Audio(commands.Cog):
"""Updates all tracks in a playlist. """Updates all tracks in a playlist.
**Usage**: **Usage**:
[p]playlist update playlist_name_OR_id args `[p]playlist update playlist_name_OR_id [args]`
**Args**: **Args**:
The following are all optional: The following are all optional:
@ -5485,16 +5530,16 @@ class Audio(commands.Cog):
Exact guild name Exact guild name
Example use: Example use:
[p]playlist update MyGuildPlaylist `[p]playlist update MyGuildPlaylist`
[p]playlist update MyGlobalPlaylist --scope Global `[p]playlist update MyGlobalPlaylist --scope Global`
[p]playlist update MyPersonalPlaylist --scope User `[p]playlist update MyPersonalPlaylist --scope User`
""" """
if scope_data is None: 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, author, guild, specified_user = scope_data
try: 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 ctx, playlist_matches, scope, author, guild, specified_user
) )
except TooManyMatches as e: except TooManyMatches as e:
@ -5610,10 +5655,10 @@ class Audio(commands.Cog):
"""Uploads a playlist file as a playlist for the bot. """Uploads a playlist file as a playlist for the bot.
V2 and old V3 playlist will be slow. 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**: **Usage**:
[p]playlist upload args `[p]playlist upload [args]`
**Args**: **Args**:
The following are all optional: The following are all optional:
@ -5636,9 +5681,9 @@ class Audio(commands.Cog):
Exact guild name Exact guild name
Example use: Example use:
[p]playlist upload `[p]playlist upload`
[p]playlist upload --scope Global `[p]playlist upload --scope Global`
[p]playlist upload --scope User `[p]playlist upload --scope User`
""" """
if scope_data is None: if scope_data is None:
scope_data = [PlaylistScope.GUILD.value, ctx.author, ctx.guild, False] 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) @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( async def _playlist_rename(
self, self,
ctx: commands.Context, ctx: commands.Context,
@ -5740,7 +5787,7 @@ class Audio(commands.Cog):
"""Rename an existing playlist. """Rename an existing playlist.
**Usage**: **Usage**:
[p]playlist rename playlist_name_OR_id new_name args `[p]playlist rename playlist_name_OR_id new_name [args]`
**Args**: **Args**:
The following are all optional: The following are all optional:
@ -5763,12 +5810,12 @@ class Audio(commands.Cog):
Exact guild name Exact guild name
Example use: Example use:
[p]playlist rename MyGuildPlaylist RenamedGuildPlaylist `[p]playlist rename MyGuildPlaylist RenamedGuildPlaylist`
[p]playlist rename MyGlobalPlaylist RenamedGlobalPlaylist --scope Global `[p]playlist rename MyGlobalPlaylist RenamedGlobalPlaylist --scope Global`
[p]playlist rename MyPersonalPlaylist RenamedPersonalPlaylist --scope User `[p]playlist rename MyPersonalPlaylist RenamedPersonalPlaylist --scope User`
""" """
if scope_data is None: 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, author, guild, specified_user = scope_data
new_name = new_name.split(" ")[0].strip('"')[:32] new_name = new_name.split(" ")[0].strip('"')[:32]
@ -5784,7 +5831,7 @@ class Audio(commands.Cog):
) )
try: 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 ctx, playlist_matches, scope, author, guild, specified_user
) )
except TooManyMatches as e: except TooManyMatches as e:
@ -5882,11 +5929,13 @@ class Audio(commands.Cog):
for t in track_list: for t in track_list:
uri = t.get("info", {}).get("uri") uri = t.get("info", {}).get("uri")
if 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( database_entries.append(
{ {
"query": uri, "query": uri,
"data": json.dumps(t), "data": data,
"last_updated": time_now, "last_updated": time_now,
"last_fetched": time_now, "last_fetched": time_now,
} }
@ -6793,8 +6842,8 @@ class Audio(commands.Cog):
async def search(self, ctx: commands.Context, *, query: str): async def search(self, ctx: commands.Context, *, query: str):
"""Pick a track with a search. """Pick a track with a search.
Use `[p]search list <search term>` to queue all tracks found on YouTube. `[p]search sc Use `[p]search list <search term>` to queue all tracks found on YouTube.
<search term>` will search SoundCloud instead of YouTube. `[p]search sc<search term>` will search SoundCloud instead of YouTube.
""" """
async def _search_menu( async def _search_menu(
@ -7357,8 +7406,8 @@ class Audio(commands.Cog):
async def _shuffle_bumpped(self, ctx: commands.Context): async def _shuffle_bumpped(self, ctx: commands.Context):
"""Toggle bumped track shuffle. """Toggle bumped track shuffle.
Set this to disabled if you wish to avoid bumped songs being shuffled. This takes priority Set this to disabled if you wish to avoid bumped songs being shuffled.
over `[p]shuffle`. This takes priority over `[p]shuffle`.
""" """
dj_enabled = self._dj_status_cache.setdefault( dj_enabled = self._dj_status_cache.setdefault(
ctx.guild.id, await self.config.guild(ctx.guild).dj_enabled() ctx.guild.id, await self.config.guild(ctx.guild).dj_enabled()

View File

@ -1,9 +1,12 @@
import asyncio
import contextlib
import glob
import ntpath import ntpath
import os import os
import posixpath import posixpath
import re import re
from pathlib import Path, PosixPath, WindowsPath 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 from urllib.parse import urlparse
import lavalink import lavalink
@ -167,29 +170,48 @@ class LocalPath:
modified.path = modified.path.joinpath(*args) modified.path = modified.path.joinpath(*args)
return modified return modified
def multiglob(self, *patterns): def rglob(self, pattern, folder=False) -> Iterator[str]:
paths = [] 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: for p in patterns:
paths.extend(list(self.path.glob(p))) for rp in self.glob(p):
for p in self._filtered(paths): rp = LocalPath(rp)
yield p 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): async def multirglob(self, *patterns, folder=False) -> AsyncIterator["LocalPath"]:
paths = []
for p in patterns: for p in patterns:
paths.extend(list(self.path.rglob(p))) for rp in self.rglob(p):
rp = LocalPath(rp)
for p in self._filtered(paths): if folder and rp.is_dir() and rp.exists():
yield p yield rp
await asyncio.sleep(0)
def _filtered(self, paths: List[Path]): else:
for p in paths: if rp.suffix in self._all_music_ext and rp.is_file() and rp.exists():
if p.suffix in self._all_music_ext: yield rp
yield p await asyncio.sleep(0)
def __str__(self): def __str__(self):
return self.to_string() return self.to_string()
def __repr__(self):
return str(self)
def to_string(self): def to_string(self):
try: try:
return str(self.path.absolute()) return str(self.path.absolute())
@ -209,48 +231,56 @@ class LocalPath:
string = f"...{os.sep}{string}" string = f"...{os.sep}{string}"
return string return string
def tracks_in_tree(self): async def tracks_in_tree(self):
tracks = [] tracks = []
for track in self.multirglob(*[f"*{ext}" for ext in self._all_music_ext]): async 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: with contextlib.suppress(ValueError):
tracks.append(Query.process_input(LocalPath(str(track.absolute())))) 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()) return sorted(tracks, key=lambda x: x.to_string_user().lower())
def subfolders_in_tree(self): async 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)
return_folders = [] return_folders = []
for folder in folders: async for f in self.multirglob("", folder=True):
if folder.exists() and folder.is_dir(): with contextlib.suppress(ValueError):
return_folders.append(LocalPath(str(folder.absolute()))) 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()) return sorted(return_folders, key=lambda x: x.to_string_user().lower())
def tracks_in_folder(self): async def tracks_in_folder(self):
tracks = [] tracks = []
for track in self.multiglob(*[f"*{ext}" for ext in self._all_music_ext]): async 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: with contextlib.suppress(ValueError):
tracks.append(Query.process_input(LocalPath(str(track.absolute())))) 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()) return sorted(tracks, key=lambda x: x.to_string_user().lower())
def subfolders(self): async 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)
return_folders = [] return_folders = []
for folder in folders: async for f in self.multiglob("", folder=True):
if folder.exists() and folder.is_dir(): with contextlib.suppress(ValueError):
return_folders.append(LocalPath(str(folder.absolute()))) 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()) return sorted(return_folders, key=lambda x: x.to_string_user().lower())
def __eq__(self, other): def __eq__(self, other):
if not isinstance(other, LocalPath): if isinstance(other, LocalPath):
return NotImplemented
return self.path._cparts == other.path._cparts return self.path._cparts == other.path._cparts
elif isinstance(other, Path):
return self.path._cparts == other._cpart
return NotImplemented
def __hash__(self): def __hash__(self):
try: try:
@ -260,24 +290,32 @@ class LocalPath:
return self._hash return self._hash
def __lt__(self, other): def __lt__(self, other):
if not isinstance(other, LocalPath): if isinstance(other, LocalPath):
return NotImplemented
return self.path._cparts < other.path._cparts return self.path._cparts < other.path._cparts
elif isinstance(other, Path):
return self.path._cparts < other._cpart
return NotImplemented
def __le__(self, other): def __le__(self, other):
if not isinstance(other, LocalPath): if isinstance(other, LocalPath):
return NotImplemented
return self.path._cparts <= other.path._cparts return self.path._cparts <= other.path._cparts
elif isinstance(other, Path):
return self.path._cparts <= other._cpart
return NotImplemented
def __gt__(self, other): def __gt__(self, other):
if not isinstance(other, LocalPath): if isinstance(other, LocalPath):
return NotImplemented
return self.path._cparts > other.path._cparts return self.path._cparts > other.path._cparts
elif isinstance(other, Path):
return self.path._cparts > other._cpart
return NotImplemented
def __ge__(self, other): def __ge__(self, other):
if not isinstance(other, LocalPath): if isinstance(other, LocalPath):
return NotImplemented
return self.path._cparts >= other.path._cparts return self.path._cparts >= other.path._cparts
elif isinstance(other, Path):
return self.path._cparts >= other._cpart
return NotImplemented
class Query: class Query:
@ -378,6 +416,10 @@ class Query:
if isinstance(query, str): if isinstance(query, str):
query = query.strip("<>") query = query.strip("<>")
while "ytsearch:" in query:
query = query.replace("ytsearch:", "")
while "scsearch:" in query:
query = query.replace("scsearch:", "")
elif isinstance(query, Query): elif isinstance(query, Query):
for key, val in kwargs.items(): for key, val in kwargs.items():

View File

@ -158,6 +158,7 @@ class PlaylistConverter(commands.Converter):
PlaylistScope.GLOBAL.value: global_matches, PlaylistScope.GLOBAL.value: global_matches,
PlaylistScope.GUILD.value: guild_matches, PlaylistScope.GUILD.value: guild_matches,
PlaylistScope.USER.value: user_matches, PlaylistScope.USER.value: user_matches,
"all": [*global_matches, *guild_matches, *user_matches],
"arg": arg, "arg": arg,
} }
@ -170,7 +171,7 @@ class NoExitParser(argparse.ArgumentParser):
class ScopeParser(commands.Converter): class ScopeParser(commands.Converter):
async def convert( async def convert(
self, ctx: commands.Context, argument: str 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_scope: Optional[str] = None
target_user: Optional[Union[discord.Member, discord.User]] = 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"]): elif any(x in argument for x in ["--author", "--user", "--member"]):
raise commands.ArgParserFailure("--scope", "Nothing", custom_help=_USER_HELP) 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_user: Union[discord.Member, discord.User] = target_user or ctx.author
target_guild: discord.Guild = target_guild or ctx.guild target_guild: discord.Guild = target_guild or ctx.guild

View File

@ -43,6 +43,7 @@ __all__ = [
"CacheLevel", "CacheLevel",
"format_playlist_picker_data", "format_playlist_picker_data",
"get_track_description_unformatted", "get_track_description_unformatted",
"track_remaining_duration",
"Notifier", "Notifier",
"PlaylistScope", "PlaylistScope",
] ]
@ -126,6 +127,20 @@ async def queue_duration(ctx) -> int:
return queue_total_duration 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: async def draw_time(ctx) -> str:
player = lavalink.get_player(ctx.guild.id) player = lavalink.get_player(ctx.guild.id)
paused = player.paused 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]: def get_track_description(track) -> Optional[str]:
if track and getattr(track, "uri", None): if track and getattr(track, "uri", None):
query = Query.process_input(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": if track.title != "Unknown title":
return f'**{escape(f"{track.author} - {track.title}")}**' + escape( return f'**{escape(f"{track.author} - {track.title}")}**' + escape(
f"\n{query.to_string_user()} " 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]: def get_track_description_unformatted(track) -> Optional[str]:
if track and hasattr(track, "uri"): if track and hasattr(track, "uri"):
query = Query.process_input(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": if track.title != "Unknown title":
return escape(f"{track.author} - {track.title}") return escape(f"{track.author} - {track.title}")
else: else:
@ -521,8 +536,8 @@ class PlaylistScope(Enum):
def humanize_scope(scope, ctx=None, the=None): def humanize_scope(scope, ctx=None, the=None):
if scope == PlaylistScope.GLOBAL.value: if scope == PlaylistScope.GLOBAL.value:
return _("the ") if the else "" + _("Global") return (_("the ") if the else "") + _("Global")
elif scope == PlaylistScope.GUILD.value: 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: 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")

View File

@ -227,6 +227,9 @@ class CustomCommands(commands.Cog):
await ctx.send(_("There already exists a bot command with the same name.")) await ctx.send(_("There already exists a bot command with the same name."))
return return
responses = await self.commandobj.get_responses(ctx=ctx) responses = await self.commandobj.get_responses(ctx=ctx)
if not responses:
await ctx.send(_("Custom command process cancelled."))
return
try: try:
await self.commandobj.create(ctx=ctx, command=command, response=responses) await self.commandobj.create(ctx=ctx, command=command, response=responses)
await ctx.send(_("Custom command successfully added.")) await ctx.send(_("Custom command successfully added."))

View File

@ -3,5 +3,5 @@ from .downloader import Downloader
async def setup(bot): async def setup(bot):
cog = Downloader(bot) cog = Downloader(bot)
await cog.initialize()
bot.add_cog(cog) bot.add_cog(cog)
cog.create_init_task()

View File

@ -15,6 +15,8 @@ class InstalledCog(InstalledModule):
cog = discord.utils.get(await downloader.installed_cogs(), name=arg) cog = discord.utils.get(await downloader.installed_cogs(), name=arg)
if cog is None: 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 return cog

View File

@ -29,7 +29,7 @@ _ = Translator("Downloader", __file__)
DEPRECATION_NOTICE = _( DEPRECATION_NOTICE = _(
"\n**WARNING:** The following repos are using shared libraries" "\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." " You should inform maintainers of these repos about this message."
) )
@ -53,6 +53,9 @@ class Downloader(commands.Cog):
self._create_lib_folder() self._create_lib_folder()
self._repo_manager = RepoManager() 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: def _create_lib_folder(self, *, remove_first: bool = False) -> None:
if remove_first: if remove_first:
@ -62,9 +65,38 @@ class Downloader(commands.Cog):
with self.SHAREDLIB_INIT.open(mode="w", encoding="utf-8") as _: with self.SHAREDLIB_INIT.open(mode="w", encoding="utf-8") as _:
pass 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: async def initialize(self) -> None:
await self._repo_manager.initialize() await self._repo_manager.initialize()
await self._maybe_update_config() await self._maybe_update_config()
self._ready.set()
async def _maybe_update_config(self) -> None: async def _maybe_update_config(self) -> None:
schema_version = await self.conf.schema_version() schema_version = await self.conf.schema_version()
@ -205,7 +237,7 @@ class Downloader(commands.Cog):
await self.conf.installed_libraries.set(installed_libraries) await self.conf.installed_libraries.set(installed_libraries)
async def _shared_lib_load_check(self, cog_name: str) -> Optional[Repo]: 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) is_installed, cog = await self.is_installed(cog_name)
# it's not gonna be None when `is_installed` is True # it's not gonna be None when `is_installed` is True
# if we'll use typing_extensions in future, `Literal` can solve this # if we'll use typing_extensions in future, `Literal` can solve this
@ -418,6 +450,11 @@ class Downloader(commands.Cog):
elif target.is_file(): elif target.is_file():
os.remove(str(target)) 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() @commands.command()
@checks.is_owner() @checks.is_owner()
async def pipinstall(self, ctx: commands.Context, *deps: str) -> None: async def pipinstall(self, ctx: commands.Context, *deps: str) -> None:
@ -425,7 +462,7 @@ class Downloader(commands.Cog):
if not deps: if not deps:
await ctx.send_help() await ctx.send_help()
return return
repo = Repo("", "", "", "", Path.cwd(), loop=ctx.bot.loop) repo = Repo("", "", "", "", Path.cwd())
async with ctx.typing(): async with ctx.typing():
success = await repo.install_raw_requirements(deps, self.LIB_PATH) success = await repo.install_raw_requirements(deps, self.LIB_PATH)
@ -550,7 +587,7 @@ class Downloader(commands.Cog):
if failed: if failed:
message += "\n" + self.format_failed_repos(failed) message += "\n" + self.format_failed_repos(failed)
await ctx.send(message) await self.send_pagified(ctx, message)
@commands.group() @commands.group()
@checks.is_owner() @checks.is_owner()
@ -596,12 +633,13 @@ class Downloader(commands.Cog):
tuple(map(inline, libnames)) tuple(map(inline, libnames))
) )
if message: if message:
await ctx.send( await self.send_pagified(
ctx,
_( _(
"Cog requirements and shared libraries for all installed cogs" "Cog requirements and shared libraries for all installed cogs"
" have been reinstalled but there were some errors:\n" " have been reinstalled but there were some errors:\n"
) )
+ message + message,
) )
else: else:
await ctx.send( await ctx.send(
@ -643,8 +681,7 @@ class Downloader(commands.Cog):
f"**{candidate.object_type} {candidate.rev}**" f"**{candidate.object_type} {candidate.rev}**"
f" - {candidate.description}\n" f" - {candidate.description}\n"
) )
for page in pagify(msg): await self.send_pagified(ctx, msg)
await ctx.send(msg)
return return
except errors.UnknownRevision: except errors.UnknownRevision:
await ctx.send( await ctx.send(
@ -658,14 +695,14 @@ class Downloader(commands.Cog):
async with repo.checkout(commit, exit_to_rev=repo.branch): async with repo.checkout(commit, exit_to_rev=repo.branch):
cogs, message = await self._filter_incorrect_cogs_by_names(repo, cog_names) cogs, message = await self._filter_incorrect_cogs_by_names(repo, cog_names)
if not cogs: if not cogs:
await ctx.send(message) await self.send_pagified(ctx, message)
return return
failed_reqs = await self._install_requirements(cogs) failed_reqs = await self._install_requirements(cogs)
if failed_reqs: if failed_reqs:
message += _("\nFailed to install requirements: ") + humanize_list( message += _("\nFailed to install requirements: ") + humanize_list(
tuple(map(inline, failed_reqs)) tuple(map(inline, failed_reqs))
) )
await ctx.send(message) await self.send_pagified(ctx, message)
return return
installed_cogs, failed_cogs = await self._install_cogs(cogs) installed_cogs, failed_cogs = await self._install_cogs(cogs)
@ -711,7 +748,7 @@ class Downloader(commands.Cog):
+ message + message
) )
# "---" added to separate cog install messages from Downloader's 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: for cog in installed_cogs:
if cog.install_msg: if cog.install_msg:
await ctx.send(cog.install_msg.replace("[p]", ctx.prefix)) 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) message += _("Successfully uninstalled cogs: ") + humanize_list(uninstalled_cogs)
if failed_cogs: if failed_cogs:
message += ( 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))) + humanize_list(tuple(map(inline, failed_cogs)))
+ _( + _(
"\nYou may need to remove their files manually if they are still usable." "\nThey were most likely removed without using `{prefix}cog uninstall`.\n"
" Also make sure you've unloaded those cogs with `{prefix}unload {cogs}`." "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)) ).format(prefix=ctx.prefix, cogs=" ".join(failed_cogs))
) )
await ctx.send(message) await self.send_pagified(ctx, message)
@cog.command(name="pin", usage="<cogs>") @cog.command(name="pin", usage="<cogs>")
async def _cog_pin(self, ctx: commands.Context, *cogs: InstalledCog) -> None: 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) message += _("Pinned cogs: ") + humanize_list(cognames)
if already_pinned: if already_pinned:
message += _("\nThese cogs were already pinned: ") + humanize_list(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>") @cog.command(name="unpin", usage="<cogs>")
async def _cog_unpin(self, ctx: commands.Context, *cogs: InstalledCog) -> None: 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) message += _("Unpinned cogs: ") + humanize_list(cognames)
if not_pinned: if not_pinned:
message += _("\nThese cogs weren't pinned: ") + humanize_list(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") @cog.command(name="checkforupdates")
async def _cog_checkforupdates(self, ctx: commands.Context) -> None: async def _cog_checkforupdates(self, ctx: commands.Context) -> None:
@ -833,7 +874,7 @@ class Downloader(commands.Cog):
if failed: if failed:
message += "\n" + self.format_failed_repos(failed) message += "\n" + self.format_failed_repos(failed)
await ctx.send(message) await self.send_pagified(ctx, message)
@cog.command(name="update") @cog.command(name="update")
async def _cog_update(self, ctx: commands.Context, *cogs: InstalledCog) -> None: async def _cog_update(self, ctx: commands.Context, *cogs: InstalledCog) -> None:
@ -869,7 +910,6 @@ class Downloader(commands.Cog):
rev: Optional[str] = None, rev: Optional[str] = None,
cogs: Optional[List[InstalledModule]] = None, cogs: Optional[List[InstalledModule]] = None,
) -> None: ) -> None:
message = ""
failed_repos = set() failed_repos = set()
updates_available = set() updates_available = set()
@ -882,7 +922,7 @@ class Downloader(commands.Cog):
await repo.update() await repo.update()
except errors.UpdateError: except errors.UpdateError:
message = self.format_failed_repos([repo.name]) message = self.format_failed_repos([repo.name])
await ctx.send(message) await self.send_pagified(ctx, message)
return return
try: try:
@ -896,11 +936,10 @@ class Downloader(commands.Cog):
f"**{candidate.object_type} {candidate.rev}**" f"**{candidate.object_type} {candidate.rev}**"
f" - {candidate.description}\n" f" - {candidate.description}\n"
) )
for page in pagify(msg): await self.send_pagified(ctx, msg)
await ctx.send(msg)
return return
except errors.UnknownRevision: except errors.UnknownRevision:
message += _( message = _(
"Error: there is no revision `{rev}` in repo `{repo.name}`" "Error: there is no revision `{rev}` in repo `{repo.name}`"
).format(rev=rev, repo=repo) ).format(rev=rev, repo=repo)
await ctx.send(message) await ctx.send(message)
@ -917,6 +956,8 @@ class Downloader(commands.Cog):
pinned_cogs = {cog for cog in cogs_to_check if cog.pinned} pinned_cogs = {cog for cog in cogs_to_check if cog.pinned}
cogs_to_check -= pinned_cogs cogs_to_check -= pinned_cogs
message = ""
if not cogs_to_check: if not cogs_to_check:
cogs_to_update = libs_to_update = () cogs_to_update = libs_to_update = ()
message += _("There were no cogs to check.") message += _("There were no cogs to check.")
@ -972,7 +1013,7 @@ class Downloader(commands.Cog):
if repos_with_libs: if repos_with_libs:
message += DEPRECATION_NOTICE.format(repo_list=humanize_list(list(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: if updates_available and updated_cognames:
await self._ask_for_cog_reload(ctx, updated_cognames) await self._ask_for_cog_reload(ctx, updated_cognames)

View File

@ -38,6 +38,10 @@ class GitException(DownloaderException):
Generic class for git exceptions. 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): class InvalidRepoName(DownloaderException):
""" """
@ -138,8 +142,8 @@ class AmbiguousRevision(GitException):
Thrown when specified revision is ambiguous. Thrown when specified revision is ambiguous.
""" """
def __init__(self, message: str, candidates: List[Candidate]) -> None: def __init__(self, message: str, git_command: str, candidates: List[Candidate]) -> None:
super().__init__(message) super().__init__(message, git_command)
self.candidates = candidates self.candidates = candidates

View File

@ -1,7 +1,7 @@
from __future__ import annotations from __future__ import annotations
import json import json
import distutils.dir_util import functools
import shutil import shutil
from enum import IntEnum from enum import IntEnum
from pathlib import Path from pathlib import Path
@ -127,15 +127,13 @@ class Installable(RepoJSONMixin):
if self._location.is_file(): if self._location.is_file():
copy_func = shutil.copy2 copy_func = shutil.copy2
else: else:
# clear copy_tree's cache to make sure missing directories are created (GH-2690) copy_func = functools.partial(shutil.copytree, dirs_exist_ok=True)
distutils.dir_util._path_created = {}
copy_func = distutils.dir_util.copy_tree
# noinspection PyBroadException # noinspection PyBroadException
try: try:
copy_func(src=str(self._location), dst=str(target_dir / self._location.stem)) copy_func(src=str(self._location), dst=str(target_dir / self._location.stem))
except: # noqa: E722 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 False
return True return True

View File

@ -135,7 +135,6 @@ class Repo(RepoJSONMixin):
commit: str, commit: str,
folder_path: Path, folder_path: Path,
available_modules: Tuple[Installable, ...] = (), available_modules: Tuple[Installable, ...] = (),
loop: Optional[asyncio.AbstractEventLoop] = None,
): ):
self.url = url self.url = url
self.branch = branch self.branch = branch
@ -154,8 +153,6 @@ class Repo(RepoJSONMixin):
self._repo_lock = asyncio.Lock() self._repo_lock = asyncio.Lock()
self._loop = loop if loop is not None else asyncio.get_event_loop()
@property @property
def clean_url(self) -> str: def clean_url(self) -> str:
"""Sanitized repo URL (with removed HTTP Basic Auth)""" """Sanitized repo URL (with removed HTTP Basic Auth)"""
@ -203,21 +200,20 @@ class Repo(RepoJSONMixin):
""" """
valid_exit_codes = (0, 1) valid_exit_codes = (0, 1)
p = await self._run( git_command = ProcessFormatter().format(
ProcessFormatter().format(
self.GIT_IS_ANCESTOR, self.GIT_IS_ANCESTOR,
path=self.folder_path, path=self.folder_path,
maybe_ancestor_rev=maybe_ancestor_rev, maybe_ancestor_rev=maybe_ancestor_rev,
descendant_rev=descendant_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: if p.returncode in valid_exit_codes:
return not bool(p.returncode) return not bool(p.returncode)
raise errors.GitException( raise errors.GitException(
f"Git failed to determine if commit {maybe_ancestor_rev}" 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: async def is_on_branch(self) -> bool:
@ -253,15 +249,14 @@ class Repo(RepoJSONMixin):
""" """
if new_rev is None: if new_rev is None:
new_rev = self.branch new_rev = self.branch
p = await self._run( git_command = ProcessFormatter().format(
ProcessFormatter().format(
self.GIT_DIFF_FILE_STATUS, path=self.folder_path, old_rev=old_rev, new_rev=new_rev 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: if p.returncode != 0:
raise errors.GitDiffError( 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") 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): async with self.checkout(descendant_rev):
return discord.utils.get(self.available_modules, name=module_name) return discord.utils.get(self.available_modules, name=module_name)
p = await self._run( git_command = ProcessFormatter().format(
ProcessFormatter().format(
self.GIT_GET_LAST_MODULE_OCCURRENCE_COMMIT, self.GIT_GET_LAST_MODULE_OCCURRENCE_COMMIT,
path=self.folder_path, path=self.folder_path,
descendant_rev=descendant_rev, descendant_rev=descendant_rev,
module_name=module_name, module_name=module_name,
) )
) p = await self._run(git_command)
if p.returncode != 0: if p.returncode != 0:
raise errors.GitException( 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() commit = p.stdout.decode().strip()
@ -418,19 +412,18 @@ class Repo(RepoJSONMixin):
to get messages for. to get messages for.
:return: Git commit note log :return: Git commit note log
""" """
p = await self._run( git_command = ProcessFormatter().format(
ProcessFormatter().format(
self.GIT_LOG, self.GIT_LOG,
path=self.folder_path, path=self.folder_path,
old_rev=old_rev, old_rev=old_rev,
relative_file_path=relative_file_path, relative_file_path=relative_file_path,
) )
) p = await self._run(git_command)
if p.returncode != 0: if p.returncode != 0:
raise errors.GitException( raise errors.GitException(
"An exception occurred while executing git log on" f"An exception occurred while executing git log on this repo: {self.folder_path}",
" this repo: {}".format(self.folder_path) git_command,
) )
return p.stdout.decode().strip() return p.stdout.decode().strip()
@ -457,21 +450,24 @@ class Repo(RepoJSONMixin):
Full sha1 object name for provided revision. Full sha1 object name for provided revision.
""" """
p = await self._run( git_command = ProcessFormatter().format(
ProcessFormatter().format(self.GIT_GET_FULL_SHA1, path=self.folder_path, rev=rev) self.GIT_GET_FULL_SHA1, path=self.folder_path, rev=rev
) )
p = await self._run(git_command)
if p.returncode != 0: if p.returncode != 0:
stderr = p.stderr.decode().strip() stderr = p.stderr.decode().strip()
ambiguous_error = f"error: short SHA1 {rev} is ambiguous\nhint: The candidates are:\n" ambiguous_error = f"error: short SHA1 {rev} is ambiguous\nhint: The candidates are:\n"
if not stderr.startswith(ambiguous_error): 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 = [] candidates = []
for match in self.AMBIGUOUS_ERROR_REGEX.finditer(stderr, len(ambiguous_error)): for match in self.AMBIGUOUS_ERROR_REGEX.finditer(stderr, len(ambiguous_error)):
candidates.append(Candidate(match["rev"], match["type"], match["desc"])) candidates.append(Candidate(match["rev"], match["type"], match["desc"]))
if candidates: if candidates:
raise errors.AmbiguousRevision(f"Short SHA1 {rev} is ambiguous.", candidates) raise errors.AmbiguousRevision(
raise errors.UnknownRevision(f"Revision {rev} cannot be found.") 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() return p.stdout.decode().strip()
@ -530,7 +526,7 @@ class Repo(RepoJSONMixin):
env["LANGUAGE"] = "C" env["LANGUAGE"] = "C"
kwargs["env"] = env kwargs["env"] = env
async with self._repo_lock: 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, self._executor,
functools.partial(sp_run, *args, stdout=PIPE, stderr=PIPE, **kwargs), functools.partial(sp_run, *args, stdout=PIPE, stderr=PIPE, **kwargs),
) )
@ -554,17 +550,14 @@ class Repo(RepoJSONMixin):
return return
exists, __ = self._existing_git_repo() exists, __ = self._existing_git_repo()
if not exists: if not exists:
raise errors.MissingGitRepo( raise errors.MissingGitRepo(f"A git repo does not exist at path: {self.folder_path}")
"A git repo does not exist at path: {}".format(self.folder_path)
)
p = await self._run( git_command = ProcessFormatter().format(self.GIT_CHECKOUT, path=self.folder_path, rev=rev)
ProcessFormatter().format(self.GIT_CHECKOUT, path=self.folder_path, rev=rev) p = await self._run(git_command)
)
if p.returncode != 0: if p.returncode != 0:
raise errors.UnknownRevision( 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() await self._setup_repo()
@ -619,25 +612,22 @@ class Repo(RepoJSONMixin):
""" """
exists, path = self._existing_git_repo() exists, path = self._existing_git_repo()
if exists: 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: if self.branch is not None:
p = await self._run( git_command = ProcessFormatter().format(
ProcessFormatter().format(
self.GIT_CLONE, branch=self.branch, url=self.url, folder=self.folder_path self.GIT_CLONE, branch=self.branch, url=self.url, folder=self.folder_path
) )
)
else: else:
p = await self._run( git_command = ProcessFormatter().format(
ProcessFormatter().format(
self.GIT_CLONE_NO_BRANCH, url=self.url, folder=self.folder_path self.GIT_CLONE_NO_BRANCH, url=self.url, folder=self.folder_path
) )
) p = await self._run(git_command)
if p.returncode: if p.returncode:
# Try cleaning up folder # Try cleaning up folder
shutil.rmtree(str(self.folder_path), ignore_errors=True) 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: if self.branch is None:
self.branch = await self.current_branch() self.branch = await self.current_branch()
@ -657,17 +647,14 @@ class Repo(RepoJSONMixin):
""" """
exists, __ = self._existing_git_repo() exists, __ = self._existing_git_repo()
if not exists: if not exists:
raise errors.MissingGitRepo( raise errors.MissingGitRepo(f"A git repo does not exist at path: {self.folder_path}")
"A git repo does not exist at path: {}".format(self.folder_path)
)
p = await self._run( git_command = ProcessFormatter().format(self.GIT_CURRENT_BRANCH, path=self.folder_path)
ProcessFormatter().format(self.GIT_CURRENT_BRANCH, path=self.folder_path) p = await self._run(git_command)
)
if p.returncode != 0: if p.returncode != 0:
raise errors.GitException( 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() return p.stdout.decode().strip()
@ -683,16 +670,13 @@ class Repo(RepoJSONMixin):
""" """
exists, __ = self._existing_git_repo() exists, __ = self._existing_git_repo()
if not exists: if not exists:
raise errors.MissingGitRepo( raise errors.MissingGitRepo(f"A git repo does not exist at path: {self.folder_path}")
"A git repo does not exist at path: {}".format(self.folder_path)
)
p = await self._run( git_command = ProcessFormatter().format(self.GIT_CURRENT_COMMIT, path=self.folder_path)
ProcessFormatter().format(self.GIT_CURRENT_COMMIT, path=self.folder_path) p = await self._run(git_command)
)
if p.returncode != 0: 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() return p.stdout.decode().strip()
@ -715,16 +699,15 @@ class Repo(RepoJSONMixin):
exists, __ = self._existing_git_repo() exists, __ = self._existing_git_repo()
if not exists: if not exists:
raise errors.MissingGitRepo( raise errors.MissingGitRepo(f"A git repo does not exist at path: {self.folder_path}")
"A git repo does not exist at path: {}".format(self.folder_path)
)
p = await self._run( git_command = ProcessFormatter().format(
ProcessFormatter().format(self.GIT_LATEST_COMMIT, path=self.folder_path, branch=branch) self.GIT_LATEST_COMMIT, path=self.folder_path, branch=branch
) )
p = await self._run(git_command)
if p.returncode != 0: 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() return p.stdout.decode().strip()
@ -751,10 +734,11 @@ class Repo(RepoJSONMixin):
if folder is None: if folder is None:
folder = self.folder_path 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: 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() return p.stdout.decode().strip()
@ -773,19 +757,18 @@ class Repo(RepoJSONMixin):
await self.checkout(branch) await self.checkout(branch)
exists, __ = self._existing_git_repo() exists, __ = self._existing_git_repo()
if not exists: if not exists:
raise errors.MissingGitRepo( raise errors.MissingGitRepo(f"A git repo does not exist at path: {self.folder_path}")
"A git repo does not exist at path: {}".format(self.folder_path)
)
p = await self._run( git_command = ProcessFormatter().format(
ProcessFormatter().format(self.GIT_HARD_RESET, path=self.folder_path, branch=branch) self.GIT_HARD_RESET, path=self.folder_path, branch=branch
) )
p = await self._run(git_command)
if p.returncode != 0: if p.returncode != 0:
raise errors.HardResetError( raise errors.HardResetError(
"Some error occurred when trying to" "Some error occurred when trying to execute a hard reset on the repo at"
" execute a hard reset on the repo at" f" the following path: {self.folder_path}",
" the following path: {}".format(self.folder_path) git_command,
) )
async def update(self) -> Tuple[str, str]: async def update(self) -> Tuple[str, str]:
@ -804,12 +787,14 @@ class Repo(RepoJSONMixin):
await self.hard_reset() 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: if p.returncode != 0:
raise errors.UpdateError( raise errors.UpdateError(
"Git pull returned a non zero exit code" "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() await self._setup_repo()
@ -1114,7 +1099,7 @@ class RepoManager:
""" """
repo = self.get_repo(name) repo = self.get_repo(name)
if repo is None: 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) safe_delete(repo.folder_path)

View File

@ -2,6 +2,7 @@ import datetime
import time import time
from enum import Enum from enum import Enum
from random import randint, choice from random import randint, choice
from typing import Final
import aiohttp import aiohttp
import discord import discord
from redbot.core import commands from redbot.core import commands
@ -31,6 +32,9 @@ class RPSParser:
self.choice = None self.choice = None
MAX_ROLL: Final[int] = 2 ** 64 - 1
@cog_i18n(_) @cog_i18n(_)
class General(commands.Cog): class General(commands.Cog):
"""General commands.""" """General commands."""
@ -87,15 +91,21 @@ class General(commands.Cog):
`<number>` defaults to 100. `<number>` defaults to 100.
""" """
author = ctx.author author = ctx.author
if number > 1: if 1 < number <= MAX_ROLL:
n = randint(1, number) n = randint(1, number)
await ctx.send( await ctx.send(
"{author.mention} :game_die: {n} :game_die:".format( "{author.mention} :game_die: {n} :game_die:".format(
author=author, n=humanize_number(n) author=author, n=humanize_number(n)
) )
) )
else: elif number <= 1:
await ctx.send(_("{author.mention} Maybe higher than 1? ;P").format(author=author)) 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() @commands.command()
async def flip(self, ctx, user: discord.Member = None): async def flip(self, ctx, user: discord.Member = None):

View File

@ -101,7 +101,7 @@ class Events(MixinMeta):
while None in name_list: # clean out null entries from a bug while None in name_list: # clean out null entries from a bug
name_list.remove(None) name_list.remove(None)
if after.name in name_list: 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.remove(after.name)
name_list.append(after.name) name_list.append(after.name)
while len(name_list) > 20: while len(name_list) > 20:

View File

@ -7,7 +7,7 @@ from typing import cast, Optional, Union
import discord import discord
from redbot.core import commands, i18n, checks, modlog 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 redbot.core.utils.mod import is_allowed_by_hierarchy, get_audit_reason
from .abc import MixinMeta from .abc import MixinMeta
from .converters import RawUserIds from .converters import RawUserIds
@ -124,6 +124,19 @@ class KickBanMixin(MixinMeta):
elif not (0 <= days <= 7): elif not (0 <= days <= 7):
return _("Invalid days. Must be between 0 and 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) audit_reason = get_audit_reason(author, reason)
queue_entry = (guild.id, user.id) queue_entry = (guild.id, user.id)
@ -137,7 +150,7 @@ class KickBanMixin(MixinMeta):
except discord.Forbidden: except discord.Forbidden:
return _("I'm not allowed to do that.") return _("I'm not allowed to do that.")
except Exception as e: 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: if create_modlog_case:
try: try:
@ -228,6 +241,18 @@ class KickBanMixin(MixinMeta):
await ctx.send(_("I cannot do that due to discord hierarchy rules")) await ctx.send(_("I cannot do that due to discord hierarchy rules"))
return return
audit_reason = get_audit_reason(author, reason) 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: try:
await guild.kick(user, reason=audit_reason) await guild.kick(user, reason=audit_reason)
log.info("{}({}) kicked {}({})".format(author.name, author.id, user.name, user.id)) log.info("{}({}) kicked {}({})".format(author.name, author.id, user.name, user.id))
@ -260,14 +285,19 @@ class KickBanMixin(MixinMeta):
self, self,
ctx: commands.Context, ctx: commands.Context,
user: discord.Member, user: discord.Member,
days: Optional[int] = 0, days: Optional[int] = None,
*, *,
reason: str = None, reason: str = None,
): ):
"""Ban a user from this server and optionally delete days of messages. """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. 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( result = await self.ban_user(
user=user, ctx=ctx, days=days, reason=reason, create_modlog_case=True user=user, ctx=ctx, days=days, reason=reason, create_modlog_case=True
@ -286,7 +316,7 @@ class KickBanMixin(MixinMeta):
self, self,
ctx: commands.Context, ctx: commands.Context,
user_ids: commands.Greedy[RawUserIds], user_ids: commands.Greedy[RawUserIds],
days: Optional[int] = 0, days: Optional[int] = None,
*, *,
reason: str = None, reason: str = None,
): ):
@ -294,7 +324,6 @@ class KickBanMixin(MixinMeta):
User IDs need to be provided in order to ban User IDs need to be provided in order to ban
using this command""" using this command"""
days = cast(int, days)
banned = [] banned = []
errors = {} errors = {}
@ -321,6 +350,9 @@ class KickBanMixin(MixinMeta):
await ctx.send_help() await ctx.send_help()
return return
if days is None:
days = await self.settings.guild(guild).default_days()
if not (0 <= days <= 7): if not (0 <= days <= 7):
await ctx.send(_("Invalid days. Must be between 0 and 7.")) await ctx.send(_("Invalid days. Must be between 0 and 7."))
return return

View File

@ -51,6 +51,8 @@ class Mod(
"delete_delay": -1, "delete_delay": -1,
"reinvite_on_unban": False, "reinvite_on_unban": False,
"current_tempbans": [], "current_tempbans": [],
"dm_on_kickban": False,
"default_days": 0,
} }
default_channel_settings = {"ignored": False} default_channel_settings = {"ignored": False}

View File

@ -21,11 +21,14 @@ class ModSettings(MixinMeta):
if ctx.invoked_subcommand is None: if ctx.invoked_subcommand is None:
guild = ctx.guild guild = ctx.guild
# Display current settings # Display current settings
delete_repeats = await self.settings.guild(guild).delete_repeats() data = await self.settings.guild(guild).all()
ban_mention_spam = await self.settings.guild(guild).ban_mention_spam() delete_repeats = data["delete_repeats"]
respect_hierarchy = await self.settings.guild(guild).respect_hierarchy() ban_mention_spam = data["ban_mention_spam"]
delete_delay = await self.settings.guild(guild).delete_delay() respect_hierarchy = data["respect_hierarchy"]
reinvite_on_unban = await self.settings.guild(guild).reinvite_on_unban() 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 = ""
msg += _("Delete repeats: {num_repeats}\n").format( msg += _("Delete repeats: {num_repeats}\n").format(
num_repeats=_("after {num} repeats").format(num=delete_repeats) 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( msg += _("Reinvite on unban: {yes_or_no}\n").format(
yes_or_no=_("Yes") if reinvite_on_unban else _("No") 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)) await ctx.send(box(msg))
@modset.command() @modset.command()
@ -199,3 +211,43 @@ class ModSettings(MixinMeta):
command=f"{ctx.prefix}unban" 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
)
)

View File

@ -26,6 +26,13 @@ class ModLog(commands.Cog):
"""Manage modlog settings.""" """Manage modlog settings."""
pass 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() @modlogset.command()
@commands.guild_only() @commands.guild_only()
async def modlog(self, ctx: commands.Context, channel: discord.TextChannel = None): async def modlog(self, ctx: commands.Context, channel: discord.TextChannel = None):

View File

@ -61,7 +61,7 @@ At which Arena can you unlock X-Bow?:
- 6 - 6
- Builder's Workshop - Builder's Workshop
At which Arena do you get a chance for Legendary cards to appear in the shop?: At which Arena do you get a chance for Legendary cards to appear in the shop?:
- Hog Mountian - Hog Mountain
- A10 - A10
- 10 - 10
- Arena 10 - Arena 10

View File

@ -375,7 +375,7 @@ Porky Pig had a girlfriend named ________?:
Randy Travis said his love was 'deeper than the ______'?: Randy Travis said his love was 'deeper than the ______'?:
- Holler - Holler
Richard Strauss' majestic overture "Also Sprach Zarathustra" was the theme music for which Stanley Kubrick film?: 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?: Rolling Stones first hit was written by what group?:
- The Beatles - The Beatles
Russian modernist Igor _________?: Russian modernist Igor _________?:

View File

@ -12,7 +12,6 @@ from redbot.cogs.warnings.helpers import (
from redbot.core import Config, checks, commands, modlog from redbot.core import Config, checks, commands, modlog
from redbot.core.bot import Red from redbot.core.bot import Red
from redbot.core.i18n import Translator, cog_i18n 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.chat_formatting import warning, pagify
from redbot.core.utils.menus import menu, DEFAULT_CONTROLS from redbot.core.utils.menus import menu, DEFAULT_CONTROLS
@ -342,23 +341,9 @@ class Warnings(commands.Cog):
@commands.command() @commands.command()
@commands.guild_only() @commands.guild_only()
async def warnings( @checks.admin()
self, ctx: commands.Context, user: Optional[Union[discord.Member, int]] = None async def warnings(self, ctx: commands.Context, user: Union[discord.Member, int]):
): """List the warnings for the specified user."""
"""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!"))
)
try: try:
userid: int = user.id 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) 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.command()
@commands.guild_only() @commands.guild_only()
@checks.admin_or_permissions(ban_members=True) @checks.admin_or_permissions(ban_members=True)

View File

@ -22,7 +22,7 @@ class SharedLibImportWarner(MetaPathFinder):
return None return None
msg = ( msg = (
"One of cogs uses shared libraries which are" "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." "You should inform author of the cog about this message."
) )
warnings.warn(msg, SharedLibDeprecationWarning, stacklevel=2) warnings.warn(msg, SharedLibDeprecationWarning, stacklevel=2)

View File

@ -838,9 +838,9 @@ async def set_default_balance(amount: int, guild: discord.Guild = None) -> int:
amount = int(amount) amount = int(amount)
max_bal = await get_max_balance(guild) max_bal = await get_max_balance(guild)
if not (0 < amount <= max_bal): if not (0 <= amount <= max_bal):
raise ValueError( 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") max=humanize_number(max_bal, override_locale="en_US")
) )
) )

View File

@ -10,11 +10,25 @@ from datetime import datetime
from enum import IntEnum from enum import IntEnum
from importlib.machinery import ModuleSpec from importlib.machinery import ModuleSpec
from pathlib import Path 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 from types import MappingProxyType
import discord import discord
from discord.ext import commands as dpy_commands
from discord.ext.commands import when_mentioned_or 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 . import Config, i18n, commands, errors, drivers, modlog, bank
from .cog_manager import CogManager, CogManagerUI from .cog_manager import CogManager, CogManagerUI
@ -24,6 +38,8 @@ from .dev_commands import Dev
from .events import init_events from .events import init_events
from .global_checks import init_global_checks from .global_checks import init_global_checks
from .settings_caches import PrefixManager
from .rpc import RPCMixin from .rpc import RPCMixin
from .utils import common_filters from .utils import common_filters
@ -36,13 +52,18 @@ __all__ = ["RedBase", "Red", "ExitCodes"]
NotMessage = namedtuple("NotMessage", "guild") NotMessage = namedtuple("NotMessage", "guild")
PreInvokeCoroutine = Callable[[commands.Context], Awaitable[Any]]
T_BIC = TypeVar("T_BIC", bound=PreInvokeCoroutine)
def _is_submodule(parent, child): def _is_submodule(parent, child):
return parent == child or child.startswith(parent + ".") return parent == child or child.startswith(parent + ".")
# barely spurious warning caused by our intentional shadowing # 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. """Mixin for the main bot class.
This exists because `Red` inherits from `discord.AutoShardedClient`, which 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, custom_info=None,
help__page_char_limit=1000, help__page_char_limit=1000,
help__max_pages_in_guild=2, help__max_pages_in_guild=2,
help__delete_delay=0,
help__use_menus=False, help__use_menus=False,
help__show_hidden=False, help__show_hidden=False,
help__verify_checks=True, help__verify_checks=True,
help__verify_exists=False, help__verify_exists=False,
help__tagline="", help__tagline="",
description="Red V3",
invite_public=False, invite_public=False,
invite_perm=0, invite_perm=0,
disabled_commands=[], disabled_commands=[],
@ -101,6 +124,7 @@ class RedBase(commands.GroupMixin, commands.bot.BotBase, RPCMixin): # pylint: d
autoimmune_ids=[], autoimmune_ids=[],
) )
self._config.register_channel(embeds=None)
self._config.register_user(embeds=None) self._config.register_user(embeds=None)
self._config.init_custom(CUSTOM_GROUPS, 2) 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.init_custom(SHARED_API_TOKENS, 2)
self._config.register_custom(SHARED_API_TOKENS) self._config.register_custom(SHARED_API_TOKENS)
self._prefix_cache = PrefixManager(self._config, cli_flags)
async def prefix_manager(bot, message): async def prefix_manager(bot, message) -> List[str]:
if not cli_flags.prefix: prefixes = await self._prefix_cache.get_prefixes(message.guild)
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()
if cli_flags.mentionable: if cli_flags.mentionable:
return ( return when_mentioned_or(*prefixes)(bot, message)
when_mentioned_or(*server_prefix)(bot, message) return prefixes
if server_prefix
else when_mentioned_or(*global_prefix)(bot, message)
)
else:
return server_prefix if server_prefix else global_prefix
if "command_prefix" not in kwargs: if "command_prefix" not in kwargs:
kwargs["command_prefix"] = prefix_manager 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: if "command_not_found" not in kwargs:
kwargs["command_not_found"] = "Command {} not found.\n{}" 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._uptime = None
self._checked_time_accuracy = None self._checked_time_accuracy = None
self._color = discord.Embed.Empty # This is needed or color ends up 0x000000 self._color = discord.Embed.Empty # This is needed or color ends up 0x000000
self._main_dir = bot_dir self._main_dir = bot_dir
self._cog_mgr = CogManager() self._cog_mgr = CogManager()
self._use_team_features = cli_flags.use_team_features
super().__init__(*args, help_command=None, **kwargs) super().__init__(*args, help_command=None, **kwargs)
# Do not manually use the help formatter attribute here, see `send_help_for`, # 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. # 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._permissions_hooks: List[commands.CheckPredicate] = []
self._red_ready = asyncio.Event() 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 @property
def cog_mgr(self) -> NoReturn: def cog_mgr(self) -> NoReturn:
@ -188,6 +277,10 @@ class RedBase(commands.GroupMixin, commands.bot.BotBase, RPCMixin): # pylint: d
def colour(self) -> NoReturn: def colour(self) -> NoReturn:
raise AttributeError("Please fetch the embed colour with `get_embed_colour`") 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( async def allowed_by_whitelist_blacklist(
self, self,
who: Optional[Union[discord.Member, discord.User]] = None, 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. This should only be run once, prior to connecting to discord.
""" """
await self._maybe_update_config() await self._maybe_update_config()
self.description = await self._config.description()
init_global_checks(self) init_global_checks(self)
init_events(self, cli_flags) init_events(self, cli_flags)
@ -547,23 +641,57 @@ class RedBase(commands.GroupMixin, commands.bot.BotBase, RPCMixin): # pylint: d
bool bool
:code:`True` if an embed is requested :code:`True` if an embed is requested
""" """
if isinstance(channel, discord.abc.PrivateChannel) or ( if isinstance(channel, discord.abc.PrivateChannel):
command and command == self.get_command("help")
):
user_setting = await self._config.user(user).embeds() user_setting = await self._config.user(user).embeds()
if user_setting is not None: if user_setting is not None:
return user_setting return user_setting
else: 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() guild_setting = await self._config.guild(channel.guild).embeds()
if guild_setting is not None: if guild_setting is not None:
return guild_setting return guild_setting
global_setting = await self._config.embeds() global_setting = await self._config.embeds()
return global_setting 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: if user.id in self._co_owners:
return True 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: async def is_admin(self, member: discord.Member) -> bool:
"""Checks if a member is an admin of their guild.""" """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() await self.wait_until_red_ready()
destinations = [] destinations = []
opt_outs = await self._config.owner_opt_out_list() 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: if user_id not in opt_outs:
user = self.get_user(user_id) 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) destinations.append(user)
else: else:
log.warning( log.warning(

View File

@ -74,6 +74,22 @@ async def interactive_config(red, token_set, prefix_set, *, print_header=True):
return token 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): def parse_cli_flags(args):
parser = argparse.ArgumentParser( parser = argparse.ArgumentParser(
description="Red - Discord Bot", usage="redbot <instance_name> [arguments]" description="Red - Discord Bot", usage="redbot <instance_name> [arguments]"
@ -90,7 +106,7 @@ def parse_cli_flags(args):
action="store_true", action="store_true",
help="Edit the instance. This can be done without console interaction " help="Edit the instance. This can be done without console interaction "
"by passing --no-prompt and arguments that you want to change (available arguments: " "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( parser.add_argument(
"--edit-instance-name", "--edit-instance-name",
@ -135,7 +151,9 @@ def parse_cli_flags(args):
"security implications if misused. Can be " "security implications if misused. Can be "
"multiple.", "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( parser.add_argument(
"--no-prompt", "--no-prompt",
action="store_true", action="store_true",
@ -198,6 +216,27 @@ def parse_cli_flags(args):
parser.add_argument( parser.add_argument(
"instance_name", nargs="?", help="Name of the bot instance created during `redbot-setup`." "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) args = parser.parse_args(args)

View File

@ -1,7 +1,145 @@
from discord.ext.commands import * ########## SENSITIVE SECTION WARNING ###########
from .commands import * ################################################
from .context import * # Any edits of any of the exported names #
from .converter import * # may result in a breaking change. #
from .errors import * # Ensure no names are removed without warning. #
from .requires import * ################################################
from .help import *
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,
)

View 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

View File

@ -1,23 +1,53 @@
"""Module for command helpers and classes. """Module for command helpers and classes.
This module contains extended classes and functions which are intended to 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 inspect
import re
import weakref 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 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 . import converter as converters
from .errors import ConversionFailure from .errors import ConversionFailure
from .requires import PermState, PrivilegeLevel, Requires from .requires import PermState, PrivilegeLevel, Requires, PermStateAllowedStates
from ..i18n import Translator from ..i18n import Translator
if TYPE_CHECKING: if TYPE_CHECKING:
# circular import avoidance
from .context import Context from .context import Context
__all__ = [ __all__ = [
"Cog", "Cog",
"CogMixin", "CogMixin",
@ -37,11 +67,17 @@ RESERVED_COMMAND_NAMES = (
) )
_ = Translator("commands.commands", __file__) _ = Translator("commands.commands", __file__)
DisablerDictType = MutableMapping[discord.Guild, Callable[["Context"], Awaitable[bool]]]
class CogCommandMixin: class CogCommandMixin:
"""A mixin for cogs and commands.""" """A mixin for cogs and commands."""
@property
def help(self) -> str:
"""To be defined by subclasses"""
...
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
if isinstance(self, Command): if isinstance(self, Command):
@ -57,6 +93,77 @@ class CogCommandMixin:
checks=getattr(decorated, "__requires_checks__", []), 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: def allow_for(self, model_id: Union[int, str], guild_id: int) -> None:
"""Actively allow this command for the given model. """Actively allow this command for the given model.
@ -138,7 +245,7 @@ class CogCommandMixin:
self.deny_to(Requires.DEFAULT, guild_id=guild_id) self.deny_to(Requires.DEFAULT, guild_id=guild_id)
class Command(CogCommandMixin, commands.Command): class Command(CogCommandMixin, DPYCommand):
"""Command class for Red. """Command class for Red.
This should not be created directly, and instead via the decorator. This should not be created directly, and instead via the decorator.
@ -154,10 +261,21 @@ class Command(CogCommandMixin, commands.Command):
`Requires.checks`. `Requires.checks`.
translator : Translator translator : Translator
A translator for this command's help docstring. 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): def __init__(self, *args, **kwargs):
self.ignore_optional_for_conversion = kwargs.pop("ignore_optional_for_conversion", False)
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
self._help_override = kwargs.pop("help_override", None) self._help_override = kwargs.pop("help_override", None)
self.translator = kwargs.pop("i18n", None) self.translator = kwargs.pop("i18n", None)
@ -178,8 +296,62 @@ class Command(CogCommandMixin, commands.Command):
# Red specific # Red specific
other.requires = self.requires other.requires = self.requires
other.ignore_optional_for_conversion = self.ignore_optional_for_conversion
return other 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 @property
def help(self): def help(self):
"""Help string for this command. """Help string for this command.
@ -260,7 +432,7 @@ class Command(CogCommandMixin, commands.Command):
for parent in reversed(self.parents): for parent in reversed(self.parents):
try: try:
result = await parent.can_run(ctx, change_permission_state=True) result = await parent.can_run(ctx, change_permission_state=True)
except commands.CommandError: except CommandError:
result = False result = False
if result is False: if result is False:
@ -279,14 +451,24 @@ class Command(CogCommandMixin, commands.Command):
if not change_permission_state: if not change_permission_state:
ctx.permission_state = original_state ctx.permission_state = original_state
async def _verify_checks(self, ctx): async def prepare(self, ctx):
if not self.enabled: ctx.command = self
raise commands.DisabledCommand(f"{self.name} command is disabled")
if not (await self.can_run(ctx, change_permission_state=True)): if not self.enabled:
raise commands.CheckFailure( raise DisabledCommand(f"{self.name} command is disabled")
f"The check functions for command {self.qualified_name} failed."
) 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( async def do_conversion(
self, ctx: "Context", converter, argument: str, param: inspect.Parameter self, ctx: "Context", converter, argument: str, param: inspect.Parameter
@ -310,7 +492,7 @@ class Command(CogCommandMixin, commands.Command):
try: try:
return await super().do_conversion(ctx, converter, argument, param) 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 raise ConversionFailure(converter, argument, param, *exc.args) from exc
except ValueError as exc: except ValueError as exc:
# Some common converters need special treatment... # Some common converters need special treatment...
@ -345,7 +527,7 @@ class Command(CogCommandMixin, commands.Command):
can_run = await self.can_run( can_run = await self.can_run(
ctx, check_all_parents=True, change_permission_state=False ctx, check_all_parents=True, change_permission_state=False
) )
except (commands.CheckFailure, commands.errors.DisabledCommand): except (CheckFailure, DisabledCommand):
return False return False
else: else:
if can_run is False: if can_run is False:
@ -465,6 +647,28 @@ class Command(CogCommandMixin, commands.Command):
""" """
return super().error(coro) 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): class GroupMixin(discord.ext.commands.GroupMixin):
"""Mixin for `Group` and `Red` classes. """Mixin for `Group` and `Red` classes.
@ -501,10 +705,9 @@ class GroupMixin(discord.ext.commands.GroupMixin):
class CogGroupMixin: class CogGroupMixin:
requires: Requires requires: Requires
all_commands: Dict[str, Command]
def reevaluate_rules_for( 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]: ) -> Tuple[PermState, bool]:
"""Re-evaluate a rule by checking subcommand rules. """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) cur_rule = self.requires.get_rule(model_id, guild_id=guild_id)
if cur_rule in (PermState.NORMAL, PermState.ACTIVE_ALLOW, PermState.ACTIVE_DENY): if cur_rule not in (PermState.NORMAL, PermState.ACTIVE_ALLOW, PermState.ACTIVE_DENY):
# These three states are unaffected by subcommand rules # The above three states are unaffected by subcommand rules
return cur_rule, False
else:
# Remaining states can be changed if there exists no actively-allowed # Remaining states can be changed if there exists no actively-allowed
# subcommand (this includes subcommands multiple levels below) # subcommand (this includes subcommands multiple levels below)
all_commands: Dict[str, Command] = getattr(self, "all_commands", {})
if any( if any(
cmd.requires.get_rule(model_id, guild_id=guild_id) in PermState.ALLOWED_STATES cmd.requires.get_rule(model_id, guild_id=guild_id) in PermStateAllowedStates
for cmd in self.all_commands.values() for cmd in all_commands.values()
): ):
return cur_rule, False return cur_rule, False
elif cur_rule is PermState.PASSIVE_ALLOW: 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) self.requires.set_rule(model_id, PermState.ACTIVE_DENY, guild_id=guild_id)
return PermState.ACTIVE_DENY, True 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. """Group command class for Red.
This class inherits from `Command`, with :class:`GroupMixin` and 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 ctx.invoked_subcommand is None or self == ctx.invoked_subcommand:
if self.autohelp and not self.invoke_without_command: 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() await ctx.send_help()
elif self.invoke_without_command: elif self.invoke_without_command:
# So invoke_without_command when a subcommand of this group is invoked # So invoke_without_command when a subcommand of this group is invoked
# will skip the the invokation of *this* command. However, because of # 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 # how our permissions system works, we don't want it to skip the checks
# as well. # 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. # this is actually why we don't prepare earlier.
await super().invoke(ctx) await super().invoke(ctx)
@ -590,14 +797,6 @@ class Group(GroupMixin, Command, CogGroupMixin, commands.Group):
class CogMixin(CogGroupMixin, CogCommandMixin): class CogMixin(CogGroupMixin, CogCommandMixin):
"""Mixin class for a cog, intended for use with discord.py's cog class""" """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 @property
def help(self): def help(self):
doc = self.__doc__ doc = self.__doc__
@ -626,7 +825,7 @@ class CogMixin(CogGroupMixin, CogCommandMixin):
try: try:
can_run = await self.requires.verify(ctx) can_run = await self.requires.verify(ctx)
except commands.CommandError: except CommandError:
return False return False
return can_run return can_run
@ -655,16 +854,22 @@ class CogMixin(CogGroupMixin, CogCommandMixin):
return await self.can_run(ctx) return await self.can_run(ctx)
class Cog(CogMixin, commands.Cog): class Cog(CogMixin, DPYCog, metaclass=DPYCogMeta):
""" """
Red's Cog base class Red's Cog base class
This includes a metaclass from discord.py This includes a metaclass from discord.py
""" """
# NB: Do not move the inheritcance of this. Keeping the mix of that metaclass __cog_commands__: Tuple[Command]
# seperate gives us more freedoms in several places.
pass @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): 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`. Same interface as `discord.ext.commands.command`.
""" """
attrs["help_override"] = attrs.pop("help", None) 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): 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`. 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]]: 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: async def disabler(ctx: "Context") -> bool:
if ctx.guild == guild: if ctx.guild == guild:
raise commands.DisabledCommand() raise DisabledCommand()
return True return True
__command_disablers[guild] = disabler __command_disablers[guild] = disabler
@ -727,6 +933,3 @@ class _AlwaysAvailableCommand(Command):
async def can_run(self, ctx, *args, **kwargs) -> bool: async def can_run(self, ctx, *args, **kwargs) -> bool:
return not ctx.author.bot return not ctx.author.bot
async def _verify_checks(self, ctx) -> bool:
return not ctx.author.bot

View File

@ -1,21 +1,28 @@
from __future__ import annotations
import asyncio import asyncio
import contextlib import contextlib
import os
import re import re
from typing import Iterable, List, Union from typing import Iterable, List, Union, Optional, TYPE_CHECKING
import discord import discord
from discord.ext import commands from discord.ext.commands import Context as DPYContext
from .requires import PermState from .requires import PermState
from ..utils.chat_formatting import box from ..utils.chat_formatting import box
from ..utils.predicates import MessagePredicate from ..utils.predicates import MessagePredicate
from ..utils import common_filters from ..utils import common_filters
if TYPE_CHECKING:
from .commands import Command
from ..bot import Red
TICK = "\N{WHITE HEAVY CHECK MARK}" TICK = "\N{WHITE HEAVY CHECK MARK}"
__all__ = ["Context"] __all__ = ["Context", "GuildContext", "DMContext"]
class Context(commands.Context): class Context(DPYContext):
"""Command invocation context for Red. """Command invocation context for Red.
All context passed into commands will be of this type. 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. The permission state the current context is in.
""" """
command: "Command"
invoked_subcommand: "Optional[Command]"
bot: "Red"
def __init__(self, **attrs): def __init__(self, **attrs):
self.assume_yes = attrs.pop("assume_yes", False) self.assume_yes = attrs.pop("assume_yes", False)
super().__init__(**attrs) super().__init__(**attrs)
@ -254,7 +265,7 @@ class Context(commands.Context):
return pattern.sub(f"@{me.display_name}", self.prefix) return pattern.sub(f"@{me.display_name}", self.prefix)
@property @property
def me(self) -> discord.abc.User: def me(self) -> Union[discord.ClientUser, discord.Member]:
"""discord.abc.User: The bot member or user object. """discord.abc.User: The bot member or user object.
If the context is DM, this will be a `discord.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 return self.guild.me
else: else:
return self.bot.user 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

View File

@ -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 re
import functools import functools
from datetime import timedelta 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 import discord
from discord.ext import commands as dpy_commands from discord.ext import commands as dpy_commands
from discord.ext.commands import BadArgument
from . import BadArgument
from ..i18n import Translator from ..i18n import Translator
from ..utils.chat_formatting import humanize_timedelta from ..utils.chat_formatting import humanize_timedelta, humanize_list
if TYPE_CHECKING: if TYPE_CHECKING:
from .context import Context from .context import Context
@ -17,10 +36,13 @@ __all__ = [
"APIToken", "APIToken",
"DictConverter", "DictConverter",
"GuildConverter", "GuildConverter",
"UserInputOptional",
"NoParseOptional",
"TimedeltaConverter", "TimedeltaConverter",
"get_dict_converter", "get_dict_converter",
"get_timedelta_converter", "get_timedelta_converter",
"parse_timedelta", "parse_timedelta",
"Literal",
] ]
_ = Translator("commands.converter", __file__) _ = Translator("commands.converter", __file__)
@ -67,7 +89,7 @@ def parse_timedelta(
allowed_units : Optional[List[str]] allowed_units : Optional[List[str]]
If provided, you can constrain a user to expressing the amount of time 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 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 Returns
------- -------
@ -138,17 +160,18 @@ class APIToken(discord.ext.commands.Converter):
This will parse the input argument separating the key value pairs into a This will parse the input argument separating the key value pairs into a
format to be used for the core bots API token storage. 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, 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 this leaves the onus on the cog creator to clearly define how to setup the correct
credential names for their cogs. 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 bot = ctx.bot
result = {} result = {}
match = re.split(r";|,| ", argument) match = re.split(r";|,| ", argument)
@ -162,7 +185,16 @@ class APIToken(discord.ext.commands.Converter):
return result 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 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)) 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]: async def convert(self, ctx: "Context", argument: str) -> Dict[str, str]:
ret: Dict[str, str] = {} ret: Dict[str, str] = {}
args = self.pattern.split(argument) args = self.pattern.split(argument)
@ -191,12 +222,20 @@ class DictConverter(dpy_commands.Converter):
return ret 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 Returns a typechecking safe `DictConverter` suitable for use with discord.py
""" """
class PartialMeta(type(DictConverter)): class PartialMeta(type):
__call__ = functools.partialmethod( __call__ = functools.partialmethod(
type(DictConverter).__call__, *expected_keys, delims=delims 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 return ValidatedConverter
class TimedeltaConverter(dpy_commands.Converter): if TYPE_CHECKING:
TimedeltaConverter = timedelta
else:
class TimedeltaConverter(dpy_commands.Converter):
""" """
This is a converter for timedeltas. This is a converter for timedeltas.
The units should be in order from largest to smallest. 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 If provided, any parsed value lower than this will raise an exception
allowed_units : Optional[List[str]] allowed_units : Optional[List[str]]
If provided, you can constrain a user to expressing the amount of time 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 in specific units. The units you can choose to provide are the same as the
parser understands: `weeks` `days` `hours` `minutes` `seconds` parser understands: (``weeks``, ``days``, ``hours``, ``minutes``, ``seconds``)
default_unit : Optional[str] default_unit : Optional[str]
If provided, it will additionally try to match integer-only input into 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. apply.
""" """
@ -239,26 +282,41 @@ class TimedeltaConverter(dpy_commands.Converter):
async def convert(self, ctx: "Context", argument: str) -> timedelta: async def convert(self, ctx: "Context", argument: str) -> timedelta:
if self.default_unit and argument.isdecimal(): if self.default_unit and argument.isdecimal():
delta = timedelta(**{self.default_unit: int(argument)}) argument = argument + self.default_unit
else:
delta = parse_timedelta( delta = parse_timedelta(
argument, argument,
minimum=self.minimum, minimum=self.minimum,
maximum=self.maximum, maximum=self.maximum,
allowed_units=self.allowed_units, allowed_units=self.allowed_units,
) )
if delta is not None: if delta is not None:
return delta return delta
raise BadArgument() # This allows this to be a required argument. 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, default_unit: Optional[str] = None,
maximum: Optional[timedelta] = None, maximum: Optional[timedelta] = None,
minimum: Optional[timedelta] = None, minimum: Optional[timedelta] = None,
allowed_units: Optional[List[str]] = 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 This creates a type suitable for typechecking which works with discord.py's
commands. commands.
@ -273,11 +331,11 @@ def get_timedelta_converter(
If provided, any parsed value lower than this will raise an exception If provided, any parsed value lower than this will raise an exception
allowed_units : Optional[List[str]] allowed_units : Optional[List[str]]
If provided, you can constrain a user to expressing the amount of time 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 in specific units. The units you can choose to provide are the same as the
parser understands: `weeks` `days` `hours` `minutes` `seconds` parser understands: (``weeks``, ``days``, ``hours``, ``minutes``, ``seconds``)
default_unit : Optional[str] default_unit : Optional[str]
If provided, it will additionally try to match integer-only input into 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. apply.
Returns Returns
@ -286,7 +344,7 @@ def get_timedelta_converter(
The converter class, which will be a subclass of `TimedeltaConverter` The converter class, which will be a subclass of `TimedeltaConverter`
""" """
class PartialMeta(type(TimedeltaConverter)): class PartialMeta(type):
__call__ = functools.partialmethod( __call__ = functools.partialmethod(
type(DictConverter).__call__, type(DictConverter).__call__,
allowed_units=allowed_units, allowed_units=allowed_units,
@ -299,3 +357,91 @@ def get_timedelta_converter(
pass pass
return ValidatedConverter 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,))

View File

@ -44,6 +44,7 @@ from . import commands
from .context import Context from .context import Context
from ..i18n import Translator from ..i18n import Translator
from ..utils import menus from ..utils import menus
from ..utils.mod import mass_purge
from ..utils._internal_utils import fuzzy_command_search, format_fuzzy_results from ..utils._internal_utils import fuzzy_command_search, format_fuzzy_results
from ..utils.chat_formatting import box, pagify from ..utils.chat_formatting import box, pagify
@ -162,10 +163,10 @@ class RedHelpFormatter:
@staticmethod @staticmethod
def get_default_tagline(ctx: Context): def get_default_tagline(ctx: Context):
return ( return T_(
f"Type {ctx.clean_prefix}help <command> for more info on a command. " "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." "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): async def format_command_help(self, ctx: Context, obj: commands.Command):
@ -187,7 +188,9 @@ class RedHelpFormatter:
description = command.description or "" description = command.description or ""
tagline = (await ctx.bot._config.help.tagline()) or self.get_default_tagline(ctx) 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 subcommands = None
if hasattr(command, "all_commands"): if hasattr(command, "all_commands"):
@ -198,18 +201,19 @@ class RedHelpFormatter:
emb = {"embed": {"title": "", "description": ""}, "footer": {"text": ""}, "fields": []} emb = {"embed": {"title": "", "description": ""}, "footer": {"text": ""}, "fields": []}
if description: if description:
emb["embed"]["title"] = f"*{description[:2044]}*" emb["embed"]["title"] = f"*{description[:250]}*"
emb["footer"]["text"] = tagline emb["footer"]["text"] = tagline
emb["embed"]["description"] = signature emb["embed"]["description"] = signature
if command.help: command_help = command.format_help_for_context(ctx)
splitted = command.help.split("\n\n") if command_help:
splitted = command_help.split("\n\n")
name = splitted[0] name = splitted[0]
value = "\n\n".join(splitted[1:]).replace("[p]", ctx.clean_prefix) value = "\n\n".join(splitted[1:])
if not value: if not value:
value = EMPTY_STRING value = EMPTY_STRING
field = EmbedField(name[:252], value[:1024], False) field = EmbedField(name[:250], value[:1024], False)
emb["fields"].append(field) emb["fields"].append(field)
if subcommands: if subcommands:
@ -220,14 +224,14 @@ class RedHelpFormatter:
return a_line[:67] + "..." return a_line[:67] + "..."
subtext = "\n".join( 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 name, command in sorted(subcommands.items())
) )
for i, page in enumerate(pagify(subtext, page_length=500, shorten_by=0)): for i, page in enumerate(pagify(subtext, page_length=500, shorten_by=0)):
if i == 0: if i == 0:
title = "**__Subcommands:__**" title = T_("**__Subcommands:__**")
else: else:
title = "**__Subcommands:__** (continued)" title = T_("**__Subcommands:__** (continued)")
field = EmbedField(title, page, False) field = EmbedField(title, page, False)
emb["fields"].append(field) emb["fields"].append(field)
@ -238,14 +242,14 @@ class RedHelpFormatter:
subtext = None subtext = None
subtext_header = None subtext_header = None
if subcommands: if subcommands:
subtext_header = "Subcommands:" subtext_header = T_("Subcommands:")
max_width = max(discord.utils._string_width(name) for name in subcommands.keys()) max_width = max(discord.utils._string_width(name) for name in subcommands.keys())
def width_maker(cmds): def width_maker(cmds):
doc_max_width = 80 - max_width doc_max_width = 80 - max_width
for nm, com in sorted(cmds): for nm, com in sorted(cmds):
width_gap = discord.utils._string_width(nm) - len(nm) 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: if len(doc) > doc_max_width:
doc = doc[: doc_max_width - 3] + "..." doc = doc[: doc_max_width - 3] + "..."
yield nm, doc, max_width - width_gap yield nm, doc, max_width - width_gap
@ -261,7 +265,7 @@ class RedHelpFormatter:
( (
description, description,
signature[1:-1], signature[1:-1],
command.help.replace("[p]", ctx.clean_prefix), command.format_help_for_context(ctx),
subtext_header, subtext_header,
subtext, subtext,
), ),
@ -301,7 +305,10 @@ class RedHelpFormatter:
page_char_limit = await ctx.bot._config.help.page_char_limit() 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... 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 # Offset calculation here is for total embed size limit
# 20 accounts for# *Page {i} of {page_count}* # 20 accounts for# *Page {i} of {page_count}*
@ -346,7 +353,9 @@ class RedHelpFormatter:
embed = discord.Embed(color=color, **embed_dict["embed"]) embed = discord.Embed(color=color, **embed_dict["embed"])
if page_count > 1: 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.description = description
embed.set_author(**author_info) embed.set_author(**author_info)
@ -366,7 +375,7 @@ class RedHelpFormatter:
if not (coms or await ctx.bot._config.help.verify_exists()): if not (coms or await ctx.bot._config.help.verify_exists()):
return return
description = obj.help description = obj.format_help_for_context(ctx)
tagline = (await ctx.bot._config.help.tagline()) or self.get_default_tagline(ctx) tagline = (await ctx.bot._config.help.tagline()) or self.get_default_tagline(ctx)
if await ctx.embed_requested(): if await ctx.embed_requested():
@ -376,7 +385,7 @@ class RedHelpFormatter:
if description: if description:
splitted = description.split("\n\n") splitted = description.split("\n\n")
name = splitted[0] name = splitted[0]
value = "\n\n".join(splitted[1:]).replace("[p]", ctx.clean_prefix) value = "\n\n".join(splitted[1:])
if not value: if not value:
value = EMPTY_STRING value = EMPTY_STRING
field = EmbedField(name[:252], value[:1024], False) field = EmbedField(name[:252], value[:1024], False)
@ -390,14 +399,14 @@ class RedHelpFormatter:
return a_line[:67] + "..." return a_line[:67] + "..."
command_text = "\n".join( 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 name, command in sorted(coms.items())
) )
for i, page in enumerate(pagify(command_text, page_length=500, shorten_by=0)): for i, page in enumerate(pagify(command_text, page_length=500, shorten_by=0)):
if i == 0: if i == 0:
title = "**__Commands:__**" title = T_("**__Commands:__**")
else: else:
title = "**__Commands:__** (continued)" title = T_("**__Commands:__** (continued)")
field = EmbedField(title, page, False) field = EmbedField(title, page, False)
emb["fields"].append(field) emb["fields"].append(field)
@ -407,14 +416,14 @@ class RedHelpFormatter:
subtext = None subtext = None
subtext_header = None subtext_header = None
if coms: if coms:
subtext_header = "Commands:" subtext_header = T_("Commands:")
max_width = max(discord.utils._string_width(name) for name in coms.keys()) max_width = max(discord.utils._string_width(name) for name in coms.keys())
def width_maker(cmds): def width_maker(cmds):
doc_max_width = 80 - max_width doc_max_width = 80 - max_width
for nm, com in sorted(cmds): for nm, com in sorted(cmds):
width_gap = discord.utils._string_width(nm) - len(nm) 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: if len(doc) > doc_max_width:
doc = doc[: doc_max_width - 3] + "..." doc = doc[: doc_max_width - 3] + "..."
yield nm, doc, max_width - width_gap yield nm, doc, max_width - width_gap
@ -442,14 +451,14 @@ class RedHelpFormatter:
emb["footer"]["text"] = tagline emb["footer"]["text"] = tagline
if description: if description:
emb["embed"]["title"] = f"*{description[:2044]}*" emb["embed"]["title"] = f"*{description[:250]}*"
for cog_name, data in coms: for cog_name, data in coms:
if cog_name: if cog_name:
title = f"**__{cog_name}:__**" title = f"**__{cog_name}:__**"
else: else:
title = f"**__No Category:__**" title = f"**__{T_('No Category')}:__**"
def shorten_line(a_line: str) -> str: def shorten_line(a_line: str) -> str:
if len(a_line) < 70: # embed max width needs to be lower if len(a_line) < 70: # embed max width needs to be lower
@ -457,12 +466,12 @@ class RedHelpFormatter:
return a_line[:67] + "..." return a_line[:67] + "..."
cog_text = "\n".join( 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 name, command in sorted(data.items())
) )
for i, page in enumerate(pagify(cog_text, page_length=1000, shorten_by=0)): 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) field = EmbedField(title, page, False)
emb["fields"].append(field) emb["fields"].append(field)
@ -478,21 +487,21 @@ class RedHelpFormatter:
names.extend(list(v.name for v in v.values())) names.extend(list(v.name for v in v.values()))
max_width = max( 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): def width_maker(cmds):
doc_max_width = 80 - max_width doc_max_width = 80 - max_width
for nm, com in cmds: for nm, com in cmds:
width_gap = discord.utils._string_width(nm) - len(nm) 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: if len(doc) > doc_max_width:
doc = doc[: doc_max_width - 3] + "..." doc = doc[: doc_max_width - 3] + "..."
yield nm, doc, max_width - width_gap yield nm, doc, max_width - width_gap
for cog_name, data in coms: 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) to_join.append(title)
for name, doc, width in width_maker(sorted(data.items())): for name, doc, width in width_maker(sorted(data.items())):
@ -543,7 +552,9 @@ class RedHelpFormatter:
if fuzzy_commands: if fuzzy_commands:
ret = await format_fuzzy_results(ctx, fuzzy_commands, embed=use_embeds) ret = await format_fuzzy_results(ctx, fuzzy_commands, embed=use_embeds)
if 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) tagline = (await ctx.bot._config.help.tagline()) or self.get_default_tagline(ctx)
ret.set_footer(text=tagline) ret.set_footer(text=tagline)
await ctx.send(embed=ret) 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) ret = T_("Help topic for *{command_name}* not found.").format(command_name=help_for)
if use_embeds: if use_embeds:
ret = discord.Embed(color=(await ctx.embed_color()), description=ret) 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) tagline = (await ctx.bot._config.help.tagline()) or self.get_default_tagline(ctx)
ret.set_footer(text=tagline) ret.set_footer(text=tagline)
await ctx.send(embed=ret) await ctx.send(embed=ret)
@ -569,7 +582,9 @@ class RedHelpFormatter:
) )
if await ctx.embed_requested(): if await ctx.embed_requested():
ret = discord.Embed(color=(await ctx.embed_color()), description=ret) 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) tagline = (await ctx.bot._config.help.tagline()) or self.get_default_tagline(ctx)
ret.set_footer(text=tagline) ret.set_footer(text=tagline)
await ctx.send(embed=ret) await ctx.send(embed=ret)
@ -613,18 +628,24 @@ class RedHelpFormatter:
Sends pages based on settings. Sends pages based on settings.
""" """
if not ( # save on config calls
ctx.channel.permissions_for(ctx.me).add_reactions config_help = await ctx.bot._config.help()
and await ctx.bot._config.help.use_menus() channel_permissions = ctx.channel.permissions_for(ctx.me)
):
max_pages_in_guild = await ctx.bot._config.help.max_pages_in_guild() if not (channel_permissions.add_reactions and config_help["use_menus"]):
destination = ctx.author if len(pages) > max_pages_in_guild else ctx
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: for page in pages:
try: try:
await destination.send(embed=page) if embed:
msg = await destination.send(embed=page)
else:
msg = await destination.send(page)
except discord.Forbidden: except discord.Forbidden:
return await ctx.send( return await ctx.send(
T_( T_(
@ -633,16 +654,26 @@ class RedHelpFormatter:
) )
) )
else: else:
for page in pages: messages.append(msg)
try:
await destination.send(page) # The if statement takes into account that 'destination' will be
except discord.Forbidden: # the context channel in non-DM context, reusing 'channel_permissions' to avoid
return await ctx.send( # computing the permissions twice.
T_( if (
"I couldn't send the help message to you in DM. " not use_DMs # we're not in DMs
"Either you blocked me or you disabled DMs in this server." 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: else:
# Specifically ensuring the menu's message is sent prior to returning # 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])) m = await (ctx.send(embed=pages[0]) if embed else ctx.send(pages[0]))

View File

@ -8,6 +8,8 @@ checks like bot permissions checks.
""" """
import asyncio import asyncio
import enum import enum
import inspect
from collections import ChainMap
from typing import ( from typing import (
Union, Union,
Optional, Optional,
@ -20,6 +22,7 @@ from typing import (
TypeVar, TypeVar,
Tuple, Tuple,
ClassVar, ClassVar,
Mapping,
) )
import discord import discord
@ -45,6 +48,7 @@ __all__ = [
"permissions_check", "permissions_check",
"bot_has_permissions", "bot_has_permissions",
"has_permissions", "has_permissions",
"has_guild_permissions",
"is_owner", "is_owner",
"guildowner", "guildowner",
"guildowner_or_permissions", "guildowner_or_permissions",
@ -52,6 +56,9 @@ __all__ = [
"admin_or_permissions", "admin_or_permissions",
"mod", "mod",
"mod_or_permissions", "mod_or_permissions",
"transition_permstate_to",
"PermStateTransitions",
"PermStateAllowedStates",
] ]
_T = TypeVar("_T") _T = TypeVar("_T")
@ -95,8 +102,8 @@ class PrivilegeLevel(enum.IntEnum):
"""Enumeration for special privileges.""" """Enumeration for special privileges."""
# Maintainer Note: do NOT re-order these. # Maintainer Note: do NOT re-order these.
# Each privelege level also implies access to the ones before it. # Each privilege level also implies access to the ones before it.
# Inserting new privelege levels at a later point is fine if that is considered. # Inserting new privilege levels at a later point is fine if that is considered.
NONE = enum.auto() NONE = enum.auto()
"""No special privilege level.""" """No special privilege level."""
@ -182,11 +189,6 @@ class PermState(enum.Enum):
"""This command has been actively denied by a permission hook """This command has been actively denied by a permission hook
check validation doesn't need this, but is useful to developers""" 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 @classmethod
def from_bool(cls, value: Optional[bool]) -> "PermState": def from_bool(cls, value: Optional[bool]) -> "PermState":
"""Get a PermState from a bool or ``NoneType``.""" """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 # result of the default permission checks - the transition from NORMAL
# to PASSIVE_ALLOW. In this case "next state" is a dict mapping the # to PASSIVE_ALLOW. In this case "next state" is a dict mapping the
# permission check results to the actual next state. # 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: {
PermState.ACTIVE_ALLOW: (True, PermState.ACTIVE_ALLOW), PermState.ACTIVE_ALLOW: (True, PermState.ACTIVE_ALLOW),
PermState.NORMAL: (True, PermState.ACTIVE_ALLOW), PermState.NORMAL: (True, PermState.ACTIVE_ALLOW),
@ -248,13 +254,18 @@ PermState.TRANSITIONS = {
PermState.ACTIVE_DENY: (False, PermState.ACTIVE_DENY), PermState.ACTIVE_DENY: (False, PermState.ACTIVE_DENY),
}, },
} }
PermState.ALLOWED_STATES = (
PermStateAllowedStates = (
PermState.ACTIVE_ALLOW, PermState.ACTIVE_ALLOW,
PermState.PASSIVE_ALLOW, PermState.PASSIVE_ALLOW,
PermState.CAUTIOUS_ALLOW, PermState.CAUTIOUS_ALLOW,
) )
def transition_permstate_to(prev: PermState, next_state: PermState) -> TransitionResult:
return PermStateTransitions[prev][next_state]
class Requires: class Requires:
"""This class describes the requirements for executing a specific command. """This class describes the requirements for executing a specific command.
@ -326,13 +337,13 @@ class Requires:
@staticmethod @staticmethod
def get_decorator( 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"]: ) -> Callable[["_CommandOrCoro"], "_CommandOrCoro"]:
if not user_perms: if not user_perms:
user_perms = None user_perms = None
def decorator(func: "_CommandOrCoro") -> "_CommandOrCoro": def decorator(func: "_CommandOrCoro") -> "_CommandOrCoro":
if asyncio.iscoroutinefunction(func): if inspect.iscoroutinefunction(func):
func.__requires_privilege_level__ = privilege_level func.__requires_privilege_level__ = privilege_level
func.__requires_user_perms__ = user_perms func.__requires_user_perms__ = user_perms
else: else:
@ -341,6 +352,7 @@ class Requires:
func.requires.user_perms = None func.requires.user_perms = None
else: else:
_validate_perms_dict(user_perms) _validate_perms_dict(user_perms)
assert func.requires.user_perms is not None
func.requires.user_perms.update(**user_perms) func.requires.user_perms.update(**user_perms)
return func return func
@ -357,6 +369,8 @@ class Requires:
guild_id : int guild_id : int
The ID of the guild for the rule's scope. Set to The ID of the guild for the rule's scope. Set to
`Requires.GLOBAL` for a global rule. `Requires.GLOBAL` for a global rule.
If a global rule is set for a model,
it will be prefered over the guild rule.
Returns Returns
------- -------
@ -367,8 +381,9 @@ class Requires:
""" """
if not isinstance(model, (str, int)): if not isinstance(model, (str, int)):
model = model.id model = model.id
rules: Mapping[Union[int, str], PermState]
if guild_id: if guild_id:
rules = self._guild_rules.get(guild_id, _RulesDict()) rules = ChainMap(self._global_rules, self._guild_rules.get(guild_id, _RulesDict()))
else: else:
rules = self._global_rules rules = self._global_rules
return rules.get(model, PermState.NORMAL) return rules.get(model, PermState.NORMAL)
@ -488,7 +503,7 @@ class Requires:
async def _transition_state(self, ctx: "Context") -> bool: async def _transition_state(self, ctx: "Context") -> bool:
prev_state = ctx.permission_state prev_state = ctx.permission_state
cur_state = self._get_rule_from_ctx(ctx) 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: if should_invoke is None:
# NORMAL invokation, we simply follow standard procedure # NORMAL invokation, we simply follow standard procedure
should_invoke = await self._verify_user(ctx) should_invoke = await self._verify_user(ctx)
@ -509,6 +524,7 @@ class Requires:
would_invoke = await self._verify_user(ctx) would_invoke = await self._verify_user(ctx)
next_state = next_state[would_invoke] next_state = next_state[would_invoke]
assert isinstance(next_state, PermState)
ctx.permission_state = next_state ctx.permission_state = next_state
return should_invoke return should_invoke
@ -635,6 +651,20 @@ def permissions_check(predicate: CheckPredicate):
return decorator 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): def bot_has_permissions(**perms: bool):
"""Complain if the bot is missing permissions. """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: 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(): 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: if value is not True:
# We reject any permission not specified as 'True', since this is the only value which # We reject any permission not specified as 'True', since this is the only value which
# makes practical sense. # makes practical sense.

View File

@ -979,7 +979,7 @@ class Config:
""" """
return self._get_base_group(self.CHANNEL, str(channel_id)) 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. """Returns a `Group` for the given channel.
This does not discriminate between text and voice channels. This does not discriminate between text and voice channels.

View File

@ -126,7 +126,7 @@ class CoreLogic:
else: else:
await bot.add_loaded_package(name) await bot.add_loaded_package(name)
loaded_packages.append(name) loaded_packages.append(name)
# remove in Red 3.3 # remove in Red 3.4
downloader = bot.get_cog("Downloader") downloader = bot.get_cog("Downloader")
if downloader is None: if downloader is None:
continue continue
@ -257,10 +257,9 @@ class CoreLogic:
The current (or new) list of prefixes. The current (or new) list of prefixes.
""" """
if prefixes: if prefixes:
prefixes = sorted(prefixes, reverse=True) await self.bot._prefix_cache.set_prefixes(guild=None, prefixes=prefixes)
await self.bot._config.prefix.set(prefixes)
return prefixes return prefixes
return await self.bot._config.prefix() return await self.bot._prefix_cache.get_prefixes(guild=None)
@classmethod @classmethod
async def _version_info(cls) -> Dict[str, str]: 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) python_version = "[{}.{}.{}]({})".format(*sys.version_info[:3], python_url)
red_version = "[{}]({})".format(__version__, red_pypi) red_version = "[{}]({})".format(__version__, red_pypi)
app_info = await self.bot.application_info() app_info = await self.bot.application_info()
if app_info.team:
owner = app_info.team.name
else:
owner = app_info.owner owner = app_info.owner
custom_info = await self.bot._config.custom_info() custom_info = await self.bot._config.custom_info()
@ -359,7 +361,7 @@ class Core(commands.Cog, CoreLogic):
@commands.command() @commands.command()
async def uptime(self, ctx: commands.Context): 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") since = ctx.bot.uptime.strftime("%Y-%m-%d %H:%M:%S")
delta = datetime.datetime.utcnow() - self.bot.uptime delta = datetime.datetime.utcnow() - self.bot.uptime
uptime_str = humanize_timedelta(timedelta=delta) or _("Less than one second") uptime_str = humanize_timedelta(timedelta=delta) or _("Less than one second")
@ -386,6 +388,9 @@ class Core(commands.Cog, CoreLogic):
if ctx.guild: if ctx.guild:
guild_setting = await self.bot._config.guild(ctx.guild).embeds() guild_setting = await self.bot._config.guild(ctx.guild).embeds()
text += _("Guild setting: {}\n").format(guild_setting) 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() user_setting = await self.bot._config.user(ctx.author).embeds()
text += _("User setting: {}").format(user_setting) text += _("User setting: {}").format(user_setting)
await ctx.send(box(text)) 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") @embedset.command(name="user")
async def embedset_user(self, ctx: commands.Context, enabled: bool = None): async def embedset_user(self, ctx: commands.Context, enabled: bool = None):
""" """
@ -472,7 +502,7 @@ class Core(commands.Cog, CoreLogic):
@commands.command() @commands.command()
@commands.check(CoreLogic._can_get_invite_url) @commands.check(CoreLogic._can_get_invite_url)
async def invite(self, ctx): async def invite(self, ctx):
"""Show's Red's invite url""" """Show's [botname]'s invite url"""
try: try:
await ctx.author.send(await self._invite_url()) await ctx.author.send(await self._invite_url())
except discord.errors.Forbidden: except discord.errors.Forbidden:
@ -563,7 +593,7 @@ class Core(commands.Cog, CoreLogic):
msg = "" msg = ""
responses = [] responses = []
for i, server in enumerate(guilds, 1): 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)) responses.append(str(i))
for page in pagify(msg, ["\n"]): for page in pagify(msg, ["\n"]):
@ -675,13 +705,13 @@ class Core(commands.Cog, CoreLogic):
if len(repos_with_shared_libs) == 1: if len(repos_with_shared_libs) == 1:
formed = _( formed = _(
"**WARNING**: The following repo is using shared libs" "**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." "You should inform maintainer of the repo about this message."
).format(repo=inline(repos_with_shared_libs.pop())) ).format(repo=inline(repos_with_shared_libs.pop()))
else: else:
formed = _( formed = _(
"**WARNING**: The following repos are using shared libs" "**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." "You should inform maintainers of these repos about this message."
).format(repos=humanize_list([inline(repo) for repo in repos_with_shared_libs])) ).format(repos=humanize_list([inline(repo) for repo in repos_with_shared_libs]))
output.append(formed) output.append(formed)
@ -793,13 +823,13 @@ class Core(commands.Cog, CoreLogic):
if len(repos_with_shared_libs) == 1: if len(repos_with_shared_libs) == 1:
formed = _( formed = _(
"**WARNING**: The following repo is using shared libs" "**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." "You should inform maintainers of these repos about this message."
).format(repo=inline(repos_with_shared_libs.pop())) ).format(repo=inline(repos_with_shared_libs.pop()))
else: else:
formed = _( formed = _(
"**WARNING**: The following repos are using shared libs" "**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." "You should inform maintainers of these repos about this message."
).format(repos=humanize_list([inline(repo) for repo in repos_with_shared_libs])) ).format(repos=humanize_list([inline(repo) for repo in repos_with_shared_libs]))
output.append(formed) output.append(formed)
@ -835,7 +865,7 @@ class Core(commands.Cog, CoreLogic):
@commands.group(name="set") @commands.group(name="set")
async def _set(self, ctx: commands.Context): async def _set(self, ctx: commands.Context):
"""Changes Red's settings""" """Changes [botname]'s settings"""
if ctx.invoked_subcommand is None: if ctx.invoked_subcommand is None:
if ctx.guild: if ctx.guild:
guild = 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_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_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." 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( guild_settings = _("Admin roles: {admin}\nMod roles: {mod}\n").format(
admin=admin_roles_str, mod=mod_roles_str admin=admin_roles_str, mod=mod_roles_str
) )
else: else:
guild_settings = "" guild_settings = ""
prefixes = None # This is correct. The below can happen in a guild.
if not prefixes: prefixes = await ctx.bot._prefix_cache.get_prefixes(ctx.guild)
prefixes = await ctx.bot._config.prefix()
locale = await ctx.bot._config.locale() locale = await ctx.bot._config.locale()
prefix_string = " ".join(prefixes) prefix_string = " ".join(prefixes)
@ -873,6 +901,32 @@ class Core(commands.Cog, CoreLogic):
for page in pagify(settings): for page in pagify(settings):
await ctx.send(box(page)) 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() @_set.command()
@checks.guildowner() @checks.guildowner()
@commands.guild_only() @commands.guild_only()
@ -997,7 +1051,7 @@ class Core(commands.Cog, CoreLogic):
@_set.command() @_set.command()
@checks.is_owner() @checks.is_owner()
async def avatar(self, ctx: commands.Context, url: str): 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 aiohttp.ClientSession() as session:
async with session.get(url) as r: async with session.get(url) as r:
data = await r.read() data = await r.read()
@ -1021,7 +1075,7 @@ class Core(commands.Cog, CoreLogic):
@checks.bot_in_a_guild() @checks.bot_in_a_guild()
@checks.is_owner() @checks.is_owner()
async def _game(self, ctx: commands.Context, *, game: str = None): async def _game(self, ctx: commands.Context, *, game: str = None):
"""Sets Red's playing status""" """Sets [botname]'s playing status"""
if game: if game:
game = discord.Game(name=game) game = discord.Game(name=game)
@ -1035,7 +1089,7 @@ class Core(commands.Cog, CoreLogic):
@checks.bot_in_a_guild() @checks.bot_in_a_guild()
@checks.is_owner() @checks.is_owner()
async def _listening(self, ctx: commands.Context, *, listening: str = None): 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 status = ctx.bot.guilds[0].me.status if len(ctx.bot.guilds) > 0 else discord.Status.online
if listening: if listening:
@ -1049,7 +1103,7 @@ class Core(commands.Cog, CoreLogic):
@checks.bot_in_a_guild() @checks.bot_in_a_guild()
@checks.is_owner() @checks.is_owner()
async def _watching(self, ctx: commands.Context, *, watching: str = None): 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 status = ctx.bot.guilds[0].me.status if len(ctx.bot.guilds) > 0 else discord.Status.online
if watching: if watching:
@ -1063,7 +1117,7 @@ class Core(commands.Cog, CoreLogic):
@checks.bot_in_a_guild() @checks.bot_in_a_guild()
@checks.is_owner() @checks.is_owner()
async def status(self, ctx: commands.Context, *, status: str): async def status(self, ctx: commands.Context, *, status: str):
"""Sets Red's status """Sets [botname]'s status
Available statuses: Available statuses:
online online
@ -1092,7 +1146,7 @@ class Core(commands.Cog, CoreLogic):
@checks.bot_in_a_guild() @checks.bot_in_a_guild()
@checks.is_owner() @checks.is_owner()
async def stream(self, ctx: commands.Context, streamer=None, *, stream_title=None): 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.""" 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 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"]) @_set.command(name="username", aliases=["name"])
@checks.is_owner() @checks.is_owner()
async def _username(self, ctx: commands.Context, *, username: str): async def _username(self, ctx: commands.Context, *, username: str):
"""Sets Red's username""" """Sets [botname]'s username"""
try: try:
await self._name(name=username) await self._name(name=username)
except discord.HTTPException: except discord.HTTPException:
@ -1132,7 +1186,7 @@ class Core(commands.Cog, CoreLogic):
@checks.admin() @checks.admin()
@commands.guild_only() @commands.guild_only()
async def _nickname(self, ctx: commands.Context, *, nickname: str = None): async def _nickname(self, ctx: commands.Context, *, nickname: str = None):
"""Sets Red's nickname""" """Sets [botname]'s nickname"""
try: try:
await ctx.guild.me.edit(nick=nickname) await ctx.guild.me.edit(nick=nickname)
except discord.Forbidden: except discord.Forbidden:
@ -1143,7 +1197,7 @@ class Core(commands.Cog, CoreLogic):
@_set.command(aliases=["prefixes"]) @_set.command(aliases=["prefixes"])
@checks.is_owner() @checks.is_owner()
async def prefix(self, ctx: commands.Context, *prefixes: str): async def prefix(self, ctx: commands.Context, *prefixes: str):
"""Sets Red's global prefix(es)""" """Sets [botname]'s global prefix(es)"""
if not prefixes: if not prefixes:
await ctx.send_help() await ctx.send_help()
return return
@ -1154,13 +1208,13 @@ class Core(commands.Cog, CoreLogic):
@checks.admin() @checks.admin()
@commands.guild_only() @commands.guild_only()
async def serverprefix(self, ctx: commands.Context, *prefixes: str): async def serverprefix(self, ctx: commands.Context, *prefixes: str):
"""Sets Red's server prefix(es)""" """Sets [botname]'s server prefix(es)"""
if not prefixes: 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.")) await ctx.send(_("Guild prefixes have been reset."))
return return
prefixes = sorted(prefixes, reverse=True) 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.")) await ctx.send(_("Prefix set."))
@_set.command() @_set.command()
@ -1345,6 +1399,30 @@ class Core(commands.Cog, CoreLogic):
await ctx.bot._config.help.max_pages_in_guild.set(pages) await ctx.bot._config.help.max_pages_in_guild.set(pages)
await ctx.send(_("Done. The page limit has been set to {}.").format(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") @helpset.command(name="tagline")
async def helpset_tagline(self, ctx: commands.Context, *, tagline: str = None): 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: if not destination.permissions_for(destination.guild.me).send_messages:
continue continue
if destination.permissions_for(destination.guild.me).embed_links: 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() send_embed = await ctx.bot._config.guild(destination.guild).embeds()
else: else:
send_embed = False send_embed = False
@ -1501,12 +1581,12 @@ class Core(commands.Cog, CoreLogic):
settings, 'appearance' tab. Then right click a user settings, 'appearance' tab. Then right click a user
and copy their id""" and copy their id"""
destination = discord.utils.get(ctx.bot.get_all_members(), id=user_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( await ctx.send(
_( _(
"Invalid ID or user not found. You can only " "Invalid ID, user not found, or user is a bot. "
"send messages to people I share a server " "You can only send messages to people I share "
"with." "a server with."
) )
) )
return return

View File

@ -271,7 +271,7 @@ class BaseDriver(abc.ABC):
The driver must be initialized before this operation. 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. by subclasses.
Parameters Parameters

View File

@ -217,7 +217,7 @@ class JsonDriver(BaseDriver):
def _save_json(path: Path, data: Dict[str, Any]) -> None: 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. On windows, it is not available in entirety.
If a windows user ends up with tons of temp files, they should consider hosting on If a windows user ends up with tons of temp files, they should consider hosting on

View File

@ -49,6 +49,11 @@ def init_events(bot, cli_flags):
users = len(set([m for m in bot.get_all_members()])) users = len(set([m for m in bot.get_all_members()]))
app_info = await bot.application_info() 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: if bot.owner_id is None:
bot.owner_id = app_info.owner.id bot.owner_id = app_info.owner.id
@ -213,6 +218,12 @@ def init_events(bot, cli_flags):
), ),
delete_after=error.retry_after, 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: else:
log.exception(type(error).__name__, exc_info=error) log.exception(type(error).__name__, exc_info=error)

View File

@ -4,18 +4,20 @@ from . import commands
def init_global_checks(bot): def init_global_checks(bot):
@bot.check_once @bot.check_once
def actually_up(ctx): def minimum_bot_perms(ctx) -> bool:
""" """
Uptime is set during the initial startup process. Too many 403, 401, and 429 Errors can cause bots to get global'd
If this hasn't been set, we should assume the bot isn't ready yet.
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 @bot.check_once
async def whiteblacklist_checks(ctx): async def whiteblacklist_checks(ctx) -> bool:
return await ctx.bot.allowed_by_whitelist_blacklist(ctx.author) return await ctx.bot.allowed_by_whitelist_blacklist(ctx.author)
@bot.check_once @bot.check_once
def bots(ctx): def bots(ctx) -> bool:
"""Check the user is not another bot.""" """Check the user is not another bot."""
return not ctx.author.bot return not ctx.author.bot

View File

@ -142,6 +142,18 @@ async def _init(bot: Red):
bot.add_listener(on_member_unban) 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): async def _migrate_config(from_version: int, to_version: int):
if from_version == to_version: if from_version == to_version:
return 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") await _conf.guild(cast(discord.Guild, discord.Object(id=guild_id))).clear_raw("cases")
if from_version < 3 <= to_version: if from_version < 3 <= to_version:
all_casetypes = { await handle_auditype_key()
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 _conf.schema_version.set(3) await _conf.schema_version.set(3)
if from_version < 4 <= to_version: if from_version < 4 <= to_version:
@ -321,9 +324,7 @@ class Case:
if embed: if embed:
emb = discord.Embed(title=title, description=reason) emb = discord.Embed(title=title, description=reason)
emb.set_author(name=user)
if avatar_url is not None:
emb.set_author(name=user, icon_url=avatar_url)
emb.add_field(name=_("Moderator"), value=moderator, inline=False) emb.add_field(name=_("Moderator"), value=moderator, inline=False)
if until and duration: if until and duration:
emb.add_field(name=_("Until"), value=until) emb.add_field(name=_("Until"), value=until)
@ -507,8 +508,15 @@ class CaseType:
self.image = image self.image = image
self.case_str = case_str self.case_str = case_str
self.guild = guild 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: 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): async def to_json(self):
"""Transforms the case type into a dict and saves it""" """Transforms the case type into a dict and saves it"""

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

View File

@ -1,4 +1,5 @@
import asyncio import asyncio
import warnings
from asyncio import AbstractEventLoop, as_completed, Semaphore from asyncio import AbstractEventLoop, as_completed, Semaphore
from asyncio.futures import isfuture from asyncio.futures import isfuture
from itertools import chain from itertools import chain
@ -177,14 +178,20 @@ def bounded_gather_iter(
TypeError TypeError
When invalid parameters are passed When invalid parameters are passed
""" """
if loop is None: if loop is not None:
loop = asyncio.get_event_loop() 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 semaphore is None:
if not isinstance(limit, int) or limit <= 0: if not isinstance(limit, int) or limit <= 0:
raise TypeError("limit must be an int > 0") raise TypeError("limit must be an int > 0")
semaphore = Semaphore(limit, loop=loop) semaphore = Semaphore(limit)
pending = [] pending = []
@ -195,7 +202,7 @@ def bounded_gather_iter(
cof = _sem_wrapper(semaphore, cof) cof = _sem_wrapper(semaphore, cof)
pending.append(cof) pending.append(cof)
return as_completed(pending, loop=loop) return as_completed(pending)
def bounded_gather( def bounded_gather(
@ -228,15 +235,21 @@ def bounded_gather(
TypeError TypeError
When invalid parameters are passed When invalid parameters are passed
""" """
if loop is None: if loop is not None:
loop = asyncio.get_event_loop() 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 semaphore is None:
if not isinstance(limit, int) or limit <= 0: if not isinstance(limit, int) or limit <= 0:
raise TypeError("limit must be an int > 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) 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)

View File

@ -21,7 +21,7 @@ class AntiSpam:
# TODO : Decorator interface for command check using `spammy` # TODO : Decorator interface for command check using `spammy`
# with insertion of the antispam element into context # with insertion of the antispam element into context
# for manual stamping on succesful command completion # for manual stamping on successful command completion
default_intervals = [ default_intervals = [
(timedelta(seconds=5), 3), (timedelta(seconds=5), 3),

View File

@ -5,6 +5,7 @@
import asyncio import asyncio
import contextlib import contextlib
import functools import functools
import warnings
from typing import Union, Iterable, Optional from typing import Union, Iterable, Optional
import discord import discord
@ -200,7 +201,9 @@ def start_adding_reactions(
await message.add_reaction(emoji) await message.add_reaction(emoji)
if loop is None: 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()) return loop.create_task(task())

View File

@ -38,12 +38,13 @@ async def mass_purge(messages: List[discord.Message], channel: discord.TextChann
""" """
while messages: 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]) await channel.delete_messages(messages[:100])
except discord.errors.HTTPException:
pass
messages = messages[100:] messages = messages[100:]
else:
await messages[0].delete()
messages = []
await asyncio.sleep(1.5) await asyncio.sleep(1.5)

View File

@ -1,3 +1,5 @@
from __future__ import annotations
import re import re
from typing import Callable, ClassVar, List, Optional, Pattern, Sequence, Tuple, Union, cast from typing import Callable, ClassVar, List, Optional, Pattern, Sequence, Tuple, Union, cast

0
redbot/py.typed Normal file
View File

View File

@ -76,7 +76,6 @@ def bot_repo(event_loop):
commit="", commit="",
url="https://empty.com/something.git", url="https://empty.com/something.git",
folder_path=cwd, 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): async def _session_git_repo(tmp_path_factory, event_loop):
# we will import repo only once once per session and duplicate the repo folder # we will import repo only once once per session and duplicate the repo folder
repo_path = tmp_path_factory.mktemp("session_git_repo") repo_path = tmp_path_factory.mktemp("session_git_repo")
repo = Repo( repo = Repo(name="redbot-testrepo", url="", branch="master", commit="", folder_path=repo_path)
name="redbot-testrepo",
url="",
branch="master",
commit="",
folder_path=repo_path,
loop=event_loop,
)
git_dirparams = _init_test_repo(repo_path) git_dirparams = _init_test_repo(repo_path)
fast_import = sp.Popen((*git_dirparams, "fast-import", "--quiet"), stdin=sp.PIPE) fast_import = sp.Popen((*git_dirparams, "fast-import", "--quiet"), stdin=sp.PIPE)
with TEST_REPO_EXPORT_PTH.open(mode="rb") as f: 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, branch=_session_git_repo.branch,
commit=_session_git_repo.commit, commit=_session_git_repo.commit,
folder_path=repo_path, folder_path=repo_path,
loop=event_loop,
) )
return repo return repo
@ -208,7 +199,6 @@ async def cloned_git_repo(_session_git_repo, tmp_path, event_loop):
branch=_session_git_repo.branch, branch=_session_git_repo.branch,
commit=_session_git_repo.commit, commit=_session_git_repo.commit,
folder_path=repo_path, folder_path=repo_path,
loop=event_loop,
) )
sp.run(("git", "clone", str(_session_git_repo.folder_path), str(repo_path)), check=True) sp.run(("git", "clone", str(_session_git_repo.folder_path), str(repo_path)), check=True)
return repo return repo
@ -224,7 +214,6 @@ async def git_repo_with_remote(git_repo, tmp_path, event_loop):
branch=git_repo.branch, branch=git_repo.branch,
commit=git_repo.commit, commit=git_repo.commit,
folder_path=repo_path, folder_path=repo_path,
loop=event_loop,
) )
sp.run(("git", "clone", str(git_repo.folder_path), str(repo_path)), check=True) sp.run(("git", "clone", str(git_repo.folder_path), str(repo_path)), check=True)
return repo return repo

View File

@ -253,7 +253,8 @@ async def remove_instance(
backend = get_current_backend(instance) backend = get_current_backend(instance)
driver_cls = drivers.get_driver_class(backend) driver_cls = drivers.get_driver_class(backend)
await driver_cls.initialize(**data_manager.storage_details())
try:
if delete_data is True: if delete_data is True:
await driver_cls.delete_all_data(interactive=interactive, drop_db=drop_db) await driver_cls.delete_all_data(interactive=interactive, drop_db=drop_db)
@ -267,6 +268,8 @@ async def remove_instance(
safe_delete(data_path) safe_delete(data_path)
save_config(instance, {}, remove=True) save_config(instance, {}, remove=True)
finally:
await driver_cls.teardown()
print("The instance {} has been removed\n".format(instance)) print("The instance {} has been removed\n".format(instance))
@ -368,8 +371,7 @@ def delete(
remove_datapath: Optional[bool], remove_datapath: Optional[bool],
): ):
"""Removes an instance.""" """Removes an instance."""
loop = asyncio.get_event_loop() asyncio.run(
loop.run_until_complete(
remove_instance( remove_instance(
instance, interactive, delete_data, _create_backup, drop_db, remove_datapath 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 = deepcopy(data_manager.basic_config_default)
default_dirs["DATA_PATH"] = str(Path(instance_data[instance]["DATA_PATH"])) default_dirs["DATA_PATH"] = str(Path(instance_data[instance]["DATA_PATH"]))
loop = asyncio.get_event_loop()
if current_backend == BackendType.MONGOV1: if current_backend == BackendType.MONGOV1:
raise RuntimeError("Please see the 3.2 release notes for upgrading a bot using mongo.") raise RuntimeError("Please see the 3.2 release notes for upgrading a bot using mongo.")
elif current_backend == BackendType.POSTGRES: # TODO: GH-3115 elif current_backend == BackendType.POSTGRES: # TODO: GH-3115
raise RuntimeError("Converting away from postgres isn't currently supported") raise RuntimeError("Converting away from postgres isn't currently supported")
else: 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: if new_storage_details is not None:
default_dirs["STORAGE_TYPE"] = target.value default_dirs["STORAGE_TYPE"] = target.value
@ -419,8 +419,7 @@ def convert(instance, backend):
) )
def backup(instance: str, destination_folder: Union[str, Path]) -> None: def backup(instance: str, destination_folder: Union[str, Path]) -> None:
"""Backup instance's data.""" """Backup instance's data."""
loop = asyncio.get_event_loop() asyncio.run(create_backup(instance, Path(destination_folder)))
loop.run_until_complete(create_backup(instance, Path(destination_folder)))
def run_cli(): def run_cli():

View File

@ -27,7 +27,7 @@ packages = find_namespace:
python_requires = >=3.8.1 python_requires = >=3.8.1
install_requires = install_requires =
aiohttp==3.6.2 aiohttp==3.6.2
aiohttp-json-rpc==0.12.1 aiohttp-json-rpc==0.12.2
aiosqlite==0.11.0 aiosqlite==0.11.0
appdirs==1.4.3 appdirs==1.4.3
apsw-wheels==3.30.1.post3 apsw-wheels==3.30.1.post3
@ -38,7 +38,7 @@ install_requires =
Click==7.0 Click==7.0
colorama==0.4.3 colorama==0.4.3
contextlib2==0.5.5 contextlib2==0.5.5
discord.py==1.2.5 discord.py==1.3.1
distro==1.4.0; sys_platform == "linux" distro==1.4.0; sys_platform == "linux"
fuzzywuzzy==0.17.0 fuzzywuzzy==0.17.0
idna==2.8 idna==2.8
@ -46,7 +46,7 @@ install_requires =
python-Levenshtein-wheels==0.13.1 python-Levenshtein-wheels==0.13.1
pytz==2019.3 pytz==2019.3
PyYAML==5.3 PyYAML==5.3
Red-Lavalink==0.4.1 Red-Lavalink==0.4.2
schema==0.7.1 schema==0.7.1
tqdm==4.41.1 tqdm==4.41.1
uvloop==0.14.0; sys_platform != "win32" and platform_python_implementation == "CPython" uvloop==0.14.0; sys_platform != "win32" and platform_python_implementation == "CPython"
@ -127,5 +127,6 @@ include =
data/* data/*
data/**/* data/**/*
*.export *.export
py.typed
redbot.core.drivers.postgres = redbot.core.drivers.postgres =
*.sql *.sql

View File

@ -12,8 +12,10 @@ _update_event_loop_policy()
@pytest.fixture(scope="session") @pytest.fixture(scope="session")
def event_loop(request): def event_loop(request):
"""Create an instance of the default event loop for entire session.""" """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 yield loop
asyncio.set_event_loop(None)
loop.close() loop.close()