Merge remote-tracking branch 'release/V3/develop' into V3/develop

This commit is contained in:
palmtree5 2019-12-08 20:17:45 -09:00
commit 7dd5ed3446
117 changed files with 4007 additions and 1424 deletions

44
.codeclimate.yml Normal file
View File

@ -0,0 +1,44 @@
version: "2" # required to adjust maintainability checks
checks:
argument-count:
config:
threshold: 6
complex-logic:
enabled: false # Disabled in favor of using Radon for this
config:
threshold: 4
file-lines:
config:
threshold: 1000 # I would set this lower if not for cogs as command containers.
method-complexity:
enabled: false # Disabled in favor of using Radon for this
config:
threshold: 5
method-count:
enabled: false # I would set this lower if not for cogs as command containers.
config:
threshold: 20
method-lines:
enabled: false
config:
threshold: 25 # I'm fine with long methods, cautious about the complexity of a single method.
nested-control-flow:
config:
threshold: 4
return-statements:
config:
threshold: 6
similar-code:
enabled: false
config:
threshold: # language-specific defaults. an override will affect all languages.
identical-code:
config:
threshold: # language-specific defaults. an override will affect all languages.
plugins:
bandit:
enabled: true
radon:
enabled: true
config:
threshold: "D"

5
.github/CODEOWNERS vendored
View File

