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

This commit is contained in:
Michael H 2020-01-17 20:25:45 -05:00
commit 4c77cde249
39 changed files with 1090 additions and 551 deletions

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.
# 2. Ground Rules
We've made a point to use [ZenHub](https://www.zenhub.com/) (a plugin for GitHub) as our main source of collaboration and coordination. Your experience contributing to Red will be greatly improved if you go get that plugin.
1. Ensure cross compatibility for Windows, Mac OS and Linux.
2. Ensure all Python features used in contributions exist and work in Python 3.7 and above.
2. Ensure all Python features used in contributions exist and work in Python 3.8.1 and above.
3. Create new tests for code you add or bugs you fix. It helps us help you by making sure we don't accidentally break anything :grinning:
4. Create any issues for new features you'd like to implement and explain why this feature is useful to everyone and not just you personally.
5. Don't add new cogs unless specifically given approval in an issue discussing said cog idea.
@ -54,7 +53,7 @@ Red's repository is configured to follow a particular development workflow, usin
### 4.1 Setting up your development environment
The following requirements must be installed prior to setting up:
- Python 3.7.0 or greater
- Python 3.8.1 or greater
- git
- pip
@ -83,7 +82,7 @@ If you're not on Windows, you should also have GNU make installed, and you can o
We've recently started using [tox](https://github.com/tox-dev/tox) to run all of our tests. It's extremely simple to use, and if you followed the previous section correctly, it is already installed to your virtual environment.
Currently, tox does the following, creating its own virtual environments for each stage:
- Runs all of our unit tests with [pytest](https://github.com/pytest-dev/pytest) on python 3.7 (test environment `py37`)
- Runs all of our unit tests with [pytest](https://github.com/pytest-dev/pytest) on python 3.8 (test environment `py38`)
- Ensures documentation builds without warnings, and all hyperlinks have a valid destination (test environment `docs`)
- Ensures that the code meets our style guide with [black](https://github.com/ambv/black) (test environment `style`)
@ -107,7 +106,7 @@ You may have noticed we have a `Makefile` and a `make.bat` in the top-level dire
The other make recipes are most likely for project maintainers rather than contributors.
You can specify the Python executable used in the make recipes with the `PYTHON` environment variable, e.g. `make PYTHON=/usr/bin/python3.7 newenv`.
You can specify the Python executable used in the make recipes with the `PYTHON` environment variable, e.g. `make PYTHON=/usr/bin/python3.8 newenv`.
### 4.5 Keeping your dependencies up to date
Whenever you pull from upstream (V3/develop on the main repository) and you notice either of the files `setup.cfg` or `tools/dev-requirements.txt` have been changed, it can often mean some package dependencies have been updated, added or removed. To make sure you're testing and formatting with the most up-to-date versions of our dependencies, run `make syncenv`. You could also simply do `make newenv` to install them to a clean new virtual environment.

View File

@ -13,9 +13,14 @@ jobs:
with:
github-token: ${{secrets.GITHUB_TOKEN}}
script: |
const is_status_label = (label) => label.name.startsWith('Status: ');
if (context.payload.issue.labels.some(is_status_label)) {
console.log('Issue already has Status label, skipping...');
return;
}
github.issues.addLabels({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
labels: ['Status: Needs Triage']
})
});

View File

@ -16,7 +16,7 @@
<img src="https://img.shields.io/badge/Support-Red!-yellow.svg" alt="Support Red on Patreon!">
</a>
<a href="https://www.python.org/downloads/">
<img src="https://img.shields.io/badge/Made%20With-Python%203.7-blue.svg?style=for-the-badge" alt="Made with Python 3.7">
<img src="https://img.shields.io/badge/Made%20With-Python%203.8-blue.svg?style=for-the-badge" alt="Made with Python 3.8">
</a>
<a href="https://crowdin.com/project/red-discordbot">
<img src="https://d322cqt584bo4o.cloudfront.net/red-discordbot/localized.svg" alt="Localized with Crowdin">

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.
You can add additional Red based arguments after the instance, such as :code:`--dev`.
:code:`pm2 start redbot --name "<Insert a name here>" --interpreter "<Location to your Python Interpreter>" -- <Red Instance> --no-prompt`
.. code-block:: none
pm2 start redbot --name "<Insert a name here>" --interpreter "<Location to your Python Interpreter>" --interpreter-args "-O" -- <Red Instance> --no-prompt
.. code-block:: none
Arguments to replace.
--name ""
<Insert a name here>
A name to identify the bot within pm2, this is not your Red instance.
--interpreter ""
The location of your Python interpreter, to find out where that is use the following command:
which python3.6
<Location to your Python Interpreter>
The location of your Python interpreter, to find out where that is use the following command inside activated venv:
which python
<Red Instance>
The name of your Red instance.

View File

@ -18,7 +18,7 @@ In order to create the service file, you will first need the location of your :c
# If you are using pyenv
pyenv shell <name>
which redbot
which python
Then create the new service file:
@ -33,7 +33,7 @@ Paste the following and replace all instances of :code:`username` with the usern
After=multi-user.target
[Service]
ExecStart=path %I --no-prompt
ExecStart=path -O -m redbot %I --no-prompt
User=username
Group=username
Type=idle

View File

@ -1,5 +1,68 @@
.. 3.2.x Changelogs
Redbot 3.2.3 (2020-01-17)
=========================
Core Bot Changes
----------------
- Further improvements have been made to bot startup and shutdown.
- Prefixes are now cached for performance.
- Added the means for cog creators to use a global preinvoke hook.
- The bot now ensures it has at least the bare neccessary permissions before running commands.
- Deleting instances works as intended again.
- Sinbad stopped fighting it and embraced the entrypoint madness.
Core Commands
-------------
- The servers command now also shows the ids.
Admin Cog
---------
- The selfrole command now has reasonable expectations about hierarchy.
Help Formatter
--------------
- ``[botname]`` is now replaced with the bot's display name in help text.
- New features added for cog creators to further customize help behavior.
- Check out our command reference for details on new ``format_help_for_context`` method.
- Embed settings are now consistent.
Downloader
----------
- Improved a few user facing messages.
- Added pagination of output on cog update.
- Added logging of failures.
Docs
----
There's more detail to the below changes, so go read the docs.
For some reason, documenting documentation changes is hard.
- Added instructions about git version.
- Clarified instructions for installation and update.
- Added more details to the API key reference.
- Fixed some typos and versioning mistakes.
Audio
-----
Draper did things.
- No seriously, Draper did things.
- Wait you wanted details? Ok, I guess we can share those.
- Audio properly disconnects with autodisconnect, even if notify is being used.
- Symbolic links now work as intended for local tracks.
- Bump play now shows the correct time till next track.
- Multiple user facing messages have been made more correct.
Redbot 3.2.2 (2020-01-10)
=========================
@ -49,7 +112,8 @@ Breaking Changes
- ``bot.get_mod_role_ids`` (`#2967 <https://github.com/Cog-Creators/Red-DiscordBot/issues/2967>`_)
- Reserved some command names for internal Red use. These are available programatically as ``redbot.core.commands.RESERVED_COMMAND_NAMES``. (`#2973 <https://github.com/Cog-Creators/Red-DiscordBot/issues/2973>`_)
- Removed ``bot._counter``, Made a few more attrs private (``cog_mgr``, ``main_dir``). (`#2976 <https://github.com/Cog-Creators/Red-DiscordBot/issues/2976>`_)
- ``bot.wait_until_ready`` should no longer be used during extension setup. (`#3073 <https://github.com/Cog-Creators/Red-DiscordBot/issues/3073>`_)
- Extension's ``setup()`` function should no longer assume that we are, or even will be connected to Discord.
This also means that cog creators should no longer use ``bot.wait_until_ready()`` inside it. (`#3073 <https://github.com/Cog-Creators/Red-DiscordBot/issues/3073>`_)
- Removed the mongo driver. (`#3099 <https://github.com/Cog-Creators/Red-DiscordBot/issues/3099>`_)

View File

@ -60,3 +60,16 @@ Event Reference
:type service_name: :class:`str`
:param api_tokens: New Mapping of token names to tokens. This contains api tokens that weren't changed too.
:type api_tokens: Mapping[:class:`str`, :class:`str`]
*********************
Additional References
*********************
.. py:currentmodule:: redbot.core.bot
.. automethod:: Red.get_shared_api_tokens
.. automethod:: Red.set_shared_api_tokens
.. automethod:: Red.remove_shared_api_tokens

View File

@ -15,6 +15,7 @@ extend functionlities used throughout the bot, as outlined below.
.. autoclass:: redbot.core.commands.Command
:members:
:inherited-members: format_help_for_context
.. autoclass:: redbot.core.commands.Group
:members:

View File

@ -25,7 +25,7 @@ Basic Usage
async def ban(self, ctx, user: discord.Member, reason: str = None):
await ctx.guild.ban(user)
case = await modlog.create_case(
ctx.bot, ctx.guild, ctx.message.created_at, action="ban",
ctx.bot, ctx.guild, ctx.message.created_at, action_type="ban",
user=user, moderator=ctx.author, reason=reason
)
await ctx.send("Done. It was about time.")

View File

@ -19,12 +19,22 @@ Please install the pre-requirements using the commands listed for your operating
The pre-requirements are:
- Python 3.8.1 or greater
- Pip 18.1 or greater
- Git
- Git 2.11+
- Java Runtime Environment 11 or later (for audio support)
We also recommend installing some basic compiler tools, in case our dependencies don't provide
pre-built "wheels" for your architecture.
*****************
Operating systems
*****************
.. contents::
:local:
----
.. _install-arch:
~~~~~~~~~~
@ -35,6 +45,10 @@ Arch Linux
sudo pacman -Syu python python-pip git jre-openjdk-headless base-devel
Continue by `creating-venv-linux`.
----
.. _install-centos:
.. _install-rhel:
@ -51,15 +65,44 @@ CentOS and RHEL 7
Complete the rest of the installation by `installing Python 3.8 with pyenv <install-python-pyenv>`.
----
.. _install-debian-stretch:
~~~~~~~~~~~~~~
Debian Stretch
~~~~~~~~~~~~~~
.. note::
This guide is only for Debian Stretch users, these instructions won't work with
Raspbian Stretch. Raspbian Buster is the only version of Raspbian supported by Red.
We recommend installing pyenv as a method of installing non-native versions of python on
Debian Stretch. This guide will tell you how. First, run the following commands:
.. code-block:: none
sudo echo "deb http://deb.debian.org/debian stretch-backports main" >> /etc/apt/sources.list.d/red-sources.list
sudo apt update
sudo apt -y install make build-essential libssl-dev zlib1g-dev libbz2-dev libreadline-dev \
libsqlite3-dev wget curl llvm libncurses5-dev xz-utils tk-dev libxml2-dev \
libxmlsec1-dev libffi-dev liblzma-dev libgdbm-dev uuid-dev python3-openssl git openjdk-11-jre
CXX=/usr/bin/g++
Complete the rest of the installation by `installing Python 3.8 with pyenv <install-python-pyenv>`.
----
.. _install-debian:
.. _install-raspbian:
~~~~~~~~~~~~~~~~~~~
Debian and Raspbian
~~~~~~~~~~~~~~~~~~~
~~~~~~~~~~~~~~~~~~~~~~~~~~
Debian and Raspbian Buster
~~~~~~~~~~~~~~~~~~~~~~~~~~
We recommend installing pyenv as a method of installing non-native versions of python on
Debian/Raspbian. This guide will tell you how. First, run the following commands:
Debian/Raspbian Buster. This guide will tell you how. First, run the following commands:
.. code-block:: none
@ -71,6 +114,8 @@ Debian/Raspbian. This guide will tell you how. First, run the following commands
Complete the rest of the installation by `installing Python 3.8 with pyenv <install-python-pyenv>`.
----
.. _install-fedora:
~~~~~~~~~~~~
@ -84,6 +129,10 @@ them with dnf:
sudo dnf -y install python38 git java-latest-openjdk-headless @development-tools
Continue by `creating-venv-linux`.
----
.. _install-mac:
~~~
@ -110,6 +159,10 @@ one-by-one:
It's possible you will have network issues. If so, go in your Applications folder, inside it, go in
the Python 3.8 folder then double click ``Install certificates.command``.
Continue by `creating-venv-linux`.
----
.. _install-opensuse:
~~~~~~~~
@ -150,6 +203,8 @@ Now, install pip with easy_install:
sudo /opt/python/bin/easy_install-3.8 pip
Continue by `creating-venv-linux`.
openSUSE Tumbleweed
*******************
@ -161,35 +216,74 @@ with zypper:
sudo zypper install python3-base python3-pip git-core java-12-openjdk-headless
sudo zypper install -t pattern devel_basis
Continue by `creating-venv-linux`.
----
.. _install-ubuntu:
~~~~~~
Ubuntu
~~~~~~
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
Ubuntu LTS versions (18.04 and 16.04)
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
.. note:: **Ubuntu Python Availability**
We recommend using the deadsnakes ppa to ensure up to date python availability.
.. code-block:: none
sudo apt update
sudo apt install software-properties-common
sudo add-apt-repository ppa:deadsnakes/ppa
Install the pre-requirements with apt:
We recommend adding the ``git-core`` ppa to install Git 2.11 or greater:
.. code-block:: none
sudo apt update
sudo apt install software-properties-common
sudo add-apt-repository 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 ppa:deadsnakes/ppa
Now install the pre-requirements with apt:
.. code-block:: none
sudo apt -y install python3.8 python3.8-dev python3.8-venv python3-pip git default-jre-headless \
build-essential
Continue by `creating-venv-linux`.
----
.. _install-ubuntu-non-lts:
~~~~~~~~~~~~~~~~~~~~~~~
Ubuntu non-LTS versions
~~~~~~~~~~~~~~~~~~~~~~~
We recommend adding the ``git-core`` ppa to install Git 2.11 or greater:
.. code-block:: none
sudo apt update
sudo apt install software-properties-common
sudo add-apt-repository ppa:git-core/ppa
Now, to install non-native version of python on non-LTS versions of Ubuntu, we recommend
installing pyenv. To do this, first run the following commands:
.. code-block:: none
sudo apt -y install make build-essential libssl-dev zlib1g-dev libbz2-dev libreadline-dev \
libsqlite3-dev wget curl llvm libncurses5-dev xz-utils tk-dev libxml2-dev \
libxmlsec1-dev libffi-dev liblzma-dev libgdbm-dev uuid-dev python3-openssl git openjdk-11-jre
CXX=/usr/bin/g++
And then complete the rest of the installation by `installing Python 3.8 with pyenv <install-python-pyenv>`.
----
.. _install-python-pyenv:
~~~~~~~~~~~~~~~~~~~~~~~~~~~~
****************************
Installing Python with pyenv
~~~~~~~~~~~~~~~~~~~~~~~~~~~~
****************************
.. note::
@ -227,11 +321,15 @@ After that is finished, run:
Pyenv is now installed and your system should be configured to run Python 3.8.
Continue by `creating-venv-linux`.
.. _creating-venv-linux:
------------------------------
Creating a Virtual Environment
------------------------------
We **strongly** recommend installing Red into a virtual environment. Don't be scared, it's very
We require installing Red into a virtual environment. Don't be scared, it's very
straightforward. See the section `installing-in-virtual-environment`.
.. _installing-red-linux-mac:
@ -242,31 +340,25 @@ Installing Red
Choose one of the following commands to install Red.
.. note::
If you're not inside an activated virtual environment, include the ``--user`` flag with all
``python3.8 -m pip install`` commands, like this:
.. code-block:: none
python3.8 -m pip install --user -U setuptools wheel
python3.8 -m pip install --user -U Red-DiscordBot
To install without additional config backend support:
.. code-block:: none
python3.8 -m pip install -U setuptools wheel
python3.8 -m pip install -U Red-DiscordBot
python -m pip install -U pip setuptools wheel
python -m pip install -U Red-DiscordBot
Or, to install with PostgreSQL support:
.. code-block:: none
python3.8 -m pip install -U setuptools wheel
python3.8 -m pip install -U Red-DiscordBot[postgres]
python -m pip install -U pip setuptools wheel
python -m pip install -U Red-DiscordBot[postgres]
.. note::
These commands are also used for updating Red
--------------------------
Setting Up and Running Red
--------------------------

View File

@ -64,6 +64,13 @@ Manually installing dependencies
.. _installing-red-windows:
------------------------------
Creating a Virtual Environment
------------------------------
We require installing Red into a virtual environment. Don't be scared, it's very
straightforward. See the section `installing-in-virtual-environment`.
--------------
Installing Red
--------------
@ -72,34 +79,27 @@ Installing Red
for the PATH changes to take effect.
1. Open a command prompt (open Start, search for "command prompt", then click it)
2. Create and activate a virtual environment (strongly recommended), see the section `using-venv`
3. Run **one** of the following commands, depending on what extras you want installed
.. note::
If you're not inside an activated virtual environment, use ``py -3.8`` in place of
``python``, and include the ``--user`` flag with all ``pip install`` commands, like this:
.. code-block:: none
py -3.8 -m pip install --user -U setuptools wheel
py -3.8 -m pip install --user -U Red-DiscordBot
2. Run **one** of the following set of commands, depending on what extras you want installed
* Normal installation:
.. code-block:: none
python -m pip install -U setuptools wheel
python -m pip install -U pip setuptools wheel
python -m pip install -U Red-DiscordBot
* With PostgreSQL support:
.. code-block:: none
python -m pip install -U setuptools wheel
python -m pip install -U pip setuptools wheel
python -m pip install -U Red-DiscordBot[postgres]
.. note::
These commands are also used for updating Red
--------------------------
Setting Up and Running Red
--------------------------

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-pyenv-virtualenv` (recommended if you installed Python with pyenv)
**Why Should I Use a Virtual Environment?**
90% of the installation and setup issues raised in our support channels are resolved when the user
creates a virtual environment.
**What Are Virtual Environments For?**
Virtual environments allow you to isolate red's library dependencies, cog dependencies and python
Virtual environments allow you to isolate Red's library dependencies, cog dependencies and python
binaries from the rest of your system. It also makes sure Red and its dependencies are installed to
a predictable location. It makes uninstalling Red as simple as removing a single folder, without
worrying about losing your data or other things on your system becoming broken.
@ -31,18 +26,18 @@ python.
First, choose a directory where you would like to create your virtual environment. It's a good idea
to keep it in a location which is easy to type out the path to. From now, we'll call it
``redenv``.
``redenv`` and it will be located in your home directory.
~~~~~~~~~~~~~~~~~~~~~~~~
``venv`` on Linux or Mac
~~~~~~~~~~~~~~~~~~~~~~~~
Create your virtual environment with the following command::
python3.8 -m venv redenv
python3.8 -m venv ~/redenv
And activate it with the following command::
source redenv/bin/activate
source ~/redenv/bin/activate
.. important::
@ -56,11 +51,11 @@ Continue reading `below <after-activating-virtual-environment>`.
~~~~~~~~~~~~~~~~~~~
Create your virtual environment with the following command::
py -3.8 -m venv redenv
py -3.8 -m venv %userprofile%\redenv
And activate it with the following command::
redenv\Scripts\activate.bat
%userprofile%\redenv\Scripts\activate.bat
.. important::

View File

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

View File

@ -16,6 +16,7 @@ import sys
from argparse import Namespace
from copy import deepcopy
from pathlib import Path
from typing import NoReturn
import discord
@ -287,7 +288,18 @@ def handle_edit(cli_flags: Namespace):
sys.exit(0)
async def run_bot(red: Red, cli_flags: Namespace):
async def run_bot(red: Red, cli_flags: Namespace) -> None:
"""
This runs the bot.
Any shutdown which is a result of not being able to log in needs to raise
a SystemExit exception.
If the bot starts normally, the bot should be left to handle the exit case.
It will raise SystemExit in a task, which will reach the event loop and
interrupt running forever, then trigger our cleanup process, and does not
need additional handling in this function.
"""
driver_cls = drivers.get_driver_class()
@ -341,6 +353,10 @@ async def run_bot(red: Red, cli_flags: Namespace):
if confirm("\nDo you want to reset the token?"):
await red._config.token.set("")
print("Token has been reset.")
sys.exit(0)
sys.exit(1)
return None
def handle_early_exit_flags(cli_flags: Namespace):
@ -474,14 +490,13 @@ def main():
# Allows transports to close properly, and prevent new ones from being opened.
# Transports may still not be closed correcly on windows, see below
loop.run_until_complete(loop.shutdown_asyncgens())
if os.name == "nt":
# *we* aren't cleaning up more here, but it prevents
# a runtime error at the event loop on windows
# with resources which require longer to clean up.
# With other event loops, a failure to cleanup prior to here
# results in a resource warning instead and does not break us.
log.info("Please wait, cleaning up a bit more")
loop.run_until_complete(asyncio.sleep(1))
# *we* aren't cleaning up more here, but it prevents
# a runtime error at the event loop on windows
# with resources which require longer to clean up.
# With other event loops, a failure to cleanup prior to here
# results in a resource warning instead
log.info("Please wait, cleaning up a bit more")
loop.run_until_complete(asyncio.sleep(2))
loop.stop()
loop.close()
exit_code = red._shutdown_mode if red is not None else 1

View File

@ -116,12 +116,14 @@ class Admin(commands.Cog):
:param role:
:return:
"""
return ctx.author.top_role > role
return ctx.author.top_role > role or ctx.author == ctx.guild.owner
async def _addrole(self, ctx: commands.Context, member: discord.Member, role: discord.Role):
async def _addrole(
self, ctx: commands.Context, member: discord.Member, role: discord.Role, *, check_user=True
):
if member is None:
member = ctx.author
if not self.pass_user_hierarchy_check(ctx, role):
if check_user and not self.pass_user_hierarchy_check(ctx, role):
await ctx.send(_(USER_HIERARCHY_ISSUE_ADD).format(role=role, member=member))
return
if not self.pass_hierarchy_check(ctx, role):
@ -141,10 +143,12 @@ class Admin(commands.Cog):
)
)
async def _removerole(self, ctx: commands.Context, member: discord.Member, role: discord.Role):
async def _removerole(
self, ctx: commands.Context, member: discord.Member, role: discord.Role, *, check_user=True
):
if member is None:
member = ctx.author
if not self.pass_user_hierarchy_check(ctx, role):
if check_user and not self.pass_user_hierarchy_check(ctx, role):
await ctx.send(_(USER_HIERARCHY_ISSUE_REMOVE).format(role=role, member=member))
return
if not self.pass_hierarchy_check(ctx, role):
@ -365,7 +369,7 @@ class Admin(commands.Cog):
NOTE: The role is case sensitive!
"""
# noinspection PyTypeChecker
await self._addrole(ctx, ctx.author, selfrole)
await self._addrole(ctx, ctx.author, selfrole, check_user=False)
@selfrole.command(name="remove")
async def selfrole_remove(self, ctx: commands.Context, *, selfrole: SelfRole):
@ -376,7 +380,7 @@ class Admin(commands.Cog):
NOTE: The role is case sensitive!
"""
# noinspection PyTypeChecker
await self._removerole(ctx, ctx.author, selfrole)
await self._removerole(ctx, ctx.author, selfrole, check_user=False)
@selfrole.command(name="list")
async def selfrole_list(self, ctx: commands.Context):
@ -406,6 +410,13 @@ class Admin(commands.Cog):
NOTE: The role is case sensitive!
"""
if not self.pass_user_hierarchy_check(ctx, role):
await ctx.send(
_(
"I cannot let you add {role.name} as a selfrole because that role is higher than or equal to your highest role in the Discord hierarchy."
).format(role=role)
)
return
async with self.conf.guild(ctx.guild).selfroles() as curr_selfroles:
if role.id not in curr_selfroles:
curr_selfroles.append(role.id)
@ -421,6 +432,13 @@ class Admin(commands.Cog):
NOTE: The role is case sensitive!
"""
if not self.pass_user_hierarchy_check(ctx, role):
await ctx.send(
_(
"I cannot let you remove {role.name} from being a selfrole because that role is higher than or equal to your highest role in the Discord hierarchy."
).format(role=role)
)
return
async with self.conf.guild(ctx.guild).selfroles() as curr_selfroles:
curr_selfroles.remove(role.id)

View File

@ -746,13 +746,17 @@ class MusicCache:
(val, update) = await self.database.fetch_one("lavalink", "data", {"query": query})
if update:
val = None
if val and not isinstance(val, str):
if val and isinstance(val, dict):
log.debug(f"Querying Local Database for {query}")
task = ("update", ("lavalink", {"query": query}))
self.append_task(ctx, *task)
if val and not forced:
else:
val = None
if val and not forced and isinstance(val, dict):
data = val
data["query"] = query
if data.get("loadType") == "V2_COMPACT":
data["loadType"] = "V2_COMPAT"
results = LoadResult(data)
called_api = False
if results.has_error:
@ -778,21 +782,25 @@ class MusicCache:
):
with contextlib.suppress(SQLError):
time_now = int(datetime.datetime.now(datetime.timezone.utc).timestamp())
task = (
"insert",
(
"lavalink",
[
{
"query": query,
"data": json.dumps(results._raw),
"last_updated": time_now,
"last_fetched": time_now,
}
],
),
)
self.append_task(ctx, *task)
data = json.dumps(results._raw)
if all(
k in data for k in ["loadType", "playlistInfo", "isSeekable", "isStream"]
):
task = (
"insert",
(
"lavalink",
[
{
"query": query,
"data": data,
"last_updated": time_now,
"last_fetched": time_now,
}
],
),
)
self.append_task(ctx, *task)
return results, called_api
async def run_tasks(self, ctx: Optional[commands.Context] = None, _id=None):
@ -853,10 +861,12 @@ class MusicCache:
query_data["maxage"] = maxage_int
vals = await self.database.fetch_all("lavalink", "data", query_data)
recently_played = [r.tracks for r in vals if r]
recently_played = [r.tracks for r in vals if r if isinstance(tracks, dict)]
if recently_played:
track = random.choice(recently_played)
if track.get("loadType") == "V2_COMPACT":
track["loadType"] = "V2_COMPAT"
results = LoadResult(track)
tracks = list(results.tracks)
except Exception:

View File

@ -245,15 +245,20 @@ class Audio(commands.Cog):
for t in tracks_in_playlist:
uri = t.get("info", {}).get("uri")
if uri:
t = {"loadType": "V2_COMPACT", "tracks": [t], "query": uri}
database_entries.append(
{
"query": uri,
"data": json.dumps(t),
"last_updated": time_now,
"last_fetched": time_now,
}
)
t = {"loadType": "V2_COMPAT", "tracks": [t], "query": uri}
data = json.dumps(t)
if all(
k in data
for k in ["loadType", "playlistInfo", "isSeekable", "isStream"]
):
database_entries.append(
{
"query": uri,
"data": data,
"last_updated": time_now,
"last_fetched": time_now,
}
)
await asyncio.sleep(0)
if guild_playlist:
all_playlist[str(guild_id)] = guild_playlist
@ -530,17 +535,18 @@ class Audio(commands.Cog):
player_check = await self._players_check()
await self._status_check(*player_check)
if not autoplay and event_type == lavalink.LavalinkEvents.QUEUE_END and notify:
notify_channel = player.fetch("channel")
if notify_channel:
notify_channel = self.bot.get_channel(notify_channel)
await self._embed_msg(notify_channel, title=_("Queue Ended."))
elif not autoplay and event_type == lavalink.LavalinkEvents.QUEUE_END and disconnect:
self.bot.dispatch("red_audio_audio_disconnect", guild)
await player.disconnect()
if event_type == lavalink.LavalinkEvents.QUEUE_END and status:
player_check = await self._players_check()
await self._status_check(*player_check)
if event_type == lavalink.LavalinkEvents.QUEUE_END:
if not autoplay:
notify_channel = player.fetch("channel")
if notify_channel and notify:
notify_channel = self.bot.get_channel(notify_channel)
await self._embed_msg(notify_channel, title=_("Queue Ended."))
if disconnect:
self.bot.dispatch("red_audio_audio_disconnect", guild)
await player.disconnect()
if status:
player_check = await self._players_check()
await self._status_check(*player_check)
if event_type in [
lavalink.LavalinkEvents.TRACK_EXCEPTION,
@ -690,7 +696,7 @@ class Audio(commands.Cog):
async def dc(self, ctx: commands.Context):
"""Toggle the bot auto-disconnecting when done playing.
This setting takes precedence over [p]audioset emptydisconnect.
This setting takes precedence over `[p]audioset emptydisconnect`.
"""
disconnect = await self.config.guild(ctx.guild).disconnect()
@ -1117,7 +1123,7 @@ class Audio(commands.Cog):
"""Set a playlist to auto-play songs from.
**Usage**:
[p]audioset autoplay playlist_name_OR_id args
`[p]audioset autoplay playlist_name_OR_id [args]`
**Args**:
The following are all optional:
@ -1140,9 +1146,9 @@ class Audio(commands.Cog):
Exact guild name
Example use:
[p]audioset autoplay MyGuildPlaylist
[p]audioset autoplay MyGlobalPlaylist --scope Global
[p]audioset autoplay PersonalPlaylist --scope User --author Draper
`[p]audioset autoplay MyGuildPlaylist`
`[p]audioset autoplay MyGlobalPlaylist --scope Global`
`[p]audioset autoplay PersonalPlaylist --scope User --author Draper`
"""
if scope_data is None:
scope_data = [PlaylistScope.GUILD.value, ctx.author, ctx.guild, False]
@ -1253,7 +1259,10 @@ class Audio(commands.Cog):
@audioset.command()
@checks.mod_or_permissions(administrator=True)
async def emptydisconnect(self, ctx: commands.Context, seconds: int):
"""Auto-disconnect from channel when bot is alone in it for x seconds, 0 to disable."""
"""Auto-disconnect from channel when bot is alone in it for x seconds, 0 to disable.
`[p]audioset dc` takes precedence over this setting.
"""
if seconds < 0:
return await self._embed_msg(
ctx, title=_("Invalid Time"), description=_("Seconds can't be less than zero.")
@ -2443,7 +2452,11 @@ class Audio(commands.Cog):
if not await self._localtracks_check(ctx):
return
return audio_data.subfolders_in_tree() if search_subfolders else audio_data.subfolders()
return (
await audio_data.subfolders_in_tree()
if search_subfolders
else await audio_data.subfolders()
)
async def _folder_list(
self, ctx: commands.Context, query: audio_dataclasses.Query
@ -2454,9 +2467,9 @@ class Audio(commands.Cog):
if not query.track.exists():
return
return (
query.track.tracks_in_tree()
await query.track.tracks_in_tree()
if query.search_subfolders
else query.track.tracks_in_folder()
else await query.track.tracks_in_folder()
)
async def _folder_tracks(
@ -2495,9 +2508,9 @@ class Audio(commands.Cog):
return
return (
query.track.tracks_in_tree()
await query.track.tracks_in_tree()
if query.search_subfolders
else query.track.tracks_in_folder()
else await query.track.tracks_in_folder()
)
async def _localtracks_check(self, ctx: commands.Context) -> bool:
@ -2948,8 +2961,7 @@ class Audio(commands.Cog):
return await self._embed_msg(ctx, embed=embed)
elif isinstance(tracks, discord.Message):
return
queue_dur = await queue_duration(ctx)
lavalink.utils.format_time(queue_dur)
queue_dur = await track_remaining_duration(ctx)
index = query.track_index
seek = 0
if query.start_time:
@ -3996,7 +4008,7 @@ class Audio(commands.Cog):
The track(s) will be appended to the end of the playlist.
**Usage**:
[p]playlist append playlist_name_OR_id track_name_OR_url args
`[p]playlist append playlist_name_OR_id track_name_OR_url [args]`
**Args**:
The following are all optional:
@ -4019,10 +4031,9 @@ class Audio(commands.Cog):
Exact guild name
Example use:
[p]playlist append MyGuildPlaylist Hello by Adele
[p]playlist append MyGlobalPlaylist Hello by Adele --scope Global
[p]playlist append MyGlobalPlaylist Hello by Adele --scope Global
--Author Draper#6666
`[p]playlist append MyGuildPlaylist Hello by Adele`
`[p]playlist append MyGlobalPlaylist Hello by Adele --scope Global`
`[p]playlist append MyGlobalPlaylist Hello by Adele --scope Global --Author Draper#6666`
"""
if scope_data is None:
scope_data = [PlaylistScope.GUILD.value, ctx.author, ctx.guild, False]
@ -4144,8 +4155,8 @@ class Audio(commands.Cog):
else None,
)
@commands.cooldown(1, 300, commands.BucketType.member)
@playlist.command(name="copy", usage="<id_or_name> [args]")
@commands.cooldown(1, 150, commands.BucketType.member)
@playlist.command(name="copy", usage="<id_or_name> [args]", cooldown_after_parsing=True)
async def _playlist_copy(
self,
ctx: commands.Context,
@ -4157,7 +4168,7 @@ class Audio(commands.Cog):
"""Copy a playlist from one scope to another.
**Usage**:
[p]playlist copy playlist_name_OR_id args
`[p]playlist copy playlist_name_OR_id [args]`
**Args**:
The following are all optional:
@ -4184,11 +4195,9 @@ class Audio(commands.Cog):
Exact guild name
Example use:
[p]playlist copy MyGuildPlaylist --from-scope Guild --to-scope Global
[p]playlist copy MyGlobalPlaylist --from-scope Global --to-author Draper#6666
--to-scope User
[p]playlist copy MyPersonalPlaylist --from-scope user --to-author Draper#6666
--to-scope Guild --to-guild Red - Discord Bot
`[p]playlist copy MyGuildPlaylist --from-scope Guild --to-scope Global`
`[p]playlist copy MyGlobalPlaylist --from-scope Global --to-author Draper#6666 --to-scope User`
`[p]playlist copy MyPersonalPlaylist --from-scope user --to-author Draper#6666 --to-scope Guild --to-guild Red - Discord Bot`
"""
if scope_data is None:
@ -4284,8 +4293,8 @@ class Audio(commands.Cog):
).format(
name=from_playlist.name,
from_id=from_playlist.id,
from_scope=humanize_scope(from_scope, ctx=from_scope_name, the=True),
to_scope=humanize_scope(to_scope, ctx=to_scope_name, the=True),
from_scope=humanize_scope(from_scope, ctx=from_scope_name),
to_scope=humanize_scope(to_scope, ctx=to_scope_name),
to_id=to_playlist.id,
),
)
@ -4297,7 +4306,7 @@ class Audio(commands.Cog):
"""Create an empty playlist.
**Usage**:
[p]playlist create playlist_name args
`[p]playlist create playlist_name [args]`
**Args**:
The following are all optional:
@ -4320,9 +4329,9 @@ class Audio(commands.Cog):
Exact guild name
Example use:
[p]playlist create MyGuildPlaylist
[p]playlist create MyGlobalPlaylist --scope Global
[p]playlist create MyPersonalPlaylist --scope User
`[p]playlist create MyGuildPlaylist`
`[p]playlist create MyGlobalPlaylist --scope Global`
`[p]playlist create MyPersonalPlaylist --scope User`
"""
if scope_data is None:
scope_data = [PlaylistScope.GUILD.value, ctx.author, ctx.guild, False]
@ -4364,7 +4373,7 @@ class Audio(commands.Cog):
"""Delete a saved playlist.
**Usage**:
[p]playlist delete playlist_name_OR_id args
`[p]playlist delete playlist_name_OR_id [args]`
**Args**:
The following are all optional:
@ -4387,9 +4396,9 @@ class Audio(commands.Cog):
Exact guild name
Example use:
[p]playlist delete MyGuildPlaylist
[p]playlist delete MyGlobalPlaylist --scope Global
[p]playlist delete MyPersonalPlaylist --scope User
`[p]playlist delete MyGuildPlaylist`
`[p]playlist delete MyGlobalPlaylist --scope Global`
`[p]playlist delete MyPersonalPlaylist --scope User`
"""
if scope_data is None:
scope_data = [PlaylistScope.GUILD.value, ctx.author, ctx.guild, False]
@ -4438,7 +4447,9 @@ class Audio(commands.Cog):
)
@commands.cooldown(1, 30, commands.BucketType.member)
@playlist.command(name="dedupe", usage="<playlist_name_OR_id> [args]")
@playlist.command(
name="dedupe", usage="<playlist_name_OR_id> [args]", cooldown_after_parsing=True
)
async def _playlist_remdupe(
self,
ctx: commands.Context,
@ -4449,7 +4460,7 @@ class Audio(commands.Cog):
"""Remove duplicate tracks from a saved playlist.
**Usage**:
[p]playlist dedupe playlist_name_OR_id args
`[p]playlist dedupe playlist_name_OR_id [args]`
**Args**:
The following are all optional:
@ -4472,9 +4483,9 @@ class Audio(commands.Cog):
Exact guild name
Example use:
[p]playlist dedupe MyGuildPlaylist
[p]playlist dedupe MyGlobalPlaylist --scope Global
[p]playlist dedupe MyPersonalPlaylist --scope User
`[p]playlist dedupe MyGuildPlaylist`
`[p]playlist dedupe MyGlobalPlaylist --scope Global`
`[p]playlist dedupe MyPersonalPlaylist --scope User`
"""
async with ctx.typing():
if scope_data is None:
@ -4571,9 +4582,13 @@ class Audio(commands.Cog):
)
@checks.is_owner()
@playlist.command(name="download", usage="<playlist_name_OR_id> [v2=False] [args]")
@playlist.command(
name="download",
usage="<playlist_name_OR_id> [v2=False] [args]",
cooldown_after_parsing=True,
)
@commands.bot_has_permissions(attach_files=True)
@commands.cooldown(1, 60, commands.BucketType.guild)
@commands.cooldown(1, 30, commands.BucketType.guild)
async def _playlist_download(
self,
ctx: commands.Context,
@ -4584,12 +4599,12 @@ class Audio(commands.Cog):
):
"""Download a copy of a playlist.
These files can be used with the [p]playlist upload command.
These files can be used with the `[p]playlist upload` command.
Red v2-compatible playlists can be generated by passing True
for the v2 variable.
**Usage**:
[p]playlist download playlist_name_OR_id [v2=True_OR_False] args
`[p]playlist download playlist_name_OR_id [v2=True_OR_False] [args]`
**Args**:
The following are all optional:
@ -4612,9 +4627,9 @@ class Audio(commands.Cog):
Exact guild name
Example use:
[p]playlist download MyGuildPlaylist True
[p]playlist download MyGlobalPlaylist False --scope Global
[p]playlist download MyPersonalPlaylist --scope User
`[p]playlist download MyGuildPlaylist True`
`[p]playlist download MyGlobalPlaylist False --scope Global`
`[p]playlist download MyPersonalPlaylist --scope User`
"""
if scope_data is None:
scope_data = [PlaylistScope.GUILD.value, ctx.author, ctx.guild, False]
@ -4715,8 +4730,10 @@ class Audio(commands.Cog):
await ctx.send(file=discord.File(to_write, filename=f"{file_name}.txt"))
to_write.close()
@commands.cooldown(1, 20, commands.BucketType.member)
@playlist.command(name="info", usage="<playlist_name_OR_id> [args]")
@commands.cooldown(1, 10, commands.BucketType.member)
@playlist.command(
name="info", usage="<playlist_name_OR_id> [args]", cooldown_after_parsing=True
)
async def _playlist_info(
self,
ctx: commands.Context,
@ -4727,7 +4744,7 @@ class Audio(commands.Cog):
"""Retrieve information from a saved playlist.
**Usage**:
[p]playlist info playlist_name_OR_id args
`[p]playlist info playlist_name_OR_id [args]`
**Args**:
The following are all optional:
@ -4750,9 +4767,9 @@ class Audio(commands.Cog):
Exact guild name
Example use:
[p]playlist info MyGuildPlaylist
[p]playlist info MyGlobalPlaylist --scope Global
[p]playlist info MyPersonalPlaylist --scope User
`[p]playlist info MyGuildPlaylist`
`[p]playlist info MyGlobalPlaylist --scope Global`
`[p]playlist info MyPersonalPlaylist --scope User`
"""
if scope_data is None:
scope_data = [PlaylistScope.GUILD.value, ctx.author, ctx.guild, False]
@ -4852,14 +4869,14 @@ class Audio(commands.Cog):
page_list.append(embed)
await menu(ctx, page_list, DEFAULT_CONTROLS)
@commands.cooldown(1, 30, commands.BucketType.guild)
@playlist.command(name="list", usage="[args]")
@commands.cooldown(1, 15, commands.BucketType.guild)
@playlist.command(name="list", usage="[args]", cooldown_after_parsing=True)
@commands.bot_has_permissions(add_reactions=True)
async def _playlist_list(self, ctx: commands.Context, *, scope_data: ScopeParser = None):
"""List saved playlists.
**Usage**:
[p]playlist list args
`[p]playlist list [args]`
**Args**:
The following are all optional:
@ -4882,9 +4899,9 @@ class Audio(commands.Cog):
Exact guild name
Example use:
[p]playlist list
[p]playlist list --scope Global
[p]playlist list --scope User
`[p]playlist list`
`[p]playlist list --scope Global`
`[p]playlist list --scope User`
"""
if scope_data is None:
scope_data = [PlaylistScope.GUILD.value, ctx.author, ctx.guild, False]
@ -4976,15 +4993,15 @@ class Audio(commands.Cog):
)
return embed
@playlist.command(name="queue", usage="<name> [args]")
@commands.cooldown(1, 600, commands.BucketType.member)
@playlist.command(name="queue", usage="<name> [args]", cooldown_after_parsing=True)
@commands.cooldown(1, 300, commands.BucketType.member)
async def _playlist_queue(
self, ctx: commands.Context, playlist_name: str, *, scope_data: ScopeParser = None
):
"""Save the queue to a playlist.
**Usage**:
[p]playlist queue playlist_name
`[p]playlist queue playlist_name [args]`
**Args**:
The following are all optional:
@ -5007,9 +5024,9 @@ class Audio(commands.Cog):
Exact guild name
Example use:
[p]playlist queue MyGuildPlaylist
[p]playlist queue MyGlobalPlaylist --scope Global
[p]playlist queue MyPersonalPlaylist --scope User
`[p]playlist queue MyGuildPlaylist`
`[p]playlist queue MyGlobalPlaylist --scope Global`
`[p]playlist queue MyPersonalPlaylist --scope User`
"""
async with ctx.typing():
if scope_data is None:
@ -5087,7 +5104,7 @@ class Audio(commands.Cog):
"""Remove a track from a playlist by url.
**Usage**:
[p]playlist remove playlist_name_OR_id url args
`[p]playlist remove playlist_name_OR_id url [args]`
**Args**:
The following are all optional:
@ -5110,11 +5127,9 @@ class Audio(commands.Cog):
Exact guild name
Example use:
[p]playlist remove MyGuildPlaylist https://www.youtube.com/watch?v=MN3x-kAbgFU
[p]playlist remove MyGlobalPlaylist https://www.youtube.com/watch?v=MN3x-kAbgFU
--scope Global
[p]playlist remove MyPersonalPlaylist https://www.youtube.com/watch?v=MN3x-kAbgFU
--scope User
`[p]playlist remove MyGuildPlaylist https://www.youtube.com/watch?v=MN3x-kAbgFU`
`[p]playlist remove MyGlobalPlaylist https://www.youtube.com/watch?v=MN3x-kAbgFU --scope Global`
`[p]playlist remove MyPersonalPlaylist https://www.youtube.com/watch?v=MN3x-kAbgFU --scope User`
"""
if scope_data is None:
scope_data = [PlaylistScope.GUILD.value, ctx.author, ctx.guild, False]
@ -5188,8 +5203,8 @@ class Audio(commands.Cog):
).format(playlist_name=playlist.name, id=playlist.id, scope=scope_name),
)
@playlist.command(name="save", usage="<name> <url> [args]")
@commands.cooldown(1, 120, commands.BucketType.member)
@playlist.command(name="save", usage="<name> <url> [args]", cooldown_after_parsing=True)
@commands.cooldown(1, 60, commands.BucketType.member)
async def _playlist_save(
self,
ctx: commands.Context,
@ -5201,7 +5216,7 @@ class Audio(commands.Cog):
"""Save a playlist from a url.
**Usage**:
[p]playlist save name url args
`[p]playlist save name url [args]`
**Args**:
The following are all optional:
@ -5224,12 +5239,9 @@ class Audio(commands.Cog):
Exact guild name
Example use:
[p]playlist save MyGuildPlaylist
https://www.youtube.com/playlist?list=PLx0sYbCqOb8Q_CLZC2BdBSKEEB59BOPUM
[p]playlist save MyGlobalPlaylist
https://www.youtube.com/playlist?list=PLx0sYbCqOb8Q_CLZC2BdBSKEEB59BOPUM --scope Global
[p]playlist save MyPersonalPlaylist
https://open.spotify.com/playlist/1RyeIbyFeIJVnNzlGr5KkR --scope User
`[p]playlist save MyGuildPlaylist https://www.youtube.com/playlist?list=PLx0sYbCqOb8Q_CLZC2BdBSKEEB59BOPUM`
`[p]playlist save MyGlobalPlaylist https://www.youtube.com/playlist?list=PLx0sYbCqOb8Q_CLZC2BdBSKEEB59BOPUM --scope Global`
`[p]playlist save MyPersonalPlaylist https://open.spotify.com/playlist/1RyeIbyFeIJVnNzlGr5KkR --scope User`
"""
if scope_data is None:
scope_data = [PlaylistScope.GUILD.value, ctx.author, ctx.guild, False]
@ -5282,8 +5294,13 @@ class Audio(commands.Cog):
else None,
)
@commands.cooldown(1, 60, commands.BucketType.member)
@playlist.command(name="start", aliases=["play"], usage="<playlist_name_OR_id> [args]")
@commands.cooldown(1, 30, commands.BucketType.member)
@playlist.command(
name="start",
aliases=["play"],
usage="<playlist_name_OR_id> [args]",
cooldown_after_parsing=True,
)
async def _playlist_start(
self,
ctx: commands.Context,
@ -5294,7 +5311,7 @@ class Audio(commands.Cog):
"""Load a playlist into the queue.
**Usage**:
[p]playlist start playlist_name_OR_id args
` [p]playlist start playlist_name_OR_id [args]`
**Args**:
The following are all optional:
@ -5317,9 +5334,9 @@ class Audio(commands.Cog):
Exact guild name
Example use:
[p]playlist start MyGuildPlaylist
[p]playlist start MyGlobalPlaylist --scope Global
[p]playlist start MyPersonalPlaylist --scope User
`[p]playlist start MyGuildPlaylist`
`[p]playlist start MyGlobalPlaylist --scope Global`
`[p]playlist start MyPersonalPlaylist --scope User`
"""
if scope_data is None:
scope_data = [PlaylistScope.GUILD.value, ctx.author, ctx.guild, False]
@ -5451,7 +5468,9 @@ class Audio(commands.Cog):
return await ctx.invoke(self.play, query=playlist.url)
@commands.cooldown(1, 60, commands.BucketType.member)
@playlist.command(name="update", usage="<playlist_name_OR_id> [args]")
@playlist.command(
name="update", usage="<playlist_name_OR_id> [args]", cooldown_after_parsing=True
)
async def _playlist_update(
self,
ctx: commands.Context,
@ -5462,7 +5481,7 @@ class Audio(commands.Cog):
"""Updates all tracks in a playlist.
**Usage**:
[p]playlist update playlist_name_OR_id args
`[p]playlist update playlist_name_OR_id [args]`
**Args**:
The following are all optional:
@ -5485,9 +5504,9 @@ class Audio(commands.Cog):
Exact guild name
Example use:
[p]playlist update MyGuildPlaylist
[p]playlist update MyGlobalPlaylist --scope Global
[p]playlist update MyPersonalPlaylist --scope User
`[p]playlist update MyGuildPlaylist`
`[p]playlist update MyGlobalPlaylist --scope Global`
`[p]playlist update MyPersonalPlaylist --scope User`
"""
if scope_data is None:
@ -5610,10 +5629,10 @@ class Audio(commands.Cog):
"""Uploads a playlist file as a playlist for the bot.
V2 and old V3 playlist will be slow.
V3 Playlist made with [p]playlist download will load a lot faster.
V3 Playlist made with `[p]playlist download` will load a lot faster.
**Usage**:
[p]playlist upload args
`[p]playlist upload [args]`
**Args**:
The following are all optional:
@ -5636,9 +5655,9 @@ class Audio(commands.Cog):
Exact guild name
Example use:
[p]playlist upload
[p]playlist upload --scope Global
[p]playlist upload --scope User
`[p]playlist upload`
`[p]playlist upload --scope Global`
`[p]playlist upload --scope User`
"""
if scope_data is None:
scope_data = [PlaylistScope.GUILD.value, ctx.author, ctx.guild, False]
@ -5728,7 +5747,9 @@ class Audio(commands.Cog):
)
@commands.cooldown(1, 60, commands.BucketType.member)
@playlist.command(name="rename", usage="<playlist_name_OR_id> <new_name> [args]")
@playlist.command(
name="rename", usage="<playlist_name_OR_id> <new_name> [args]", cooldown_after_parsing=True
)
async def _playlist_rename(
self,
ctx: commands.Context,
@ -5740,7 +5761,7 @@ class Audio(commands.Cog):
"""Rename an existing playlist.
**Usage**:
[p]playlist rename playlist_name_OR_id new_name args
`[p]playlist rename playlist_name_OR_id new_name [args]`
**Args**:
The following are all optional:
@ -5763,9 +5784,9 @@ class Audio(commands.Cog):
Exact guild name
Example use:
[p]playlist rename MyGuildPlaylist RenamedGuildPlaylist
[p]playlist rename MyGlobalPlaylist RenamedGlobalPlaylist --scope Global
[p]playlist rename MyPersonalPlaylist RenamedPersonalPlaylist --scope User
`[p]playlist rename MyGuildPlaylist RenamedGuildPlaylist`
`[p]playlist rename MyGlobalPlaylist RenamedGlobalPlaylist --scope Global`
`[p]playlist rename MyPersonalPlaylist RenamedPersonalPlaylist --scope User`
"""
if scope_data is None:
scope_data = [PlaylistScope.GUILD.value, ctx.author, ctx.guild, False]
@ -5882,15 +5903,17 @@ class Audio(commands.Cog):
for t in track_list:
uri = t.get("info", {}).get("uri")
if uri:
t = {"loadType": "V2_COMPACT", "tracks": [t], "query": uri}
database_entries.append(
{
"query": uri,
"data": json.dumps(t),
"last_updated": time_now,
"last_fetched": time_now,
}
)
t = {"loadType": "V2_COMPAT", "tracks": [t], "query": uri}
data = json.dumps(t)
if all(k in data for k in ["loadType", "playlistInfo", "isSeekable", "isStream"]):
database_entries.append(
{
"query": uri,
"data": data,
"last_updated": time_now,
"last_fetched": time_now,
}
)
if database_entries:
await self.music_cache.database.insert("lavalink", database_entries)
@ -6793,8 +6816,8 @@ class Audio(commands.Cog):
async def search(self, ctx: commands.Context, *, query: str):
"""Pick a track with a search.
Use `[p]search list <search term>` to queue all tracks found on YouTube. `[p]search sc
<search term>` will search SoundCloud instead of YouTube.
Use `[p]search list <search term>` to queue all tracks found on YouTube.
`[p]search sc<search term>` will search SoundCloud instead of YouTube.
"""
async def _search_menu(
@ -7357,8 +7380,8 @@ class Audio(commands.Cog):
async def _shuffle_bumpped(self, ctx: commands.Context):
"""Toggle bumped track shuffle.
Set this to disabled if you wish to avoid bumped songs being shuffled. This takes priority
over `[p]shuffle`.
Set this to disabled if you wish to avoid bumped songs being shuffled.
This takes priority over `[p]shuffle`.
"""
dj_enabled = self._dj_status_cache.setdefault(
ctx.guild.id, await self.config.guild(ctx.guild).dj_enabled()

View File

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

View File

@ -43,6 +43,7 @@ __all__ = [
"CacheLevel",
"format_playlist_picker_data",
"get_track_description_unformatted",
"track_remaining_duration",
"Notifier",
"PlaylistScope",
]
@ -126,6 +127,20 @@ async def queue_duration(ctx) -> int:
return queue_total_duration
async def track_remaining_duration(ctx) -> int:
player = lavalink.get_player(ctx.guild.id)
if not player.current:
return 0
try:
if not player.current.is_stream:
remain = player.current.length - player.position
else:
remain = 0
except AttributeError:
remain = 0
return remain
async def draw_time(ctx) -> str:
player = lavalink.get_player(ctx.guild.id)
paused = player.paused
@ -213,7 +228,7 @@ async def clear_react(bot: Red, message: discord.Message, emoji: MutableMapping
def get_track_description(track) -> Optional[str]:
if track and getattr(track, "uri", None):
query = Query.process_input(track.uri)
if query.is_local:
if query.is_local or "localtracks/" in track.uri:
if track.title != "Unknown title":
return f'**{escape(f"{track.author} - {track.title}")}**' + escape(
f"\n{query.to_string_user()} "
@ -229,7 +244,7 @@ def get_track_description(track) -> Optional[str]:
def get_track_description_unformatted(track) -> Optional[str]:
if track and hasattr(track, "uri"):
query = Query.process_input(track.uri)
if query.is_local:
if query.is_local or "localtracks/" in track.uri:
if track.title != "Unknown title":
return escape(f"{track.author} - {track.title}")
else:
@ -521,8 +536,8 @@ class PlaylistScope(Enum):
def humanize_scope(scope, ctx=None, the=None):
if scope == PlaylistScope.GLOBAL.value:
return _("the ") if the else "" + _("Global")
return (_("the ") if the else "") + _("Global")
elif scope == PlaylistScope.GUILD.value:
return ctx.name if ctx else _("the ") if the else "" + _("Server")
return ctx.name if ctx else (_("the ") if the else "") + _("Server")
elif scope == PlaylistScope.USER.value:
return str(ctx) if ctx else _("the ") if the else "" + _("User")
return str(ctx) if ctx else (_("the ") if the else "") + _("User")

View File

@ -418,6 +418,11 @@ class Downloader(commands.Cog):
elif target.is_file():
os.remove(str(target))
@staticmethod
async def send_pagified(target: discord.abc.Messageable, content: str) -> None:
for page in pagify(content):
await target.send(page)
@commands.command()
@checks.is_owner()
async def pipinstall(self, ctx: commands.Context, *deps: str) -> None:
@ -550,7 +555,7 @@ class Downloader(commands.Cog):
if failed:
message += "\n" + self.format_failed_repos(failed)
await ctx.send(message)
await self.send_pagified(ctx, message)
@commands.group()
@checks.is_owner()
@ -596,12 +601,13 @@ class Downloader(commands.Cog):
tuple(map(inline, libnames))
)
if message:
await ctx.send(
await self.send_pagified(
ctx,
_(
"Cog requirements and shared libraries for all installed cogs"
" have been reinstalled but there were some errors:\n"
)
+ message
+ message,
)
else:
await ctx.send(
@ -643,8 +649,7 @@ class Downloader(commands.Cog):
f"**{candidate.object_type} {candidate.rev}**"
f" - {candidate.description}\n"
)
for page in pagify(msg):
await ctx.send(msg)
await self.send_pagified(ctx, msg)
return
except errors.UnknownRevision:
await ctx.send(
@ -658,14 +663,14 @@ class Downloader(commands.Cog):
async with repo.checkout(commit, exit_to_rev=repo.branch):
cogs, message = await self._filter_incorrect_cogs_by_names(repo, cog_names)
if not cogs:
await ctx.send(message)
await self.send_pagified(ctx, message)
return
failed_reqs = await self._install_requirements(cogs)
if failed_reqs:
message += _("\nFailed to install requirements: ") + humanize_list(
tuple(map(inline, failed_reqs))
)
await ctx.send(message)
await self.send_pagified(ctx, message)
return
installed_cogs, failed_cogs = await self._install_cogs(cogs)
@ -711,7 +716,7 @@ class Downloader(commands.Cog):
+ message
)
# "---" added to separate cog install messages from Downloader's message
await ctx.send(f"{message}{deprecation_notice}\n---")
await self.send_pagified(ctx, f"{message}{deprecation_notice}\n---")
for cog in installed_cogs:
if cog.install_msg:
await ctx.send(cog.install_msg.replace("[p]", ctx.prefix))
@ -748,14 +753,18 @@ class Downloader(commands.Cog):
message += _("Successfully uninstalled cogs: ") + humanize_list(uninstalled_cogs)
if failed_cogs:
message += (
_("\nThese cog were installed but can no longer be located: ")
_(
"\nDownloader has removed these cogs from the installed cogs list"
" but it wasn't able to find their files: "
)
+ humanize_list(tuple(map(inline, failed_cogs)))
+ _(
"\nYou may need to remove their files manually if they are still usable."
" Also make sure you've unloaded those cogs with `{prefix}unload {cogs}`."
"\nThey were most likely removed without using `{prefix}cog uninstall`.\n"
"You may need to remove those files manually if the cogs are still usable."
" If so, ensure the cogs have been unloaded with `{prefix}unload {cogs}`."
).format(prefix=ctx.prefix, cogs=" ".join(failed_cogs))
)
await ctx.send(message)
await self.send_pagified(ctx, message)
@cog.command(name="pin", usage="<cogs>")
async def _cog_pin(self, ctx: commands.Context, *cogs: InstalledCog) -> None:
@ -778,7 +787,7 @@ class Downloader(commands.Cog):
message += _("Pinned cogs: ") + humanize_list(cognames)
if already_pinned:
message += _("\nThese cogs were already pinned: ") + humanize_list(already_pinned)
await ctx.send(message)
await self.send_pagified(ctx, message)
@cog.command(name="unpin", usage="<cogs>")
async def _cog_unpin(self, ctx: commands.Context, *cogs: InstalledCog) -> None:
@ -801,7 +810,7 @@ class Downloader(commands.Cog):
message += _("Unpinned cogs: ") + humanize_list(cognames)
if not_pinned:
message += _("\nThese cogs weren't pinned: ") + humanize_list(not_pinned)
await ctx.send(message)
await self.send_pagified(ctx, message)
@cog.command(name="checkforupdates")
async def _cog_checkforupdates(self, ctx: commands.Context) -> None:
@ -833,7 +842,7 @@ class Downloader(commands.Cog):
if failed:
message += "\n" + self.format_failed_repos(failed)
await ctx.send(message)
await self.send_pagified(ctx, message)
@cog.command(name="update")
async def _cog_update(self, ctx: commands.Context, *cogs: InstalledCog) -> None:
@ -869,7 +878,6 @@ class Downloader(commands.Cog):
rev: Optional[str] = None,
cogs: Optional[List[InstalledModule]] = None,
) -> None:
message = ""
failed_repos = set()
updates_available = set()
@ -882,7 +890,7 @@ class Downloader(commands.Cog):
await repo.update()
except errors.UpdateError:
message = self.format_failed_repos([repo.name])
await ctx.send(message)
await self.send_pagified(ctx, message)
return
try:
@ -896,11 +904,10 @@ class Downloader(commands.Cog):
f"**{candidate.object_type} {candidate.rev}**"
f" - {candidate.description}\n"
)
for page in pagify(msg):
await ctx.send(msg)
await self.send_pagified(ctx, msg)
return
except errors.UnknownRevision:
message += _(
message = _(
"Error: there is no revision `{rev}` in repo `{repo.name}`"
).format(rev=rev, repo=repo)
await ctx.send(message)
@ -917,6 +924,8 @@ class Downloader(commands.Cog):
pinned_cogs = {cog for cog in cogs_to_check if cog.pinned}
cogs_to_check -= pinned_cogs
message = ""
if not cogs_to_check:
cogs_to_update = libs_to_update = ()
message += _("There were no cogs to check.")
@ -972,7 +981,7 @@ class Downloader(commands.Cog):
if repos_with_libs:
message += DEPRECATION_NOTICE.format(repo_list=humanize_list(list(repos_with_libs)))
await ctx.send(message)
await self.send_pagified(ctx, message)
if updates_available and updated_cognames:
await self._ask_for_cog_reload(ctx, updated_cognames)

View File

@ -38,6 +38,10 @@ class GitException(DownloaderException):
Generic class for git exceptions.
"""
def __init__(self, message: str, git_command: str) -> None:
self.git_command = git_command
super().__init__(f"Git command failed: {git_command}\nError message: {message}")
class InvalidRepoName(DownloaderException):
"""
@ -138,8 +142,8 @@ class AmbiguousRevision(GitException):
Thrown when specified revision is ambiguous.
"""
def __init__(self, message: str, candidates: List[Candidate]) -> None:
super().__init__(message)
def __init__(self, message: str, git_command: str, candidates: List[Candidate]) -> None:
super().__init__(message, git_command)
self.candidates = candidates

View File

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

View File

@ -203,21 +203,20 @@ class Repo(RepoJSONMixin):
"""
valid_exit_codes = (0, 1)
p = await self._run(
ProcessFormatter().format(
self.GIT_IS_ANCESTOR,
path=self.folder_path,
maybe_ancestor_rev=maybe_ancestor_rev,
descendant_rev=descendant_rev,
),
valid_exit_codes=valid_exit_codes,
git_command = ProcessFormatter().format(
self.GIT_IS_ANCESTOR,
path=self.folder_path,
maybe_ancestor_rev=maybe_ancestor_rev,
descendant_rev=descendant_rev,
)
p = await self._run(git_command, valid_exit_codes=valid_exit_codes)
if p.returncode in valid_exit_codes:
return not bool(p.returncode)
raise errors.GitException(
f"Git failed to determine if commit {maybe_ancestor_rev}"
f" is ancestor of {descendant_rev} for repo at path: {self.folder_path}"
f" is ancestor of {descendant_rev} for repo at path: {self.folder_path}",
git_command,
)
async def is_on_branch(self) -> bool:
@ -253,15 +252,14 @@ class Repo(RepoJSONMixin):
"""
if new_rev is None:
new_rev = self.branch
p = await self._run(
ProcessFormatter().format(
self.GIT_DIFF_FILE_STATUS, path=self.folder_path, old_rev=old_rev, new_rev=new_rev
)
git_command = ProcessFormatter().format(
self.GIT_DIFF_FILE_STATUS, path=self.folder_path, old_rev=old_rev, new_rev=new_rev
)
p = await self._run(git_command)
if p.returncode != 0:
raise errors.GitDiffError(
"Git diff failed for repo at path: {}".format(self.folder_path)
f"Git diff failed for repo at path: {self.folder_path}", git_command
)
stdout = p.stdout.strip(b"\t\n\x00 ").decode().split("\x00\t")
@ -310,18 +308,17 @@ class Repo(RepoJSONMixin):
async with self.checkout(descendant_rev):
return discord.utils.get(self.available_modules, name=module_name)
p = await self._run(
ProcessFormatter().format(
self.GIT_GET_LAST_MODULE_OCCURRENCE_COMMIT,
path=self.folder_path,
descendant_rev=descendant_rev,
module_name=module_name,
)
git_command = ProcessFormatter().format(
self.GIT_GET_LAST_MODULE_OCCURRENCE_COMMIT,
path=self.folder_path,
descendant_rev=descendant_rev,
module_name=module_name,
)
p = await self._run(git_command)
if p.returncode != 0:
raise errors.GitException(
"Git log failed for repo at path: {}".format(self.folder_path)
f"Git log failed for repo at path: {self.folder_path}", git_command
)
commit = p.stdout.decode().strip()
@ -418,19 +415,18 @@ class Repo(RepoJSONMixin):
to get messages for.
:return: Git commit note log
"""
p = await self._run(
ProcessFormatter().format(
self.GIT_LOG,
path=self.folder_path,
old_rev=old_rev,
relative_file_path=relative_file_path,
)
git_command = ProcessFormatter().format(
self.GIT_LOG,
path=self.folder_path,
old_rev=old_rev,
relative_file_path=relative_file_path,
)
p = await self._run(git_command)
if p.returncode != 0:
raise errors.GitException(
"An exception occurred while executing git log on"
" this repo: {}".format(self.folder_path)
f"An exception occurred while executing git log on this repo: {self.folder_path}",
git_command,
)
return p.stdout.decode().strip()
@ -457,21 +453,24 @@ class Repo(RepoJSONMixin):
Full sha1 object name for provided revision.
"""
p = await self._run(
ProcessFormatter().format(self.GIT_GET_FULL_SHA1, path=self.folder_path, rev=rev)
git_command = ProcessFormatter().format(
self.GIT_GET_FULL_SHA1, path=self.folder_path, rev=rev
)
p = await self._run(git_command)
if p.returncode != 0:
stderr = p.stderr.decode().strip()
ambiguous_error = f"error: short SHA1 {rev} is ambiguous\nhint: The candidates are:\n"
if not stderr.startswith(ambiguous_error):
raise errors.UnknownRevision(f"Revision {rev} cannot be found.")
raise errors.UnknownRevision(f"Revision {rev} cannot be found.", git_command)
candidates = []
for match in self.AMBIGUOUS_ERROR_REGEX.finditer(stderr, len(ambiguous_error)):
candidates.append(Candidate(match["rev"], match["type"], match["desc"]))
if candidates:
raise errors.AmbiguousRevision(f"Short SHA1 {rev} is ambiguous.", candidates)
raise errors.UnknownRevision(f"Revision {rev} cannot be found.")
raise errors.AmbiguousRevision(
f"Short SHA1 {rev} is ambiguous.", git_command, candidates
)
raise errors.UnknownRevision(f"Revision {rev} cannot be found.", git_command)
return p.stdout.decode().strip()
@ -554,17 +553,14 @@ class Repo(RepoJSONMixin):
return
exists, __ = self._existing_git_repo()
if not exists:
raise errors.MissingGitRepo(
"A git repo does not exist at path: {}".format(self.folder_path)
)
raise errors.MissingGitRepo(f"A git repo does not exist at path: {self.folder_path}")
p = await self._run(
ProcessFormatter().format(self.GIT_CHECKOUT, path=self.folder_path, rev=rev)
)
git_command = ProcessFormatter().format(self.GIT_CHECKOUT, path=self.folder_path, rev=rev)
p = await self._run(git_command)
if p.returncode != 0:
raise errors.UnknownRevision(
"Could not checkout to {}. This revision may not exist".format(rev)
f"Could not checkout to {rev}. This revision may not exist", git_command
)
await self._setup_repo()
@ -619,25 +615,22 @@ class Repo(RepoJSONMixin):
"""
exists, path = self._existing_git_repo()
if exists:
raise errors.ExistingGitRepo("A git repo already exists at path: {}".format(path))
raise errors.ExistingGitRepo(f"A git repo already exists at path: {path}")
if self.branch is not None:
p = await self._run(
ProcessFormatter().format(
self.GIT_CLONE, branch=self.branch, url=self.url, folder=self.folder_path
)
git_command = ProcessFormatter().format(
self.GIT_CLONE, branch=self.branch, url=self.url, folder=self.folder_path
)
else:
p = await self._run(
ProcessFormatter().format(
self.GIT_CLONE_NO_BRANCH, url=self.url, folder=self.folder_path
)
git_command = ProcessFormatter().format(
self.GIT_CLONE_NO_BRANCH, url=self.url, folder=self.folder_path
)
p = await self._run(git_command)
if p.returncode:
# Try cleaning up folder
shutil.rmtree(str(self.folder_path), ignore_errors=True)
raise errors.CloningError("Error when running git clone.")
raise errors.CloningError("Error when running git clone.", git_command)
if self.branch is None:
self.branch = await self.current_branch()
@ -657,17 +650,14 @@ class Repo(RepoJSONMixin):
"""
exists, __ = self._existing_git_repo()
if not exists:
raise errors.MissingGitRepo(
"A git repo does not exist at path: {}".format(self.folder_path)
)
raise errors.MissingGitRepo(f"A git repo does not exist at path: {self.folder_path}")
p = await self._run(
ProcessFormatter().format(self.GIT_CURRENT_BRANCH, path=self.folder_path)
)
git_command = ProcessFormatter().format(self.GIT_CURRENT_BRANCH, path=self.folder_path)
p = await self._run(git_command)
if p.returncode != 0:
raise errors.GitException(
"Could not determine current branch at path: {}".format(self.folder_path)
f"Could not determine current branch at path: {self.folder_path}", git_command
)
return p.stdout.decode().strip()
@ -683,16 +673,13 @@ class Repo(RepoJSONMixin):
"""
exists, __ = self._existing_git_repo()
if not exists:
raise errors.MissingGitRepo(
"A git repo does not exist at path: {}".format(self.folder_path)
)
raise errors.MissingGitRepo(f"A git repo does not exist at path: {self.folder_path}")
p = await self._run(
ProcessFormatter().format(self.GIT_CURRENT_COMMIT, path=self.folder_path)
)
git_command = ProcessFormatter().format(self.GIT_CURRENT_COMMIT, path=self.folder_path)
p = await self._run(git_command)
if p.returncode != 0:
raise errors.CurrentHashError("Unable to determine commit hash.")
raise errors.CurrentHashError("Unable to determine commit hash.", git_command)
return p.stdout.decode().strip()
@ -715,16 +702,15 @@ class Repo(RepoJSONMixin):
exists, __ = self._existing_git_repo()
if not exists:
raise errors.MissingGitRepo(
"A git repo does not exist at path: {}".format(self.folder_path)
)
raise errors.MissingGitRepo(f"A git repo does not exist at path: {self.folder_path}")
p = await self._run(
ProcessFormatter().format(self.GIT_LATEST_COMMIT, path=self.folder_path, branch=branch)
git_command = ProcessFormatter().format(
self.GIT_LATEST_COMMIT, path=self.folder_path, branch=branch
)
p = await self._run(git_command)
if p.returncode != 0:
raise errors.CurrentHashError("Unable to determine latest commit hash.")
raise errors.CurrentHashError("Unable to determine latest commit hash.", git_command)
return p.stdout.decode().strip()
@ -751,10 +737,11 @@ class Repo(RepoJSONMixin):
if folder is None:
folder = self.folder_path
p = await self._run(ProcessFormatter().format(Repo.GIT_DISCOVER_REMOTE_URL, path=folder))
git_command = ProcessFormatter().format(Repo.GIT_DISCOVER_REMOTE_URL, path=folder)
p = await self._run(git_command)
if p.returncode != 0:
raise errors.NoRemoteURL("Unable to discover a repo URL.")
raise errors.NoRemoteURL("Unable to discover a repo URL.", git_command)
return p.stdout.decode().strip()
@ -773,19 +760,18 @@ class Repo(RepoJSONMixin):
await self.checkout(branch)
exists, __ = self._existing_git_repo()
if not exists:
raise errors.MissingGitRepo(
"A git repo does not exist at path: {}".format(self.folder_path)
)
raise errors.MissingGitRepo(f"A git repo does not exist at path: {self.folder_path}")
p = await self._run(
ProcessFormatter().format(self.GIT_HARD_RESET, path=self.folder_path, branch=branch)
git_command = ProcessFormatter().format(
self.GIT_HARD_RESET, path=self.folder_path, branch=branch
)
p = await self._run(git_command)
if p.returncode != 0:
raise errors.HardResetError(
"Some error occurred when trying to"
" execute a hard reset on the repo at"
" the following path: {}".format(self.folder_path)
"Some error occurred when trying to execute a hard reset on the repo at"
f" the following path: {self.folder_path}",
git_command,
)
async def update(self) -> Tuple[str, str]:
@ -804,12 +790,14 @@ class Repo(RepoJSONMixin):
await self.hard_reset()
p = await self._run(ProcessFormatter().format(self.GIT_PULL, path=self.folder_path))
git_command = ProcessFormatter().format(self.GIT_PULL, path=self.folder_path)
p = await self._run(git_command)
if p.returncode != 0:
raise errors.UpdateError(
"Git pull returned a non zero exit code"
" for the repo located at path: {}".format(self.folder_path)
f" for the repo located at path: {self.folder_path}",
git_command,
)
await self._setup_repo()
@ -1114,7 +1102,7 @@ class RepoManager:
"""
repo = self.get_repo(name)
if repo is None:
raise errors.MissingGitRepo("There is no repo with the name {}".format(name))
raise errors.MissingGitRepo(f"There is no repo with the name {name}")
safe_delete(repo.folder_path)

View File

@ -26,6 +26,13 @@ class ModLog(commands.Cog):
"""Manage modlog settings."""
pass
@checks.is_owner()
@modlogset.command(hidden=True, name="fixcasetypes")
async def reapply_audittype_migration(self, ctx: commands.Context):
"""Command to fix misbehaving casetypes."""
await modlog.handle_auditype_key()
await ctx.tick()
@modlogset.command()
@commands.guild_only()
async def modlog(self, ctx: commands.Context, channel: discord.TextChannel = None):

View File

@ -12,7 +12,6 @@ from redbot.cogs.warnings.helpers import (
from redbot.core import Config, checks, commands, modlog
from redbot.core.bot import Red
from redbot.core.i18n import Translator, cog_i18n
from redbot.core.utils.mod import is_admin_or_superior
from redbot.core.utils.chat_formatting import warning, pagify
from redbot.core.utils.menus import menu, DEFAULT_CONTROLS
@ -342,30 +341,16 @@ class Warnings(commands.Cog):
@commands.command()
@commands.guild_only()
async def warnings(
self, ctx: commands.Context, user: Optional[Union[discord.Member, int]] = None
):
"""List the warnings for the specified user.
@checks.admin()
async def warnings(self, ctx: commands.Context, user: Union[discord.Member, int]):
"""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:
userid: int = user.id
except AttributeError:
userid: int = user
user = ctx.guild.get_member(userid)
user = user or namedtuple("Member", "id guild")(userid, ctx.guild)
try:
userid: int = user.id
except AttributeError:
userid: int = user
user = ctx.guild.get_member(userid)
user = user or namedtuple("Member", "id guild")(userid, ctx.guild)
msg = ""
member_settings = self.config.member(user)
@ -389,6 +374,35 @@ class Warnings(commands.Cog):
pagify(msg, shorten_by=58), box_lang=_("Warnings for {user}").format(user=user)
)
@commands.command()
@commands.guild_only()
async def mywarnings(self, ctx: commands.Context):
"""List warnings for yourself."""
user = ctx.author
msg = ""
member_settings = self.config.member(user)
async with member_settings.warnings() as user_warnings:
if not user_warnings.keys(): # no warnings for the user
await ctx.send(_("You have no warnings!"))
else:
for key in user_warnings.keys():
mod_id = user_warnings[key]["mod"]
mod = ctx.bot.get_user(mod_id) or _("Unknown Moderator ({})").format(mod_id)
msg += _(
"{num_points} point warning {reason_name} issued by {user} for "
"{description}\n"
).format(
num_points=user_warnings[key]["points"],
reason_name=key,
user=mod,
description=user_warnings[key]["description"],
)
await ctx.send_interactive(
pagify(msg, shorten_by=58), box_lang=_("Warnings for {user}").format(user=user)
)
@commands.command()
@commands.guild_only()
@checks.admin_or_permissions(ban_members=True)

View File

@ -838,9 +838,9 @@ async def set_default_balance(amount: int, guild: discord.Guild = None) -> int:
amount = int(amount)
max_bal = await get_max_balance(guild)
if not (0 < amount <= max_bal):
if not (0 <= amount <= max_bal):
raise ValueError(
"Amount must be greater than zero and less than {max}.".format(
"Amount must be greater than or equal zero and less than or equal {max}.".format(
max=humanize_number(max_bal, override_locale="en_US")
)
)

View File

@ -10,7 +10,19 @@ from datetime import datetime
from enum import IntEnum
from importlib.machinery import ModuleSpec
from pathlib import Path
from typing import Optional, Union, List, Dict, NoReturn
from typing import (
Optional,
Union,
List,
Dict,
NoReturn,
Set,
Coroutine,
TypeVar,
Callable,
Awaitable,
Any,
)
from types import MappingProxyType
import discord
@ -24,6 +36,8 @@ from .dev_commands import Dev
from .events import init_events
from .global_checks import init_global_checks
from .settings_caches import PrefixManager
from .rpc import RPCMixin
from .utils import common_filters
@ -36,6 +50,9 @@ __all__ = ["RedBase", "Red", "ExitCodes"]
NotMessage = namedtuple("NotMessage", "guild")
PreInvokeCoroutine = Callable[[commands.Context], Awaitable[Any]]
T_BIC = TypeVar("T_BIC", bound=PreInvokeCoroutine)
def _is_submodule(parent, child):
return parent == child or child.startswith(parent + ".")
@ -76,6 +93,7 @@ class RedBase(commands.GroupMixin, commands.bot.BotBase, RPCMixin): # pylint: d
help__verify_checks=True,
help__verify_exists=False,
help__tagline="",
description="Red V3",
invite_public=False,
invite_perm=0,
disabled_commands=[],
@ -108,23 +126,13 @@ class RedBase(commands.GroupMixin, commands.bot.BotBase, RPCMixin): # pylint: d
self._config.init_custom(SHARED_API_TOKENS, 2)
self._config.register_custom(SHARED_API_TOKENS)
self._prefix_cache = PrefixManager(self._config, cli_flags)
async def prefix_manager(bot, message):
if not cli_flags.prefix:
global_prefix = await bot._config.prefix()
else:
global_prefix = cli_flags.prefix
if message.guild is None:
return global_prefix
server_prefix = await bot._config.guild(message.guild).prefix()
async def prefix_manager(bot, message) -> List[str]:
prefixes = await self._prefix_cache.get_prefixes(message.guild)
if cli_flags.mentionable:
return (
when_mentioned_or(*server_prefix)(bot, message)
if server_prefix
else when_mentioned_or(*global_prefix)(bot, message)
)
else:
return server_prefix if server_prefix else global_prefix
return when_mentioned_or(*prefixes)(bot, message)
return prefixes
if "command_prefix" not in kwargs:
kwargs["command_prefix"] = prefix_manager
@ -149,6 +157,64 @@ class RedBase(commands.GroupMixin, commands.bot.BotBase, RPCMixin): # pylint: d
self._permissions_hooks: List[commands.CheckPredicate] = []
self._red_ready = asyncio.Event()
self._red_before_invoke_objs: Set[PreInvokeCoroutine] = set()
@property
def _before_invoke(self): # DEP-WARN
return self._red_before_invoke_method
@_before_invoke.setter
def _before_invoke(self, val): # DEP-WARN
"""Prevent this from being overwritten in super().__init__"""
pass
async def _red_before_invoke_method(self, ctx):
await self.wait_until_red_ready()
return_exceptions = isinstance(ctx.command, commands.commands._AlwaysAvailableCommand)
if self._red_before_invoke_objs:
await asyncio.gather(
*(coro(ctx) for coro in self._red_before_invoke_objs),
return_exceptions=return_exceptions,
)
def remove_before_invoke_hook(self, coro: PreInvokeCoroutine) -> None:
"""
Functional method to remove a `before_invoke` hook.
"""
self._red_before_invoke_objs.discard(coro)
def before_invoke(self, coro: T_BIC) -> T_BIC:
"""
Overridden decorator method for Red's ``before_invoke`` behavior.
This can safely be used purely functionally as well.
3rd party cogs should remove any hooks which they register at unload
using `remove_before_invoke_hook`
Below behavior shared with discord.py:
.. note::
The ``before_invoke`` hooks are
only called if all checks and argument parsing procedures pass
without error. If any check or argument parsing procedures fail
then the hooks are not called.
Parameters
----------
coro: Callable[[commands.Context], Awaitable[Any]]
The coroutine to register as the pre-invoke hook.
Raises
------
TypeError
The coroutine passed is not actually a coroutine.
"""
if not asyncio.iscoroutinefunction(coro):
raise TypeError("The pre-invoke hook must be a coroutine.")
self._red_before_invoke_objs.add(coro)
return coro
@property
def cog_mgr(self) -> NoReturn:
@ -400,6 +466,7 @@ class RedBase(commands.GroupMixin, commands.bot.BotBase, RPCMixin): # pylint: d
This should only be run once, prior to connecting to discord.
"""
await self._maybe_update_config()
self.description = await self._config.description()
init_global_checks(self)
init_events(self, cli_flags)
@ -547,9 +614,7 @@ class RedBase(commands.GroupMixin, commands.bot.BotBase, RPCMixin): # pylint: d
bool
:code:`True` if an embed is requested
"""
if isinstance(channel, discord.abc.PrivateChannel) or (
command and command == self.get_command("help")
):
if isinstance(channel, discord.abc.PrivateChannel):
user_setting = await self._config.user(user).embeds()
if user_setting is not None:
return user_setting
@ -557,6 +622,7 @@ class RedBase(commands.GroupMixin, commands.bot.BotBase, RPCMixin): # pylint: d
guild_setting = await self._config.guild(channel.guild).embeds()
if guild_setting is not None:
return guild_setting
global_setting = await self._config.embeds()
return global_setting

View File

@ -135,7 +135,9 @@ def parse_cli_flags(args):
"security implications if misused. Can be "
"multiple.",
)
parser.add_argument("--prefix", "-p", action="append", help="Global prefix. Can be multiple")
parser.add_argument(
"--prefix", "-p", action="append", help="Global prefix. Can be multiple", default=[]
)
parser.add_argument(
"--no-prompt",
action="store_true",

View File

@ -4,6 +4,7 @@ This module contains extended classes and functions which are intended to
replace those from the `discord.ext.commands` module.
"""
import inspect
import re
import weakref
from typing import Awaitable, Callable, Dict, List, Optional, Tuple, Union, TYPE_CHECKING
@ -57,6 +58,49 @@ class CogCommandMixin:
checks=getattr(decorated, "__requires_checks__", []),
)
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
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, help_str)
def allow_for(self, model_id: Union[int, str], guild_id: int) -> None:
"""Actively allow this command for the given model.

View File

@ -162,10 +162,10 @@ class RedHelpFormatter:
@staticmethod
def get_default_tagline(ctx: Context):
return (
f"Type {ctx.clean_prefix}help <command> for more info on a command. "
f"You can also type {ctx.clean_prefix}help <category> for more info on a category."
)
return T_(
"Type {ctx.clean_prefix}help <command> for more info on a command. "
"You can also type {ctx.clean_prefix}help <category> for more info on a category."
).format(ctx=ctx)
async def format_command_help(self, ctx: Context, obj: commands.Command):
@ -187,7 +187,9 @@ class RedHelpFormatter:
description = command.description or ""
tagline = (await ctx.bot._config.help.tagline()) or self.get_default_tagline(ctx)
signature = f"`Syntax: {ctx.clean_prefix}{command.qualified_name} {command.signature}`"
signature = (
f"`{T_('Syntax')}: {ctx.clean_prefix}{command.qualified_name} {command.signature}`"
)
subcommands = None
if hasattr(command, "all_commands"):
@ -198,18 +200,19 @@ class RedHelpFormatter:
emb = {"embed": {"title": "", "description": ""}, "footer": {"text": ""}, "fields": []}
if description:
emb["embed"]["title"] = f"*{description[:2044]}*"
emb["embed"]["title"] = f"*{description[:250]}*"
emb["footer"]["text"] = tagline
emb["embed"]["description"] = signature
if command.help:
splitted = command.help.split("\n\n")
command_help = command.format_help_for_context(ctx)
if command_help:
splitted = command_help.split("\n\n")
name = splitted[0]
value = "\n\n".join(splitted[1:]).replace("[p]", ctx.clean_prefix)
value = "\n\n".join(splitted[1:])
if not value:
value = EMPTY_STRING
field = EmbedField(name[:252], value[:1024], False)
field = EmbedField(name[:250], value[:1024], False)
emb["fields"].append(field)
if subcommands:
@ -225,9 +228,9 @@ class RedHelpFormatter:
)
for i, page in enumerate(pagify(subtext, page_length=500, shorten_by=0)):
if i == 0:
title = "**__Subcommands:__**"
title = T_("**__Subcommands:__**")
else:
title = "**__Subcommands:__** (continued)"
title = T_("**__Subcommands:__** (continued)")
field = EmbedField(title, page, False)
emb["fields"].append(field)
@ -238,7 +241,7 @@ class RedHelpFormatter:
subtext = None
subtext_header = None
if subcommands:
subtext_header = "Subcommands:"
subtext_header = T_("Subcommands:")
max_width = max(discord.utils._string_width(name) for name in subcommands.keys())
def width_maker(cmds):
@ -261,7 +264,7 @@ class RedHelpFormatter:
(
description,
signature[1:-1],
command.help.replace("[p]", ctx.clean_prefix),
command.format_help_for_context(ctx),
subtext_header,
subtext,
),
@ -301,7 +304,10 @@ class RedHelpFormatter:
page_char_limit = await ctx.bot._config.help.page_char_limit()
page_char_limit = min(page_char_limit, 5500) # Just in case someone was manually...
author_info = {"name": f"{ctx.me.display_name} Help Menu", "icon_url": ctx.me.avatar_url}
author_info = {
"name": f"{ctx.me.display_name} {T_('Help Menu')}",
"icon_url": ctx.me.avatar_url,
}
# Offset calculation here is for total embed size limit
# 20 accounts for# *Page {i} of {page_count}*
@ -346,7 +352,9 @@ class RedHelpFormatter:
embed = discord.Embed(color=color, **embed_dict["embed"])
if page_count > 1:
description = f"*Page {i} of {page_count}*\n{embed.description}"
description = T_(
"*Page {page_num} of {page_count}*\n{content_description}"
).format(content_description=embed.description, page_num=i, page_count=page_count)
embed.description = description
embed.set_author(**author_info)
@ -366,7 +374,7 @@ class RedHelpFormatter:
if not (coms or await ctx.bot._config.help.verify_exists()):
return
description = obj.help
description = obj.format_help_for_context(ctx)
tagline = (await ctx.bot._config.help.tagline()) or self.get_default_tagline(ctx)
if await ctx.embed_requested():
@ -376,7 +384,7 @@ class RedHelpFormatter:
if description:
splitted = description.split("\n\n")
name = splitted[0]
value = "\n\n".join(splitted[1:]).replace("[p]", ctx.clean_prefix)
value = "\n\n".join(splitted[1:])
if not value:
value = EMPTY_STRING
field = EmbedField(name[:252], value[:1024], False)
@ -395,9 +403,9 @@ class RedHelpFormatter:
)
for i, page in enumerate(pagify(command_text, page_length=500, shorten_by=0)):
if i == 0:
title = "**__Commands:__**"
title = T_("**__Commands:__**")
else:
title = "**__Commands:__** (continued)"
title = T_("**__Commands:__** (continued)")
field = EmbedField(title, page, False)
emb["fields"].append(field)
@ -407,7 +415,7 @@ class RedHelpFormatter:
subtext = None
subtext_header = None
if coms:
subtext_header = "Commands:"
subtext_header = T_("Commands:")
max_width = max(discord.utils._string_width(name) for name in coms.keys())
def width_maker(cmds):
@ -442,14 +450,14 @@ class RedHelpFormatter:
emb["footer"]["text"] = tagline
if description:
emb["embed"]["title"] = f"*{description[:2044]}*"
emb["embed"]["title"] = f"*{description[:250]}*"
for cog_name, data in coms:
if cog_name:
title = f"**__{cog_name}:__**"
else:
title = f"**__No Category:__**"
title = f"**__{T_('No Category')}:__**"
def shorten_line(a_line: str) -> str:
if len(a_line) < 70: # embed max width needs to be lower
@ -462,7 +470,7 @@ class RedHelpFormatter:
)
for i, page in enumerate(pagify(cog_text, page_length=1000, shorten_by=0)):
title = title if i < 1 else f"{title} (continued)"
title = title if i < 1 else f"{title} {T_('(continued)')}"
field = EmbedField(title, page, False)
emb["fields"].append(field)
@ -478,7 +486,7 @@ class RedHelpFormatter:
names.extend(list(v.name for v in v.values()))
max_width = max(
discord.utils._string_width((name or "No Category:")) for name in names
discord.utils._string_width((name or T_("No Category:"))) for name in names
)
def width_maker(cmds):
@ -492,7 +500,7 @@ class RedHelpFormatter:
for cog_name, data in coms:
title = f"{cog_name}:" if cog_name else "No Category:"
title = f"{cog_name}:" if cog_name else T_("No Category:")
to_join.append(title)
for name, doc, width in width_maker(sorted(data.items())):
@ -543,7 +551,9 @@ class RedHelpFormatter:
if fuzzy_commands:
ret = await format_fuzzy_results(ctx, fuzzy_commands, embed=use_embeds)
if use_embeds:
ret.set_author(name=f"{ctx.me.display_name} Help Menu", icon_url=ctx.me.avatar_url)
ret.set_author(
name=f"{ctx.me.display_name} {T_('Help Menu')}", icon_url=ctx.me.avatar_url
)
tagline = (await ctx.bot._config.help.tagline()) or self.get_default_tagline(ctx)
ret.set_footer(text=tagline)
await ctx.send(embed=ret)
@ -553,7 +563,9 @@ class RedHelpFormatter:
ret = T_("Help topic for *{command_name}* not found.").format(command_name=help_for)
if use_embeds:
ret = discord.Embed(color=(await ctx.embed_color()), description=ret)
ret.set_author(name=f"{ctx.me.display_name} Help Menu", icon_url=ctx.me.avatar_url)
ret.set_author(
name=f"{ctx.me.display_name} {T_('Help Menu')}", icon_url=ctx.me.avatar_url
)
tagline = (await ctx.bot._config.help.tagline()) or self.get_default_tagline(ctx)
ret.set_footer(text=tagline)
await ctx.send(embed=ret)
@ -569,7 +581,9 @@ class RedHelpFormatter:
)
if await ctx.embed_requested():
ret = discord.Embed(color=(await ctx.embed_color()), description=ret)
ret.set_author(name=f"{ctx.me.display_name} Help Menu", icon_url=ctx.me.avatar_url)
ret.set_author(
name=f"{ctx.me.display_name} {T_('Help Menu')}", icon_url=ctx.me.avatar_url
)
tagline = (await ctx.bot._config.help.tagline()) or self.get_default_tagline(ctx)
ret.set_footer(text=tagline)
await ctx.send(embed=ret)

View File

@ -95,8 +95,8 @@ class PrivilegeLevel(enum.IntEnum):
"""Enumeration for special privileges."""
# Maintainer Note: do NOT re-order these.
# Each privelege level also implies access to the ones before it.
# Inserting new privelege levels at a later point is fine if that is considered.
# Each privilege level also implies access to the ones before it.
# Inserting new privilege levels at a later point is fine if that is considered.
NONE = enum.auto()
"""No special privilege level."""

View File

@ -257,10 +257,9 @@ class CoreLogic:
The current (or new) list of prefixes.
"""
if prefixes:
prefixes = sorted(prefixes, reverse=True)
await self.bot._config.prefix.set(prefixes)
await self.bot._prefix_cache.set_prefixes(guild=None, prefixes=prefixes)
return prefixes
return await self.bot._config.prefix()
return await self.bot._prefix_cache.get_prefixes(guild=None)
@classmethod
async def _version_info(cls) -> Dict[str, str]:
@ -563,7 +562,7 @@ class Core(commands.Cog, CoreLogic):
msg = ""
responses = []
for i, server in enumerate(guilds, 1):
msg += "{}: {}\n".format(i, server.name)
msg += "{}: {} (`{}`)\n".format(i, server.name, server.id)
responses.append(str(i))
for page in pagify(msg, ["\n"]):
@ -847,15 +846,13 @@ class Core(commands.Cog, CoreLogic):
mod_role_ids = await ctx.bot._config.guild(ctx.guild).mod_role()
mod_role_names = [r.name for r in guild.roles if r.id in mod_role_ids]
mod_roles_str = humanize_list(mod_role_names) if mod_role_names else "Not Set."
prefixes = await ctx.bot._config.guild(ctx.guild).prefix()
guild_settings = _("Admin roles: {admin}\nMod roles: {mod}\n").format(
admin=admin_roles_str, mod=mod_roles_str
)
else:
guild_settings = ""
prefixes = None # This is correct. The below can happen in a guild.
if not prefixes:
prefixes = await ctx.bot._config.prefix()
prefixes = await ctx.bot._prefix_cache.get_prefixes(ctx.guild)
locale = await ctx.bot._config.locale()
prefix_string = " ".join(prefixes)
@ -873,6 +870,32 @@ class Core(commands.Cog, CoreLogic):
for page in pagify(settings):
await ctx.send(box(page))
@checks.is_owner()
@_set.command(name="description")
async def setdescription(self, ctx: commands.Context, *, description: str = ""):
"""
Sets the bot's description.
Use without a description to reset.
This is shown in a few locations, including the help menu.
The default is "Red V3"
"""
if not description:
await ctx.bot._config.description.clear()
ctx.bot.description = "Red V3"
await ctx.send(_("Description reset."))
elif len(description) > 250: # While the limit is 256, we bold it adding characters.
await ctx.send(
_(
"This description is too long to properly display. "
"Please try again with below 250 characters"
)
)
else:
await ctx.bot._config.description.set(description)
ctx.bot.description = description
await ctx.tick()
@_set.command()
@checks.guildowner()
@commands.guild_only()
@ -1156,11 +1179,11 @@ class Core(commands.Cog, CoreLogic):
async def serverprefix(self, ctx: commands.Context, *prefixes: str):
"""Sets Red's server prefix(es)"""
if not prefixes:
await ctx.bot._config.guild(ctx.guild).prefix.set([])
await ctx.bot._prefix_cache.set_prefixes(guild=ctx.guild, prefixes=[])
await ctx.send(_("Guild prefixes have been reset."))
return
prefixes = sorted(prefixes, reverse=True)
await ctx.bot._config.guild(ctx.guild).prefix.set(prefixes)
await ctx.bot._prefix_cache.set_prefixes(guild=ctx.guild, prefixes=prefixes)
await ctx.send(_("Prefix set."))
@_set.command()

View File

@ -4,18 +4,20 @@ from . import commands
def init_global_checks(bot):
@bot.check_once
def actually_up(ctx):
def minimum_bot_perms(ctx) -> bool:
"""
Uptime is set during the initial startup process.
If this hasn't been set, we should assume the bot isn't ready yet.
Too many 403, 401, and 429 Errors can cause bots to get global'd
It's reasonable to assume the below as a minimum amount of perms for
commands.
"""
return ctx.bot.uptime is not None
return ctx.channel.permissions_for(ctx.me).send_messages
@bot.check_once
async def whiteblacklist_checks(ctx):
async def whiteblacklist_checks(ctx) -> bool:
return await ctx.bot.allowed_by_whitelist_blacklist(ctx.author)
@bot.check_once
def bots(ctx):
def bots(ctx) -> bool:
"""Check the user is not another bot."""
return not ctx.author.bot

View File

@ -142,6 +142,18 @@ async def _init(bot: Red):
bot.add_listener(on_member_unban)
async def handle_auditype_key():
all_casetypes = {
casetype_name: {
inner_key: inner_value
for inner_key, inner_value in casetype_data.items()
if inner_key != "audit_type"
}
for casetype_name, casetype_data in (await _conf.custom(_CASETYPES).all()).items()
}
await _conf.custom(_CASETYPES).set(all_casetypes)
async def _migrate_config(from_version: int, to_version: int):
if from_version == to_version:
return
@ -170,16 +182,7 @@ async def _migrate_config(from_version: int, to_version: int):
await _conf.guild(cast(discord.Guild, discord.Object(id=guild_id))).clear_raw("cases")
if from_version < 3 <= to_version:
all_casetypes = {
casetype_name: {
inner_key: inner_value
for inner_key, inner_value in casetype_data.items()
if inner_key != "audit_type"
}
for casetype_name, casetype_data in (await _conf.custom(_CASETYPES).all()).items()
}
await _conf.custom(_CASETYPES).set(all_casetypes)
await handle_auditype_key()
await _conf.schema_version.set(3)
if from_version < 4 <= to_version:
@ -507,8 +510,15 @@ class CaseType:
self.image = image
self.case_str = case_str
self.guild = guild
if "audit_type" in kwargs:
kwargs.pop("audit_type", None)
log.warning(
"Fix this using the hidden command: `modlogset fixcasetypes` in Discord: "
"Got outdated key in casetype: audit_type"
)
if kwargs:
log.warning("Got unexpected keys in case %s", ",".join(kwargs.keys()))
log.warning("Got unexpected key(s) in casetype: %s", ",".join(kwargs.keys()))
async def to_json(self):
"""Transforms the case type into a dict and saves it"""

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)

0
redbot/py.typed Normal file
View File

View File

@ -253,20 +253,23 @@ async def remove_instance(
backend = get_current_backend(instance)
driver_cls = drivers.get_driver_class(backend)
await driver_cls.initialize(**data_manager.storage_details())
try:
if delete_data is True:
await driver_cls.delete_all_data(interactive=interactive, drop_db=drop_db)
if delete_data is True:
await driver_cls.delete_all_data(interactive=interactive, drop_db=drop_db)
if interactive is True and remove_datapath is None:
remove_datapath = click.confirm(
"Would you like to delete the instance's entire datapath?", default=False
)
if interactive is True and remove_datapath is None:
remove_datapath = click.confirm(
"Would you like to delete the instance's entire datapath?", default=False
)
if remove_datapath is True:
data_path = data_manager.core_data_path().parent
safe_delete(data_path)
if remove_datapath is True:
data_path = data_manager.core_data_path().parent
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))

View File

@ -127,5 +127,6 @@ include =
data/*
data/**/*
*.export
py.typed
redbot.core.drivers.postgres =
*.sql