@ -33,7 +33,7 @@ redbot/cogs/audio/* @aikaterna
redbot/cogs/bank/* @tekulvw redbot/cogs/bank/* @tekulvw
redbot/cogs/cleanup/* @palmtree5 redbot/cogs/cleanup/* @palmtree5
redbot/cogs/customcom/* @palmtree5 redbot/cogs/customcom/* @palmtree5
redbot/cogs/downloader/* @tekulvw redbot/cogs/downloader/* @tekulvw @jack1142
redbot/cogs/economy/* @palmtree5 redbot/cogs/economy/* @palmtree5
redbot/cogs/filter/* @palmtree5 redbot/cogs/filter/* @palmtree5
redbot/cogs/general/* @palmtree5 redbot/cogs/general/* @palmtree5
@ -49,6 +49,9 @@ redbot/cogs/warnings/* @palmtree5
# Docs # Docs
docs/* @tekulvw @palmtree5 docs/* @tekulvw @palmtree5
# Tests
tests/cogs/downloader/* @jack1142
# Setup, instance setup, and running the bot # Setup, instance setup, and running the bot
setup.py @tekulvw setup.py @tekulvw
redbot/__init__.py @tekulvw redbot/__init__.py @tekulvw

View File

@ -1,6 +1,9 @@
--- ---
name: Bug reports for commands name: Bug reports for commands
about: For bugs that involve commands found within Red about: For bugs that involve commands found within Red
title: ''
labels: 'Type: Bug'
assignees: ''
--- ---

View File

@ -1,6 +1,9 @@
--- ---
name: Feature request name: Feature request
about: For feature requests regarding Red itself. about: For feature requests regarding Red itself.
title: ''
labels: 'Type: Feature'
assignees: ''
--- ---

View File

@ -1,6 +1,9 @@
--- ---
name: Bug report name: Bug report
about: For bugs that don't involve a command. about: For bugs that don't involve a command.
title: ''
labels: 'Type: Bug'
assignees: ''
--- ---

View File

@ -14,4 +14,3 @@ python:
path: . path: .
extra_requirements: extra_requirements:
- docs - docs
- mongo

View File

@ -27,10 +27,6 @@ jobs:
postgresql: "10" postgresql: "10"
before_script: before_script:
- psql -c 'create database red_db;' -U postgres - psql -c 'create database red_db;' -U postgres
- env: TOXENV=mongo
services: mongodb
before_script:
- mongo red_db --eval 'db.createUser({user:"red",pwd:"red",roles:["readWrite"]});'
# These jobs only occur on tag creation if the prior ones succeed # These jobs only occur on tag creation if the prior ones succeed
- stage: PyPi Deployment - stage: PyPi Deployment
if: tag IS present if: tag IS present

View File

@ -0,0 +1 @@
Added documentation for PM2 support.

View File

@ -0,0 +1 @@
Tests now use same event loop policy as Red's code.

View File

@ -0,0 +1 @@
```redbot-setup delete`` now has the option to leave Red's data untouched on database backends.

View File

@ -0,0 +1 @@
Adds autostart documentation for Red users who installed it inside a virtual environment.

View File

@ -0,0 +1 @@
Bot now handles more things prior to connecting to discord to reduce issues with initial load

View File

@ -0,0 +1 @@
All ``y/n`` confirmations in cli commands are now unified.

View File

@ -0,0 +1 @@
Added ``redbot --edit`` cli flag that can be used to edit instance name, token, owner and datapath.

1
changelog.d/3060.fix.rst Normal file
View File

@ -0,0 +1 @@
Arguments ``--co-owner`` and ``--load-cogs`` now properly require at least one argument to be passed.

View File

@ -0,0 +1 @@
``bot.wait_until_ready`` should no longer be used during extension setup

View File

@ -0,0 +1 @@
Word using dev during install more strongly, to try to avoid end users using dev.

View File

@ -0,0 +1 @@
Fix some typos and wording, add MS Azure to host list

View File

@ -0,0 +1 @@
adds a licenseinfo command

View File

@ -0,0 +1 @@
Removes the mongo driver.

View File

@ -0,0 +1 @@
fix ``is_automod_immune`` handling of guild check and support for checking webhooks

View File

@ -0,0 +1 @@
Update docs footer copyright to 2019.

View File

@ -0,0 +1 @@
Update apikey framework documentation. Change bot.get_shared_api_keys() to bot.get_shared_api_tokens().

View File

@ -0,0 +1 @@
Adds a command to list disabled commands globally or per guild.

View File

@ -0,0 +1 @@
Change ``[p]info`` to say "This bot is an..." instead of "This is an..." for clarity.

View File

@ -0,0 +1 @@
Add information about ``info.json``'s ``min_python_version`` key in Downloader Framework docs.

View File

@ -0,0 +1 @@
Add event reference for ``on_red_api_tokens_update`` event in Shared API Keys docs.

View File

@ -0,0 +1 @@
New event ``on_red_api_tokens_update`` is now dispatched when shared api keys for the service are updated.

View File

@ -0,0 +1 @@
Clarified that ``[p]backup`` saves the **bot's** data in the help text.

View File

@ -0,0 +1 @@
``--owner`` and ``-p`` cli flags now work when added from launcher.

View File

@ -0,0 +1 @@
Fixed ``[p]announce`` failing after encountering an error attempting to message the bot owner.

View File

@ -0,0 +1 @@
Unify capitalisation in ``[p]help playlist``.

View File

@ -0,0 +1 @@
Bot's status is now properly cleared on emptydisconnect.

View File

@ -0,0 +1 @@
Improved explanation in help string for ``[p]audioset emptydisconnect``.

View File

@ -0,0 +1 @@
Add typing indicator to playlist dedupe

View File

@ -0,0 +1 @@
Expose FriendlyExceptions to users on the play command.

View File

@ -0,0 +1 @@
Fix an issue where some YouTube playlists were being recognised as single tracks.

View File

@ -0,0 +1 @@
Downloader will now check if Python and bot version match requirements in ``info.json`` during update.

View File

@ -0,0 +1 @@
Added :func:`redbot.cogs.downloader.installable.InstalledModule` to Downloader's framework docs.

View File

@ -0,0 +1 @@
User can now pass multiple cog names to ``[p]cog install``.

View File

@ -0,0 +1 @@
When passing cogs to ``[p]cog update`` command, it will now only update those cogs, not all cogs from the repo these cogs are from.

View File

@ -0,0 +1 @@
Added ``[p]repo update [repos]`` command that allows you to update repos without updating cogs from them.

View File

@ -0,0 +1 @@
Added ``[p]cog installversion <repo_name> <revision> <cogs>`` command that allows you to install cogs from specified revision (commit, tag) of given repo. When using this command, the cog will automatically be pinned.

View File

@ -0,0 +1 @@
Added ``[p]cog pin <cogs>`` and ``[p]cog unpin <cogs>`` for pinning cogs. Cogs that are pinned will not be updated when using update commands.

View File

@ -0,0 +1 @@
Added ``[p]cog checkforupdates`` command that will tell which cogs can be updated (including pinned cog) without updating them.

View File

@ -0,0 +1 @@
Added ``[p]cog updateallfromrepos <repos>`` command that will update all cogs from given repos.

View File

@ -0,0 +1 @@
Added ``[p]cog updatetoversion <repo_name> <revision> [cogs]`` command that updates all cogs or ones of user's choosing to chosen revision of given repo.

View File

@ -0,0 +1,4 @@
Added :func:`redbot.cogs.downloader.installable.InstalledModule` which is used instead of :func:`redbot.cogs.downloader.installable.Installable` when we refer to installed cog or shared library.
Therefore:
- ``to_json`` and ``from_json`` methods were moved from :func:`redbot.cogs.downloader.installable.Installable` to :func:`redbot.cogs.downloader.installable.InstalledModule`
- return types changed for :func:`redbot.cogs.downloader.converters.InstalledCog.convert`, :func:`redbot.cogs.downloader.downloader.Downloader.installed_cogs`, :func:`redbot.cogs.downloader.repo_manager.Repo.install_cog` to use :func:`redbot.cogs.downloader.installable.InstalledModule`.

View File

@ -0,0 +1 @@
Made regex for repo names use raw string to stop ``DeprecationWarning`` about invalid escape sequence.

View File

@ -0,0 +1 @@
Downloader will no longer allow to install cog that is already installed.

View File

@ -0,0 +1 @@
Added ``pytest-mock`` requirement to ``tests`` extra.

View File

@ -0,0 +1 @@
Added error messages for failures during installing/reinstalling requirements and copying cogs and shared libraries.

View File

@ -0,0 +1 @@
Added more Downloader tests for Repo logic and git integration. New git tests use a test repo file that can be generated using new tool at ``tools/edit_testrepo.py``.

View File

@ -0,0 +1 @@
Downloader will no longer allow to install cog with same name as other that is installed.

View File

@ -0,0 +1 @@
Catch errors if remote repository or branch is deleted, notify user which repository failed and continue updating others.

View File

@ -0,0 +1 @@
`RepoManager.update_all_repos` replaced by new method `update_repos` which additionally handles failing repositories.

View File

@ -0,0 +1 @@
Added `Downloader.format_failed_repos` for formatting error message of repos failing to update.

View File

@ -0,0 +1 @@
Use sanitized url (without HTTP Basic Auth fragments) in `[p]findcog` command.

View File

@ -0,0 +1 @@
Add `clean_url` property to :class:`redbot.cogs.downloader.repo_manager.Repo` which contains sanitized repo URL (without HTTP Basic Auth).

View File

@ -0,0 +1 @@
Make :attr:`redbot.cogs.downloader.repo_manager.Repo.clean_url` work with relative urls. This property uses `str` type now.

View File

@ -0,0 +1 @@
Fixed an error on repo add from empty string values for the `install_msg` info.json field.

View File

@ -0,0 +1 @@
Disable all git auth prompts when adding/updating repo with Downloader.

View File

@ -0,0 +1 @@
Ensure consistent output from git commands for purpose of parsing.

View File

@ -0,0 +1 @@
``[p]findcog`` now properly works for cogs with less typical folder structure.

View File

@ -0,0 +1 @@
defaults are cleared properly when clearing all rules

42
docs/autostart_pm2.rst Normal file
View File

@ -0,0 +1,42 @@
.. pm2 service guide
==============================================
Setting up auto-restart using pm2 on Linux
==============================================
.. note:: This guide is for setting up PM2 on a Linux environment. This guide assumes that you already have a working Red instance.
--------------
Installing PM2
--------------
Start by installing Node.JS and NPM via your favorite package distributor. From there run the following command:
:code:`npm install pm2 -g`
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
Arguments to replace.
--name ""
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
<Red Instance>
The name of your Red instance.
------------------------------
Ensuring that PM2 stays online
------------------------------
To make sure that PM2 stays online and persistence between machine restarts, run the following commands:
:code:`pm2 save` & :code:`pm2 startup`

View File

@ -8,11 +8,23 @@ Setting up auto-restart using systemd on Linux
Creating the service file Creating the service file
------------------------- -------------------------
Create the new service file: In order to create the service file, you will first need the location of your :code:`redbot` binary.
.. code-block:: bash
# If redbot is installed in a virtualenv
source redenv/bin/activate
# If you are using pyenv
pyenv shell <name>
which redbot
Then create the new service file:
:code:`sudo -e /etc/systemd/system/red@.service` :code:`sudo -e /etc/systemd/system/red@.service`
Paste the following and replace all instances of :code:`username` with the username your bot is running under (hopefully not root): Paste the following and replace all instances of :code:`username` with the username, and :code:`path` with the location you obtained above:
.. code-block:: none .. code-block:: none
@ -21,7 +33,7 @@ Paste the following and replace all instances of :code:`username` with the usern
After=multi-user.target After=multi-user.target
[Service] [Service]
ExecStart=/home/username/.local/bin/redbot %I --no-prompt ExecStart=path %I --no-prompt
User=username User=username
Group=username Group=username
Type=idle Type=idle

View File

@ -58,7 +58,7 @@ master_doc = "index"
# General information about the project. # General information about the project.
project = "Red - Discord Bot" project = "Red - Discord Bot"
copyright = "2018, Cog Creators" copyright = "2018-2019, Cog Creators"
author = "Cog Creators" author = "Cog Creators"
# The version info for the project you're documenting, acts as replacement for # The version info for the project you're documenting, acts as replacement for

View File

@ -18,7 +18,7 @@ and when accessed in the code it should be done by
.. code-block:: python .. code-block:: python
await self.bot.get_shared_api_keys("twitch") await self.bot.get_shared_api_tokens("twitch")
Each service has its own dict of key, value pairs for each required key type. If there's only one key required then a name for the key is still required for storing and accessing. Each service has its own dict of key, value pairs for each required key type. If there's only one key required then a name for the key is still required for storing and accessing.
@ -30,7 +30,7 @@ and when accessed in the code it should be done by
.. code-block:: python .. code-block:: python
await self.bot.get_shared_api_keys("youtube") await self.bot.get_shared_api_tokens("youtube")
*********** ***********
@ -42,7 +42,21 @@ Basic Usage
class MyCog: class MyCog:
@commands.command() @commands.command()
async def youtube(self, ctx, user: str): async def youtube(self, ctx, user: str):
youtube_keys = await self.bot.get_shared_api_keys("youtube") youtube_keys = await self.bot.get_shared_api_tokens("youtube")
if youtube_keys.get("api_key") is None: if youtube_keys.get("api_key") is None:
return await ctx.send("The YouTube API key has not been set.") return await ctx.send("The YouTube API key has not been set.")
# Use the API key to access content as you normally would # Use the API key to access content as you normally would
***************
Event Reference
***************
.. function:: on_red_api_tokens_update(service_name, api_tokens)
Dispatched when service's api keys are updated.
:param service_name: Name of the service.
: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`]

View File

@ -429,7 +429,3 @@ JSON Driver
.. autoclass:: redbot.core.drivers.JsonDriver .. autoclass:: redbot.core.drivers.JsonDriver
:members: :members:
Mongo Driver
^^^^^^^^^^^^
.. autoclass:: redbot.core.drivers.MongoDriver
:members:

View File

@ -35,6 +35,9 @@ Keys specific to the cog info.json (case sensitive)
- ``max_bot_version`` (string) - Max version number of Red in the format ``MAJOR.MINOR.MICRO``, - ``max_bot_version`` (string) - Max version number of Red in the format ``MAJOR.MINOR.MICRO``,
if ``min_bot_version`` is newer than ``max_bot_version``, ``max_bot_version`` will be ignored if ``min_bot_version`` is newer than ``max_bot_version``, ``max_bot_version`` will be ignored
- ``min_python_version`` (list of integers) - Min version number of Python
in the format ``[MAJOR, MINOR, PATCH]``
- ``hidden`` (bool) - Determines if a cog is visible in the cog list for a repo. - ``hidden`` (bool) - Determines if a cog is visible in the cog list for a repo.
- ``disabled`` (bool) - Determines if a cog is available for install. - ``disabled`` (bool) - Determines if a cog is available for install.
@ -68,6 +71,12 @@ Installable
.. autoclass:: Installable .. autoclass:: Installable
:members: :members:
InstalledModule
^^^^^^^^^^^^^^^
.. autoclass:: InstalledModule
:members:
.. automodule:: redbot.cogs.downloader.repo_manager .. automodule:: redbot.cogs.downloader.repo_manager
Repo Repo

View File

@ -115,8 +115,8 @@ to use one, do it like this: ``[p]cleanup messages 10``
Cogs Cogs
---- ----
Red is built with cogs, fancy term for plugins. They are Red is built with cogs, a fancy term for plugins. They are
modules that enhance the Red functionalities. They contain modules that add functionality to Red. They contain
commands to use. commands to use.
Red comes with 19 cogs containing the basic features, such Red comes with 19 cogs containing the basic features, such
@ -162,10 +162,10 @@ there are hundreds of cogs available!
.. 26-cogs not available, let's use my repo :3 .. 26-cogs not available, let's use my repo :3
Cogs comes with repositories. A repository is a container of cogs Cogs come in repositories. A repository is a container of cogs
that you can install. Let's suppose you want to install the ``say`` that you can install. Let's suppose you want to install the ``say``
cog from the repository ``Laggrons-Dumb-Cogs``. You'll first need cog from the repository ``Laggrons-Dumb-Cogs``. You'll first need
to install the repository. to add the repository.
.. code-block:: none .. code-block:: none
@ -173,7 +173,7 @@ to install the repository.
.. note:: You may need to specify a branch. If so, add its name after the link. .. note:: You may need to specify a branch. If so, add its name after the link.
Then you can add the cog Then you can install the cog
.. code-block:: none .. code-block:: none
@ -195,7 +195,7 @@ the level of permission needed for a command.
Bot owner Bot owner
~~~~~~~~~ ~~~~~~~~~
The bot owner can access all commands on every guild. He can also use The bot owner can access all commands on every guild. They can also use
exclusive commands that can interact with the global settings exclusive commands that can interact with the global settings
or system files. or system files.
@ -214,7 +214,7 @@ Administrator
~~~~~~~~~~~~~ ~~~~~~~~~~~~~
The administrator is defined by its roles. You can set multiple admin roles The administrator is defined by its roles. You can set multiple admin roles
with the ``[p]addadminrole`` and ``[p]removeadminrole`` commands. with the ``[p]set addadminrole`` and ``[p]set removeadminrole`` commands.
For example, in the mod cog, an admin can use the ``[p]modset`` command For example, in the mod cog, an admin can use the ``[p]modset`` command
which defines the cog settings. which defines the cog settings.
@ -224,7 +224,7 @@ Moderator
~~~~~~~~~ ~~~~~~~~~
A moderator is a step above the average users. You can set multiple moderator A moderator is a step above the average users. You can set multiple moderator
roles with the ``[p]addmodrole`` and ``[p]removemodrole`` commands. roles with the ``[p]set addmodrole`` and ``[p]set removemodrole`` commands.
For example, in the mod cog (again), a mod will be able to mute, kick and ban; For example, in the mod cog (again), a mod will be able to mute, kick and ban;
but he won't be able to modify the cog settings with the ``[p]modset`` command. but he won't be able to modify the cog settings with the ``[p]modset`` command.

View File

@ -56,6 +56,9 @@ Others
|`Google Cloud |Same as AWS, but it's Google. | |`Google Cloud |Same as AWS, but it's Google. |
|<https://cloud.google.com/compute/>`_| | |<https://cloud.google.com/compute/>`_| |
+-------------------------------------+-----------------------------------------------------+ +-------------------------------------+-----------------------------------------------------+
|`Microsoft Azure |Same as AWS, but it's Microsoft. |
|<https://azure.microsoft.com>`_ | |
+-------------------------------------+-----------------------------------------------------+
|`LowEndBox <http://lowendbox.com/>`_ |A curator for lower specced servers. | |`LowEndBox <http://lowendbox.com/>`_ |A curator for lower specced servers. |
+-------------------------------------+-----------------------------------------------------+ +-------------------------------------+-----------------------------------------------------+

View File

@ -16,6 +16,7 @@ Welcome to Red - Discord Bot's documentation!
install_linux_mac install_linux_mac
venv_guide venv_guide
autostart_systemd autostart_systemd
autostart_pm2
.. toctree:: .. toctree::
:maxdepth: 2 :maxdepth: 2

View File

@ -265,18 +265,12 @@ Choose one of the following commands to install Red.
python3.7 -m pip install --user -U Red-DiscordBot python3.7 -m pip install --user -U Red-DiscordBot
To install without MongoDB support: To install without additional config backend support:
.. code-block:: none .. code-block:: none
python3.7 -m pip install -U Red-DiscordBot python3.7 -m pip install -U Red-DiscordBot
Or, to install with MongoDB support:
.. code-block:: none
python3.7 -m pip install -U Red-DiscordBot[mongo]
Or, to install with PostgreSQL support: Or, to install with PostgreSQL support:
.. code-block:: none .. code-block:: none
@ -286,7 +280,11 @@ Or, to install with PostgreSQL support:
.. note:: .. note::
To install the development version, replace ``Red-DiscordBot`` in the above commands with the To install the development version, replace ``Red-DiscordBot`` in the above commands with the
following link: link below. **The development version of the bot contains experimental changes. It is not
intended for normal users.** We will not support anyone using the development version in any
support channels. Using the development version may break third party cogs and not all core
commands may work. Downgrading to stable after installing the development version may cause
data loss, crashes or worse.
.. code-block:: none .. code-block:: none

View File

@ -76,12 +76,6 @@ Installing Red
python -m pip install -U Red-DiscordBot python -m pip install -U Red-DiscordBot
* With MongoDB support:
.. code-block:: none
python -m pip install -U Red-DiscordBot[mongo]
* With PostgreSQL support: * With PostgreSQL support:
.. code-block:: none .. code-block:: none
@ -91,7 +85,11 @@ Installing Red
.. note:: .. note::
To install the development version, replace ``Red-DiscordBot`` in the above commands with the To install the development version, replace ``Red-DiscordBot`` in the above commands with the
following link: link below. **The development version of the bot contains experimental changes. It is not
intended for normal users.** We will not support anyone using the development version in any
support channels. Using the development version may break third party cogs and not all core
commands may work. Downgrading to stable after installing the development version may cause
data loss, crashes or worse.
.. code-block:: none .. code-block:: none

View File

@ -1,3 +1,4 @@
import asyncio as _asyncio
import re as _re import re as _re
import sys as _sys import sys as _sys
import warnings as _warnings import warnings as _warnings
@ -15,8 +16,13 @@ from typing import (
MIN_PYTHON_VERSION = (3, 7, 0) MIN_PYTHON_VERSION = (3, 7, 0)
__all__ = ["MIN_PYTHON_VERSION", "__version__", "version_info", "VersionInfo"] __all__ = [
"MIN_PYTHON_VERSION",
"__version__",
"version_info",
"VersionInfo",
"_update_event_loop_policy",
]
if _sys.version_info < MIN_PYTHON_VERSION: if _sys.version_info < MIN_PYTHON_VERSION:
print( print(
f"Python {'.'.join(map(str, MIN_PYTHON_VERSION))} is required to run Red, but you have " f"Python {'.'.join(map(str, MIN_PYTHON_VERSION))} is required to run Red, but you have "
@ -173,7 +179,20 @@ class VersionInfo:
) )
__version__ = "3.1.6" def _update_event_loop_policy():
if _sys.platform == "win32":
_asyncio.set_event_loop_policy(_asyncio.WindowsProactorEventLoopPolicy())
elif _sys.implementation.name == "cpython":
# Let's not force this dependency, uvloop is much faster on cpython
try:
import uvloop as _uvloop
except ImportError:
pass
else:
_asyncio.set_event_loop_policy(_uvloop.EventLoopPolicy())
__version__ = "3.1.8"
version_info = VersionInfo.from_str(__version__) version_info = VersionInfo.from_str(__version__)
# Filter fuzzywuzzy slow sequence matcher warning # Filter fuzzywuzzy slow sequence matcher warning

View File

@ -6,24 +6,19 @@ import asyncio
import json import json
import logging import logging
import os import os
import shutil
import sys import sys
from copy import deepcopy
from pathlib import Path
import discord import discord
# Set the event loop policies here so any subsequent `get_event_loop()` # Set the event loop policies here so any subsequent `get_event_loop()`
# calls, in particular those as a result of the following imports, # calls, in particular those as a result of the following imports,
# return the correct loop object. # return the correct loop object.
if sys.platform == "win32": from redbot import _update_event_loop_policy
asyncio.set_event_loop_policy(asyncio.WindowsProactorEventLoopPolicy())
elif sys.implementation.name == "cpython": _update_event_loop_policy()
# Let's not force this dependency, uvloop is much faster on cpython
try:
import uvloop
except ImportError:
uvloop = None
pass
else:
asyncio.set_event_loop_policy(uvloop.EventLoopPolicy())
import redbot.logging import redbot.logging
from redbot.core.bot import Red, ExitCodes from redbot.core.bot import Red, ExitCodes
@ -31,7 +26,8 @@ from redbot.core.cog_manager import CogManagerUI
from redbot.core.global_checks import init_global_checks from redbot.core.global_checks import init_global_checks
from redbot.core.events import init_events from redbot.core.events import init_events
from redbot.core.cli import interactive_config, confirm, parse_cli_flags from redbot.core.cli import interactive_config, confirm, parse_cli_flags
from redbot.core.core_commands import Core from redbot.core.core_commands import Core, license_info_command
from redbot.setup import get_data_dir, get_name, save_config
from redbot.core.dev_commands import Dev from redbot.core.dev_commands import Dev
from redbot.core import __version__, modlog, bank, data_manager, drivers from redbot.core import __version__, modlog, bank, data_manager, drivers
from signal import SIGTERM from signal import SIGTERM
@ -56,6 +52,12 @@ async def _get_prefix_and_token(red, indict):
indict["prefix"] = await red._config.prefix() indict["prefix"] = await red._config.prefix()
def _get_instance_names():
with data_manager.config_file.open(encoding="utf-8") as fs:
data = json.load(fs)
return sorted(data.keys())
def list_instances(): def list_instances():
if not data_manager.config_file.exists(): if not data_manager.config_file.exists():
print( print(
@ -64,22 +66,164 @@ def list_instances():
) )
sys.exit(1) sys.exit(1)
else: else:
with data_manager.config_file.open(encoding="utf-8") as fs:
data = json.load(fs)
text = "Configured Instances:\n\n" text = "Configured Instances:\n\n"
for instance_name in sorted(data.keys()): for instance_name in _get_instance_names():
text += "{}\n".format(instance_name) text += "{}\n".format(instance_name)
print(text) print(text)
sys.exit(0) sys.exit(0)
def edit_instance(red, cli_flags):
no_prompt = cli_flags.no_prompt
token = cli_flags.token
owner = cli_flags.owner
old_name = cli_flags.instance_name
new_name = cli_flags.edit_instance_name
data_path = cli_flags.edit_data_path
copy_data = cli_flags.copy_data
confirm_overwrite = cli_flags.overwrite_existing_instance
if data_path is None and copy_data:
print("--copy-data can't be used without --edit-data-path argument")
sys.exit(1)
if new_name is None and confirm_overwrite:
print("--overwrite-existing-instance can't be used without --edit-instance-name argument")
sys.exit(1)
if no_prompt and all(to_change is None for to_change in (token, owner, new_name, data_path)):
print(
"No arguments to edit were provided. Available arguments (check help for more "
"information): --edit-instance-name, --edit-data-path, --copy-data, --owner, --token"
)
sys.exit(1)
_edit_token(red, token, no_prompt)
_edit_owner(red, owner, no_prompt)
data = deepcopy(data_manager.basic_config)
name = _edit_instance_name(old_name, new_name, confirm_overwrite, no_prompt)
_edit_data_path(data, data_path, copy_data, no_prompt)
save_config(name, data)
if old_name != name:
save_config(old_name, {}, remove=True)
def _edit_token(red, token, no_prompt):
if token:
if not len(token) >= 50:
print(
"The provided token doesn't look a valid Discord bot token."
" Instance's token will remain unchanged.\n"
)
return
red.loop.run_until_complete(red._config.token.set(token))
elif not no_prompt and confirm("Would you like to change instance's token?", default=False):
interactive_config(red, False, True, print_header=False)
print("Token updated.\n")
def _edit_owner(red, owner, no_prompt):
if owner:
if not (15 <= len(str(owner)) <= 21):
print(
"The provided owner id doesn't look like a valid Discord user id."
" Instance's owner will remain unchanged."
)
return
red.loop.run_until_complete(red._config.owner.set(owner))
elif not no_prompt and confirm("Would you like to change instance's owner?", default=False):
print(
"Remember:\n"
"ONLY the person who is hosting Red should be owner."
" This has SERIOUS security implications."
" The owner can access any data that is present on the host system.\n"
)
if confirm("Are you sure you want to change instance's owner?", default=False):
print("Please enter a Discord user id for new owner:")
while True:
owner_id = input("> ").strip()
if not (15 <= len(owner_id) <= 21 and owner_id.isdecimal()):
print("That doesn't look like a valid Discord user id.")
continue
owner_id = int(owner_id)
red.loop.run_until_complete(red._config.owner.set(owner_id))
print("Owner updated.")
break
else:
print("Instance's owner will remain unchanged.")
print()
def _edit_instance_name(old_name, new_name, confirm_overwrite, no_prompt):
if new_name:
name = new_name
if name in _get_instance_names() and not confirm_overwrite:
name = old_name
print(
"An instance with this name already exists.\n"
"If you want to remove the existing instance and replace it with this one,"
" run this command with --overwrite-existing-instance flag."
)
elif not no_prompt and confirm("Would you like to change the instance name?", default=False):
name = get_name()
if name in _get_instance_names():
print(
"WARNING: An instance already exists with this name. "
"Continuing will overwrite the existing instance config."
)
if not confirm(
"Are you absolutely certain you want to continue with this instance name?",
default=False,
):
print("Instance name will remain unchanged.")
name = old_name
else:
print("Instance name updated.")
print()
else:
name = old_name
return name
def _edit_data_path(data, data_path, copy_data, no_prompt):
# This modifies the passed dict.
if data_path:
data["DATA_PATH"] = data_path
if copy_data and not _copy_data(data):
print("Can't copy data to non-empty location. Data location will remain unchanged.")
data["DATA_PATH"] = data_manager.basic_config["DATA_PATH"]
elif not no_prompt and confirm("Would you like to change the data location?", default=False):
data["DATA_PATH"] = get_data_dir()
if confirm(
"Do you want to copy the data from old location?", default=True
) and not _copy_data(data):
print("Can't copy the data to non-empty location.")
if not confirm("Do you still want to use the new data location?"):
data["DATA_PATH"] = data_manager.basic_config["DATA_PATH"]
print("Data location will remain unchanged.")
else:
print("Data location updated.")
def _copy_data(data):
if Path(data["DATA_PATH"]).exists():
if any(os.scandir(data["DATA_PATH"])):
return False
else:
# this is needed because copytree doesn't work when destination folder exists
# Python 3.8 has `dirs_exist_ok` option for that
os.rmdir(data["DATA_PATH"])
shutil.copytree(data_manager.basic_config["DATA_PATH"], data["DATA_PATH"])
return True
async def sigterm_handler(red, log): async def sigterm_handler(red, log):
log.info("SIGTERM received. Quitting...") log.info("SIGTERM received. Quitting...")
await red.shutdown(restart=False) await red.shutdown(restart=False)
def main(): def main():
description = "Red V3 (c) Cog Creators" description = "Red V3"
cli_flags = parse_cli_flags(sys.argv[1:]) cli_flags = parse_cli_flags(sys.argv[1:])
if cli_flags.list_instances: if cli_flags.list_instances:
list_instances() list_instances()
@ -87,7 +231,7 @@ def main():
print(description) print(description)
print("Current Version: {}".format(__version__)) print("Current Version: {}".format(__version__))
sys.exit(0) sys.exit(0)
elif not cli_flags.instance_name and not cli_flags.no_instance: elif not cli_flags.instance_name and (not cli_flags.no_instance or cli_flags.edit):
print("Error: No instance name was provided!") print("Error: No instance name was provided!")
sys.exit(1) sys.exit(1)
if cli_flags.no_instance: if cli_flags.no_instance:
@ -116,6 +260,16 @@ def main():
cli_flags=cli_flags, description=description, dm_help=None, fetch_offline_members=True cli_flags=cli_flags, description=description, dm_help=None, fetch_offline_members=True
) )
loop.run_until_complete(red._maybe_update_config()) loop.run_until_complete(red._maybe_update_config())
if cli_flags.edit:
try:
edit_instance(red, cli_flags)
except (KeyboardInterrupt, EOFError):
print("Aborted!")
finally:
loop.run_until_complete(driver_cls.teardown())
sys.exit(0)
init_global_checks(red) init_global_checks(red)
init_events(red, cli_flags) init_events(red, cli_flags)
@ -128,6 +282,7 @@ def main():
red.add_cog(Core(red)) red.add_cog(Core(red))
red.add_cog(CogManagerUI()) red.add_cog(CogManagerUI())
red.add_command(license_info_command)
if cli_flags.dev: if cli_flags.dev:
red.add_cog(Dev()) red.add_cog(Dev())
# noinspection PyProtectedMember # noinspection PyProtectedMember
@ -157,13 +312,12 @@ def main():
loop.run_until_complete(red.http.close()) loop.run_until_complete(red.http.close())
sys.exit(0) sys.exit(0)
try: try:
loop.run_until_complete(red.start(token, bot=True)) loop.run_until_complete(red.start(token, bot=True, cli_flags=cli_flags))
except discord.LoginFailure: except discord.LoginFailure:
log.critical("This token doesn't seem to be valid.") log.critical("This token doesn't seem to be valid.")
db_token = loop.run_until_complete(red._config.token()) db_token = loop.run_until_complete(red._config.token())
if db_token and not cli_flags.no_prompt: if db_token and not cli_flags.no_prompt:
print("\nDo you want to reset the token? (y/n)") if confirm("\nDo you want to reset the token?"):
if confirm("> "):
loop.run_until_complete(red._config.token.set("")) loop.run_until_complete(red._config.token.set(""))
print("Token has been reset.") print("Token has been reset.")
except KeyboardInterrupt: except KeyboardInterrupt:

View File

@ -3,6 +3,7 @@ import asyncio
import discord import discord
from redbot.core import commands from redbot.core import commands
from redbot.core.i18n import Translator from redbot.core.i18n import Translator
from redbot.core.utils.chat_formatting import humanize_list, inline
_ = Translator("Announcer", __file__) _ = Translator("Announcer", __file__)
@ -53,7 +54,7 @@ class Announcer:
async def announcer(self): async def announcer(self):
guild_list = self.ctx.bot.guilds guild_list = self.ctx.bot.guilds
bot_owner = (await self.ctx.bot.application_info()).owner failed = []
for g in guild_list: for g in guild_list:
if not self.active: if not self.active:
return return
@ -66,9 +67,14 @@ class Announcer:
try: try:
await channel.send(self.message) await channel.send(self.message)
except discord.Forbidden: except discord.Forbidden:
await bot_owner.send( failed.append(str(g.id))
_("I could not announce to server: {server.id}").format(server=g)
)
await asyncio.sleep(0.5) await asyncio.sleep(0.5)
msg = (
_("I could not announce to the following server: ")
if len(failed) == 1
else _("I could not announce to the following servers: ")
)
msg += humanize_list(tuple(map(inline, failed)))
await self.ctx.bot.send_to_owners(msg)
self.active = False self.active = False

View File

@ -3,7 +3,6 @@ from redbot.core import commands
from .audio import Audio from .audio import Audio
async def setup(bot: commands.Bot): def setup(bot: commands.Bot):
cog = Audio(bot) cog = Audio(bot)
await cog.initialize()
bot.add_cog(cog) bot.add_cog(cog)

View File

@ -9,7 +9,7 @@ import random
import time import time
import traceback import traceback
from collections import namedtuple from collections import namedtuple
from typing import Callable, Dict, List, Mapping, NoReturn, Optional, Tuple, Union from typing import Callable, Dict, List, Mapping, Optional, Tuple, Union
try: try:
from sqlite3 import Error as SQLError from sqlite3 import Error as SQLError
@ -32,7 +32,7 @@ from lavalink.rest_api import LoadResult
from redbot.core import Config, commands from redbot.core import Config, commands
from redbot.core.bot import Red from redbot.core.bot import Red
from redbot.core.i18n import Translator, cog_i18n from redbot.core.i18n import Translator, cog_i18n
from . import dataclasses from . import audio_dataclasses
from .errors import InvalidTableError, SpotifyFetchError, YouTubeApiError from .errors import InvalidTableError, SpotifyFetchError, YouTubeApiError
from .playlists import get_playlist from .playlists import get_playlist
from .utils import CacheLevel, Notifier, is_allowed, queue_duration, track_limit from .utils import CacheLevel, Notifier, is_allowed, queue_duration, track_limit
@ -193,7 +193,7 @@ class SpotifyAPI:
) )
return await r.json() return await r.json()
async def _get_auth(self) -> NoReturn: async def _get_auth(self):
if self.client_id is None or self.client_secret is None: if self.client_id is None or self.client_secret is None:
tokens = await self.bot.get_shared_api_tokens("spotify") tokens = await self.bot.get_shared_api_tokens("spotify")
self.client_id = tokens.get("client_id", "") self.client_id = tokens.get("client_id", "")
@ -331,7 +331,7 @@ class MusicCache:
self._lock: asyncio.Lock = asyncio.Lock() self._lock: asyncio.Lock = asyncio.Lock()
self.config: Optional[Config] = None self.config: Optional[Config] = None
async def initialize(self, config: Config) -> NoReturn: async def initialize(self, config: Config):
if HAS_SQL: if HAS_SQL:
await self.database.connect() await self.database.connect()
@ -348,12 +348,12 @@ class MusicCache:
await self.database.execute(query=_CREATE_UNIQUE_INDEX_SPOTIFY_TABLE) await self.database.execute(query=_CREATE_UNIQUE_INDEX_SPOTIFY_TABLE)
self.config = config self.config = config
async def close(self) -> NoReturn: async def close(self):
if HAS_SQL: if HAS_SQL:
await self.database.execute(query="PRAGMA optimize;") await self.database.execute(query="PRAGMA optimize;")
await self.database.disconnect() await self.database.disconnect()
async def insert(self, table: str, values: List[dict]) -> NoReturn: async def insert(self, table: str, values: List[dict]):
# if table == "spotify": # if table == "spotify":
# return # return
if HAS_SQL: if HAS_SQL:
@ -363,7 +363,7 @@ class MusicCache:
await self.database.execute_many(query=query, values=values) await self.database.execute_many(query=query, values=values)
async def update(self, table: str, values: Dict[str, str]) -> NoReturn: async def update(self, table: str, values: Dict[str, str]):
# if table == "spotify": # if table == "spotify":
# return # return
if HAS_SQL: if HAS_SQL:
@ -746,7 +746,7 @@ class MusicCache:
if val: if val:
try: try:
result, called_api = await self.lavalink_query( result, called_api = await self.lavalink_query(
ctx, player, dataclasses.Query.process_input(val) ctx, player, audio_dataclasses.Query.process_input(val)
) )
except (RuntimeError, aiohttp.ServerDisconnectedError): except (RuntimeError, aiohttp.ServerDisconnectedError):
lock(ctx, False) lock(ctx, False)
@ -805,7 +805,7 @@ class MusicCache:
ctx.guild, ctx.guild,
( (
f"{single_track.title} {single_track.author} {single_track.uri} " f"{single_track.title} {single_track.author} {single_track.uri} "
f"{str(dataclasses.Query.process_input(single_track))}" f"{str(audio_dataclasses.Query.process_input(single_track))}"
), ),
): ):
has_not_allowed = True has_not_allowed = True
@ -911,7 +911,7 @@ class MusicCache:
self, self,
ctx: commands.Context, ctx: commands.Context,
player: lavalink.Player, player: lavalink.Player,
query: dataclasses.Query, query: audio_dataclasses.Query,
forced: bool = False, forced: bool = False,
) -> Tuple[LoadResult, bool]: ) -> Tuple[LoadResult, bool]:
""" """
@ -925,7 +925,7 @@ class MusicCache:
The context this method is being called under. The context this method is being called under.
player : lavalink.Player player : lavalink.Player
The player who's requesting the query. The player who's requesting the query.
query: dataclasses.Query query: audio_dataclasses.Query
The Query object for the query in question. The Query object for the query in question.
forced:bool forced:bool
Whether or not to skip cache and call API first.. Whether or not to skip cache and call API first..
@ -939,7 +939,7 @@ class MusicCache:
) )
cache_enabled = CacheLevel.set_lavalink().is_subset(current_cache_level) cache_enabled = CacheLevel.set_lavalink().is_subset(current_cache_level)
val = None val = None
_raw_query = dataclasses.Query.process_input(query) _raw_query = audio_dataclasses.Query.process_input(query)
query = str(_raw_query) query = str(_raw_query)
if cache_enabled and not forced and not _raw_query.is_local: if cache_enabled and not forced and not _raw_query.is_local:
update = True update = True
@ -1003,14 +1003,10 @@ class MusicCache:
tasks = self._tasks[ctx.message.id] tasks = self._tasks[ctx.message.id]
del self._tasks[ctx.message.id] del self._tasks[ctx.message.id]
await asyncio.gather( await asyncio.gather(
*[asyncio.ensure_future(self.insert(*a)) for a in tasks["insert"]], *[self.insert(*a) for a in tasks["insert"]], return_exceptions=True
loop=self.bot.loop,
return_exceptions=True,
) )
await asyncio.gather( await asyncio.gather(
*[asyncio.ensure_future(self.update(*a)) for a in tasks["update"]], *[self.update(*a) for a in tasks["update"]], return_exceptions=True
loop=self.bot.loop,
return_exceptions=True,
) )
log.debug(f"Completed database writes for {lock_id} " f"({lock_author})") log.debug(f"Completed database writes for {lock_id} " f"({lock_author})")
@ -1025,14 +1021,10 @@ class MusicCache:
self._tasks = {} self._tasks = {}
await asyncio.gather( await asyncio.gather(
*[asyncio.ensure_future(self.insert(*a)) for a in tasks["insert"]], *[self.insert(*a) for a in tasks["insert"]], return_exceptions=True
loop=self.bot.loop,
return_exceptions=True,
) )
await asyncio.gather( await asyncio.gather(
*[asyncio.ensure_future(self.update(*a)) for a in tasks["update"]], *[self.update(*a) for a in tasks["update"]], return_exceptions=True
loop=self.bot.loop,
return_exceptions=True,
) )
log.debug("Completed pending writes to database have finished") log.debug("Completed pending writes to database have finished")
@ -1096,7 +1088,9 @@ class MusicCache:
if not tracks: if not tracks:
ctx = namedtuple("Context", "message") ctx = namedtuple("Context", "message")
results, called_api = await self.lavalink_query( results, called_api = await self.lavalink_query(
ctx(player.channel.guild), player, dataclasses.Query.process_input(_TOP_100_US) ctx(player.channel.guild),
player,
audio_dataclasses.Query.process_input(_TOP_100_US),
) )
tracks = list(results.tracks) tracks = list(results.tracks)
if tracks: if tracks:
@ -1107,7 +1101,7 @@ class MusicCache:
while valid is False and multiple: while valid is False and multiple:
track = random.choice(tracks) track = random.choice(tracks)
query = dataclasses.Query.process_input(track) query = audio_dataclasses.Query.process_input(track)
if not query.valid: if not query.valid:
continue continue
if query.is_local and not query.track.exists(): if query.is_local and not query.track.exists():
@ -1116,7 +1110,7 @@ class MusicCache:
player.channel.guild, player.channel.guild,
( (
f"{track.title} {track.author} {track.uri} " f"{track.title} {track.author} {track.uri} "
f"{str(dataclasses.Query.process_input(track))}" f"{str(audio_dataclasses.Query.process_input(track))}"
), ),
): ):
log.debug( log.debug(

View File

@ -34,7 +34,7 @@ from redbot.core.utils.menus import (
start_adding_reactions, start_adding_reactions,
) )
from redbot.core.utils.predicates import MessagePredicate, ReactionPredicate from redbot.core.utils.predicates import MessagePredicate, ReactionPredicate
from . import dataclasses from . import audio_dataclasses
from .apis import MusicCache, HAS_SQL, _ERROR from .apis import MusicCache, HAS_SQL, _ERROR
from .checks import can_have_caching from .checks import can_have_caching
from .converters import ComplexScopeParser, ScopeParser, get_lazy_converter, get_playlist_converter from .converters import ComplexScopeParser, ScopeParser, get_lazy_converter, get_playlist_converter
@ -142,7 +142,11 @@ class Audio(commands.Cog):
self.play_lock = {} self.play_lock = {}
self._manager: Optional[ServerManager] = None self._manager: Optional[ServerManager] = None
self.bot.dispatch("red_audio_initialized", self) # These has to be a task since this requires the bot to be ready
# If it waits for ready in startup, we cause a deadlock during initial load
# as initial load happens before the bot can ever be ready.
self._init_task = self.bot.loop.create_task(self.initialize())
self._ready_event = asyncio.Event()
@property @property
def owns_autoplay(self): def owns_autoplay(self):
@ -166,9 +170,14 @@ class Audio(commands.Cog):
self._cog_id = None self._cog_id = None
async def cog_before_invoke(self, ctx: commands.Context): async def cog_before_invoke(self, ctx: commands.Context):
await self._ready_event.wait()
# check for unsupported arch
# Check on this needs refactoring at a later date
# so that we have a better way to handle the tasks
if self.llsetup in [ctx.command, ctx.command.root_parent]: if self.llsetup in [ctx.command, ctx.command.root_parent]:
pass pass
elif self._connect_task.cancelled():
elif self._connect_task and self._connect_task.cancelled():
await ctx.send( await ctx.send(
"You have attempted to run Audio's Lavalink server on an unsupported" "You have attempted to run Audio's Lavalink server on an unsupported"
" architecture. Only settings related commands will be available." " architecture. Only settings related commands will be available."
@ -176,6 +185,7 @@ class Audio(commands.Cog):
raise RuntimeError( raise RuntimeError(
"Not running audio command due to invalid machine architecture for Lavalink." "Not running audio command due to invalid machine architecture for Lavalink."
) )
dj_enabled = await self.config.guild(ctx.guild).dj_enabled() dj_enabled = await self.config.guild(ctx.guild).dj_enabled()
if dj_enabled: if dj_enabled:
dj_role_obj = ctx.guild.get_role(await self.config.guild(ctx.guild).dj_role()) dj_role_obj = ctx.guild.get_role(await self.config.guild(ctx.guild).dj_role())
@ -185,13 +195,13 @@ class Audio(commands.Cog):
await self._embed_msg(ctx, _("No DJ role found. Disabling DJ mode.")) await self._embed_msg(ctx, _("No DJ role found. Disabling DJ mode."))
async def initialize(self): async def initialize(self):
pass_config_to_dependencies(self.config, self.bot, await self.config.localpath()) await self.bot.wait_until_ready()
# Unlike most cases, we want the cache to exit before migration.
await self.music_cache.initialize(self.config) await self.music_cache.initialize(self.config)
asyncio.ensure_future( await self._migrate_config(
self._migrate_config( from_version=await self.config.schema_version(), to_version=_SCHEMA_VERSION
from_version=await self.config.schema_version(), to_version=_SCHEMA_VERSION
)
) )
pass_config_to_dependencies(self.config, self.bot, await self.config.localpath())
self._restart_connect() self._restart_connect()
self._disconnect_task = self.bot.loop.create_task(self.disconnect_timer()) self._disconnect_task = self.bot.loop.create_task(self.disconnect_timer())
lavalink.register_event_listener(self.event_handler) lavalink.register_event_listener(self.event_handler)
@ -209,6 +219,9 @@ class Audio(commands.Cog):
await self.bot.send_to_owners(page) await self.bot.send_to_owners(page)
log.critical(error_message) log.critical(error_message)
self._ready_event.set()
self.bot.dispatch("red_audio_initialized", self)
async def _migrate_config(self, from_version: int, to_version: int): async def _migrate_config(self, from_version: int, to_version: int):
database_entries = [] database_entries = []
time_now = str(datetime.datetime.now(datetime.timezone.utc)) time_now = str(datetime.datetime.now(datetime.timezone.utc))
@ -253,7 +266,7 @@ class Audio(commands.Cog):
cast(discord.Guild, discord.Object(id=guild_id)) cast(discord.Guild, discord.Object(id=guild_id))
).clear_raw("playlists") ).clear_raw("playlists")
if database_entries and HAS_SQL: if database_entries and HAS_SQL:
asyncio.ensure_future(self.music_cache.insert("lavalink", database_entries)) await self.music_cache.insert("lavalink", database_entries)
def _restart_connect(self): def _restart_connect(self):
if self._connect_task: if self._connect_task:
@ -366,7 +379,9 @@ class Audio(commands.Cog):
async def _players_check(): async def _players_check():
try: try:
get_single_title = lavalink.active_players()[0].current.title get_single_title = lavalink.active_players()[0].current.title
query = dataclasses.Query.process_input(lavalink.active_players()[0].current.uri) query = audio_dataclasses.Query.process_input(
lavalink.active_players()[0].current.uri
)
if get_single_title == "Unknown title": if get_single_title == "Unknown title":
get_single_title = lavalink.active_players()[0].current.uri get_single_title = lavalink.active_players()[0].current.uri
if not get_single_title.startswith("http"): if not get_single_title.startswith("http"):
@ -463,18 +478,18 @@ class Audio(commands.Cog):
) )
await notify_channel.send(embed=embed) await notify_channel.send(embed=embed)
query = dataclasses.Query.process_input(player.current.uri) query = audio_dataclasses.Query.process_input(player.current.uri)
if query.is_local if player.current else False: if query.is_local if player.current else False:
if player.current.title != "Unknown title": if player.current.title != "Unknown title":
description = "**{} - {}**\n{}".format( description = "**{} - {}**\n{}".format(
player.current.author, player.current.author,
player.current.title, player.current.title,
dataclasses.LocalPath(player.current.uri).to_string_hidden(), audio_dataclasses.LocalPath(player.current.uri).to_string_hidden(),
) )
else: else:
description = "{}".format( description = "{}".format(
dataclasses.LocalPath(player.current.uri).to_string_hidden() audio_dataclasses.LocalPath(player.current.uri).to_string_hidden()
) )
else: else:
description = "**[{}]({})**".format(player.current.title, player.current.uri) description = "**[{}]({})**".format(player.current.title, player.current.uri)
@ -532,9 +547,9 @@ class Audio(commands.Cog):
message_channel = player.fetch("channel") message_channel = player.fetch("channel")
if message_channel: if message_channel:
message_channel = self.bot.get_channel(message_channel) message_channel = self.bot.get_channel(message_channel)
query = dataclasses.Query.process_input(player.current.uri) query = audio_dataclasses.Query.process_input(player.current.uri)
if player.current and query.is_local: if player.current and query.is_local:
query = dataclasses.Query.process_input(player.current.uri) query = audio_dataclasses.Query.process_input(player.current.uri)
if player.current.title == "Unknown title": if player.current.title == "Unknown title":
description = "{}".format(query.track.to_string_hidden()) description = "{}".format(query.track.to_string_hidden())
else: else:
@ -590,7 +605,7 @@ class Audio(commands.Cog):
player.store("channel", channel.id) player.store("channel", channel.id)
player.store("guild", guild.id) player.store("guild", guild.id)
await self._data_check(guild.me) await self._data_check(guild.me)
query = dataclasses.Query.process_input(query) query = audio_dataclasses.Query.process_input(query)
ctx = namedtuple("Context", "message") ctx = namedtuple("Context", "message")
results, called_api = await self.music_cache.lavalink_query(ctx(guild), player, query) results, called_api = await self.music_cache.lavalink_query(ctx(guild), player, query)
@ -985,7 +1000,7 @@ class Audio(commands.Cog):
@audioset.command() @audioset.command()
@checks.mod_or_permissions(administrator=True) @checks.mod_or_permissions(administrator=True)
async def emptydisconnect(self, ctx: commands.Context, seconds: int): async def emptydisconnect(self, ctx: commands.Context, seconds: int):
"""Auto-disconnection after x seconds while stopped. 0 to disable.""" """Auto-disconnect from channel when bot is alone in it for x seconds. 0 to disable."""
if seconds < 0: if seconds < 0:
return await self._embed_msg(ctx, _("Can't be less than zero.")) return await self._embed_msg(ctx, _("Can't be less than zero."))
if 10 > seconds > 0: if 10 > seconds > 0:
@ -1094,7 +1109,7 @@ class Audio(commands.Cog):
with contextlib.suppress(discord.HTTPException): with contextlib.suppress(discord.HTTPException):
await info.delete() await info.delete()
return return
temp = dataclasses.LocalPath(local_path, forced=True) temp = audio_dataclasses.LocalPath(local_path, forced=True)
if not temp.exists() or not temp.is_dir(): if not temp.exists() or not temp.is_dir():
return await self._embed_msg( return await self._embed_msg(
ctx, ctx,
@ -1536,7 +1551,7 @@ class Audio(commands.Cog):
int((datetime.datetime.utcnow() - connect_start).total_seconds()) int((datetime.datetime.utcnow() - connect_start).total_seconds())
) )
try: try:
query = dataclasses.Query.process_input(p.current.uri) query = audio_dataclasses.Query.process_input(p.current.uri)
if query.is_local: if query.is_local:
if p.current.title == "Unknown title": if p.current.title == "Unknown title":
current_title = localtracks.LocalPath(p.current.uri).to_string_hidden() current_title = localtracks.LocalPath(p.current.uri).to_string_hidden()
@ -1606,9 +1621,9 @@ class Audio(commands.Cog):
bump_song = player.queue[bump_index] bump_song = player.queue[bump_index]
player.queue.insert(0, bump_song) player.queue.insert(0, bump_song)
removed = player.queue.pop(index) removed = player.queue.pop(index)
query = dataclasses.Query.process_input(removed.uri) query = audio_dataclasses.Query.process_input(removed.uri)
if query.is_local: if query.is_local:
localtrack = dataclasses.LocalPath(removed.uri) localtrack = audio_dataclasses.LocalPath(removed.uri)
if removed.title != "Unknown title": if removed.title != "Unknown title":
description = "**{} - {}**\n{}".format( description = "**{} - {}**\n{}".format(
removed.author, removed.title, localtrack.to_string_hidden() removed.author, removed.title, localtrack.to_string_hidden()
@ -1997,12 +2012,12 @@ class Audio(commands.Cog):
await ctx.invoke(self.local_play, play_subfolders=play_subfolders) await ctx.invoke(self.local_play, play_subfolders=play_subfolders)
else: else:
folder = folder.strip() folder = folder.strip()
_dir = dataclasses.LocalPath.joinpath(folder) _dir = audio_dataclasses.LocalPath.joinpath(folder)
if not _dir.exists(): if not _dir.exists():
return await self._embed_msg( return await self._embed_msg(
ctx, _("No localtracks folder named {name}.").format(name=folder) ctx, _("No localtracks folder named {name}.").format(name=folder)
) )
query = dataclasses.Query.process_input(_dir, search_subfolders=play_subfolders) query = audio_dataclasses.Query.process_input(_dir, search_subfolders=play_subfolders)
await self._local_play_all(ctx, query, from_search=False if not folder else True) await self._local_play_all(ctx, query, from_search=False if not folder else True)
@local.command(name="play") @local.command(name="play")
@ -2064,8 +2079,8 @@ class Audio(commands.Cog):
all_tracks = await self._folder_list( all_tracks = await self._folder_list(
ctx, ctx,
( (
dataclasses.Query.process_input( audio_dataclasses.Query.process_input(
dataclasses.LocalPath( audio_dataclasses.LocalPath(
await self.config.localpath() await self.config.localpath()
).localtrack_folder.absolute(), ).localtrack_folder.absolute(),
search_subfolders=play_subfolders, search_subfolders=play_subfolders,
@ -2081,18 +2096,18 @@ class Audio(commands.Cog):
return await ctx.invoke(self.search, query=search_list) return await ctx.invoke(self.search, query=search_list)
async def _localtracks_folders(self, ctx: commands.Context, search_subfolders=False): async def _localtracks_folders(self, ctx: commands.Context, search_subfolders=False):
audio_data = dataclasses.LocalPath( audio_data = audio_dataclasses.LocalPath(
dataclasses.LocalPath(None).localtrack_folder.absolute() audio_dataclasses.LocalPath(None).localtrack_folder.absolute()
) )
if not await self._localtracks_check(ctx): if not await self._localtracks_check(ctx):
return return
return audio_data.subfolders_in_tree() if search_subfolders else audio_data.subfolders() return audio_data.subfolders_in_tree() if search_subfolders else audio_data.subfolders()
async def _folder_list(self, ctx: commands.Context, query: dataclasses.Query): async def _folder_list(self, ctx: commands.Context, query: audio_dataclasses.Query):
if not await self._localtracks_check(ctx): if not await self._localtracks_check(ctx):
return return
query = dataclasses.Query.process_input(query) query = audio_dataclasses.Query.process_input(query)
if not query.track.exists(): if not query.track.exists():
return return
return ( return (
@ -2102,12 +2117,12 @@ class Audio(commands.Cog):
) )
async def _folder_tracks( async def _folder_tracks(
self, ctx, player: lavalink.player_manager.Player, query: dataclasses.Query self, ctx, player: lavalink.player_manager.Player, query: audio_dataclasses.Query
): ):
if not await self._localtracks_check(ctx): if not await self._localtracks_check(ctx):
return return
audio_data = dataclasses.LocalPath(None) audio_data = audio_dataclasses.LocalPath(None)
try: try:
query.track.path.relative_to(audio_data.to_string()) query.track.path.relative_to(audio_data.to_string())
except ValueError: except ValueError:
@ -2120,17 +2135,17 @@ class Audio(commands.Cog):
return local_tracks return local_tracks
async def _local_play_all( async def _local_play_all(
self, ctx: commands.Context, query: dataclasses.Query, from_search=False self, ctx: commands.Context, query: audio_dataclasses.Query, from_search=False
): ):
if not await self._localtracks_check(ctx): if not await self._localtracks_check(ctx):
return return
if from_search: if from_search:
query = dataclasses.Query.process_input( query = audio_dataclasses.Query.process_input(
query.track.to_string(), invoked_from="local folder" query.track.to_string(), invoked_from="local folder"
) )
await ctx.invoke(self.search, query=query) await ctx.invoke(self.search, query=query)
async def _all_folder_tracks(self, ctx: commands.Context, query: dataclasses.Query): async def _all_folder_tracks(self, ctx: commands.Context, query: audio_dataclasses.Query):
if not await self._localtracks_check(ctx): if not await self._localtracks_check(ctx):
return return
@ -2141,7 +2156,7 @@ class Audio(commands.Cog):
) )
async def _localtracks_check(self, ctx: commands.Context): async def _localtracks_check(self, ctx: commands.Context):
folder = dataclasses.LocalPath(None) folder = audio_dataclasses.LocalPath(None)
if folder.localtrack_folder.exists(): if folder.localtrack_folder.exists():
return True return True
if ctx.invoked_with != "start": if ctx.invoked_with != "start":
@ -2177,7 +2192,7 @@ class Audio(commands.Cog):
dur = "LIVE" dur = "LIVE"
else: else:
dur = lavalink.utils.format_time(player.current.length) dur = lavalink.utils.format_time(player.current.length)
query = dataclasses.Query.process_input(player.current.uri) query = audio_dataclasses.Query.process_input(player.current.uri)
if query.is_local: if query.is_local:
if not player.current.title == "Unknown title": if not player.current.title == "Unknown title":
song = "**{track.author} - {track.title}**\n{uri}\n" song = "**{track.author} - {track.title}**\n{uri}\n"
@ -2189,8 +2204,8 @@ class Audio(commands.Cog):
song += "\n\n{arrow}`{pos}`/`{dur}`" song += "\n\n{arrow}`{pos}`/`{dur}`"
song = song.format( song = song.format(
track=player.current, track=player.current,
uri=dataclasses.LocalPath(player.current.uri).to_string_hidden() uri=audio_dataclasses.LocalPath(player.current.uri).to_string_hidden()
if dataclasses.Query.process_input(player.current.uri).is_local if audio_dataclasses.Query.process_input(player.current.uri).is_local
else player.current.uri, else player.current.uri,
arrow=arrow, arrow=arrow,
pos=pos, pos=pos,
@ -2301,9 +2316,9 @@ class Audio(commands.Cog):
if not player.current: if not player.current:
return await self._embed_msg(ctx, _("Nothing playing.")) return await self._embed_msg(ctx, _("Nothing playing."))
query = dataclasses.Query.process_input(player.current.uri) query = audio_dataclasses.Query.process_input(player.current.uri)
if query.is_local: if query.is_local:
query = dataclasses.Query.process_input(player.current.uri) query = audio_dataclasses.Query.process_input(player.current.uri)
if player.current.title == "Unknown title": if player.current.title == "Unknown title":
description = "{}".format(query.track.to_string_hidden()) description = "{}".format(query.track.to_string_hidden())
else: else:
@ -2436,7 +2451,7 @@ class Audio(commands.Cog):
) )
if not await self._currency_check(ctx, guild_data["jukebox_price"]): if not await self._currency_check(ctx, guild_data["jukebox_price"]):
return return
query = dataclasses.Query.process_input(query) query = audio_dataclasses.Query.process_input(query)
if not query.valid: if not query.valid:
return await self._embed_msg(ctx, _("No tracks to play.")) return await self._embed_msg(ctx, _("No tracks to play."))
if query.is_spotify: if query.is_spotify:
@ -2593,7 +2608,7 @@ class Audio(commands.Cog):
) )
playlists_search_page_list.append(embed) playlists_search_page_list.append(embed)
playlists_pick = await menu(ctx, playlists_search_page_list, playlist_search_controls) playlists_pick = await menu(ctx, playlists_search_page_list, playlist_search_controls)
query = dataclasses.Query.process_input(playlists_pick) query = audio_dataclasses.Query.process_input(playlists_pick)
if not query.valid: if not query.valid:
return await self._embed_msg(ctx, _("No tracks to play.")) return await self._embed_msg(ctx, _("No tracks to play."))
if not await self._currency_check(ctx, guild_data["jukebox_price"]): if not await self._currency_check(ctx, guild_data["jukebox_price"]):
@ -2728,7 +2743,7 @@ class Audio(commands.Cog):
elif player.current: elif player.current:
await self._embed_msg(ctx, _("Adding a track to queue.")) await self._embed_msg(ctx, _("Adding a track to queue."))
async def _get_spotify_tracks(self, ctx: commands.Context, query: dataclasses.Query): async def _get_spotify_tracks(self, ctx: commands.Context, query: audio_dataclasses.Query):
if ctx.invoked_with in ["play", "genre"]: if ctx.invoked_with in ["play", "genre"]:
enqueue_tracks = True enqueue_tracks = True
else: else:
@ -2771,12 +2786,12 @@ class Audio(commands.Cog):
self._play_lock(ctx, False) self._play_lock(ctx, False)
try: try:
if enqueue_tracks: if enqueue_tracks:
new_query = dataclasses.Query.process_input(res[0]) new_query = audio_dataclasses.Query.process_input(res[0])
new_query.start_time = query.start_time new_query.start_time = query.start_time
return await self._enqueue_tracks(ctx, new_query) return await self._enqueue_tracks(ctx, new_query)
else: else:
result, called_api = await self.music_cache.lavalink_query( result, called_api = await self.music_cache.lavalink_query(
ctx, player, dataclasses.Query.process_input(res[0]) ctx, player, audio_dataclasses.Query.process_input(res[0])
) )
tracks = result.tracks tracks = result.tracks
if not tracks: if not tracks:
@ -2808,7 +2823,9 @@ class Audio(commands.Cog):
ctx, _("This doesn't seem to be a supported Spotify URL or code.") ctx, _("This doesn't seem to be a supported Spotify URL or code.")
) )
async def _enqueue_tracks(self, ctx: commands.Context, query: Union[dataclasses.Query, list]): async def _enqueue_tracks(
self, ctx: commands.Context, query: Union[audio_dataclasses.Query, list]
):
player = lavalink.get_player(ctx.guild.id) player = lavalink.get_player(ctx.guild.id)
try: try:
if self.play_lock[ctx.message.guild.id]: if self.play_lock[ctx.message.guild.id]:
@ -2835,6 +2852,8 @@ class Audio(commands.Cog):
if not tracks: if not tracks:
self._play_lock(ctx, False) self._play_lock(ctx, False)
embed = discord.Embed(title=_("Nothing found."), colour=await ctx.embed_colour()) embed = discord.Embed(title=_("Nothing found."), colour=await ctx.embed_colour())
if result.exception_message:
embed.set_footer(text=result.exception_message)
if await self.config.use_external_lavalink() and query.is_local: if await self.config.use_external_lavalink() and query.is_local:
embed.description = _( embed.description = _(
"Local tracks will not work " "Local tracks will not work "
@ -2861,7 +2880,7 @@ class Audio(commands.Cog):
ctx.guild, ctx.guild,
( (
f"{track.title} {track.author} {track.uri} " f"{track.title} {track.author} {track.uri} "
f"{str(dataclasses.Query.process_input(track))}" f"{str(audio_dataclasses.Query.process_input(track))}"
), ),
): ):
log.debug(f"Query is not allowed in {ctx.guild} ({ctx.guild.id})") log.debug(f"Query is not allowed in {ctx.guild} ({ctx.guild.id})")
@ -2921,7 +2940,7 @@ class Audio(commands.Cog):
ctx.guild, ctx.guild,
( (
f"{single_track.title} {single_track.author} {single_track.uri} " f"{single_track.title} {single_track.author} {single_track.uri} "
f"{str(dataclasses.Query.process_input(single_track))}" f"{str(audio_dataclasses.Query.process_input(single_track))}"
), ),
): ):
log.debug(f"Query is not allowed in {ctx.guild} ({ctx.guild.id})") log.debug(f"Query is not allowed in {ctx.guild} ({ctx.guild.id})")
@ -2954,17 +2973,17 @@ class Audio(commands.Cog):
return await self._embed_msg( return await self._embed_msg(
ctx, _("Nothing found. Check your Lavalink logs for details.") ctx, _("Nothing found. Check your Lavalink logs for details.")
) )
query = dataclasses.Query.process_input(single_track.uri) query = audio_dataclasses.Query.process_input(single_track.uri)
if query.is_local: if query.is_local:
if single_track.title != "Unknown title": if single_track.title != "Unknown title":
description = "**{} - {}**\n{}".format( description = "**{} - {}**\n{}".format(
single_track.author, single_track.author,
single_track.title, single_track.title,
dataclasses.LocalPath(single_track.uri).to_string_hidden(), audio_dataclasses.LocalPath(single_track.uri).to_string_hidden(),
) )
else: else:
description = "{}".format( description = "{}".format(
dataclasses.LocalPath(single_track.uri).to_string_hidden() audio_dataclasses.LocalPath(single_track.uri).to_string_hidden()
) )
else: else:
description = "**[{}]({})**".format(single_track.title, single_track.uri) description = "**[{}]({})**".format(single_track.title, single_track.uri)
@ -2985,7 +3004,11 @@ class Audio(commands.Cog):
self._play_lock(ctx, False) self._play_lock(ctx, False)
async def _spotify_playlist( async def _spotify_playlist(
self, ctx: commands.Context, stype: str, query: dataclasses.Query, enqueue: bool = False self,
ctx: commands.Context,
stype: str,
query: audio_dataclasses.Query,
enqueue: bool = False,
): ):
player = lavalink.get_player(ctx.guild.id) player = lavalink.get_player(ctx.guild.id)
@ -3253,8 +3276,8 @@ class Audio(commands.Cog):
Only editable by bot owner. Only editable by bot owner.
**Guild**: **Guild**:
Visible to all users in this guild. Visible to all users in this guild.
Editable By Bot Owner, Guild Owner, Guild Admins, Editable by bot owner, guild owner, guild admins,
Guild Mods, DJ Role and playlist creator. guild mods, DJ role and playlist creator.
**User**: **User**:
Visible to all bot users, if --author is passed. Visible to all bot users, if --author is passed.
Editable by bot owner and creator. Editable by bot owner and creator.
@ -3338,7 +3361,7 @@ class Audio(commands.Cog):
return return
player = lavalink.get_player(ctx.guild.id) player = lavalink.get_player(ctx.guild.id)
to_append = await self._playlist_tracks( to_append = await self._playlist_tracks(
ctx, player, dataclasses.Query.process_input(query) ctx, player, audio_dataclasses.Query.process_input(query)
) )
if not to_append: if not to_append:
return await self._embed_msg(ctx, _("Could not find a track matching your query.")) return await self._embed_msg(ctx, _("Could not find a track matching your query."))
@ -3714,89 +3737,92 @@ class Audio(commands.Cog):
[p]playlist dedupe MyGlobalPlaylist --scope Global [p]playlist dedupe MyGlobalPlaylist --scope Global
[p]playlist dedupe MyPersonalPlaylist --scope User [p]playlist dedupe MyPersonalPlaylist --scope User
""" """
if scope_data is None: async with ctx.typing():
scope_data = [PlaylistScope.GUILD.value, ctx.author, ctx.guild, False] if scope_data is None:
scope, author, guild, specified_user = scope_data scope_data = [PlaylistScope.GUILD.value, ctx.author, ctx.guild, False]
scope_name = humanize_scope( scope, author, guild, specified_user = scope_data
scope, ctx=guild if scope == PlaylistScope.GUILD.value else author scope_name = humanize_scope(
) scope, ctx=guild if scope == PlaylistScope.GUILD.value else author
try:
playlist_id, playlist_arg = await self._get_correct_playlist_id(
ctx, playlist_matches, scope, author, guild, specified_user
)
except TooManyMatches as e:
return await self._embed_msg(ctx, str(e))
if playlist_id is None:
return await self._embed_msg(
ctx, _("Could not match '{arg}' to a playlist.").format(arg=playlist_arg)
) )
try: try:
playlist = await get_playlist(playlist_id, scope, self.bot, guild, author) playlist_id, playlist_arg = await self._get_correct_playlist_id(
except RuntimeError: ctx, playlist_matches, scope, author, guild, specified_user
return await self._embed_msg( )
ctx, except TooManyMatches as e:
_("Playlist {id} does not exist in {scope} scope.").format( return await self._embed_msg(ctx, str(e))
id=playlist_id, scope=humanize_scope(scope, the=True) if playlist_id is None:
), return await self._embed_msg(
) ctx, _("Could not match '{arg}' to a playlist.").format(arg=playlist_arg)
except MissingGuild: )
return await self._embed_msg(
ctx, _("You need to specify the Guild ID for the guild to lookup.")
)
if not await self.can_manage_playlist(scope, playlist, ctx, author, guild): try:
return playlist = await get_playlist(playlist_id, scope, self.bot, guild, author)
except RuntimeError:
return await self._embed_msg(
ctx,
_("Playlist {id} does not exist in {scope} scope.").format(
id=playlist_id, scope=humanize_scope(scope, the=True)
),
)
except MissingGuild:
return await self._embed_msg(
ctx, _("You need to specify the Guild ID for the guild to lookup.")
)
track_objects = playlist.tracks_obj if not await self.can_manage_playlist(scope, playlist, ctx, author, guild):
original_count = len(track_objects) return
unique_tracks = set()
unique_tracks_add = unique_tracks.add
track_objects = [
x for x in track_objects if not (x in unique_tracks or unique_tracks_add(x))
]
tracklist = [] track_objects = playlist.tracks_obj
for track in track_objects: original_count = len(track_objects)
track_keys = track._info.keys() unique_tracks = set()
track_values = track._info.values() unique_tracks_add = unique_tracks.add
track_id = track.track_identifier track_objects = [
track_info = {} x for x in track_objects if not (x in unique_tracks or unique_tracks_add(x))
for k, v in zip(track_keys, track_values): ]
track_info[k] = v
keys = ["track", "info"]
values = [track_id, track_info]
track_obj = {}
for key, value in zip(keys, values):
track_obj[key] = value
tracklist.append(track_obj)
final_count = len(tracklist) tracklist = []
if original_count - final_count != 0: for track in track_objects:
update = {"tracks": tracklist, "url": None} track_keys = track._info.keys()
await playlist.edit(update) track_values = track._info.values()
track_id = track.track_identifier
track_info = {}
for k, v in zip(track_keys, track_values):
track_info[k] = v
keys = ["track", "info"]
values = [track_id, track_info]
track_obj = {}
for key, value in zip(keys, values):
track_obj[key] = value
tracklist.append(track_obj)
if original_count - final_count != 0: final_count = len(tracklist)
await self._embed_msg( if original_count - final_count != 0:
ctx, update = {"tracks": tracklist, "url": None}
_( await playlist.edit(update)
"Removed {track_diff} duplicated "
"tracks from {name} (`{id}`) [**{scope}**] playlist." if original_count - final_count != 0:
).format( await self._embed_msg(
name=playlist.name, ctx,
id=playlist.id, _(
track_diff=original_count - final_count, "Removed {track_diff} duplicated "
scope=scope_name, "tracks from {name} (`{id}`) [**{scope}**] playlist."
), ).format(
) name=playlist.name,
else: id=playlist.id,
await self._embed_msg( track_diff=original_count - final_count,
ctx, scope=scope_name,
_("{name} (`{id}`) [**{scope}**] playlist has no duplicate tracks.").format( ),
name=playlist.name, id=playlist.id, scope=scope_name )
), return
) else:
await self._embed_msg(
ctx,
_("{name} (`{id}`) [**{scope}**] playlist has no duplicate tracks.").format(
name=playlist.name, id=playlist.id, scope=scope_name
),
)
return
@checks.is_owner() @checks.is_owner()
@playlist.command(name="download", usage="<playlist_name_OR_id> [v2=False] [args]") @playlist.command(name="download", usage="<playlist_name_OR_id> [v2=False] [args]")
@ -3991,7 +4017,7 @@ class Audio(commands.Cog):
spaces = "\N{EN SPACE}" * (len(str(len(playlist.tracks))) + 2) spaces = "\N{EN SPACE}" * (len(str(len(playlist.tracks))) + 2)
for track in playlist.tracks: for track in playlist.tracks:
track_idx = track_idx + 1 track_idx = track_idx + 1
query = dataclasses.Query.process_input(track["info"]["uri"]) query = audio_dataclasses.Query.process_input(track["info"]["uri"])
if query.is_local: if query.is_local:
if track["info"]["title"] != "Unknown title": if track["info"]["title"] != "Unknown title":
msg += "`{}.` **{} - {}**\n{}{}\n".format( msg += "`{}.` **{} - {}**\n{}{}\n".format(
@ -4396,7 +4422,7 @@ class Audio(commands.Cog):
return return
player = lavalink.get_player(ctx.guild.id) player = lavalink.get_player(ctx.guild.id)
tracklist = await self._playlist_tracks( tracklist = await self._playlist_tracks(
ctx, player, dataclasses.Query.process_input(playlist_url) ctx, player, audio_dataclasses.Query.process_input(playlist_url)
) )
if tracklist is not None: if tracklist is not None:
playlist = await create_playlist( playlist = await create_playlist(
@ -4486,14 +4512,14 @@ class Audio(commands.Cog):
ctx.guild, ctx.guild,
( (
f"{track.title} {track.author} {track.uri} " f"{track.title} {track.author} {track.uri} "
f"{str(dataclasses.Query.process_input(track))}" f"{str(audio_dataclasses.Query.process_input(track))}"
), ),
): ):
log.debug(f"Query is not allowed in {ctx.guild} ({ctx.guild.id})") log.debug(f"Query is not allowed in {ctx.guild} ({ctx.guild.id})")
continue continue
query = dataclasses.Query.process_input(track.uri) query = audio_dataclasses.Query.process_input(track.uri)
if query.is_local: if query.is_local:
local_path = dataclasses.LocalPath(track.uri) local_path = audio_dataclasses.LocalPath(track.uri)
if not await self._localtracks_check(ctx): if not await self._localtracks_check(ctx):
pass pass
if not local_path.exists() and not local_path.is_file(): if not local_path.exists() and not local_path.is_file():
@ -4779,7 +4805,7 @@ class Audio(commands.Cog):
or not match_yt_playlist(uploaded_playlist_url) or not match_yt_playlist(uploaded_playlist_url)
or not ( or not (
await self.music_cache.lavalink_query( await self.music_cache.lavalink_query(
ctx, player, dataclasses.Query.process_input(uploaded_playlist_url) ctx, player, audio_dataclasses.Query.process_input(uploaded_playlist_url)
) )
)[0].tracks )[0].tracks
): ):
@ -4964,7 +4990,7 @@ class Audio(commands.Cog):
} }
) )
if database_entries and HAS_SQL: if database_entries and HAS_SQL:
asyncio.ensure_future(self.music_cache.insert("lavalink", database_entries)) await self.music_cache.insert("lavalink", database_entries)
async def _load_v2_playlist( async def _load_v2_playlist(
self, self,
@ -4991,7 +5017,7 @@ class Audio(commands.Cog):
track_count += 1 track_count += 1
try: try:
result, called_api = await self.music_cache.lavalink_query( result, called_api = await self.music_cache.lavalink_query(
ctx, player, dataclasses.Query.process_input(song_url) ctx, player, audio_dataclasses.Query.process_input(song_url)
) )
track = result.tracks track = result.tracks
except Exception: except Exception:
@ -5039,7 +5065,7 @@ class Audio(commands.Cog):
return [], [], playlist return [], [], playlist
results = {} results = {}
updated_tracks = await self._playlist_tracks( updated_tracks = await self._playlist_tracks(
ctx, player, dataclasses.Query.process_input(playlist.url) ctx, player, audio_dataclasses.Query.process_input(playlist.url)
) )
if not updated_tracks: if not updated_tracks:
# No Tracks available on url Lets set it to none to avoid repeated calls here # No Tracks available on url Lets set it to none to avoid repeated calls here
@ -5104,7 +5130,7 @@ class Audio(commands.Cog):
self, self,
ctx: commands.Context, ctx: commands.Context,
player: lavalink.player_manager.Player, player: lavalink.player_manager.Player,
query: dataclasses.Query, query: audio_dataclasses.Query,
): ):
search = query.is_search search = query.is_search
tracklist = [] tracklist = []
@ -5173,7 +5199,7 @@ class Audio(commands.Cog):
player.queue.insert(0, bump_song) player.queue.insert(0, bump_song)
player.queue.pop(queue_len) player.queue.pop(queue_len)
await player.skip() await player.skip()
query = dataclasses.Query.process_input(player.current.uri) query = audio_dataclasses.Query.process_input(player.current.uri)
if query.is_local: if query.is_local:
if player.current.title == "Unknown title": if player.current.title == "Unknown title":
@ -5225,7 +5251,7 @@ class Audio(commands.Cog):
else: else:
dur = lavalink.utils.format_time(player.current.length) dur = lavalink.utils.format_time(player.current.length)
query = dataclasses.Query.process_input(player.current) query = audio_dataclasses.Query.process_input(player.current)
if query.is_local: if query.is_local:
if player.current.title != "Unknown title": if player.current.title != "Unknown title":
@ -5238,8 +5264,8 @@ class Audio(commands.Cog):
song += "\n\n{arrow}`{pos}`/`{dur}`" song += "\n\n{arrow}`{pos}`/`{dur}`"
song = song.format( song = song.format(
track=player.current, track=player.current,
uri=dataclasses.LocalPath(player.current.uri).to_string_hidden() uri=audio_dataclasses.LocalPath(player.current.uri).to_string_hidden()
if dataclasses.Query.process_input(player.current.uri).is_local if audio_dataclasses.Query.process_input(player.current.uri).is_local
else player.current.uri, else player.current.uri,
arrow=arrow, arrow=arrow,
pos=pos, pos=pos,
@ -5311,7 +5337,7 @@ class Audio(commands.Cog):
else: else:
dur = lavalink.utils.format_time(player.current.length) dur = lavalink.utils.format_time(player.current.length)
query = dataclasses.Query.process_input(player.current) query = audio_dataclasses.Query.process_input(player.current)
if query.is_stream: if query.is_stream:
queue_list += _("**Currently livestreaming:**\n") queue_list += _("**Currently livestreaming:**\n")
@ -5325,7 +5351,7 @@ class Audio(commands.Cog):
( (
_("Playing: ") _("Playing: ")
+ "**{current.author} - {current.title}**".format(current=player.current), + "**{current.author} - {current.title}**".format(current=player.current),
dataclasses.LocalPath(player.current.uri).to_string_hidden(), audio_dataclasses.LocalPath(player.current.uri).to_string_hidden(),
_("Requested by: **{user}**\n").format(user=player.current.requester), _("Requested by: **{user}**\n").format(user=player.current.requester),
f"{arrow}`{pos}`/`{dur}`\n\n", f"{arrow}`{pos}`/`{dur}`\n\n",
) )
@ -5334,7 +5360,7 @@ class Audio(commands.Cog):
queue_list += "\n".join( queue_list += "\n".join(
( (
_("Playing: ") _("Playing: ")
+ dataclasses.LocalPath(player.current.uri).to_string_hidden(), + audio_dataclasses.LocalPath(player.current.uri).to_string_hidden(),
_("Requested by: **{user}**\n").format(user=player.current.requester), _("Requested by: **{user}**\n").format(user=player.current.requester),
f"{arrow}`{pos}`/`{dur}`\n\n", f"{arrow}`{pos}`/`{dur}`\n\n",
) )
@ -5355,13 +5381,13 @@ class Audio(commands.Cog):
track_title = track.title track_title = track.title
req_user = track.requester req_user = track.requester
track_idx = i + 1 track_idx = i + 1
query = dataclasses.Query.process_input(track) query = audio_dataclasses.Query.process_input(track)
if query.is_local: if query.is_local:
if track.title == "Unknown title": if track.title == "Unknown title":
queue_list += f"`{track_idx}.` " + ", ".join( queue_list += f"`{track_idx}.` " + ", ".join(
( (
bold(dataclasses.LocalPath(track.uri).to_string_hidden()), bold(audio_dataclasses.LocalPath(track.uri).to_string_hidden()),
_("requested by **{user}**\n").format(user=req_user), _("requested by **{user}**\n").format(user=req_user),
) )
) )
@ -5418,7 +5444,7 @@ class Audio(commands.Cog):
for track in queue_list: for track in queue_list:
queue_idx = queue_idx + 1 queue_idx = queue_idx + 1
if not match_url(track.uri): if not match_url(track.uri):
query = dataclasses.Query.process_input(track) query = audio_dataclasses.Query.process_input(track)
if track.title == "Unknown title": if track.title == "Unknown title":
track_title = query.track.to_string_hidden() track_title = query.track.to_string_hidden()
else: else:
@ -5447,7 +5473,7 @@ class Audio(commands.Cog):
): ):
track_idx = i + 1 track_idx = i + 1
if type(track) is str: if type(track) is str:
track_location = dataclasses.LocalPath(track).to_string_hidden() track_location = audio_dataclasses.LocalPath(track).to_string_hidden()
track_match += "`{}.` **{}**\n".format(track_idx, track_location) track_match += "`{}.` **{}**\n".format(track_idx, track_location)
else: else:
track_match += "`{}.` **{}**\n".format(track[0], track[1]) track_match += "`{}.` **{}**\n".format(track[0], track[1])
@ -5672,9 +5698,9 @@ class Audio(commands.Cog):
) )
index -= 1 index -= 1
removed = player.queue.pop(index) removed = player.queue.pop(index)
query = dataclasses.Query.process_input(removed.uri) query = audio_dataclasses.Query.process_input(removed.uri)
if query.is_local: if query.is_local:
local_path = dataclasses.LocalPath(removed.uri).to_string_hidden() local_path = audio_dataclasses.LocalPath(removed.uri).to_string_hidden()
if removed.title == "Unknown title": if removed.title == "Unknown title":
removed_title = local_path removed_title = local_path
else: else:
@ -5760,7 +5786,7 @@ class Audio(commands.Cog):
await self._data_check(ctx) await self._data_check(ctx)
if not isinstance(query, list): if not isinstance(query, list):
query = dataclasses.Query.process_input(query) query = audio_dataclasses.Query.process_input(query)
if query.invoked_from == "search list" or query.invoked_from == "local folder": if query.invoked_from == "search list" or query.invoked_from == "local folder":
if query.invoked_from == "search list": if query.invoked_from == "search list":
result, called_api = await self.music_cache.lavalink_query(ctx, player, query) result, called_api = await self.music_cache.lavalink_query(ctx, player, query)
@ -5789,7 +5815,7 @@ class Audio(commands.Cog):
ctx.guild, ctx.guild,
( (
f"{track.title} {track.author} {track.uri} " f"{track.title} {track.author} {track.uri} "
f"{str(dataclasses.Query.process_input(track))}" f"{str(audio_dataclasses.Query.process_input(track))}"
), ),
): ):
log.debug(f"Query is not allowed in {ctx.guild} ({ctx.guild.id})") log.debug(f"Query is not allowed in {ctx.guild} ({ctx.guild.id})")
@ -5903,10 +5929,10 @@ class Audio(commands.Cog):
except IndexError: except IndexError:
search_choice = tracks[-1] search_choice = tracks[-1]
try: try:
query = dataclasses.Query.process_input(search_choice.uri) query = audio_dataclasses.Query.process_input(search_choice.uri)
if query.is_local: if query.is_local:
localtrack = dataclasses.LocalPath(search_choice.uri) localtrack = audio_dataclasses.LocalPath(search_choice.uri)
if search_choice.title != "Unknown title": if search_choice.title != "Unknown title":
description = "**{} - {}**\n{}".format( description = "**{} - {}**\n{}".format(
search_choice.author, search_choice.title, localtrack.to_string_hidden() search_choice.author, search_choice.title, localtrack.to_string_hidden()
@ -5917,7 +5943,7 @@ class Audio(commands.Cog):
description = "**[{}]({})**".format(search_choice.title, search_choice.uri) description = "**[{}]({})**".format(search_choice.title, search_choice.uri)
except AttributeError: except AttributeError:
search_choice = dataclasses.Query.process_input(search_choice) search_choice = audio_dataclasses.Query.process_input(search_choice)
if search_choice.track.exists() and search_choice.track.is_dir(): if search_choice.track.exists() and search_choice.track.is_dir():
return await ctx.invoke(self.search, query=search_choice) return await ctx.invoke(self.search, query=search_choice)
elif search_choice.track.exists() and search_choice.track.is_file(): elif search_choice.track.exists() and search_choice.track.is_file():
@ -5933,7 +5959,7 @@ class Audio(commands.Cog):
ctx.guild, ctx.guild,
( (
f"{search_choice.title} {search_choice.author} {search_choice.uri} " f"{search_choice.title} {search_choice.author} {search_choice.uri} "
f"{str(dataclasses.Query.process_input(search_choice))}" f"{str(audio_dataclasses.Query.process_input(search_choice))}"
), ),
): ):
log.debug(f"Query is not allowed in {ctx.guild} ({ctx.guild.id})") log.debug(f"Query is not allowed in {ctx.guild} ({ctx.guild.id})")
@ -5982,12 +6008,12 @@ class Audio(commands.Cog):
if search_track_num == 0: if search_track_num == 0:
search_track_num = 5 search_track_num = 5
try: try:
query = dataclasses.Query.process_input(track.uri) query = audio_dataclasses.Query.process_input(track.uri)
if query.is_local: if query.is_local:
search_list += "`{0}.` **{1}**\n[{2}]\n".format( search_list += "`{0}.` **{1}**\n[{2}]\n".format(
search_track_num, search_track_num,
track.title, track.title,
dataclasses.LocalPath(track.uri).to_string_hidden(), audio_dataclasses.LocalPath(track.uri).to_string_hidden(),
) )
else: else:
search_list += "`{0}.` **[{1}]({2})**\n".format( search_list += "`{0}.` **[{1}]({2})**\n".format(
@ -5995,7 +6021,7 @@ class Audio(commands.Cog):
) )
except AttributeError: except AttributeError:
# query = Query.process_input(track) # query = Query.process_input(track)
track = dataclasses.Query.process_input(track) track = audio_dataclasses.Query.process_input(track)
if track.is_local and command != "search": if track.is_local and command != "search":
search_list += "`{}.` **{}**\n".format( search_list += "`{}.` **{}**\n".format(
search_track_num, track.to_string_user() search_track_num, track.to_string_user()
@ -6717,7 +6743,9 @@ class Audio(commands.Cog):
if (time.time() - stop_times[sid]) >= emptydc_timer: if (time.time() - stop_times[sid]) >= emptydc_timer:
stop_times.pop(sid) stop_times.pop(sid)
try: try:
await lavalink.get_player(sid).disconnect() player = lavalink.get_player(sid)
await player.stop()
await player.disconnect()
except Exception: except Exception:
log.error("Exception raised in Audio's emptydc_timer.", exc_info=True) log.error("Exception raised in Audio's emptydc_timer.", exc_info=True)
pass pass
@ -6888,6 +6916,7 @@ class Audio(commands.Cog):
async def on_voice_state_update( async def on_voice_state_update(
self, member: discord.Member, before: discord.VoiceState, after: discord.VoiceState self, member: discord.Member, before: discord.VoiceState, after: discord.VoiceState
): ):
await self._ready_event.wait()
if after.channel != before.channel: if after.channel != before.channel:
try: try:
self.skip_votes[before.channel.guild].remove(member.id) self.skip_votes[before.channel.guild].remove(member.id)
@ -6905,6 +6934,9 @@ class Audio(commands.Cog):
if self._connect_task: if self._connect_task:
self._connect_task.cancel() self._connect_task.cancel()
if self._init_task:
self._init_task.cancel()
lavalink.unregister_event_listener(self.event_handler) lavalink.unregister_event_listener(self.event_handler)
self.bot.loop.create_task(lavalink.close()) self.bot.loop.create_task(lavalink.close())
if self._manager is not None: if self._manager is not None:

View File

@ -381,14 +381,17 @@ class Query:
match = re.search(_re_youtube_index, track) match = re.search(_re_youtube_index, track)
if match: if match:
returning["track_index"] = int(match.group(1)) - 1 returning["track_index"] = int(match.group(1)) - 1
if all(k in track for k in ["&list=", "watch?"]): if all(k in track for k in ["&list=", "watch?"]):
returning["track_index"] = 0 returning["track_index"] = 0
returning["playlist"] = True returning["playlist"] = True
returning["single"] = False returning["single"] = False
elif all(x in track for x in ["playlist?"]): elif all(x in track for x in ["playlist?"]):
returning["playlist"] = True if not _has_index else False returning["playlist"] = not _has_index
returning["single"] = True if _has_index else False returning["single"] = _has_index
elif any(k in track for k in ["list="]):
returning["track_index"] = 0
returning["playlist"] = True
returning["single"] = False
else: else:
returning["single"] = True returning["single"] = True
elif url_domain == "spotify.com": elif url_domain == "spotify.com":

View File

@ -18,7 +18,7 @@ from redbot.core import data_manager
from .errors import LavalinkDownloadFailed from .errors import LavalinkDownloadFailed
JAR_VERSION = "3.2.1" JAR_VERSION = "3.2.1"
JAR_BUILD = 823 JAR_BUILD = 846
LAVALINK_DOWNLOAD_URL = ( LAVALINK_DOWNLOAD_URL = (
f"https://github.com/Cog-Creators/Lavalink-Jars/releases/download/{JAR_VERSION}_{JAR_BUILD}/" f"https://github.com/Cog-Creators/Lavalink-Jars/releases/download/{JAR_VERSION}_{JAR_BUILD}/"
f"Lavalink.jar" f"Lavalink.jar"

View File

@ -3,7 +3,6 @@ import contextlib
import os import os
import re import re
import time import time
from typing import NoReturn
from urllib.parse import urlparse from urllib.parse import urlparse
import discord import discord
@ -11,7 +10,7 @@ import lavalink
from redbot.core import Config, commands from redbot.core import Config, commands
from redbot.core.bot import Red from redbot.core.bot import Red
from . import dataclasses from . import audio_dataclasses
from .converters import _pass_config_to_converters from .converters import _pass_config_to_converters
@ -51,7 +50,7 @@ def pass_config_to_dependencies(config: Config, bot: Red, localtracks_folder: st
_config = config _config = config
_pass_config_to_playlist(config, bot) _pass_config_to_playlist(config, bot)
_pass_config_to_converters(config, bot) _pass_config_to_converters(config, bot)
dataclasses._pass_config_to_dataclasses(config, bot, localtracks_folder) audio_dataclasses._pass_config_to_dataclasses(config, bot, localtracks_folder)
def track_limit(track, maxlength): def track_limit(track, maxlength):
@ -168,7 +167,7 @@ async def clear_react(bot: Red, message: discord.Message, emoji: dict = None):
async def get_description(track): async def get_description(track):
if any(x in track.uri for x in [f"{os.sep}localtracks", f"localtracks{os.sep}"]): if any(x in track.uri for x in [f"{os.sep}localtracks", f"localtracks{os.sep}"]):
local_track = dataclasses.LocalPath(track.uri) local_track = audio_dataclasses.LocalPath(track.uri)
if track.title != "Unknown title": if track.title != "Unknown title":
return "**{} - {}**\n{}".format( return "**{} - {}**\n{}".format(
track.author, track.title, local_track.to_string_hidden() track.author, track.title, local_track.to_string_hidden()
@ -389,7 +388,7 @@ class Notifier:
key: str = None, key: str = None,
seconds_key: str = None, seconds_key: str = None,
seconds: str = None, seconds: str = None,
) -> NoReturn: ):
""" """
This updates an existing message. This updates an existing message.
Based on the message found in :variable:`Notifier.updates` as per the `key` param Based on the message found in :variable:`Notifier.updates` as per the `key` param
@ -410,14 +409,14 @@ class Notifier:
except discord.errors.NotFound: except discord.errors.NotFound:
pass pass
async def update_text(self, text: str) -> NoReturn: async def update_text(self, text: str):
embed2 = discord.Embed(colour=self.color, title=text) embed2 = discord.Embed(colour=self.color, title=text)
try: try:
await self.message.edit(embed=embed2) await self.message.edit(embed=embed2)
except discord.errors.NotFound: except discord.errors.NotFound:
pass pass
async def update_embed(self, embed: discord.Embed) -> NoReturn: async def update_embed(self, embed: discord.Embed):
try: try:
await self.message.edit(embed=embed) await self.message.edit(embed=embed)
self.last_msg_time = time.time() self.last_msg_time = time.time()

View File

@ -21,7 +21,7 @@ REPO_INSTALL_MSG = _(
_ = T_ _ = T_
async def do_install_agreement(ctx: commands.Context): async def do_install_agreement(ctx: commands.Context) -> bool:
downloader = ctx.cog downloader = ctx.cog
if downloader is None or downloader.already_agreed: if downloader is None or downloader.already_agreed:
return True return True

View File

@ -1,14 +1,14 @@
import discord import discord
from redbot.core import commands from redbot.core import commands
from redbot.core.i18n import Translator from redbot.core.i18n import Translator
from .installable import Installable from .installable import InstalledModule
_ = Translator("Koala", __file__) _ = Translator("Koala", __file__)
class InstalledCog(Installable): class InstalledCog(InstalledModule):
@classmethod @classmethod
async def convert(cls, ctx: commands.Context, arg: str) -> Installable: async def convert(cls, ctx: commands.Context, arg: str) -> InstalledModule:
downloader = ctx.bot.get_cog("Downloader") downloader = ctx.bot.get_cog("Downloader")
if downloader is None: if downloader is None:
raise commands.CommandError(_("No Downloader cog found.")) raise commands.CommandError(_("No Downloader cog found."))

File diff suppressed because it is too large Load Diff

View File

@ -1,7 +1,16 @@
from __future__ import annotations
from typing import List, TYPE_CHECKING
if TYPE_CHECKING:
from .repo_manager import Candidate
__all__ = [ __all__ = [
"DownloaderException", "DownloaderException",
"GitException", "GitException",
"InvalidRepoName", "InvalidRepoName",
"CopyingError",
"ExistingGitRepo", "ExistingGitRepo",
"MissingGitRepo", "MissingGitRepo",
"CloningError", "CloningError",
@ -10,6 +19,8 @@ __all__ = [
"UpdateError", "UpdateError",
"GitDiffError", "GitDiffError",
"NoRemoteURL", "NoRemoteURL",
"UnknownRevision",
"AmbiguousRevision",
"PipError", "PipError",
] ]
@ -37,6 +48,15 @@ class InvalidRepoName(DownloaderException):
pass pass
class CopyingError(DownloaderException):
"""
Throw when there was an issue
during copying of module's files.
"""
pass
class ExistingGitRepo(DownloaderException): class ExistingGitRepo(DownloaderException):
""" """
Thrown when trying to clone into a folder where a Thrown when trying to clone into a folder where a
@ -105,6 +125,24 @@ class NoRemoteURL(GitException):
pass pass
class UnknownRevision(GitException):
"""
Thrown when specified revision cannot be found.
"""
pass
class AmbiguousRevision(GitException):
"""
Thrown when specified revision is ambiguous.
"""
def __init__(self, message: str, candidates: List[Candidate]) -> None:
super().__init__(message)
self.candidates = candidates
class PipError(DownloaderException): class PipError(DownloaderException):
""" """
Thrown when pip returns a non-zero return code. Thrown when pip returns a non-zero return code.

View File

@ -1,9 +1,11 @@
from __future__ import annotations
import json import json
import distutils.dir_util import distutils.dir_util
import shutil import shutil
from enum import Enum from enum import IntEnum
from pathlib import Path from pathlib import Path
from typing import MutableMapping, Any, TYPE_CHECKING from typing import MutableMapping, Any, TYPE_CHECKING, Optional, Dict, Union, Callable, Tuple, cast
from .log import log from .log import log
from .json_mixins import RepoJSONMixin from .json_mixins import RepoJSONMixin
@ -11,10 +13,11 @@ from .json_mixins import RepoJSONMixin
from redbot.core import __version__, version_info as red_version_info, VersionInfo from redbot.core import __version__, version_info as red_version_info, VersionInfo
if TYPE_CHECKING: if TYPE_CHECKING:
from .repo_manager import RepoManager from .repo_manager import RepoManager, Repo
class InstallableType(Enum): class InstallableType(IntEnum):
# using IntEnum, because hot-reload breaks its identity
UNKNOWN = 0 UNKNOWN = 0
COG = 1 COG = 1
SHARED_LIBRARY = 2 SHARED_LIBRARY = 2
@ -34,6 +37,10 @@ class Installable(RepoJSONMixin):
---------- ----------
repo_name : `str` repo_name : `str`
Name of the repository which this package belongs to. Name of the repository which this package belongs to.
repo : Repo, optional
Repo object of the Installable, if repo is missing this will be `None`
commit : `str`, optional
Installable's commit. This is not the same as ``repo.commit``
author : `tuple` of `str`, optional author : `tuple` of `str`, optional
Name(s) of the author(s). Name(s) of the author(s).
bot_version : `tuple` of `int` bot_version : `tuple` of `int`
@ -58,30 +65,36 @@ class Installable(RepoJSONMixin):
""" """
def __init__(self, location: Path): def __init__(self, location: Path, repo: Optional[Repo] = None, commit: str = ""):
"""Base installable initializer. """Base installable initializer.
Parameters Parameters
---------- ----------
location : pathlib.Path location : pathlib.Path
Location (file or folder) to the installable. Location (file or folder) to the installable.
repo : Repo, optional
Repo object of the Installable, if repo is missing this will be `None`
commit : str
Installable's commit. This is not the same as ``repo.commit``
""" """
super().__init__(location) super().__init__(location)
self._location = location self._location = location
self.repo = repo
self.repo_name = self._location.parent.stem self.repo_name = self._location.parent.stem
self.commit = commit
self.author = () self.author: Tuple[str, ...] = ()
self.min_bot_version = red_version_info self.min_bot_version = red_version_info
self.max_bot_version = red_version_info self.max_bot_version = red_version_info
self.min_python_version = (3, 5, 1) self.min_python_version = (3, 5, 1)
self.hidden = False self.hidden = False
self.disabled = False self.disabled = False
self.required_cogs = {} # Cog name -> repo URL self.required_cogs: Dict[str, str] = {} # Cog name -> repo URL
self.requirements = () self.requirements: Tuple[str, ...] = ()
self.tags = () self.tags: Tuple[str, ...] = ()
self.type = InstallableType.UNKNOWN self.type = InstallableType.UNKNOWN
if self._info_file.exists(): if self._info_file.exists():
@ -90,15 +103,15 @@ class Installable(RepoJSONMixin):
if self._info == {}: if self._info == {}:
self.type = InstallableType.COG self.type = InstallableType.COG
def __eq__(self, other): def __eq__(self, other: Any) -> bool:
# noinspection PyProtectedMember # noinspection PyProtectedMember
return self._location == other._location return self._location == other._location
def __hash__(self): def __hash__(self) -> int:
return hash(self._location) return hash(self._location)
@property @property
def name(self): def name(self) -> str:
"""`str` : The name of this package.""" """`str` : The name of this package."""
return self._location.stem return self._location.stem
@ -111,6 +124,7 @@ class Installable(RepoJSONMixin):
:return: Status of installation :return: Status of installation
:rtype: bool :rtype: bool
""" """
copy_func: Callable[..., Any]
if self._location.is_file(): if self._location.is_file():
copy_func = shutil.copy2 copy_func = shutil.copy2
else: else:
@ -121,18 +135,20 @@ class Installable(RepoJSONMixin):
# noinspection PyBroadException # noinspection PyBroadException
try: try:
copy_func(src=str(self._location), dst=str(target_dir / self._location.stem)) copy_func(src=str(self._location), dst=str(target_dir / self._location.stem))
except: except: # noqa: E722
log.exception("Error occurred when copying path: {}".format(self._location)) log.exception("Error occurred when copying path: {}".format(self._location))
return False return False
return True return True
def _read_info_file(self): def _read_info_file(self) -> None:
super()._read_info_file() super()._read_info_file()
if self._info_file.exists(): if self._info_file.exists():
self._process_info_file() self._process_info_file()
def _process_info_file(self, info_file_path: Path = None) -> MutableMapping[str, Any]: def _process_info_file(
self, info_file_path: Optional[Path] = None
) -> MutableMapping[str, Any]:
""" """
Processes an information file. Loads dependencies among other Processes an information file. Loads dependencies among other
information into this object. information into this object.
@ -145,7 +161,7 @@ class Installable(RepoJSONMixin):
if info_file_path is None or not info_file_path.is_file(): if info_file_path is None or not info_file_path.is_file():
raise ValueError("No valid information file path was found.") raise ValueError("No valid information file path was found.")
info = {} info: Dict[str, Any] = {}
with info_file_path.open(encoding="utf-8") as f: with info_file_path.open(encoding="utf-8") as f:
try: try:
info = json.load(f) info = json.load(f)
@ -174,7 +190,7 @@ class Installable(RepoJSONMixin):
self.max_bot_version = max_bot_version self.max_bot_version = max_bot_version
try: try:
min_python_version = tuple(info.get("min_python_version", [3, 5, 1])) min_python_version = tuple(info.get("min_python_version", (3, 5, 1)))
except ValueError: except ValueError:
min_python_version = self.min_python_version min_python_version = self.min_python_version
self.min_python_version = min_python_version self.min_python_version = min_python_version
@ -212,14 +228,51 @@ class Installable(RepoJSONMixin):
return info return info
def to_json(self):
return {"repo_name": self.repo_name, "cog_name": self.name} class InstalledModule(Installable):
"""Base class for installed modules,
this is basically instance of installed `Installable`
used by Downloader.
Attributes
----------
pinned : `bool`
Whether or not this cog is pinned, always `False` if module is not a cog.
"""
def __init__(
self,
location: Path,
repo: Optional[Repo] = None,
commit: str = "",
pinned: bool = False,
json_repo_name: str = "",
):
super().__init__(location=location, repo=repo, commit=commit)
self.pinned: bool = pinned if self.type == InstallableType.COG else False
# this is here so that Downloader could use real repo name instead of "MISSING_REPO"
self._json_repo_name = json_repo_name
def to_json(self) -> Dict[str, Union[str, bool]]:
module_json: Dict[str, Union[str, bool]] = {
"repo_name": self.repo_name,
"module_name": self.name,
"commit": self.commit,
}
if self.type == InstallableType.COG:
module_json["pinned"] = self.pinned
return module_json
@classmethod @classmethod
def from_json(cls, data: dict, repo_mgr: "RepoManager"): def from_json(
repo_name = data["repo_name"] cls, data: Dict[str, Union[str, bool]], repo_mgr: RepoManager
cog_name = data["cog_name"] ) -> InstalledModule:
repo_name = cast(str, data["repo_name"])
cog_name = cast(str, data["module_name"])
commit = cast(str, data.get("commit", ""))
pinned = cast(bool, data.get("pinned", False))
# TypedDict, where are you :/
repo = repo_mgr.get_repo(repo_name) repo = repo_mgr.get_repo(repo_name)
if repo is not None: if repo is not None:
repo_folder = repo.folder_path repo_folder = repo.folder_path
@ -228,4 +281,12 @@ class Installable(RepoJSONMixin):
location = repo_folder / cog_name location = repo_folder / cog_name
return cls(location=location) return cls(
location=location, repo=repo, commit=commit, pinned=pinned, json_repo_name=repo_name
)
@classmethod
def from_installable(cls, module: Installable, *, pinned: bool = False) -> InstalledModule:
return cls(
location=module._location, repo=module.repo, commit=module.commit, pinned=pinned
)

View File

@ -1,5 +1,6 @@
import json import json
from pathlib import Path from pathlib import Path
from typing import Optional, Tuple, Dict, Any
class RepoJSONMixin: class RepoJSONMixin:
@ -8,18 +9,18 @@ class RepoJSONMixin:
def __init__(self, repo_folder: Path): def __init__(self, repo_folder: Path):
self._repo_folder = repo_folder self._repo_folder = repo_folder
self.author = None self.author: Optional[Tuple[str, ...]] = None
self.install_msg = None self.install_msg: Optional[str] = None
self.short = None self.short: Optional[str] = None
self.description = None self.description: Optional[str] = None
self._info_file = repo_folder / self.INFO_FILE_NAME self._info_file = repo_folder / self.INFO_FILE_NAME
if self._info_file.exists(): if self._info_file.exists():
self._read_info_file() self._read_info_file()
self._info = {} self._info: Dict[str, Any] = {}
def _read_info_file(self): def _read_info_file(self) -> None:
if not (self._info_file.exists() or self._info_file.is_file()): if not (self._info_file.exists() or self._info_file.is_file()):
return return

File diff suppressed because it is too large Load Diff

View File

@ -299,6 +299,14 @@ class Permissions(commands.Cog):
if not who_or_what: if not who_or_what:
await ctx.send_help() await ctx.send_help()
return return
if isinstance(cog_or_command.obj, commands.commands._AlwaysAvailableCommand):
await ctx.send(
_(
"This command is designated as being always available and "
"cannot be modified by permission rules."
)
)
return
for w in who_or_what: for w in who_or_what:
await self._add_rule( await self._add_rule(
rule=cast(bool, allow_or_deny), rule=cast(bool, allow_or_deny),
@ -334,6 +342,14 @@ class Permissions(commands.Cog):
if not who_or_what: if not who_or_what:
await ctx.send_help() await ctx.send_help()
return return
if isinstance(cog_or_command.obj, commands.commands._AlwaysAvailableCommand):
await ctx.send(
_(
"This command is designated as being always available and "
"cannot be modified by permission rules."
)
)
return
for w in who_or_what: for w in who_or_what:
await self._add_rule( await self._add_rule(
rule=cast(bool, allow_or_deny), rule=cast(bool, allow_or_deny),
@ -544,7 +560,7 @@ class Permissions(commands.Cog):
Handles config. Handles config.
""" """
self.bot.clear_permission_rules(guild_id) self.bot.clear_permission_rules(guild_id, preserve_default_rule=False)
for category in (COG, COMMAND): for category in (COG, COMMAND):
async with self.config.custom(category).all() as all_rules: async with self.config.custom(category).all() as all_rules:
for name, rules in all_rules.items(): for name, rules in all_rules.items():

View File

@ -8,6 +8,7 @@ from enum import Enum
from importlib.machinery import ModuleSpec from importlib.machinery import ModuleSpec
from pathlib import Path from pathlib import Path
from typing import Optional, Union, List, Dict, NoReturn from typing import Optional, Union, List, Dict, NoReturn
from types import MappingProxyType
import discord import discord
from discord.ext.commands import when_mentioned_or from discord.ext.commands import when_mentioned_or
@ -132,7 +133,6 @@ class RedBase(commands.GroupMixin, commands.bot.BotBase, RPCMixin): # pylint: d
self._main_dir = bot_dir self._main_dir = bot_dir
self._cog_mgr = CogManager() self._cog_mgr = CogManager()
super().__init__(*args, help_command=None, **kwargs) super().__init__(*args, help_command=None, **kwargs)
# Do not manually use the help formatter attribute here, see `send_help_for`, # Do not manually use the help formatter attribute here, see `send_help_for`,
# for a documented API. The internals of this object are still subject to change. # for a documented API. The internals of this object are still subject to change.
@ -325,6 +325,7 @@ class RedBase(commands.GroupMixin, commands.bot.BotBase, RPCMixin): # pylint: d
get_embed_colour = get_embed_color get_embed_colour = get_embed_color
# start config migrations
async def _maybe_update_config(self): async def _maybe_update_config(self):
""" """
This should be run prior to loading cogs or connecting to discord. This should be run prior to loading cogs or connecting to discord.
@ -375,6 +376,57 @@ class RedBase(commands.GroupMixin, commands.bot.BotBase, RPCMixin): # pylint: d
await self._config.guild(guild_obj).admin_role.set(admin_roles) await self._config.guild(guild_obj).admin_role.set(admin_roles)
log.info("Done updating guild configs to support multiple mod/admin roles") log.info("Done updating guild configs to support multiple mod/admin roles")
# end Config migrations
async def pre_flight(self, cli_flags):
"""
This should only be run once, prior to connecting to discord.
"""
await self._maybe_update_config()
packages = []
if cli_flags.no_cogs is False:
packages.extend(await self._config.packages())
if cli_flags.load_cogs:
packages.extend(cli_flags.load_cogs)
if packages:
# Load permissions first, for security reasons
try:
packages.remove("permissions")
except ValueError:
pass
else:
packages.insert(0, "permissions")
to_remove = []
print("Loading packages...")
for package in packages:
try:
spec = await self._cog_mgr.find_cog(package)
await asyncio.wait_for(self.load_extension(spec), 30)
except asyncio.TimeoutError:
log.exception("Failed to load package %s (timeout)", package)
to_remove.append(package)
except Exception as e:
log.exception("Failed to load package {}".format(package), exc_info=e)
await self.remove_loaded_package(package)
to_remove.append(package)
for package in to_remove:
packages.remove(package)
if packages:
print("Loaded packages: " + ", ".join(packages))
if self.rpc_enabled:
await self.rpc.initialize(self.rpc_port)
async def start(self, *args, **kwargs):
cli_flags = kwargs.pop("cli_flags")
await self.pre_flight(cli_flags=cli_flags)
return await super().start(*args, **kwargs)
async def send_help_for( async def send_help_for(
self, ctx: commands.Context, help_for: Union[commands.Command, commands.GroupMixin, str] self, ctx: commands.Context, help_for: Union[commands.Command, commands.GroupMixin, str]
): ):
@ -531,6 +583,7 @@ class RedBase(commands.GroupMixin, commands.bot.BotBase, RPCMixin): # pylint: d
async with self._config.custom(SHARED_API_TOKENS, service_name).all() as group: async with self._config.custom(SHARED_API_TOKENS, service_name).all() as group:
group.update(tokens) group.update(tokens)
self.dispatch("red_api_tokens_update", service_name, MappingProxyType(group))
async def remove_shared_api_tokens(self, service_name: str, *token_names: str): async def remove_shared_api_tokens(self, service_name: str, *token_names: str):
""" """
@ -653,7 +706,7 @@ class RedBase(commands.GroupMixin, commands.bot.BotBase, RPCMixin): # pylint: d
``True`` if immune ``True`` if immune
""" """
guild = to_check.guild guild = getattr(to_check, "guild", None)
if not guild: if not guild:
return False return False
@ -666,7 +719,8 @@ class RedBase(commands.GroupMixin, commands.bot.BotBase, RPCMixin): # pylint: d
except AttributeError: except AttributeError:
# webhook messages are a user not member, # webhook messages are a user not member,
# cheaper than isinstance # cheaper than isinstance
return True # webhooks require significant permissions to enable. if author.bot and author.discriminator == "0000":
return True # webhooks require significant permissions to enable.
else: else:
ids_to_check.append(author.id) ids_to_check.append(author.id)
@ -779,7 +833,7 @@ class RedBase(commands.GroupMixin, commands.bot.BotBase, RPCMixin): # pylint: d
for subcommand in set(command.walk_commands()): for subcommand in set(command.walk_commands()):
subcommand.requires.reset() subcommand.requires.reset()
def clear_permission_rules(self, guild_id: Optional[int]) -> None: def clear_permission_rules(self, guild_id: Optional[int], **kwargs) -> None:
"""Clear all permission overrides in a scope. """Clear all permission overrides in a scope.
Parameters Parameters
@ -789,11 +843,15 @@ class RedBase(commands.GroupMixin, commands.bot.BotBase, RPCMixin): # pylint: d
``None``, this will clear all global rules and leave all ``None``, this will clear all global rules and leave all
guild rules untouched. guild rules untouched.
**kwargs
Keyword arguments to be passed to each required call of
``commands.Requires.clear_all_rules``
""" """
for cog in self.cogs.values(): for cog in self.cogs.values():
cog.requires.clear_all_rules(guild_id) cog.requires.clear_all_rules(guild_id, **kwargs)
for command in self.walk_commands(): for command in self.walk_commands():
command.requires.clear_all_rules(guild_id) command.requires.clear_all_rules(guild_id, **kwargs)
def add_permissions_hook(self, hook: commands.CheckPredicate) -> None: def add_permissions_hook(self, hook: commands.CheckPredicate) -> None:
"""Add a permissions hook. """Add a permissions hook.

View File

@ -1,17 +1,42 @@
import argparse import argparse
import asyncio import asyncio
import logging import logging
import sys
from typing import Optional
def confirm(m=""): def confirm(text: str, default: Optional[bool] = None) -> bool:
return input(m).lower().strip() in ("y", "yes") if default is None:
options = "y/n"
elif default is True:
options = "Y/n"
elif default is False:
options = "y/N"
else:
raise TypeError(f"expected bool, not {type(default)}")
while True:
try:
value = input(f"{text}: [{options}] ").lower().strip()
except (KeyboardInterrupt, EOFError):
print("\nAborted!")
sys.exit(1)
if value in ("y", "yes"):
return True
if value in ("n", "no"):
return False
if value == "":
if default is not None:
return default
print("Error: invalid input")
def interactive_config(red, token_set, prefix_set): def interactive_config(red, token_set, prefix_set, *, print_header=True):
loop = asyncio.get_event_loop() loop = asyncio.get_event_loop()
token = "" token = ""
print("Red - Discord Bot | Configuration process\n") if print_header:
print("Red - Discord Bot | Configuration process\n")
if not token_set: if not token_set:
print("Please enter a valid token:") print("Please enter a valid token:")
@ -35,8 +60,7 @@ def interactive_config(red, token_set, prefix_set):
while not prefix: while not prefix:
prefix = input("Prefix> ") prefix = input("Prefix> ")
if len(prefix) > 10: if len(prefix) > 10:
print("Your prefix seems overly long. Are you sure that it's correct? (y/n)") if not confirm("Your prefix seems overly long. Are you sure that it's correct?"):
if not confirm("> "):
prefix = "" prefix = ""
if prefix: if prefix:
loop.run_until_complete(red._config.prefix.set([prefix])) loop.run_until_complete(red._config.prefix.set([prefix]))
@ -54,6 +78,37 @@ def parse_cli_flags(args):
action="store_true", action="store_true",
help="List all instance names setup with 'redbot-setup'", help="List all instance names setup with 'redbot-setup'",
) )
parser.add_argument(
"--edit",
action="store_true",
help="Edit the instance. This can be done without console interaction "
"by passing --no-prompt and arguments that you want to change (available arguments: "
"--edit-instance-name, --edit-data-path, --copy-data, --owner, --token).",
)
parser.add_argument(
"--edit-instance-name",
type=str,
help="New name for the instance. This argument only works with --edit argument passed.",
)
parser.add_argument(
"--overwrite-existing-instance",
action="store_true",
help="Confirm overwriting of existing instance when changing name."
" This argument only works with --edit argument passed.",
)
parser.add_argument(
"--edit-data-path",
type=str,
help=(
"New data path for the instance. This argument only works with --edit argument passed."
),
)
parser.add_argument(
"--copy-data",
action="store_true",
help="Copy data from old location. This argument only works "
"with --edit and --edit-data-path arguments passed.",
)
parser.add_argument( parser.add_argument(
"--owner", "--owner",
type=int, type=int,
@ -65,7 +120,7 @@ def parse_cli_flags(args):
"--co-owner", "--co-owner",
type=int, type=int,
default=[], default=[],
nargs="*", nargs="+",
help="ID of a co-owner. Only people who have access " help="ID of a co-owner. Only people who have access "
"to the system that is hosting Red should be " "to the system that is hosting Red should be "
"co-owners, as this gives them complete access " "co-owners, as this gives them complete access "
@ -87,7 +142,7 @@ def parse_cli_flags(args):
parser.add_argument( parser.add_argument(
"--load-cogs", "--load-cogs",
type=str, type=str,
nargs="*", nargs="+",
help="Force loading specified cogs from the installed packages. " help="Force loading specified cogs from the installed packages. "
"Can be used with the --no-cogs flag to load these cogs exclusively.", "Can be used with the --no-cogs flag to load these cogs exclusively.",
) )

View File

@ -699,3 +699,29 @@ def get_command_disabler(guild: discord.Guild) -> Callable[["Context"], Awaitabl
__command_disablers[guild] = disabler __command_disablers[guild] = disabler
return disabler return disabler
# This is intentionally left out of `__all__` as it is not intended for general use
class _AlwaysAvailableCommand(Command):
"""
This should be used only for informational commands
which should not be disabled or removed
These commands cannot belong to a cog.
These commands do not respect most forms of checks, and
should only be used with that in mind.
This particular class is not supported for 3rd party use
"""
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
if self.cog is not None:
raise TypeError("This command may not be added to a cog")
async def can_run(self, ctx, *args, **kwargs) -> bool:
return not ctx.author.bot
async def _verify_checks(self, ctx) -> bool:
return not ctx.author.bot

View File

@ -398,11 +398,9 @@ class Requires:
else: else:
rules[model_id] = rule rules[model_id] = rule
def clear_all_rules(self, guild_id: int) -> None: def clear_all_rules(self, guild_id: int, *, preserve_default_rule: bool = True) -> None:
"""Clear all rules of a particular scope. """Clear all rules of a particular scope.
This will preserve the default rule, if set.
Parameters Parameters
---------- ----------
guild_id : int guild_id : int
@ -410,6 +408,12 @@ class Requires:
`Requires.GLOBAL`, this will clear all global rules and `Requires.GLOBAL`, this will clear all global rules and
leave all guild rules untouched. leave all guild rules untouched.
Other Parameters
----------------
preserve_default_rule : bool
Whether to preserve the default rule or not.
This defaults to being preserved
""" """
if guild_id: if guild_id:
rules = self._guild_rules.setdefault(guild_id, _RulesDict()) rules = self._guild_rules.setdefault(guild_id, _RulesDict())
@ -417,7 +421,7 @@ class Requires:
rules = self._global_rules rules = self._global_rules
default = rules.get(self.DEFAULT, None) default = rules.get(self.DEFAULT, None)
rules.clear() rules.clear()
if default is not None: if default is not None and preserve_default_rule:
rules[self.DEFAULT] = default rules[self.DEFAULT] = default
def reset(self) -> None: def reset(self) -> None:

View File

@ -33,7 +33,14 @@ from . import (
) )
from .utils import create_backup from .utils import create_backup
from .utils.predicates import MessagePredicate from .utils.predicates import MessagePredicate
from .utils.chat_formatting import humanize_timedelta, pagify, box, inline, humanize_list from .utils.chat_formatting import (
box,
humanize_list,
humanize_number,
humanize_timedelta,
inline,
pagify,
)
from .commands.requires import PrivilegeLevel from .commands.requires import PrivilegeLevel
@ -293,7 +300,7 @@ class Core(commands.Cog, CoreLogic):
data = await r.json() data = await r.json()
outdated = VersionInfo.from_str(data["info"]["version"]) > red_version_info outdated = VersionInfo.from_str(data["info"]["version"]) > red_version_info
about = _( about = _(
"This is an instance of [Red, an open source Discord bot]({}) " "This bot is an instance of [Red, an open source Discord bot]({}) "
"created by [Twentysix]({}) and [improved by many]({}).\n\n" "created by [Twentysix]({}) and [improved by many]({}).\n\n"
"Red is backed by a passionate community who contributes and " "Red is backed by a passionate community who contributes and "
"creates content for everyone to enjoy. [Join us today]({}) " "creates content for everyone to enjoy. [Join us today]({}) "
@ -1136,7 +1143,7 @@ class Core(commands.Cog, CoreLogic):
@checks.is_owner() @checks.is_owner()
async def api(self, ctx: commands.Context, service: str, *, tokens: TokenConverter): async def api(self, ctx: commands.Context, service: str, *, tokens: TokenConverter):
"""Set various external API tokens. """Set various external API tokens.
This setting will be asked for by some 3rd party cogs and some core cogs. This setting will be asked for by some 3rd party cogs and some core cogs.
To add the keys provide the service name and the tokens as a comma separated To add the keys provide the service name and the tokens as a comma separated
@ -1162,7 +1169,7 @@ class Core(commands.Cog, CoreLogic):
Allows the help command to be sent as a paginated menu instead of seperate Allows the help command to be sent as a paginated menu instead of seperate
messages. messages.
This defaults to False. This defaults to False.
Using this without a setting will toggle. Using this without a setting will toggle.
""" """
if use_menus is None: if use_menus is None:
@ -1316,8 +1323,9 @@ class Core(commands.Cog, CoreLogic):
@commands.command() @commands.command()
@checks.is_owner() @checks.is_owner()
async def backup(self, ctx: commands.Context, *, backup_dir: str = None): async def backup(self, ctx: commands.Context, *, backup_dir: str = None):
"""Creates a backup of all data for the instance. """Creates a backup of all data for this bot instance.
This backs up the bot's data and settings.
You may provide a path to a directory for the backup archive to You may provide a path to a directory for the backup archive to
be placed in. If the directory does not exist, the bot will be placed in. If the directory does not exist, the bot will
attempt to create it. attempt to create it.
@ -1877,6 +1885,53 @@ class Core(commands.Cog, CoreLogic):
"""Manage the bot's commands.""" """Manage the bot's commands."""
pass pass
@command_manager.group(name="listdisabled", invoke_without_command=True)
async def list_disabled(self, ctx: commands.Context):
"""
List disabled commands.
If you're the bot owner, this will show global disabled commands by default.
"""
# Select the scope based on the author's privileges
if await ctx.bot.is_owner(ctx.author):
await ctx.invoke(self.list_disabled_global)
else:
await ctx.invoke(self.list_disabled_guild)
@list_disabled.command(name="global")
async def list_disabled_global(self, ctx: commands.Context):
"""List disabled commands globally."""
disabled_list = await self.bot._config.disabled_commands()
if not disabled_list:
return await ctx.send(_("There aren't any globally disabled commands."))
if len(disabled_list) > 1:
header = _("{} commands are disabled globally.\n").format(
humanize_number(len(disabled_list))
)
else:
header = _("1 command is disabled globally.\n")
paged = [box(x) for x in pagify(humanize_list(disabled_list), page_length=1000)]
paged[0] = header + paged[0]
await ctx.send_interactive(paged)
@list_disabled.command(name="guild")
async def list_disabled_guild(self, ctx: commands.Context):
"""List disabled commands in this server."""
disabled_list = await self.bot._config.guild(ctx.guild).disabled_commands()
if not disabled_list:
return await ctx.send(_("There aren't any disabled commands in {}.").format(ctx.guild))
if len(disabled_list) > 1:
header = _("{} commands are disabled in {}.\n").format(
humanize_number(len(disabled_list)), ctx.guild
)
else:
header = _("1 command is disabled in {}.\n").format(ctx.guild)
paged = [box(x) for x in pagify(humanize_list(disabled_list), page_length=1000)]
paged[0] = header + paged[0]
await ctx.send_interactive(paged)
@command_manager.group(name="disable", invoke_without_command=True) @command_manager.group(name="disable", invoke_without_command=True)
async def command_disable(self, ctx: commands.Context, *, command: str): async def command_disable(self, ctx: commands.Context, *, command: str):
"""Disable a command. """Disable a command.
@ -1907,6 +1962,12 @@ class Core(commands.Cog, CoreLogic):
) )
return return
if isinstance(command_obj, commands.commands._AlwaysAvailableCommand):
await ctx.send(
_("This command is designated as being always available and cannot be disabled.")
)
return
async with ctx.bot._config.disabled_commands() as disabled_commands: async with ctx.bot._config.disabled_commands() as disabled_commands:
if command not in disabled_commands: if command not in disabled_commands:
disabled_commands.append(command_obj.qualified_name) disabled_commands.append(command_obj.qualified_name)
@ -1935,6 +1996,12 @@ class Core(commands.Cog, CoreLogic):
) )
return return
if isinstance(command_obj, commands.commands._AlwaysAvailableCommand):
await ctx.send(
_("This command is designated as being always available and cannot be disabled.")
)
return
if command_obj.requires.privilege_level > await PrivilegeLevel.from_ctx(ctx): if command_obj.requires.privilege_level > await PrivilegeLevel.from_ctx(ctx):
await ctx.send(_("You are not allowed to disable that command.")) await ctx.send(_("You are not allowed to disable that command."))
return return
@ -2215,3 +2282,21 @@ class Core(commands.Cog, CoreLogic):
async def rpc_reload(self, request): async def rpc_reload(self, request):
await self.rpc_unload(request) await self.rpc_unload(request)
await self.rpc_load(request) await self.rpc_load(request)
# Removing this command from forks is a violation of the GPLv3 under which it is licensed.
# Otherwise interfering with the ability for this command to be accessible is also a violation.
@commands.command(cls=commands.commands._AlwaysAvailableCommand, name="licenseinfo", i18n=_)
async def license_info_command(ctx):
"""
Get info about Red's licenses
"""
message = (
"This bot is an instance of Red-DiscordBot (hereafter refered to as Red)\n"
"Red is a free and open source application made available to the public and "
"licensed under the GNU GPLv3. The full text of this license is available to you at "
"<https://github.com/Cog-Creators/Red-DiscordBot/blob/V3/develop/LICENSE>"
)
await ctx.send(message)
# We need a link which contains a thank you to other projects which we use at some point.

View File

@ -4,7 +4,6 @@ from typing import Optional, Type
from .. import data_manager from .. import data_manager
from .base import IdentifierData, BaseDriver, ConfigCategory from .base import IdentifierData, BaseDriver, ConfigCategory
from .json import JsonDriver from .json import JsonDriver
from .mongo import MongoDriver
from .postgres import PostgresDriver from .postgres import PostgresDriver
__all__ = [ __all__ = [
@ -13,7 +12,6 @@ __all__ = [
"IdentifierData", "IdentifierData",
"BaseDriver", "BaseDriver",
"JsonDriver", "JsonDriver",
"MongoDriver",
"PostgresDriver", "PostgresDriver",
"BackendType", "BackendType",
] ]
@ -21,16 +19,13 @@ __all__ = [
class BackendType(enum.Enum): class BackendType(enum.Enum):
JSON = "JSON" JSON = "JSON"
MONGO = "MongoDBV2"
MONGOV1 = "MongoDB"
POSTGRES = "Postgres" POSTGRES = "Postgres"
# Dead drivrs below retained for error handling.
MONGOV1 = "MongoDB"
MONGO = "MongoDBV2"
_DRIVER_CLASSES = { _DRIVER_CLASSES = {BackendType.JSON: JsonDriver, BackendType.POSTGRES: PostgresDriver}
BackendType.JSON: JsonDriver,
BackendType.MONGO: MongoDriver,
BackendType.POSTGRES: PostgresDriver,
}
def get_driver_class(storage_type: Optional[BackendType] = None) -> Type[BaseDriver]: def get_driver_class(storage_type: Optional[BackendType] = None) -> Type[BaseDriver]:
@ -86,7 +81,7 @@ def get_driver(
Raises Raises
------ ------
RuntimeError RuntimeError
If the storage type is MongoV1 or invalid. If the storage type is MongoV1, Mongo, or invalid.
""" """
if storage_type is None: if storage_type is None:
@ -98,12 +93,10 @@ def get_driver(
try: try:
driver_cls: Type[BaseDriver] = get_driver_class(storage_type) driver_cls: Type[BaseDriver] = get_driver_class(storage_type)
except ValueError: except ValueError:
if storage_type == BackendType.MONGOV1: if storage_type in (BackendType.MONGOV1, BackendType.MONGO):
raise RuntimeError( raise RuntimeError(
"Please convert to JSON first to continue using the bot." "Please convert to JSON first to continue using the bot."
" This is a required conversion prior to using the new Mongo driver." "Mongo support was removed in 3.2."
" This message will be updated with a link to the update docs once those"
" docs have been created."
) from None ) from None
else: else:
raise RuntimeError(f"Invalid driver type: '{storage_type}'") from None raise RuntimeError(f"Invalid driver type: '{storage_type}'") from None

View File

@ -221,7 +221,7 @@ def _save_json(path: Path, data: Dict[str, Any]) -> None:
On windows, it is not available in entirety. On windows, it is not available in entirety.
If a windows user ends up with tons of temp files, they should consider hosting on If a windows user ends up with tons of temp files, they should consider hosting on
something POSIX compatible, or using the mongo backend instead. something POSIX compatible, or using a different backend instead.
Most users wont encounter this issue, but with high write volumes, Most users wont encounter this issue, but with high write volumes,
without the fsync on both the temp file, and after the replace on the directory, without the fsync on both the temp file, and after the replace on the directory,

Some files were not shown because too many files have changed in this diff Show More