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

This commit is contained in:
palmtree5 2019-05-14 19:32:27 -08:00
commit 71955becb1
33 changed files with 1011 additions and 442 deletions

View File

@ -126,11 +126,6 @@ Join us on our [Official Discord Server](https://discord.gg/red)!
Released under the [GNU GPL v3](https://www.gnu.org/licenses/gpl-3.0.en.html) license. Released under the [GNU GPL v3](https://www.gnu.org/licenses/gpl-3.0.en.html) license.
This project vendors the
[discord.py library by Rapptz](https://github.com/Rapptz/discord.py/tree/rewrite), which is
licensed under the [MIT License](https://opensource.org/licenses/MIT). This amounts to everything
within the *discord* folder of this repository.
Red is named after the main character of "Transistor", a video game by Red is named after the main character of "Transistor", a video game by
[Super Giant Games](https://www.supergiantgames.com/games/transistor/). [Super Giant Games](https://www.supergiantgames.com/games/transistor/).

View File

@ -1,8 +1,41 @@
.. v3.1.0 Changelog .. v3.1.0 Changelog
================ ####################
v3.1.0 Release Notes
####################
----------------------
Mongo Driver Migration
----------------------
Due to the required changes of the Mongo driver for Config, all existing Mongo users will need to
complete the below instructions to continue to use Mongo after updating to 3.1.
This includes **all** users, regardless of any prior migration attempt to a development version of
3.1.
#. Upgrade to 3.1
#. Convert all existing Mongo instances to JSON using the new converters
#. Start each bot instance while using JSON and load any and all cogs you have in order to successfully preserve data.
#. Turn each instance off and convert back to Mongo.
**NOTE:** No data is wiped from your Mongo database when converting to JSON.
You may want to use a *new* database name when converting back to Mongo in order to not have duplicate data.
-------------
Setup Utility
-------------
New commands were introduced to simplify the conversion/editing/removal process both on our end and the users end.
Please use ``redbot-setup --help`` to learn how to use the new features.
.. HINT::
Converting to JSON: ``redbot-setup convert <instance_name> json``
Converting to Mongo: ``redbot-setup convert <instance_name> mongo``
################
v3.1.0 Changelog v3.1.0 Changelog
================ ################
----- -----
Audio Audio
@ -33,8 +66,15 @@ Audio
Core Core
---- ----
* New Event dispatch: ``on_message_without_command`` (`#2338`_)
* Improve output format of cooldown messages (`#2412`_)
* Delete cooldown messages when expired (`#2469`_) * Delete cooldown messages when expired (`#2469`_)
* Fix local blacklist/whitelist management (`#2531`_)
* ``[p]set locale`` now only accepts actual locales (`#2553`_)
* ``[p]listlocales`` now displays ``en-US`` (`#2553`_)
* ``redbot --version`` will now give you current version of Red (`#2567`_) * ``redbot --version`` will now give you current version of Red (`#2567`_)
* Default locale changed from ``en`` to ``en-US`` (`#2642`_)
* New command ``[p]datapath`` that prints the bot's datapath (`#2652`_)
------ ------
Config Config
@ -45,24 +85,50 @@ Config
* We now record custom group primary key lengths in the core config object (`#2550`_) * We now record custom group primary key lengths in the core config object (`#2550`_)
* Migrated internal UUIDs to maintain cross platform consistency (`#2604`_) * Migrated internal UUIDs to maintain cross platform consistency (`#2604`_)
-------------
DataConverter
-------------
* It's dead jim (Removal) (`#2554`_)
---------- ----------
discord.py discord.py
---------- ----------
* No longer vendoring discord.py (`#2587`_)
* Upgraded discord.py dependency to version 1.0.1 (`#2587`_)
---------- ----------
Downloader Downloader
---------- ----------
* ``[p]cog install`` will now tell user that cog has to be loaded (`#2523`_) * ``[p]cog install`` will now tell user that cog has to be loaded (`#2523`_)
* The message when libraries fail to install is now formatted (`#2576`_)
* Fixed bug, that caused Downloader to include submodules on cog list (`#2590`_) * Fixed bug, that caused Downloader to include submodules on cog list (`#2590`_)
* ``[p]cog uninstall`` allows to uninstall multiple cogs now (`#2592`_) * ``[p]cog uninstall`` allows to uninstall multiple cogs now (`#2592`_)
* ``[p]cog uninstall`` will now remove cog from installed cogs even if it can't find the cog in install path anymore (`#2595`_) * ``[p]cog uninstall`` will now remove cog from installed cogs even if it can't find the cog in install path anymore (`#2595`_)
* ``[p]cog install`` will not allow to install cogs which aren't suitable for installed version of Red anymore (`#2605`_)
* Cog Developers now have to use ``min_bot_version`` in form of version string instead of ``bot_version`` in info.json and they can also use ``max_bot_version`` to specify maximum version of Red, more in :doc:`framework_downloader`. (`#2605`_)
------
Filter
------
* Filter performs significantly better on large servers. (`#2509`_)
--- ---
Mod Mod
--- ---
* Admins can now decide how many times message has to be repeated before ``deleterepeats`` removes it (`#2437`_) * Admins can now decide how many times message has to be repeated before ``deleterepeats`` removes it (`#2437`_)
* Fix: make ``[p]ban [days]`` optional as per the doc (`#2602`_)
* Added the command ``voicekick`` to kick members from a voice channel with optional mod case. (`#2639`_)
-----------
Permissions
-----------
* Removed: ``p`` alias for ``permissions`` command (`#2467`_)
------------- -------------
Setup Scripts Setup Scripts
@ -72,6 +138,13 @@ Setup Scripts
* ``redbot-setup convert`` now used to convert between libraries (`#2579`_) * ``redbot-setup convert`` now used to convert between libraries (`#2579`_)
* Backup support for Mongo is currently broken (`#2579`_) * Backup support for Mongo is currently broken (`#2579`_)
-------
Streams
-------
* Add support for custom stream alert messages per guild (`#2600`_)
* Add ability to exclude rerun Twitch streams, and note rerun streams in embed status (`#2620`_)
----- -----
Tests Tests
----- -----
@ -88,15 +161,20 @@ Trivia
Utility Functions Utility Functions
----------------- -----------------
* New: ``chat_formatting.humaize_timedelta`` (`#2412`_)
* ``Tunnel`` - Spelling correction of method name - changed ``files_from_attatch`` to ``files_from_attach`` (old name is left for backwards compatibility) (`#2496`_) * ``Tunnel`` - Spelling correction of method name - changed ``files_from_attatch`` to ``files_from_attach`` (old name is left for backwards compatibility) (`#2496`_)
* ``Tunnel`` - fixed behavior of ``react_close()``, now when tunnel closes message will be sent to other end (`#2507`_) * ``Tunnel`` - fixed behavior of ``react_close()``, now when tunnel closes message will be sent to other end (`#2507`_)
* ``chat_formatting.humanize_list`` - Improved error handling of empty lists (`#2597`_)
.. _#2328: https://github.com/Cog-Creators/Red-DiscordBot/pull/2328 .. _#2328: https://github.com/Cog-Creators/Red-DiscordBot/pull/2328
.. _#2338: https://github.com/Cog-Creators/Red-DiscordBot/pull/2338
.. _#2412: https://github.com/Cog-Creators/Red-DiscordBot/pull/2412
.. _#2437: https://github.com/Cog-Creators/Red-DiscordBot/pull/2437 .. _#2437: https://github.com/Cog-Creators/Red-DiscordBot/pull/2437
.. _#2457: https://github.com/Cog-Creators/Red-DiscordBot/pull/2457 .. _#2457: https://github.com/Cog-Creators/Red-DiscordBot/pull/2457
.. _#2461: https://github.com/Cog-Creators/Red-DiscordBot/pull/2461 .. _#2461: https://github.com/Cog-Creators/Red-DiscordBot/pull/2461
.. _#2462: https://github.com/Cog-Creators/Red-DiscordBot/pull/2462 .. _#2462: https://github.com/Cog-Creators/Red-DiscordBot/pull/2462
.. _#2465: https://github.com/Cog-Creators/Red-DiscordBot/pull/2465 .. _#2465: https://github.com/Cog-Creators/Red-DiscordBot/pull/2465
.. _#2467: https://github.com/Cog-Creators/Red-DiscordBot/pull/2467
.. _#2469: https://github.com/Cog-Creators/Red-DiscordBot/pull/2469 .. _#2469: https://github.com/Cog-Creators/Red-DiscordBot/pull/2469
.. _#2470: https://github.com/Cog-Creators/Red-DiscordBot/pull/2470 .. _#2470: https://github.com/Cog-Creators/Red-DiscordBot/pull/2470
.. _#2472: https://github.com/Cog-Creators/Red-DiscordBot/pull/2472 .. _#2472: https://github.com/Cog-Creators/Red-DiscordBot/pull/2472
@ -106,24 +184,38 @@ Utility Functions
.. _#2482: https://github.com/Cog-Creators/Red-DiscordBot/pull/2482 .. _#2482: https://github.com/Cog-Creators/Red-DiscordBot/pull/2482
.. _#2496: https://github.com/Cog-Creators/Red-DiscordBot/pull/2496 .. _#2496: https://github.com/Cog-Creators/Red-DiscordBot/pull/2496
.. _#2507: https://github.com/Cog-Creators/Red-DiscordBot/pull/2507 .. _#2507: https://github.com/Cog-Creators/Red-DiscordBot/pull/2507
.. _#2509: https://github.com/Cog-Creators/Red-DiscordBot/pull/2509
.. _#2513: https://github.com/Cog-Creators/Red-DiscordBot/pull/2513 .. _#2513: https://github.com/Cog-Creators/Red-DiscordBot/pull/2513
.. _#2521: https://github.com/Cog-Creators/Red-DiscordBot/pull/2521 .. _#2521: https://github.com/Cog-Creators/Red-DiscordBot/pull/2521
.. _#2523: https://github.com/Cog-Creators/Red-DiscordBot/pull/2523 .. _#2523: https://github.com/Cog-Creators/Red-DiscordBot/pull/2523
.. _#2525: https://github.com/Cog-Creators/Red-DiscordBot/pull/2525 .. _#2525: https://github.com/Cog-Creators/Red-DiscordBot/pull/2525
.. _#2531: https://github.com/Cog-Creators/Red-DiscordBot/pull/2531
.. _#2533: https://github.com/Cog-Creators/Red-DiscordBot/pull/2533 .. _#2533: https://github.com/Cog-Creators/Red-DiscordBot/pull/2533
.. _#2536: https://github.com/Cog-Creators/Red-DiscordBot/pull/2536 .. _#2536: https://github.com/Cog-Creators/Red-DiscordBot/pull/2536
.. _#2540: https://github.com/Cog-Creators/Red-DiscordBot/pull/2540 .. _#2540: https://github.com/Cog-Creators/Red-DiscordBot/pull/2540
.. _#2545: https://github.com/Cog-Creators/Red-DiscordBot/pull/2545 .. _#2545: https://github.com/Cog-Creators/Red-DiscordBot/pull/2545
.. _#2550: https://github.com/Cog-Creators/Red-DiscordBot/pull/2550 .. _#2550: https://github.com/Cog-Creators/Red-DiscordBot/pull/2550
.. _#2553: https://github.com/Cog-Creators/Red-DiscordBot/pull/2553
.. _#2554: https://github.com/Cog-Creators/Red-DiscordBot/pull/2554
.. _#2556: https://github.com/Cog-Creators/Red-DiscordBot/pull/2556 .. _#2556: https://github.com/Cog-Creators/Red-DiscordBot/pull/2556
.. _#2557: https://github.com/Cog-Creators/Red-DiscordBot/pull/2557 .. _#2557: https://github.com/Cog-Creators/Red-DiscordBot/pull/2557
.. _#2565: https://github.com/Cog-Creators/Red-DiscordBot/pull/2565 .. _#2565: https://github.com/Cog-Creators/Red-DiscordBot/pull/2565
.. _#2567: https://github.com/Cog-Creators/Red-DiscordBot/pull/2567 .. _#2567: https://github.com/Cog-Creators/Red-DiscordBot/pull/2567
.. _#2576: https://github.com/Cog-Creators/Red-DiscordBot/pull/2576
.. _#2579: https://github.com/Cog-Creators/Red-DiscordBot/pull/2579 .. _#2579: https://github.com/Cog-Creators/Red-DiscordBot/pull/2579
.. _#2586: https://github.com/Cog-Creators/Red-DiscordBot/pull/2586 .. _#2586: https://github.com/Cog-Creators/Red-DiscordBot/pull/2586
.. _#2587: https://github.com/Cog-Creators/Red-DiscordBot/pull/2587
.. _#2590: https://github.com/Cog-Creators/Red-DiscordBot/pull/2590 .. _#2590: https://github.com/Cog-Creators/Red-DiscordBot/pull/2590
.. _#2591: https://github.com/Cog-Creators/Red-DiscordBot/pull/2591 .. _#2591: https://github.com/Cog-Creators/Red-DiscordBot/pull/2591
.. _#2592: https://github.com/Cog-Creators/Red-DiscordBot/pull/2592 .. _#2592: https://github.com/Cog-Creators/Red-DiscordBot/pull/2592
.. _#2595: https://github.com/Cog-Creators/Red-DiscordBot/pull/2595 .. _#2595: https://github.com/Cog-Creators/Red-DiscordBot/pull/2595
.. _#2597: https://github.com/Cog-Creators/Red-DiscordBot/pull/2597
.. _#2600: https://github.com/Cog-Creators/Red-DiscordBot/pull/2600
.. _#2602: https://github.com/Cog-Creators/Red-DiscordBot/pull/2602
.. _#2604: https://github.com/Cog-Creators/Red-DiscordBot/pull/2604 .. _#2604: https://github.com/Cog-Creators/Red-DiscordBot/pull/2604
.. _#2605: https://github.com/Cog-Creators/Red-DiscordBot/pull/2605
.. _#2606: https://github.com/Cog-Creators/Red-DiscordBot/pull/2606 .. _#2606: https://github.com/Cog-Creators/Red-DiscordBot/pull/2606
.. _#2620: https://github.com/Cog-Creators/Red-DiscordBot/pull/2620
.. _#2639: https://github.com/Cog-Creators/Red-DiscordBot/pull/2639
.. _#2642: https://github.com/Cog-Creators/Red-DiscordBot/pull/2642
.. _#2652: https://github.com/Cog-Creators/Red-DiscordBot/pull/2652

View File

@ -203,7 +203,7 @@ linkcheck_ignore = [r"https://java.com*", r"https://chocolatey.org*"]
# Intersphinx # Intersphinx
intersphinx_mapping = { intersphinx_mapping = {
"python": ("https://docs.python.org/3", None), "python": ("https://docs.python.org/3", None),
"dpy": ("https://discordpy.readthedocs.io/en/rewrite/", None), "dpy": ("https://discordpy.readthedocs.io/en/v1.0.1/", None),
"motor": ("https://motor.readthedocs.io/en/stable/", None), "motor": ("https://motor.readthedocs.io/en/stable/", None),
} }

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.db.api_tokens.get_raw("twitch", default={"client_id": None, "client_secret: None"}) await self.bot.db.api_tokens.get_raw("twitch", default={"client_id": None, "client_secret": None})
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.

View File

@ -30,7 +30,10 @@ Keys common to both repo and cog info.json (case sensitive)
Keys specific to the cog info.json (case sensitive) Keys specific to the cog info.json (case sensitive)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
- ``bot_version`` (list of integer) - Min version number of Red in the format ``(MAJOR, MINOR, PATCH)`` - ``min_bot_version`` (string) - Min 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
- ``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.

View File

@ -7,7 +7,7 @@
Migrating Cogs to V3 Migrating Cogs to V3
==================== ====================
First, be sure to read `discord.py's migration guide <http://discordpy.readthedocs.io/en/rewrite/migrating.html>`_ First, be sure to read `discord.py's migration guide <https://discordpy.readthedocs.io/en/v1.0.1/migrating.html>`_
as that covers all of the changes to discord.py that will affect the migration process as that covers all of the changes to discord.py that will affect the migration process
---------------- ----------------

View File

@ -92,7 +92,7 @@ one-by-one:
brew install python --with-brewed-openssl brew install python --with-brewed-openssl
brew install git brew install git
brew tap caskroom/versions brew tap caskroom/versions
brew cask install java8 brew cask install homebrew/cask-versions/adoptopenjdk8
It's possible you will have network issues. If so, go in your Applications folder, inside it, go in the Python 3.7 folder then double click ``Install certificates.command`` It's possible you will have network issues. If so, go in your Applications folder, inside it, go in the Python 3.7 folder then double click ``Install certificates.command``
@ -237,7 +237,7 @@ Once done setting up the instance, run the following command to run Red:
It will walk through the initial setup, asking for your token and a prefix. It will walk through the initial setup, asking for your token and a prefix.
You can find out how to obtain a token with You can find out how to obtain a token with
`this guide <https://discordpy.readthedocs.io/en/rewrite/discord.html#creating-a-bot-account>`_, `this guide <https://discordpy.readthedocs.io/en/v1.0.1/discord.html#creating-a-bot-account>`_,
section "Creating a Bot Account". section "Creating a Bot Account".
You may also run Red via the launcher, which allows you to restart the bot You may also run Red via the launcher, which allows you to restart the bot

View File

@ -111,7 +111,7 @@ Once done setting up the instance, run the following command to run Red:
It will walk through the initial setup, asking for your token and a prefix. It will walk through the initial setup, asking for your token and a prefix.
You can find out how to obtain a token with You can find out how to obtain a token with
`this guide <https://discordpy.readthedocs.io/en/rewrite/discord.html#creating-a-bot-account>`_, `this guide <https://discordpy.readthedocs.io/en/v1.0.1/discord.html#creating-a-bot-account>`_,
section "Creating a Bot Account". section "Creating a Bot Account".
You may also run Red via the launcher, which allows you to restart the bot You may also run Red via the launcher, which allows you to restart the bot

View File

@ -119,8 +119,14 @@ class VersionInfo:
"dev_release": self.dev_release, "dev_release": self.dev_release,
} }
def __lt__(self, other: "VersionInfo") -> bool: def _generate_comparison_tuples(
tups: _List[_Tuple[int, int, int, int, int, int, int]] = [] self, other: "VersionInfo"
) -> _List[
_Tuple[int, int, int, int, _Union[int, float], _Union[int, float], _Union[int, float]]
]:
tups: _List[
_Tuple[int, int, int, int, _Union[int, float], _Union[int, float], _Union[int, float]]
] = []
for obj in (self, other): for obj in (self, other):
tups.append( tups.append(
( (
@ -133,8 +139,20 @@ class VersionInfo:
obj.dev_release if obj.dev_release is not None else _inf, obj.dev_release if obj.dev_release is not None else _inf,
) )
) )
return tups
def __lt__(self, other: "VersionInfo") -> bool:
tups = self._generate_comparison_tuples(other)
return tups[0] < tups[1] return tups[0] < tups[1]
def __eq__(self, other: "VersionInfo") -> bool:
tups = self._generate_comparison_tuples(other)
return tups[0] == tups[1]
def __le__(self, other: "VersionInfo") -> bool:
tups = self._generate_comparison_tuples(other)
return tups[0] <= tups[1]
def __str__(self) -> str: def __str__(self) -> str:
ret = f"{self.major}.{self.minor}.{self.micro}" ret = f"{self.major}.{self.minor}.{self.micro}"
if self.releaselevel != self.FINAL: if self.releaselevel != self.FINAL:

View File

@ -104,7 +104,9 @@ def main():
log.debug("Data Path: %s", data_manager._base_data_path()) log.debug("Data Path: %s", data_manager._base_data_path())
log.debug("Storage Type: %s", data_manager.storage_type()) log.debug("Storage Type: %s", data_manager.storage_type())
red = Red(cli_flags=cli_flags, description=description, dm_help=None) red = Red(
cli_flags=cli_flags, description=description, dm_help=None, fetch_offline_members=True
)
init_global_checks(red) init_global_checks(red)
init_events(red, cli_flags) init_events(red, cli_flags)
red.add_cog(Core(red)) red.add_cog(Core(red))

View File

@ -1,31 +1,9 @@
from pathlib import Path from redbot.core import commands
import logging
from .audio import Audio from .audio import Audio
from .manager import start_lavalink_server, maybe_download_lavalink
from redbot.core import commands
from redbot.core.data_manager import cog_data_path
import redbot.core
log = logging.getLogger("red.audio")
LAVALINK_DOWNLOAD_URL = (
"https://github.com/Cog-Creators/Red-DiscordBot/releases/download/{}/Lavalink.jar"
).format(redbot.core.__version__)
LAVALINK_DOWNLOAD_DIR = cog_data_path(raw_name="Audio")
LAVALINK_JAR_FILE = LAVALINK_DOWNLOAD_DIR / "Lavalink.jar"
APP_YML_FILE = LAVALINK_DOWNLOAD_DIR / "application.yml"
BUNDLED_APP_YML_FILE = Path(__file__).parent / "data/application.yml"
async def setup(bot: commands.Bot): async def setup(bot: commands.Bot):
cog = Audio(bot) cog = Audio(bot)
if not await cog.config.use_external_lavalink():
await maybe_download_lavalink(bot.loop, cog)
await start_lavalink_server(bot.loop)
await cog.initialize() await cog.initialize()
bot.add_cog(cog) bot.add_cog(cog)

View File

@ -14,6 +14,7 @@ import os
import random import random
import re import re
import time import time
from typing import Optional
import redbot.core import redbot.core
from redbot.core import Config, commands, checks, bank from redbot.core import Config, commands, checks, bank
from redbot.core.data_manager import cog_data_path from redbot.core.data_manager import cog_data_path
@ -29,11 +30,11 @@ from redbot.core.utils.menus import (
) )
from redbot.core.utils.predicates import MessagePredicate, ReactionPredicate from redbot.core.utils.predicates import MessagePredicate, ReactionPredicate
from urllib.parse import urlparse from urllib.parse import urlparse
from .manager import shutdown_lavalink_server, start_lavalink_server, maybe_download_lavalink from .manager import ServerManager
_ = Translator("Audio", __file__) _ = Translator("Audio", __file__)
__version__ = "0.0.8b" __version__ = "0.0.9"
__author__ = ["aikaterna"] __author__ = ["aikaterna"]
log = logging.getLogger("red.audio") log = logging.getLogger("red.audio")
@ -43,40 +44,45 @@ log = logging.getLogger("red.audio")
class Audio(commands.Cog): class Audio(commands.Cog):
"""Play audio through voice channels.""" """Play audio through voice channels."""
_default_lavalink_settings = {
"host": "localhost",
"rest_port": 2333,
"ws_port": 2333,
"password": "youshallnotpass",
}
def __init__(self, bot): def __init__(self, bot):
super().__init__() super().__init__()
self.bot = bot self.bot = bot
self.config = Config.get_conf(self, 2711759130, force_registration=True) self.config = Config.get_conf(self, 2711759130, force_registration=True)
default_global = { default_global = dict(
"host": "localhost", status=False,
"rest_port": "2333", use_external_lavalink=False,
"ws_port": "2332", restrict=True,
"password": "youshallnotpass", current_version=redbot.core.VersionInfo.from_str("3.0.0a0").to_json(),
"status": False, localpath=str(cog_data_path(raw_name="Audio")),
"current_version": redbot.core.VersionInfo.from_str("3.0.0a0").to_json(), **self._default_lavalink_settings,
"use_external_lavalink": False, )
"restrict": True,
}
default_guild = { default_guild = dict(
"disconnect": False, disconnect=False,
"dj_enabled": False, dj_enabled=False,
"dj_role": None, dj_role=None,
"emptydc_enabled": False, emptydc_enabled=False,
"emptydc_timer": 0, emptydc_timer=0,
"jukebox": False, jukebox=False,
"jukebox_price": 0, jukebox_price=0,
"maxlength": 0, maxlength=0,
"playlists": {}, playlists={},
"notify": False, notify=False,
"repeat": False, repeat=False,
"shuffle": False, shuffle=False,
"thumbnail": False, thumbnail=False,
"volume": 100, volume=100,
"vote_enabled": False, vote_enabled=False,
"vote_percent": 0, vote_percent=0,
} )
self.config.register_guild(**default_guild) self.config.register_guild(**default_guild)
self.config.register_global(**default_global) self.config.register_global(**default_global)
@ -85,9 +91,24 @@ class Audio(commands.Cog):
self._connect_task = None self._connect_task = None
self._disconnect_task = None self._disconnect_task = None
self._cleaned_up = False self._cleaned_up = False
self.spotify_token = None self.spotify_token = None
self.play_lock = {} self.play_lock = {}
self._manager: Optional[ServerManager] = None
async def cog_before_invoke(self, ctx):
if self.llsetup in [ctx.command, ctx.command.root_parent]:
pass
elif self._connect_task.cancelled():
await ctx.send(
"You have attempted to run Audio's Lavalink server on an unsupported"
" architecture. Only settings related commands will be available."
)
raise RuntimeError(
"Not running audio command due to invalid machine architecture for Lavalink."
)
async def initialize(self): async def initialize(self):
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())
@ -102,16 +123,33 @@ class Audio(commands.Cog):
async def attempt_connect(self, timeout: int = 30): async def attempt_connect(self, timeout: int = 30):
while True: # run until success while True: # run until success
external = await self.config.use_external_lavalink() external = await self.config.use_external_lavalink()
if not external: if external is False:
shutdown_lavalink_server() settings = self._default_lavalink_settings
await maybe_download_lavalink(self.bot.loop, self) host = settings["host"]
await start_lavalink_server(self.bot.loop) password = settings["password"]
rest_port = settings["rest_port"]
ws_port = settings["ws_port"]
if self._manager is not None:
await self._manager.shutdown()
self._manager = ServerManager()
try: try:
await self._manager.start()
except RuntimeError as exc:
log.exception(
"Exception whilst starting internal Lavalink server, retrying...",
exc_info=exc,
)
await asyncio.sleep(1)
continue
except asyncio.CancelledError:
log.exception("Invalid machine architecture, cannot run Lavalink.")
raise
else:
host = await self.config.host() host = await self.config.host()
password = await self.config.password() password = await self.config.password()
rest_port = await self.config.rest_port() rest_port = await self.config.rest_port()
ws_port = await self.config.ws_port() ws_port = await self.config.ws_port()
try:
await lavalink.initialize( await lavalink.initialize(
bot=self.bot, bot=self.bot,
host=host, host=host,
@ -121,9 +159,10 @@ class Audio(commands.Cog):
timeout=timeout, timeout=timeout,
) )
return # break infinite loop return # break infinite loop
except Exception: except asyncio.TimeoutError:
if not external: log.error("Connecting to Lavalink server timed out, retrying...")
shutdown_lavalink_server() if external is False and self._manager is not None:
await self._manager.shutdown()
await asyncio.sleep(1) # prevent busylooping await asyncio.sleep(1) # prevent busylooping
async def event_handler(self, player, event_type, extra): async def event_handler(self, player, event_type, extra):
@ -133,19 +172,19 @@ class Audio(commands.Cog):
async def _players_check(): async def _players_check():
try: try:
get_players = [p for p in lavalink.players if p.current is not None] get_single_title = lavalink.active_players()[0].current.title
get_single_title = get_players[0].current.title
if get_single_title == "Unknown title": if get_single_title == "Unknown title":
get_single_title = get_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"):
get_single_title = get_single_title.rsplit("/", 1)[-1] get_single_title = get_single_title.rsplit("/", 1)[-1]
elif "localtracks/" in get_players[0].current.uri: elif "localtracks/" in lavalink.active_players()[0].current.uri:
get_single_title = "{} - {}".format( get_single_title = "{} - {}".format(
get_players[0].current.author, get_players[0].current.title lavalink.active_players()[0].current.author,
lavalink.active_players()[0].current.title,
) )
else: else:
get_single_title = get_players[0].current.title get_single_title = lavalink.active_players()[0].current.title
playing_servers = len(get_players) playing_servers = len(lavalink.active_players())
except IndexError: except IndexError:
get_single_title = None get_single_title = None
playing_servers = 0 playing_servers = 0
@ -355,6 +394,87 @@ class Audio(commands.Cog):
await self.config.guild(ctx.guild).jukebox_price.set(price) await self.config.guild(ctx.guild).jukebox_price.set(price)
await self.config.guild(ctx.guild).jukebox.set(jukebox) await self.config.guild(ctx.guild).jukebox.set(jukebox)
@audioset.command()
@checks.is_owner()
async def localpath(self, ctx, local_path=None):
"""Set the localtracks path if the Lavalink.jar is not run from the Audio data folder.
Leave the path blank to reset the path to the default, the Audio data directory.
"""
if not local_path:
await self.config.localpath.set(str(cog_data_path(raw_name="Audio")))
return await self._embed_msg(
ctx, _("The localtracks path location has been reset to the default location.")
)
info_msg = _(
"This setting is only for bot owners to set a localtracks folder location "
"if the Lavalink.jar is being ran from outside of the Audio data directory.\n"
"In the example below, the full path for 'ParentDirectory' must be passed to this command.\n"
"The path must not contain spaces.\n"
"```\n"
"ParentDirectory\n"
" |__ localtracks (folder)\n"
" | |__ Awesome Album Name (folder)\n"
" | |__01 Cool Song.mp3\n"
" | |__02 Groovy Song.mp3\n"
" |\n"
" |__ Lavalink.jar\n"
" |__ application.yml\n"
"```\n"
"The folder path given to this command must contain the Lavalink.jar, the application.yml, and the localtracks folder.\n"
"Use this command with no path given to reset it to the default, the Audio data directory for this bot.\n"
"Do you want to continue to set the provided path for local tracks?"
)
info = await ctx.maybe_send_embed(info_msg)
start_adding_reactions(info, ReactionPredicate.YES_OR_NO_EMOJIS)
pred = ReactionPredicate.yes_or_no(info, ctx.author)
await ctx.bot.wait_for("reaction_add", check=pred)
if not pred.result:
try:
await info.delete()
except discord.errors.Forbidden:
pass
return
try:
if os.getcwd() != local_path:
os.chdir(local_path)
os.listdir(local_path)
except OSError:
return await self._embed_msg(
ctx,
_("{local_path} does not seem like a valid path.").format(local_path=local_path),
)
jar_check = os.path.isfile(local_path + "/Lavalink.jar")
yml_check = os.path.isfile(local_path + "/application.yml")
if not jar_check and not yml_check:
filelist = "a Lavalink.jar and an application.yml"
elif jar_check and not yml_check:
filelist = "an application.yml"
elif not jar_check and yml_check:
filelist = "a Lavalink.jar"
else:
filelist = None
if filelist is not None:
warn_msg = _(
"The path that was entered does not have {filelist} file in "
"that location. The path will still be saved, but please check the path and "
"the file location before attempting to play local tracks or start your "
"Lavalink.jar."
).format(filelist=filelist)
await self._embed_msg(ctx, warn_msg)
await self.config.localpath.set(local_path)
await self._embed_msg(
ctx, _("Localtracks path set to: {local_path}.").format(local_path=local_path)
)
@audioset.command() @audioset.command()
@checks.mod_or_permissions(administrator=True) @checks.mod_or_permissions(administrator=True)
async def maxlength(self, ctx, seconds): async def maxlength(self, ctx, seconds):
@ -412,6 +532,7 @@ class Audio(commands.Cog):
@audioset.command() @audioset.command()
async def settings(self, ctx): async def settings(self, ctx):
"""Show the current settings.""" """Show the current settings."""
is_owner = ctx.author.id == self.bot.owner_id
data = await self.config.guild(ctx.guild).all() data = await self.config.guild(ctx.guild).all()
global_data = await self.config.all() global_data = await self.config.all()
dj_role_obj = ctx.guild.get_role(data["dj_role"]) dj_role_obj = ctx.guild.get_role(data["dj_role"])
@ -458,8 +579,10 @@ class Audio(commands.Cog):
"---Lavalink Settings--- \n" "---Lavalink Settings--- \n"
"Cog version: [{version}]\n" "Cog version: [{version}]\n"
"Jar build: [{jarbuild}]\n" "Jar build: [{jarbuild}]\n"
"External server: [{use_external_lavalink}]" "External server: [{use_external_lavalink}]\n"
).format(version=__version__, jarbuild=jarbuild, **global_data) ).format(version=__version__, jarbuild=jarbuild, **global_data)
if is_owner:
msg += _("Localtracks path: [{localpath}]\n").format(**global_data)
embed = discord.Embed(colour=await ctx.embed_colour(), description=box(msg, lang="ini")) embed = discord.Embed(colour=await ctx.embed_colour(), description=box(msg, lang="ini"))
return await ctx.send(embed=embed) return await ctx.send(embed=embed)
@ -545,11 +668,11 @@ class Audio(commands.Cog):
@commands.guild_only() @commands.guild_only()
async def audiostats(self, ctx): async def audiostats(self, ctx):
"""Audio stats.""" """Audio stats."""
server_num = len([p for p in lavalink.players if p.current is not None]) server_num = len(lavalink.active_players())
total_num = len([p for p in lavalink.players]) total_num = len(lavalink.all_players())
msg = "" msg = ""
for p in lavalink.players: for p in lavalink.all_players():
connect_start = p.fetch("connect") connect_start = p.fetch("connect")
connect_dur = self._dynamic_time( connect_dur = self._dynamic_time(
int((datetime.datetime.utcnow() - connect_start).total_seconds()) int((datetime.datetime.utcnow() - connect_start).total_seconds())
@ -778,11 +901,10 @@ class Audio(commands.Cog):
) )
track_listing = [] track_listing = []
if ctx.invoked_with == "search": if ctx.invoked_with == "search":
local_path = await self.config.localpath()
for localtrack_location in folder_list: for localtrack_location in folder_list:
track_listing.append( track_listing.append(
localtrack_location.replace( localtrack_location.replace("{}/localtracks/".format(local_path), "")
"{}/localtracks/".format(cog_data_path(raw_name="Audio")), ""
)
) )
else: else:
for localtrack_location in folder_list: for localtrack_location in folder_list:
@ -808,13 +930,16 @@ class Audio(commands.Cog):
await ctx.invoke(self.search, query=("folder:" + folder)) await ctx.invoke(self.search, query=("folder:" + folder))
async def _localtracks_check(self, ctx): async def _localtracks_check(self, ctx):
audio_data = cog_data_path(raw_name="Audio") audio_data = await self.config.localpath()
if os.getcwd() != audio_data: if os.getcwd() != audio_data:
os.chdir(audio_data) os.chdir(audio_data)
localtracks_folder = any( localtracks_folder = any(
f for f in os.listdir(os.getcwd()) if not os.path.isfile(f) if f == "localtracks" f for f in os.listdir(os.getcwd()) if not os.path.isfile(f) if f == "localtracks"
) )
if not localtracks_folder: if not localtracks_folder:
if ctx.invoked_with == "start":
return False
else:
await self._embed_msg(ctx, _("No localtracks folder.")) await self._embed_msg(ctx, _("No localtracks folder."))
return False return False
else: else:
@ -1072,10 +1197,9 @@ class Audio(commands.Cog):
return await self._get_spotify_tracks(ctx, query) return await self._get_spotify_tracks(ctx, query)
if query.startswith("localtrack:"): if query.startswith("localtrack:"):
local_path = await self.config.localpath()
await self._localtracks_check(ctx) await self._localtracks_check(ctx)
query = query.replace("localtrack:", "").replace( query = query.replace("localtrack:", "").replace(((local_path) + "/"), "")
(str(cog_data_path(raw_name="Audio")) + "/"), ""
)
allowed_files = (".mp3", ".flac", ".ogg") allowed_files = (".mp3", ".flac", ".ogg")
if not self._match_url(query) and not (query.lower().endswith(allowed_files)): if not self._match_url(query) and not (query.lower().endswith(allowed_files)):
query = "ytsearch:{}".format(query) query = "ytsearch:{}".format(query)
@ -1333,17 +1457,13 @@ class Audio(commands.Cog):
song_info = "{} {}".format(i["track"]["name"], i["track"]["artists"][0]["name"]) song_info = "{} {}".format(i["track"]["name"], i["track"]["artists"][0]["name"])
try: try:
track_url = await self._youtube_api_search(yt_key, song_info) track_url = await self._youtube_api_search(yt_key, song_info)
except: except (RuntimeError, aiohttp.client_exceptions.ServerDisconnectedError):
error_embed = discord.Embed( error_embed = discord.Embed(
colour=await ctx.embed_colour(), colour=await ctx.embed_colour(),
title=_( title=_("The connection was reset while loading the playlist."),
"The YouTube API key has not been set properly.\n"
"Use `{prefix}audioset youtubeapi` for instructions."
).format(prefix=ctx.prefix),
) )
await playlist_msg.edit(embed=error_embed) await playlist_msg.edit(embed=error_embed)
return None return None
# let's complain about errors
pass pass
try: try:
yt_track = await player.get_tracks(track_url) yt_track = await player.get_tracks(track_url)
@ -1831,6 +1951,8 @@ class Audio(commands.Cog):
player = lavalink.get_player(ctx.guild.id) player = lavalink.get_player(ctx.guild.id)
for track in playlists[playlist_name]["tracks"]: for track in playlists[playlist_name]["tracks"]:
if track["info"]["uri"].startswith("localtracks/"): if track["info"]["uri"].startswith("localtracks/"):
if not await self._localtracks_check(ctx):
pass
if not os.path.isfile(track["info"]["uri"]): if not os.path.isfile(track["info"]["uri"]):
continue continue
if maxlength > 0: if maxlength > 0:
@ -1914,7 +2036,10 @@ class Audio(commands.Cog):
) )
playlist_msg = await ctx.send(embed=embed1) playlist_msg = await ctx.send(embed=embed1)
for song_url in v2_playlist["playlist"]: for song_url in v2_playlist["playlist"]:
try:
track = await player.get_tracks(song_url) track = await player.get_tracks(song_url)
except RuntimeError:
pass
try: try:
track_obj = self._track_creator(player, other_track=track[0]) track_obj = self._track_creator(player, other_track=track[0])
track_list.append(track_obj) track_list.append(track_obj)
@ -2251,9 +2376,8 @@ class Audio(commands.Cog):
): ):
track_idx = i + 1 track_idx = i + 1
if command == "search": if command == "search":
track_location = track.replace( local_path = await self.config.localpath()
"localtrack:{}/localtracks/".format(cog_data_path(raw_name="Audio")), "" track_location = track.replace("localtrack:{}/localtracks/".format(local_path), "")
)
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])
@ -2271,11 +2395,13 @@ class Audio(commands.Cog):
@commands.guild_only() @commands.guild_only()
async def _queue_clear(self, ctx): async def _queue_clear(self, ctx):
"""Clears the queue.""" """Clears the queue."""
try:
player = lavalink.get_player(ctx.guild.id) player = lavalink.get_player(ctx.guild.id)
except KeyError:
return await self._embed_msg(ctx, _("There's nothing in the queue."))
dj_enabled = await self.config.guild(ctx.guild).dj_enabled() dj_enabled = await self.config.guild(ctx.guild).dj_enabled()
if not self._player_check(ctx) or not player.queue: if not self._player_check(ctx) or not player.queue:
return await self._embed_msg(ctx, _("There's nothing in the queue.")) return await self._embed_msg(ctx, _("There's nothing in the queue."))
player = lavalink.get_player(ctx.guild.id)
if dj_enabled: if dj_enabled:
if not await self._can_instaskip(ctx, ctx.author) and not await self._is_alone( if not await self._can_instaskip(ctx, ctx.author) and not await self._is_alone(
ctx, ctx.author ctx, ctx.author
@ -2288,7 +2414,10 @@ class Audio(commands.Cog):
@commands.guild_only() @commands.guild_only()
async def _queue_clean(self, ctx): async def _queue_clean(self, ctx):
"""Removes songs from the queue if the requester is not in the voice channel.""" """Removes songs from the queue if the requester is not in the voice channel."""
try:
player = lavalink.get_player(ctx.guild.id) player = lavalink.get_player(ctx.guild.id)
except KeyError:
return await self._embed_msg(ctx, _("There's nothing in the queue."))
dj_enabled = await self.config.guild(ctx.guild).dj_enabled() dj_enabled = await self.config.guild(ctx.guild).dj_enabled()
if not self._player_check(ctx) or not player.queue: if not self._player_check(ctx) or not player.queue:
return await self._embed_msg(ctx, _("There's nothing in the queue.")) return await self._embed_msg(ctx, _("There's nothing in the queue."))
@ -2572,7 +2701,8 @@ class Audio(commands.Cog):
if command == "search": if command == "search":
return await ctx.invoke(self.play, query=("localtracks/{}".format(search_choice))) return await ctx.invoke(self.play, query=("localtracks/{}".format(search_choice)))
search_choice = search_choice.replace("localtrack:", "") search_choice = search_choice.replace("localtrack:", "")
if not search_choice.startswith(str(cog_data_path(raw_name="Audio"))): local_path = await self.config.localpath()
if not search_choice.startswith(local_path):
return await ctx.invoke( return await ctx.invoke(
self.search, query=("localfolder:{}".format(search_choice)) self.search, query=("localfolder:{}".format(search_choice))
) )
@ -2633,14 +2763,10 @@ class Audio(commands.Cog):
search_list += "`{}.` **{}**\n".format(search_track_num, track) search_list += "`{}.` **{}**\n".format(search_track_num, track)
folder = False folder = False
else: else:
local_path = await self.config.localpath()
search_list += "`{}.` **{}**\n".format( search_list += "`{}.` **{}**\n".format(
search_track_num, search_track_num,
track.replace( track.replace("localtrack:{}/localtracks/".format(local_path), ""),
"localtrack:{}/localtracks/".format(
str(cog_data_path(raw_name="Audio"))
),
"",
),
) )
folder = False folder = False
try: try:
@ -2773,8 +2899,8 @@ class Audio(commands.Cog):
@commands.command() @commands.command()
@commands.guild_only() @commands.guild_only()
async def skip(self, ctx): async def skip(self, ctx, skip_to_track: int = None):
"""Skip to the next track.""" """Skip to the next track, or to a given track number."""
if not self._player_check(ctx): if not self._player_check(ctx):
return await self._embed_msg(ctx, _("Nothing playing.")) return await self._embed_msg(ctx, _("Nothing playing."))
player = lavalink.get_player(ctx.guild.id) player = lavalink.get_player(ctx.guild.id)
@ -2791,6 +2917,10 @@ class Audio(commands.Cog):
return await self._embed_msg(ctx, _("You need the DJ role to skip tracks.")) return await self._embed_msg(ctx, _("You need the DJ role to skip tracks."))
if vote_enabled: if vote_enabled:
if not await self._can_instaskip(ctx, ctx.author): if not await self._can_instaskip(ctx, ctx.author):
if skip_to_track is not None:
return await self._embed_msg(
ctx, _("Can't skip to a specific track in vote mode without the DJ role.")
)
if ctx.author.id in self.skip_votes[ctx.message.guild]: if ctx.author.id in self.skip_votes[ctx.message.guild]:
self.skip_votes[ctx.message.guild].remove(ctx.author.id) self.skip_votes[ctx.message.guild].remove(ctx.author.id)
reply = _("I removed your vote to skip.") reply = _("I removed your vote to skip.")
@ -2823,9 +2953,9 @@ class Audio(commands.Cog):
) )
return await self._embed_msg(ctx, reply) return await self._embed_msg(ctx, reply)
else: else:
return await self._skip_action(ctx) return await self._skip_action(ctx, skip_to_track)
else: else:
return await self._skip_action(ctx) return await self._skip_action(ctx, skip_to_track)
async def _can_instaskip(self, ctx, member): async def _can_instaskip(self, ctx, member):
mod_role = await ctx.bot.db.guild(ctx.guild).mod_role() mod_role = await ctx.bot.db.guild(ctx.guild).mod_role()
@ -2884,7 +3014,7 @@ class Audio(commands.Cog):
else: else:
return False return False
async def _skip_action(self, ctx): async def _skip_action(self, ctx, skip_to_track: int = None):
player = lavalink.get_player(ctx.guild.id) player = lavalink.get_player(ctx.guild.id)
if not player.queue: if not player.queue:
try: try:
@ -2909,23 +3039,57 @@ class Audio(commands.Cog):
) )
) )
return await ctx.send(embed=embed) return await ctx.send(embed=embed)
queue_to_append = []
if "localtracks" in player.current.uri: if skip_to_track is not None and skip_to_track != 1:
if not player.current.title == "Unknown title": if skip_to_track < 1:
description = "**{} - {}**\n{}".format( return await self._embed_msg(
player.current.author, ctx, _("Track number must be equal to or greater than 1.")
player.current.title,
player.current.uri.replace("localtracks/", ""),
) )
else: elif skip_to_track > len(player.queue):
description = "{}".format(player.current.uri.replace("localtracks/", "")) return await self._embed_msg(
else: ctx,
description = "**[{}]({})**".format(player.current.title, player.current.uri) _(
"There are only {queuelen} songs currently queued.".format(
queuelen=len(player.queue)
)
),
)
elif player.shuffle:
return await self._embed_msg(
ctx, _("Can't skip to a track while shuffle is enabled.")
)
nexttrack = player.queue[min(skip_to_track - 1, len(player.queue) - 1)]
embed = discord.Embed( embed = discord.Embed(
colour=await ctx.embed_colour(), title=_("Track Skipped"), description=description colour=await ctx.embed_colour(),
title=_("{skip_to_track} Tracks Skipped".format(skip_to_track=skip_to_track)),
) )
await ctx.send(embed=embed) await ctx.send(embed=embed)
await player.skip() if player.repeat:
queue_to_append = player.queue[0 : min(skip_to_track - 1, len(player.queue) - 1)]
player.queue = player.queue[
min(skip_to_track - 1, len(player.queue) - 1) : len(player.queue)
]
else:
embed = discord.Embed(
colour=await ctx.embed_colour(),
title=_("Track Skipped"),
description=await self._get_description(player.current),
)
await ctx.send(embed=embed)
await player.play()
player.queue += queue_to_append
async def _get_description(self, track):
if "localtracks" in track.uri:
if not track.title == "Unknown title":
return "**{} - {}**\n{}".format(
track.author, track.title, track.uri.replace("localtracks/", "")
)
else:
return "{}".format(track.uri.replace("localtracks/", ""))
else:
return "**[{}]({})**".format(track.title, track.uri)
@commands.command() @commands.command()
@commands.guild_only() @commands.guild_only()
@ -3020,19 +3184,16 @@ class Audio(commands.Cog):
await self.config.use_external_lavalink.set(not external) await self.config.use_external_lavalink.set(not external)
if external: if external:
await self.config.host.set("localhost")
await self.config.password.set("youshallnotpass")
await self.config.rest_port.set(2333)
await self.config.ws_port.set(2332)
embed = discord.Embed( embed = discord.Embed(
colour=await ctx.embed_colour(), colour=await ctx.embed_colour(),
title=_("External lavalink server: {true_or_false}.").format( title=_("External lavalink server: {true_or_false}.").format(
true_or_false=not external true_or_false=not external
), ),
) )
embed.set_footer(text=_("Defaults reset."))
await ctx.send(embed=embed) await ctx.send(embed=embed)
else: else:
if self._manager is not None:
await self._manager.shutdown()
await self._embed_msg( await self._embed_msg(
ctx, ctx,
_("External lavalink server: {true_or_false}.").format(true_or_false=not external), _("External lavalink server: {true_or_false}.").format(true_or_false=not external),
@ -3106,7 +3267,10 @@ class Audio(commands.Cog):
self._restart_connect() self._restart_connect()
async def _channel_check(self, ctx): async def _channel_check(self, ctx):
try:
player = lavalink.get_player(ctx.guild.id) player = lavalink.get_player(ctx.guild.id)
except KeyError:
return False
try: try:
in_channel = sum( in_channel = sum(
not m.bot for m in ctx.guild.get_member(self.bot.user.id).voice.channel.members not m.bot for m in ctx.guild.get_member(self.bot.user.id).voice.channel.members
@ -3145,6 +3309,8 @@ class Audio(commands.Cog):
async def _check_external(self): async def _check_external(self):
external = await self.config.use_external_lavalink() external = await self.config.use_external_lavalink()
if not external: if not external:
if self._manager is not None:
await self._manager.shutdown()
await self.config.use_external_lavalink.set(True) await self.config.use_external_lavalink.set(True)
return True return True
else: else:
@ -3191,7 +3357,7 @@ class Audio(commands.Cog):
stop_times = {} stop_times = {}
while True: while True:
for p in lavalink.players: for p in lavalink.all_players():
server = p.channel.guild server = p.channel.guild
if [self.bot.user] == p.channel.members: if [self.bot.user] == p.channel.members:
@ -3266,13 +3432,6 @@ class Audio(commands.Cog):
else: else:
return self.bot.color return self.bot.color
async def _get_playing(self, ctx):
if self._player_check(ctx):
player = lavalink.get_player(ctx.guild.id)
return len([player for p in lavalink.players if p.is_playing])
else:
return 0
async def _localtracks_folders(self, ctx): async def _localtracks_folders(self, ctx):
if not await self._localtracks_check(ctx): if not await self._localtracks_check(ctx):
return return
@ -3315,6 +3474,8 @@ class Audio(commands.Cog):
try: try:
lavalink.get_player(ctx.guild.id) lavalink.get_player(ctx.guild.id)
return True return True
except IndexError:
return False
except KeyError: except KeyError:
return False return False
@ -3422,11 +3583,14 @@ class Audio(commands.Cog):
async def _youtube_api_search(self, yt_key, query): async def _youtube_api_search(self, yt_key, query):
params = {"q": query, "part": "id", "key": yt_key, "maxResults": 1, "type": "video"} params = {"q": query, "part": "id", "key": yt_key, "maxResults": 1, "type": "video"}
yt_url = "https://www.googleapis.com/youtube/v3/search" yt_url = "https://www.googleapis.com/youtube/v3/search"
try:
async with self.session.request("GET", yt_url, params=params) as r: async with self.session.request("GET", yt_url, params=params) as r:
if r.status == 400: if r.status == 400:
return None return None
else: else:
search_response = await r.json() search_response = await r.json()
except RuntimeError:
return None
for search_result in search_response.get("items", []): for search_result in search_response.get("items", []):
if search_result["id"]["kind"] == "youtube#video": if search_result["id"]["kind"] == "youtube#video":
return "https://www.youtube.com/watch?v={}".format(search_result["id"]["videoId"]) return "https://www.youtube.com/watch?v={}".format(search_result["id"]["videoId"])
@ -3503,7 +3667,7 @@ class Audio(commands.Cog):
def cog_unload(self): def cog_unload(self):
if not self._cleaned_up: if not self._cleaned_up:
self.session.detach() self.bot.loop.create_task(self.session.close())
if self._disconnect_task: if self._disconnect_task:
self._disconnect_task.cancel() self._disconnect_task.cancel()
@ -3513,25 +3677,8 @@ class Audio(commands.Cog):
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())
shutdown_lavalink_server() if self._manager is not None:
self.bot.loop.create_task(self._manager.shutdown())
self._cleaned_up = True self._cleaned_up = True
__del__ = cog_unload __del__ = cog_unload
@commands.Cog.listener()
async def on_guild_remove(self, guild: discord.Guild):
"""
This is to clean up players when
the bot either leaves or is removed from a guild
"""
channels = {
x # x is a voice_channel
for y in [g.voice_channels for g in self.bot.guilds]
for x in y # y is a list of voice channels
} # Yes, this is ugly. It's also the most performant and commented.
zombie_players = {p for p in lavalink.player_manager.players if p.channel not in channels}
# Do not unroll to combine with next line.
# Can result in iterator changing size during context switching.
for zombie in zombie_players:
await zombie.destroy()

View File

@ -1,11 +1,9 @@
server: server:
host: "localhost"
port: 2333 # REST server port: 2333 # REST server
lavalink: lavalink:
server: server:
password: "youshallnotpass" password: "youshallnotpass"
ws:
host: "localhost"
port: 2332
sources: sources:
youtube: true youtube: true
bandcamp: true bandcamp: true

View File

@ -1,72 +1,124 @@
import shlex import itertools
import pathlib
import platform
import shutil import shutil
import asyncio import asyncio
import asyncio.subprocess import asyncio.subprocess
import os
import logging import logging
import re import re
from subprocess import Popen, DEVNULL import tempfile
from typing import Optional, Tuple from typing import Optional, Tuple, ClassVar, List
from aiohttp import ClientSession import aiohttp
import redbot.core from redbot.core import data_manager
_JavaVersion = Tuple[int, int] JAR_VERSION = "3.2.0.3"
JAR_BUILD = 751
LAVALINK_DOWNLOAD_URL = (
f"https://github.com/Cog-Creators/Lavalink-Jars/releases/download/{JAR_VERSION}_{JAR_BUILD}/"
f"Lavalink.jar"
)
LAVALINK_DOWNLOAD_DIR = data_manager.cog_data_path(raw_name="Audio")
LAVALINK_JAR_FILE = LAVALINK_DOWNLOAD_DIR / "Lavalink.jar"
BUNDLED_APP_YML = pathlib.Path(__file__).parent / "data" / "application.yml"
LAVALINK_APP_YML = LAVALINK_DOWNLOAD_DIR / "application.yml"
READY_LINE_RE = re.compile(rb"Started Launcher in \S+ seconds")
BUILD_LINE_RE = re.compile(rb"Build:\s+(?P<build>\d+)")
log = logging.getLogger("red.audio.manager") log = logging.getLogger("red.audio.manager")
proc = None
shutdown = False
class ServerManager:
def has_java_error(pid): _java_available: ClassVar[Optional[bool]] = None
from . import LAVALINK_DOWNLOAD_DIR _java_version: ClassVar[Optional[Tuple[int, int]]] = None
_up_to_date: ClassVar[Optional[bool]] = None
poss_error_file = LAVALINK_DOWNLOAD_DIR / "hs_err_pid{}.log".format(pid) _blacklisted_archs = ["armv6l", "aarch32", "aarch64"]
return poss_error_file.exists()
def __init__(self) -> None:
self.ready = asyncio.Event()
async def monitor_lavalink_server(loop): self._proc: Optional[asyncio.subprocess.Process] = None
global shutdown self._monitor_task: Optional[asyncio.Task] = None
while shutdown is False: self._shutdown: bool = False
if proc.poll() is not None:
break
await asyncio.sleep(0.5)
if shutdown is False: async def start(self) -> None:
# Lavalink was shut down by something else arch_name = platform.machine()
log.info("Lavalink jar shutdown.") if arch_name in self._blacklisted_archs:
shutdown = True raise asyncio.CancelledError(
if not has_java_error(proc.pid): "You are attempting to run Lavalink audio on an unsupported machine architecture."
log.info("Restarting Lavalink jar.")
await start_lavalink_server(loop)
else:
log.error(
"Your Java is borked. Please find the hs_err_pid{}.log file"
" in the Audio data folder and report this issue.".format(proc.pid)
) )
if self._proc is not None:
if self._proc.returncode is None:
raise RuntimeError("Internal Lavalink server is already running")
else:
raise RuntimeError("Server manager has already been used - create another one")
async def has_java(loop) -> Tuple[bool, Optional[_JavaVersion]]: await self.maybe_download_jar()
# Copy the application.yml across.
# For people to customise their Lavalink server configuration they need to run it
# externally
shutil.copyfile(BUNDLED_APP_YML, LAVALINK_APP_YML)
args = await self._get_jar_args()
self._proc = await asyncio.subprocess.create_subprocess_exec(
*args,
cwd=str(LAVALINK_DOWNLOAD_DIR),
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.STDOUT,
)
log.info("Internal Lavalink server started. PID: %s", self._proc.pid)
try:
await asyncio.wait_for(self._wait_for_launcher(), timeout=120)
except asyncio.TimeoutError:
log.warning("Timeout occurred whilst waiting for internal Lavalink server to be ready")
self._monitor_task = asyncio.create_task(self._monitor())
@classmethod
async def _get_jar_args(cls) -> List[str]:
java_available, java_version = await cls._has_java()
if not java_available:
raise RuntimeError("You must install Java 1.8+ for Lavalink to run.")
if java_version == (1, 8):
extra_flags = ["-Dsun.zip.disableMemoryMapping=true"]
elif java_version >= (11, 0):
extra_flags = ["-Djdk.tls.client.protocols=TLSv1.2"]
else:
extra_flags = []
return ["java", *extra_flags, "-jar", str(LAVALINK_JAR_FILE)]
@classmethod
async def _has_java(cls) -> Tuple[bool, Optional[Tuple[int, int]]]:
if cls._java_available is not None:
# Return cached value if we've checked this before
return cls._java_available, cls._java_version
java_available = shutil.which("java") is not None java_available = shutil.which("java") is not None
if not java_available: if not java_available:
return False, None cls.java_available = False
cls.java_version = None
else:
cls._java_version = version = await cls._get_java_version()
cls._java_available = (2, 0) > version >= (1, 8) or version >= (8, 0)
return cls._java_available, cls._java_version
version = await get_java_version(loop) @staticmethod
return (2, 0) > version >= (1, 8) or version >= (8, 0), version async def _get_java_version() -> Tuple[int, int]:
async def get_java_version(loop) -> _JavaVersion:
""" """
This assumes we've already checked that java exists. This assumes we've already checked that java exists.
""" """
_proc: asyncio.subprocess.Process = await asyncio.create_subprocess_exec( _proc: asyncio.subprocess.Process = await asyncio.create_subprocess_exec(
"java", "java", "-version", stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE
"-version",
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
loop=loop,
) )
# java -version outputs to stderr # java -version outputs to stderr
_, err = await _proc.communicate() _, err = await _proc.communicate()
@ -97,76 +149,95 @@ async def get_java_version(loop) -> _JavaVersion:
"issue tracker." "issue tracker."
) )
async def _wait_for_launcher(self) -> None:
log.debug("Waiting for Lavalink server to be ready")
for i in itertools.cycle(range(50)):
line = await self._proc.stdout.readline()
if READY_LINE_RE.search(line):
self.ready.set()
break
if self._proc.returncode is not None:
log.critical("Internal lavalink server exited early")
if i == 49:
# Sleep after 50 lines to prevent busylooping
await asyncio.sleep(0.1)
async def start_lavalink_server(loop): async def _monitor(self) -> None:
java_available, java_version = await has_java(loop) while self._proc.returncode is None:
if not java_available: await asyncio.sleep(0.5)
raise RuntimeError("You must install Java 1.8+ for Lavalink to run.")
if java_version == (1, 8): # This task hasn't been cancelled - Lavalink was shut down by something else
extra_flags = "-Dsun.zip.disableMemoryMapping=true" log.info("Internal Lavalink jar shutdown unexpectedly")
elif java_version >= (11, 0): if not self._has_java_error():
extra_flags = "-Djdk.tls.client.protocols=TLSv1.2" log.info("Restarting internal Lavalink server")
await self.start()
else: else:
extra_flags = "" log.critical(
"Your Java is borked. Please find the hs_err_pid{}.log file"
from . import LAVALINK_DOWNLOAD_DIR, LAVALINK_JAR_FILE " in the Audio data folder and report this issue.",
self._proc.pid,
start_cmd = "java {} -jar {}".format(extra_flags, LAVALINK_JAR_FILE.resolve())
global proc
if proc and proc.poll() is None:
return # already running
proc = Popen(
shlex.split(start_cmd, posix=os.name == "posix"),
cwd=str(LAVALINK_DOWNLOAD_DIR),
stdout=DEVNULL,
stderr=DEVNULL,
) )
log.info("Lavalink jar started. PID: {}".format(proc.pid)) def _has_java_error(self) -> bool:
global shutdown poss_error_file = LAVALINK_DOWNLOAD_DIR / "hs_err_pid{}.log".format(self._proc.pid)
shutdown = False return poss_error_file.exists()
loop.create_task(monitor_lavalink_server(loop)) async def shutdown(self) -> None:
if self._shutdown is True or self._proc is None:
# For convenience, calling this method more than once or calling it before starting it
# does nothing.
return
log.info("Shutting down internal Lavalink server")
if self._monitor_task is not None:
self._monitor_task.cancel()
self._proc.terminate()
await self._proc.wait()
self._shutdown = True
@staticmethod
async def _download_jar() -> None:
log.info("Downloading Lavalink.jar...")
async with aiohttp.ClientSession() as session:
async with session.get(LAVALINK_DOWNLOAD_URL) as response:
if response.status == 404:
raise RuntimeError(
f"Lavalink jar version {JAR_VERSION}_{JAR_BUILD} hasn't been published"
)
fd, path = tempfile.mkstemp()
file = open(fd, "wb")
try:
chunk = await response.content.read(1024)
while chunk:
file.write(chunk)
chunk = await response.content.read(1024)
file.flush()
finally:
file.close()
pathlib.Path(path).replace(LAVALINK_JAR_FILE)
def shutdown_lavalink_server(): @classmethod
global shutdown async def _is_up_to_date(cls):
shutdown = True if cls._up_to_date is True:
global proc # Return cached value if we've checked this before
if proc is not None: return True
log.info("Shutting down lavalink server.") args = await cls._get_jar_args()
proc.terminate() args.append("--version")
proc.wait() _proc = await asyncio.subprocess.create_subprocess_exec(
proc = None *args,
cwd=str(LAVALINK_DOWNLOAD_DIR),
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.STDOUT,
)
stdout = (await _proc.communicate())[0]
match = BUILD_LINE_RE.search(stdout)
if not match:
# Output is unexpected, suspect corrupted jarfile
return False
build = int(match["build"])
cls._up_to_date = build == JAR_BUILD
return cls._up_to_date
@classmethod
async def download_lavalink(session): async def maybe_download_jar(cls):
from . import LAVALINK_DOWNLOAD_URL, LAVALINK_JAR_FILE if not (LAVALINK_JAR_FILE.exists() and await cls._is_up_to_date()):
await cls._download_jar()
with LAVALINK_JAR_FILE.open(mode="wb") as f:
async with session.get(LAVALINK_DOWNLOAD_URL) as resp:
while True:
chunk = await resp.content.read(512)
if not chunk:
break
f.write(chunk)
async def maybe_download_lavalink(loop, cog):
from . import LAVALINK_DOWNLOAD_DIR, LAVALINK_JAR_FILE, BUNDLED_APP_YML_FILE, APP_YML_FILE
jar_exists = LAVALINK_JAR_FILE.exists()
current_build = redbot.VersionInfo.from_json(await cog.config.current_version())
if not jar_exists or current_build < redbot.core.version_info:
log.info("Downloading Lavalink.jar")
LAVALINK_DOWNLOAD_DIR.mkdir(parents=True, exist_ok=True)
async with ClientSession(loop=loop) as session:
await download_lavalink(session)
await cog.config.current_version.set(redbot.core.version_info.to_json())
shutil.copyfile(str(BUNDLED_APP_YML_FILE), str(APP_YML_FILE))

View File

@ -10,6 +10,7 @@ from redbot.core.i18n import Translator, cog_i18n
from redbot.core.utils.mod import slow_deletion, mass_purge from redbot.core.utils.mod import slow_deletion, mass_purge
from redbot.cogs.mod.log import log from redbot.cogs.mod.log import log
from redbot.core.utils.predicates import MessagePredicate from redbot.core.utils.predicates import MessagePredicate
from .converters import RawMessageIds
_ = Translator("Cleanup", __file__) _ = Translator("Cleanup", __file__)
@ -211,7 +212,9 @@ class Cleanup(commands.Cog):
@cleanup.command() @cleanup.command()
@commands.guild_only() @commands.guild_only()
@commands.bot_has_permissions(manage_messages=True) @commands.bot_has_permissions(manage_messages=True)
async def after(self, ctx: commands.Context, message_id: int, delete_pinned: bool = False): async def after(
self, ctx: commands.Context, message_id: RawMessageIds, delete_pinned: bool = False
):
"""Delete all messages after a specified message. """Delete all messages after a specified message.
To get a message id, enable developer mode in Discord's To get a message id, enable developer mode in Discord's
@ -242,7 +245,11 @@ class Cleanup(commands.Cog):
@commands.guild_only() @commands.guild_only()
@commands.bot_has_permissions(manage_messages=True) @commands.bot_has_permissions(manage_messages=True)
async def before( async def before(
self, ctx: commands.Context, message_id: int, number: int, delete_pinned: bool = False self,
ctx: commands.Context,
message_id: RawMessageIds,
number: int,
delete_pinned: bool = False,
): ):
"""Deletes X messages before specified message. """Deletes X messages before specified message.
@ -255,7 +262,7 @@ class Cleanup(commands.Cog):
author = ctx.author author = ctx.author
try: try:
before = await channel.get_message(message_id) before = await channel.fetch_message(message_id)
except discord.NotFound: except discord.NotFound:
return await ctx.send(_("Message not found.")) return await ctx.send(_("Message not found."))
@ -271,6 +278,48 @@ class Cleanup(commands.Cog):
await mass_purge(to_delete, channel) await mass_purge(to_delete, channel)
@cleanup.command()
@commands.guild_only()
@commands.bot_has_permissions(manage_messages=True)
async def between(
self,
ctx: commands.Context,
one: RawMessageIds,
two: RawMessageIds,
delete_pinned: bool = False,
):
"""Delete the messages between Messsage One and Message Two, providing the messages IDs.
The first message ID should be the older message and the second one the newer.
Example:
`[p]cleanup between 123456789123456789 987654321987654321`
"""
channel = ctx.channel
author = ctx.author
try:
mone = await channel.fetch_message(one)
except discord.errors.Notfound:
return await ctx.send(
_("Could not find a message with the ID of {id}.".format(id=one))
)
try:
mtwo = await channel.fetch_message(two)
except discord.errors.Notfound:
return await ctx.send(
_("Could not find a message with the ID of {id}.".format(id=two))
)
to_delete = await self.get_messages_for_deletion(
channel=channel, before=mtwo, after=mone, delete_pinned=delete_pinned
)
to_delete.append(ctx.message)
reason = "{}({}) deleted {} messages in channel {}.".format(
author.name, author.id, len(to_delete), channel.name
)
log.info(reason)
await mass_purge(to_delete, channel)
@cleanup.command() @cleanup.command()
@commands.guild_only() @commands.guild_only()
@commands.bot_has_permissions(manage_messages=True) @commands.bot_has_permissions(manage_messages=True)

View File

@ -0,0 +1,12 @@
from redbot.core.commands import Converter, BadArgument
from redbot.core.i18n import Translator
_ = Translator("Cleanup", __file__)
class RawMessageIds(Converter):
async def convert(self, ctx, argument):
if argument.isnumeric() and len(argument) >= 17:
return int(argument)
raise BadArgument(_("{} doesn't look like a valid message ID.").format(argument))

View File

@ -466,7 +466,7 @@ class CustomCommands(commands.Cog):
return return
# wrap the command here so it won't register with the bot # wrap the command here so it won't register with the bot
fake_cc = commands.Command(ctx.invoked_with, self.cc_callback) fake_cc = commands.command(name=ctx.invoked_with)(self.cc_callback)
fake_cc.params = self.prepare_args(raw_response) fake_cc.params = self.prepare_args(raw_response)
ctx.command = fake_cc ctx.command = fake_cc

View File

@ -8,7 +8,7 @@ from sys import path as syspath
from typing import Tuple, Union, Iterable from typing import Tuple, Union, Iterable
import discord import discord
from redbot.core import checks, commands, Config from redbot.core import checks, commands, Config, version_info as red_version_info
from redbot.core.bot import Red from redbot.core.bot import Red
from redbot.core.data_manager import cog_data_path from redbot.core.data_manager import cog_data_path
from redbot.core.i18n import Translator, cog_i18n from redbot.core.i18n import Translator, cog_i18n
@ -303,6 +303,26 @@ class Downloader(commands.Cog):
) )
) )
return return
ignore_max = cog.min_bot_version > cog.max_bot_version
if (
cog.min_bot_version > red_version_info
or not ignore_max
and cog.max_bot_version < red_version_info
):
await ctx.send(
_("This cog requires at least Red version {min_version}").format(
min_version=cog.min_bot_version
)
+ (
""
if ignore_max
else _(" and at most {max_version}").format(max_version=cog.max_bot_version)
)
+ _(", but you have {current_version}, aborting install.").format(
current_version=red_version_info
)
)
return
if not await repo.install_requirements(cog, self.LIB_PATH): if not await repo.install_requirements(cog, self.LIB_PATH):
libraries = humanize_list(tuple(map(inline, cog.requirements))) libraries = humanize_list(tuple(map(inline, cog.requirements)))
@ -344,6 +364,7 @@ class Downloader(commands.Cog):
poss_installed_path = (await self.cog_install_path()) / real_name poss_installed_path = (await self.cog_install_path()) / real_name
if poss_installed_path.exists(): if poss_installed_path.exists():
with contextlib.suppress(commands.ExtensionNotLoaded):
ctx.bot.unload_extension(real_name) ctx.bot.unload_extension(real_name)
await self._delete_cog(poss_installed_path) await self._delete_cog(poss_installed_path)
uninstalled_cogs.append(inline(real_name)) uninstalled_cogs.append(inline(real_name))

View File

@ -8,6 +8,8 @@ from typing import MutableMapping, Any, TYPE_CHECKING
from .log import log from .log import log
from .json_mixins import RepoJSONMixin from .json_mixins import RepoJSONMixin
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
@ -72,7 +74,8 @@ class Installable(RepoJSONMixin):
self.repo_name = self._location.parent.stem self.repo_name = self._location.parent.stem
self.author = () self.author = ()
self.bot_version = (3, 0, 0) self.min_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
@ -157,10 +160,16 @@ class Installable(RepoJSONMixin):
self.author = author self.author = author
try: try:
bot_version = tuple(info.get("bot_version", [3, 0, 0])) min_bot_version = VersionInfo.from_str(str(info.get("min_bot_version", __version__)))
except ValueError: except ValueError:
bot_version = self.bot_version min_bot_version = self.min_bot_version
self.bot_version = bot_version self.min_bot_version = min_bot_version
try:
max_bot_version = VersionInfo.from_str(str(info.get("max_bot_version", __version__)))
except ValueError:
max_bot_version = self.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]))

View File

@ -355,34 +355,56 @@ class Economy(commands.Cog):
author = ctx.author author = ctx.author
if top < 1: if top < 1:
top = 10 top = 10
if ( if await bank.is_global() and show_global:
await bank.is_global() and show_global # show_global is only applicable if bank is global
): # show_global is only applicable if bank is global bank_sorted = await bank.get_leaderboard(positions=top, guild=None)
guild = None
bank_sorted = await bank.get_leaderboard(positions=top, guild=guild)
header = "{pound:4}{name:36}{score:2}\n".format(
pound="#", name=_("Name"), score=_("Score")
)
highscores = [
(
f"{f'{pos}.': <{3 if pos < 10 else 2}} {acc[1]['name']: <{35}s} "
f"{acc[1]['balance']: >{2 if pos < 10 else 1}}\n"
)
if acc[0] != author.id
else (
f"{f'{pos}.': <{3 if pos < 10 else 2}} <<{acc[1]['name'] + '>>': <{33}s} "
f"{acc[1]['balance']: >{2 if pos < 10 else 1}}\n"
)
for pos, acc in enumerate(bank_sorted, 1)
]
if highscores:
pages = [
f"```md\n{header}{''.join(''.join(highscores[x:x + 10]))}```"
for x in range(0, len(highscores), 10)
]
await menu(ctx, pages, DEFAULT_CONTROLS)
else: else:
await ctx.send(_("There are no accounts in the bank.")) bank_sorted = await bank.get_leaderboard(positions=top, guild=guild)
try:
bal_len = len(str(bank_sorted[0][1]["balance"]))
# first user is the largest we'll see
except IndexError:
return await ctx.send(_("There are no accounts in the bank."))
pound_len = len(str(len(bank_sorted)))
header = "{pound:{pound_len}}{score:{bal_len}}{name:2}\n".format(
pound="#",
name=_("Name"),
score=_("Score"),
bal_len=bal_len + 6,
pound_len=pound_len + 3,
)
highscores = []
pos = 1
temp_msg = header
for acc in bank_sorted:
try:
name = guild.get_member(acc[0]).display_name
except AttributeError:
user_id = ""
if await ctx.bot.is_owner(ctx.author):
user_id = f"({str(acc[0])})"
name = f"{acc[1]['name']} {user_id}"
balance = acc[1]["balance"]
if acc[0] != author.id:
temp_msg += f"{f'{pos}.': <{pound_len+2}} {balance: <{bal_len + 5}} {name}\n"
else:
temp_msg += (
f"{f'{pos}.': <{pound_len+2}} "
f"{balance: <{bal_len + 5}} "
f"<<{author.display_name}>>\n"
)
if pos % 10 == 0:
highscores.append(box(temp_msg, lang="md"))
temp_msg = header
pos += 1
if temp_msg != header:
highscores.append(box(temp_msg, lang="md"))
if highscores:
await menu(ctx, highscores, DEFAULT_CONTROLS)
@commands.command() @commands.command()
@guild_only_check() @guild_only_check()

View File

@ -1,8 +1,8 @@
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from typing import List, Tuple from typing import List, Tuple, Optional
import discord import discord
from redbot.core import Config from redbot.core import Config, commands
from redbot.core.bot import Red from redbot.core.bot import Red
@ -20,6 +20,13 @@ class MixinMeta(ABC):
self.ban_queue: List[Tuple[int, int]] self.ban_queue: List[Tuple[int, int]]
self.unban_queue: List[Tuple[int, int]] self.unban_queue: List[Tuple[int, int]]
@staticmethod
@abstractmethod
async def _voice_perm_check(
ctx: commands.Context, user_voice_state: Optional[discord.VoiceState], **perms: bool
) -> bool:
raise NotImplementedError()
@classmethod @classmethod
@abstractmethod @abstractmethod
async def get_audit_entry_info( async def get_audit_entry_info(

View File

@ -97,4 +97,10 @@ CASETYPES = [
"case_str": "Server Unmute", "case_str": "Server Unmute",
"audit_type": "overwrite_update", "audit_type": "overwrite_update",
}, },
{
"name": "vkick",
"default_setting": False,
"image": "\N{SPEAKER WITH CANCELLATION STROKE}",
"case_str": "Voice Kick",
},
] ]

View File

@ -495,6 +495,56 @@ class KickBanMixin(MixinMeta):
await ctx.send(e) await ctx.send(e)
await ctx.send(_("Done. Enough chaos.")) await ctx.send(_("Done. Enough chaos."))
@commands.command()
@commands.guild_only()
@commands.mod_or_permissions(move_members=True)
async def voicekick(
self, ctx: commands.Context, member: discord.Member, *, reason: str = None
):
"""Kick a member from a voice channel."""
author = ctx.author
guild = ctx.guild
user_voice_state: discord.VoiceState = member.voice
if await self._voice_perm_check(ctx, user_voice_state, move_members=True) is False:
return
elif not await is_allowed_by_hierarchy(self.bot, self.settings, guild, author, member):
await ctx.send(
_(
"I cannot let you do that. You are "
"not higher than the user in the role "
"hierarchy."
)
)
return
case_channel = member.voice.channel
# Store this channel for the case channel.
try:
await member.move_to(discord.Object(id=None))
# Work around till we get D.py 1.1.0, whereby we can directly do None.
except discord.Forbidden: # Very unlikely that this will ever occur
await ctx.send(_("I am unable to kick this member from the voice channel."))
return
except discord.HTTPException:
await ctx.send(_("Something went wrong while attempting to kick that member"))
return
else:
try:
await modlog.create_case(
self.bot,
guild,
ctx.message.created_at,
"vkick",
member,
author,
reason,
until=None,
channel=case_channel,
)
except RuntimeError as e:
await ctx.send(e)
@commands.command() @commands.command()
@commands.guild_only() @commands.guild_only()
@commands.bot_has_permissions(ban_members=True) @commands.bot_has_permissions(ban_members=True)
@ -508,8 +558,9 @@ class KickBanMixin(MixinMeta):
click the user and select 'Copy ID'.""" click the user and select 'Copy ID'."""
guild = ctx.guild guild = ctx.guild
author = ctx.author author = ctx.author
try:
user = await self.bot.fetch_user(user_id) user = await self.bot.fetch_user(user_id)
if not user: except discord.errors.NotFound:
await ctx.send(_("Couldn't find a user with that ID!")) await ctx.send(_("Couldn't find a user with that ID!"))
return return
audit_reason = get_audit_reason(ctx.author, reason) audit_reason = get_audit_reason(ctx.author, reason)

View File

@ -44,6 +44,7 @@ class Streams(commands.Cog):
"mention_here": False, "mention_here": False,
"live_message_mention": False, "live_message_mention": False,
"live_message_nomention": False, "live_message_nomention": False,
"ignore_reruns": False,
} }
role_defaults = {"mention": False} role_defaults = {"mention": False}
@ -461,6 +462,19 @@ class Streams(commands.Cog):
else: else:
await ctx.send(_("Notifications will no longer be deleted.")) await ctx.send(_("Notifications will no longer be deleted."))
@streamset.command(name="ignorereruns")
@commands.guild_only()
async def ignore_reruns(self, ctx: commands.Context):
"""Toggle excluding rerun streams from alerts."""
guild = ctx.guild
current_setting = await self.db.guild(guild).ignore_reruns()
if current_setting:
await self.db.guild(guild).ignore_reruns.set(False)
await ctx.send(_("Streams of type 'rerun' will be included in alerts."))
else:
await self.db.guild(guild).ignore_reruns.set(True)
await ctx.send(_("Streams of type 'rerun' will no longer send an alert."))
async def add_or_remove(self, ctx: commands.Context, stream): async def add_or_remove(self, ctx: commands.Context, stream):
if ctx.channel.id not in stream.channels: if ctx.channel.id not in stream.channels:
stream.channels.append(ctx.channel.id) stream.channels.append(ctx.channel.id)
@ -524,7 +538,7 @@ class Streams(commands.Cog):
for stream in self.streams: for stream in self.streams:
with contextlib.suppress(Exception): with contextlib.suppress(Exception):
try: try:
embed = await stream.is_online() embed, is_rerun = await stream.is_online()
except OfflineStream: except OfflineStream:
if not stream._messages_cache: if not stream._messages_cache:
continue continue
@ -540,6 +554,9 @@ class Streams(commands.Cog):
continue continue
for channel_id in stream.channels: for channel_id in stream.channels:
channel = self.bot.get_channel(channel_id) channel = self.bot.get_channel(channel_id)
ignore_reruns = await self.db.guild(channel.guild).ignore_reruns()
if ignore_reruns and is_rerun:
continue
mention_str, edited_roles = await self._get_mention_str(channel.guild) mention_str, edited_roles = await self._get_mention_str(channel.guild)
if mention_str: if mention_str:

View File

@ -174,7 +174,8 @@ class TwitchStream(Stream):
# self.already_online = True # self.already_online = True
# In case of rename # In case of rename
self.name = data["stream"]["channel"]["name"] self.name = data["stream"]["channel"]["name"]
return self.make_embed(data) is_rerun = True if data["stream"]["stream_type"] == "rerun" else False
return self.make_embed(data), is_rerun
elif r.status == 400: elif r.status == 400:
raise InvalidTwitchCredentials() raise InvalidTwitchCredentials()
elif r.status == 404: elif r.status == 404:
@ -204,6 +205,7 @@ class TwitchStream(Stream):
def make_embed(self, data): def make_embed(self, data):
channel = data["stream"]["channel"] channel = data["stream"]["channel"]
is_rerun = data["stream"]["stream_type"] == "rerun"
url = channel["url"] url = channel["url"]
logo = channel["logo"] logo = channel["logo"]
if logo is None: if logo is None:
@ -211,6 +213,8 @@ class TwitchStream(Stream):
status = channel["status"] status = channel["status"]
if not status: if not status:
status = "Untitled broadcast" status = "Untitled broadcast"
if is_rerun:
status += " - Rerun"
embed = discord.Embed(title=status, url=url) embed = discord.Embed(title=status, url=url)
embed.set_author(name=channel["display_name"]) embed.set_author(name=channel["display_name"])
embed.add_field(name="Followers", value=channel["followers"]) embed.add_field(name="Followers", value=channel["followers"])

View File

@ -45,7 +45,7 @@ class RedBase(commands.GroupMixin, commands.bot.BotBase, RPCMixin):
owner=None, owner=None,
whitelist=[], whitelist=[],
blacklist=[], blacklist=[],
locale="en", locale="en-US",
embeds=True, embeds=True,
color=15158332, color=15158332,
fuzzy=False, fuzzy=False,
@ -190,6 +190,21 @@ class RedBase(commands.GroupMixin, commands.bot.BotBase, RPCMixin):
async def get_context(self, message, *, cls=commands.Context): async def get_context(self, message, *, cls=commands.Context):
return await super().get_context(message, cls=cls) return await super().get_context(message, cls=cls)
async def process_commands(self, message: discord.Message):
"""
modification from the base to do the same thing in the command case
but dispatch an additional event for cogs which want to handle normal messages
differently to command messages,
without the overhead of additional get_context calls per cog
"""
if not message.author.bot:
ctx = await self.get_context(message)
if ctx.valid:
return await self.invoke(ctx)
self.dispatch("message_without_command", message)
@staticmethod @staticmethod
def list_packages(): def list_packages():
"""Lists packages present in the cogs the folder""" """Lists packages present in the cogs the folder"""

View File

@ -156,6 +156,13 @@ class Command(CogCommandMixin, commands.Command):
self._help_override = kwargs.pop("help_override", None) self._help_override = kwargs.pop("help_override", None)
self.translator = kwargs.pop("i18n", None) self.translator = kwargs.pop("i18n", None)
def _ensure_assignment_on_copy(self, other):
super()._ensure_assignment_on_copy(other)
# Red specific
other.requires = self.requires
return other
@property @property
def help(self): def help(self):
"""Help string for this command. """Help string for this command.

View File

@ -47,9 +47,9 @@ class APIToken(discord.ext.commands.Converter):
This will parse the input argument separating the key value pairs into a This will parse the input argument separating the key value pairs into a
format to be used for the core bots API token storage. format to be used for the core bots API token storage.
This will split the argument by eiher `;` or `,` and return a dict This will split the argument by either `;` or `,` and return a dict
to be stored. Since all API's are different and have different naming convention, to be stored. Since all API's are different and have different naming convention,
this leaves the owness on the cog creator to clearly define how to setup the correct this leaves the onus on the cog creator to clearly define how to setup the correct
credential names for their cogs. credential names for their cogs.
""" """

View File

@ -6,6 +6,7 @@ import itertools
import json import json
import logging import logging
import os import os
import pathlib
import sys import sys
import tarfile import tarfile
import traceback import traceback
@ -38,13 +39,6 @@ __all__ = ["Core"]
log = logging.getLogger("red") log = logging.getLogger("red")
OWNER_DISCLAIMER = (
"⚠ **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.** ⚠"
)
_ = i18n.Translator("Core", __file__) _ = i18n.Translator("Core", __file__)
@ -301,42 +295,41 @@ class Core(commands.Cog, CoreLogic):
async with session.get("{}/json".format(red_pypi)) as r: async with session.get("{}/json".format(red_pypi)) as r:
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 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]({}) "
"and help us improve!\n\n" "and help us improve!\n\n"
"".format(red_repo, author_repo, org_repo, support_server_url) ).format(red_repo, author_repo, org_repo, support_server_url)
)
embed = discord.Embed(color=(await ctx.embed_colour())) embed = discord.Embed(color=(await ctx.embed_colour()))
embed.add_field(name="Instance owned by", value=str(owner)) embed.add_field(name=_("Instance owned by"), value=str(owner))
embed.add_field(name="Python", value=python_version) embed.add_field(name="Python", value=python_version)
embed.add_field(name="discord.py", value=dpy_version) embed.add_field(name="discord.py", value=dpy_version)
embed.add_field(name="Red version", value=red_version) embed.add_field(name=_("Red version"), value=red_version)
if outdated: if outdated:
embed.add_field( embed.add_field(
name="Outdated", value="Yes, {} is available".format(data["info"]["version"]) name=_("Outdated"), value=_("Yes, {} is available").format(data["info"]["version"])
) )
if custom_info: if custom_info:
embed.add_field(name="About this instance", value=custom_info, inline=False) embed.add_field(name=_("About this instance"), value=custom_info, inline=False)
embed.add_field(name="About Red", value=about, inline=False) embed.add_field(name=_("About Red"), value=about, inline=False)
embed.set_footer( embed.set_footer(
text="Bringing joy since 02 Jan 2016 (over {} days ago!)".format(days_since) text=_("Bringing joy since 02 Jan 2016 (over {} days ago!)").format(days_since)
) )
try: try:
await ctx.send(embed=embed) await ctx.send(embed=embed)
except discord.HTTPException: except discord.HTTPException:
await ctx.send("I need the `Embed links` permission to send this") await ctx.send(_("I need the `Embed links` permission to send this"))
@commands.command() @commands.command()
async def uptime(self, ctx: commands.Context): async def uptime(self, ctx: commands.Context):
"""Shows Red's uptime""" """Shows Red's uptime"""
since = ctx.bot.uptime.strftime("%Y-%m-%d %H:%M:%S") since = ctx.bot.uptime.strftime("%Y-%m-%d %H:%M:%S")
passed = self.get_bot_uptime() passed = self.get_bot_uptime()
await ctx.send("Been up for: **{}** (since {} UTC)".format(passed, since)) await ctx.send(_("Been up for: **{}** (since {} UTC)").format(passed, since))
def get_bot_uptime(self, *, brief: bool = False): def get_bot_uptime(self, *, brief: bool = False):
# Courtesy of Danny # Courtesy of Danny
@ -348,13 +341,13 @@ class Core(commands.Cog, CoreLogic):
if not brief: if not brief:
if days: if days:
fmt = "{d} days, {h} hours, {m} minutes, and {s} seconds" fmt = _("{d} days, {h} hours, {m} minutes, and {s} seconds")
else: else:
fmt = "{h} hours, {m} minutes, and {s} seconds" fmt = _("{h} hours, {m} minutes, and {s} seconds")
else: else:
fmt = "{h}h {m}m {s}s" fmt = _("{h}h {m}m {s}s")
if days: if days:
fmt = "{d}d " + fmt fmt = _("{d}d ") + fmt
return fmt.format(d=days, h=hours, m=minutes, s=seconds) return fmt.format(d=days, h=hours, m=minutes, s=seconds)
@ -369,14 +362,14 @@ class Core(commands.Cog, CoreLogic):
use embeds. use embeds.
""" """
if ctx.invoked_subcommand is None: if ctx.invoked_subcommand is None:
text = "Embed settings:\n\n" text = _("Embed settings:\n\n")
global_default = await self.bot.db.embeds() global_default = await self.bot.db.embeds()
text += "Global default: {}\n".format(global_default) text += _("Global default: {}\n").format(global_default)
if ctx.guild: if ctx.guild:
guild_setting = await self.bot.db.guild(ctx.guild).embeds() guild_setting = await self.bot.db.guild(ctx.guild).embeds()
text += "Guild setting: {}\n".format(guild_setting) text += _("Guild setting: {}\n").format(guild_setting)
user_setting = await self.bot.db.user(ctx.author).embeds() user_setting = await self.bot.db.user(ctx.author).embeds()
text += "User setting: {}".format(user_setting) text += _("User setting: {}").format(user_setting)
await ctx.send(box(text)) await ctx.send(box(text))
@embedset.command(name="global") @embedset.command(name="global")
@ -392,7 +385,7 @@ class Core(commands.Cog, CoreLogic):
current = await self.bot.db.embeds() current = await self.bot.db.embeds()
await self.bot.db.embeds.set(not current) await self.bot.db.embeds.set(not current)
await ctx.send( await ctx.send(
_("Embeds are now {} by default.").format("disabled" if current else "enabled") _("Embeds are now {} by default.").format(_("disabled") if current else _("enabled"))
) )
@embedset.command(name="guild") @embedset.command(name="guild")
@ -415,7 +408,9 @@ class Core(commands.Cog, CoreLogic):
await ctx.send(_("Embeds will now fall back to the global setting.")) await ctx.send(_("Embeds will now fall back to the global setting."))
else: else:
await ctx.send( await ctx.send(
_("Embeds are now {} for this guild.").format("enabled" if enabled else "disabled") _("Embeds are now {} for this guild.").format(
_("enabled") if enabled else _("disabled")
)
) )
@embedset.command(name="user") @embedset.command(name="user")
@ -436,7 +431,7 @@ class Core(commands.Cog, CoreLogic):
await ctx.send(_("Embeds will now fall back to the global setting.")) await ctx.send(_("Embeds will now fall back to the global setting."))
else: else:
await ctx.send( await ctx.send(
_("Embeds are now {} for you.").format("enabled" if enabled else "disabled") _("Embeds are now {} for you.").format(_("enabled") if enabled else _("disabled"))
) )
@commands.command() @commands.command()
@ -454,7 +449,7 @@ class Core(commands.Cog, CoreLogic):
for page in pagify(self.bot._last_exception, shorten_by=10): for page in pagify(self.bot._last_exception, shorten_by=10):
await destination.send(box(page, lang="py")) await destination.send(box(page, lang="py"))
else: else:
await ctx.send("No exception has occurred yet") await ctx.send(_("No exception has occurred yet"))
@commands.command() @commands.command()
@checks.is_owner() @checks.is_owner()
@ -467,21 +462,21 @@ class Core(commands.Cog, CoreLogic):
@checks.is_owner() @checks.is_owner()
async def leave(self, ctx: commands.Context): async def leave(self, ctx: commands.Context):
"""Leaves server""" """Leaves server"""
await ctx.send("Are you sure you want me to leave this server? (y/n)") await ctx.send(_("Are you sure you want me to leave this server? (y/n)"))
pred = MessagePredicate.yes_or_no(ctx) pred = MessagePredicate.yes_or_no(ctx)
try: try:
await self.bot.wait_for("message", check=pred) await self.bot.wait_for("message", check=pred)
except asyncio.TimeoutError: except asyncio.TimeoutError:
await ctx.send("Response timed out.") await ctx.send(_("Response timed out."))
return return
else: else:
if pred.result is True: if pred.result is True:
await ctx.send("Alright. Bye :wave:") await ctx.send(_("Alright. Bye :wave:"))
log.debug("Leaving guild '{}'".format(ctx.guild.name)) log.debug(_("Leaving guild '{}'").format(ctx.guild.name))
await ctx.guild.leave() await ctx.guild.leave()
else: else:
await ctx.send("Alright, I'll stay then :)") await ctx.send(_("Alright, I'll stay then :)"))
@commands.command() @commands.command()
@checks.is_owner() @checks.is_owner()
@ -497,7 +492,7 @@ class Core(commands.Cog, CoreLogic):
for page in pagify(msg, ["\n"]): for page in pagify(msg, ["\n"]):
await ctx.send(page) await ctx.send(page)
query = await ctx.send("To leave a server, just type its number.") query = await ctx.send(_("To leave a server, just type its number."))
pred = MessagePredicate.contained_in(responses, ctx) pred = MessagePredicate.contained_in(responses, ctx)
try: try:
@ -512,21 +507,21 @@ class Core(commands.Cog, CoreLogic):
async def leave_confirmation(self, guild, ctx): async def leave_confirmation(self, guild, ctx):
if guild.owner.id == ctx.bot.user.id: if guild.owner.id == ctx.bot.user.id:
await ctx.send("I cannot leave a guild I am the owner of.") await ctx.send(_("I cannot leave a guild I am the owner of."))
return return
await ctx.send("Are you sure you want me to leave {}? (yes/no)".format(guild.name)) await ctx.send(_("Are you sure you want me to leave {}? (yes/no)").format(guild.name))
pred = MessagePredicate.yes_or_no(ctx) pred = MessagePredicate.yes_or_no(ctx)
try: try:
await self.bot.wait_for("message", check=pred, timeout=15) await self.bot.wait_for("message", check=pred, timeout=15)
if pred.result is True: if pred.result is True:
await guild.leave() await guild.leave()
if guild != ctx.guild: if guild != ctx.guild:
await ctx.send("Done.") await ctx.send(_("Done."))
else: else:
await ctx.send("Alright then.") await ctx.send(_("Alright then."))
except asyncio.TimeoutError: except asyncio.TimeoutError:
await ctx.send("Response timed out.") await ctx.send(_("Response timed out."))
@commands.command() @commands.command()
@checks.is_owner() @checks.is_owner()
@ -538,17 +533,17 @@ class Core(commands.Cog, CoreLogic):
loaded, failed, not_found, already_loaded, failed_with_reason = await self._load(cogs) loaded, failed, not_found, already_loaded, failed_with_reason = await self._load(cogs)
if loaded: if loaded:
fmt = "Loaded {packs}." fmt = _("Loaded {packs}.")
formed = self._get_package_strings(loaded, fmt) formed = self._get_package_strings(loaded, fmt)
await ctx.send(formed) await ctx.send(formed)
if already_loaded: if already_loaded:
fmt = "The package{plural} {packs} {other} already loaded." fmt = _("The package{plural} {packs} {other} already loaded.")
formed = self._get_package_strings(already_loaded, fmt, ("is", "are")) formed = self._get_package_strings(already_loaded, fmt, (_("is"), _("are")))
await ctx.send(formed) await ctx.send(formed)
if failed: if failed:
fmt = ( fmt = _(
"Failed to load package{plural} {packs}. Check your console or " "Failed to load package{plural} {packs}. Check your console or "
"logs for details." "logs for details."
) )
@ -556,17 +551,17 @@ class Core(commands.Cog, CoreLogic):
await ctx.send(formed) await ctx.send(formed)
if not_found: if not_found:
fmt = "The package{plural} {packs} {other} not found in any cog path." fmt = _("The package{plural} {packs} {other} not found in any cog path.")
formed = self._get_package_strings(not_found, fmt, ("was", "were")) formed = self._get_package_strings(not_found, fmt, (_("was"), _("were")))
await ctx.send(formed) await ctx.send(formed)
if failed_with_reason: if failed_with_reason:
fmt = ( fmt = _(
"{other} package{plural} could not be loaded for the following reason{plural}:\n\n" "{other} package{plural} could not be loaded for the following reason{plural}:\n\n"
) )
reasons = "\n".join([f"`{x}`: {y}" for x, y in failed_with_reason]) reasons = "\n".join([f"`{x}`: {y}" for x, y in failed_with_reason])
formed = self._get_package_strings( formed = self._get_package_strings(
[x for x, y in failed_with_reason], fmt, ("This", "These") [x for x, y in failed_with_reason], fmt, (_("This"), _("These"))
) )
await ctx.send(formed + reasons) await ctx.send(formed + reasons)
@ -579,13 +574,13 @@ class Core(commands.Cog, CoreLogic):
unloaded, failed = await self._unload(cogs) unloaded, failed = await self._unload(cogs)
if unloaded: if unloaded:
fmt = "Package{plural} {packs} {other} unloaded." fmt = _("Package{plural} {packs} {other} unloaded.")
formed = self._get_package_strings(unloaded, fmt, ("was", "were")) formed = self._get_package_strings(unloaded, fmt, (_("was"), _("were")))
await ctx.send(formed) await ctx.send(formed)
if failed: if failed:
fmt = "The package{plural} {packs} {other} not loaded." fmt = _("The package{plural} {packs} {other} not loaded.")
formed = self._get_package_strings(failed, fmt, ("is", "are")) formed = self._get_package_strings(failed, fmt, (_("is"), _("are")))
await ctx.send(formed) await ctx.send(formed)
@commands.command(name="reload") @commands.command(name="reload")
@ -600,25 +595,27 @@ class Core(commands.Cog, CoreLogic):
) )
if loaded: if loaded:
fmt = "Package{plural} {packs} {other} reloaded." fmt = _("Package{plural} {packs} {other} reloaded.")
formed = self._get_package_strings(loaded, fmt, ("was", "were")) formed = self._get_package_strings(loaded, fmt, (_("was"), _("were")))
await ctx.send(formed) await ctx.send(formed)
if failed: if failed:
fmt = "Failed to reload package{plural} {packs}. Check your logs for details" fmt = _("Failed to reload package{plural} {packs}. Check your logs for details")
formed = self._get_package_strings(failed, fmt) formed = self._get_package_strings(failed, fmt)
await ctx.send(formed) await ctx.send(formed)
if not_found: if not_found:
fmt = "The package{plural} {packs} {other} not found in any cog path." fmt = _("The package{plural} {packs} {other} not found in any cog path.")
formed = self._get_package_strings(not_found, fmt, ("was", "were")) formed = self._get_package_strings(not_found, fmt, (_("was"), _("were")))
await ctx.send(formed) await ctx.send(formed)
if failed_with_reason: if failed_with_reason:
fmt = "{other} package{plural} could not be reloaded for the following reason{plural}:\n\n" fmt = _(
"{other} package{plural} could not be reloaded for the following reason{plural}:\n\n"
)
reasons = "\n".join([f"`{x}`: {y}" for x, y in failed_with_reason]) reasons = "\n".join([f"`{x}`: {y}" for x, y in failed_with_reason])
formed = self._get_package_strings( formed = self._get_package_strings(
[x for x, y in failed_with_reason], fmt, ("This", "These") [x for x, y in failed_with_reason], fmt, (_("This"), _("These"))
) )
await ctx.send(formed + reasons) await ctx.send(formed + reasons)
@ -659,7 +656,9 @@ class Core(commands.Cog, CoreLogic):
guild.get_role(await ctx.bot.db.guild(ctx.guild).mod_role()) or "Not set" guild.get_role(await ctx.bot.db.guild(ctx.guild).mod_role()) or "Not set"
) )
prefixes = await ctx.bot.db.guild(ctx.guild).prefix() prefixes = await ctx.bot.db.guild(ctx.guild).prefix()
guild_settings = f"Admin role: {admin_role}\nMod role: {mod_role}\n" guild_settings = _("Admin role: {admin}\nMod role: {mod}\n").format(
admin=admin_role, mod=mod_role
)
else: else:
guild_settings = "" guild_settings = ""
prefixes = None # This is correct. The below can happen in a guild. prefixes = None # This is correct. The below can happen in a guild.
@ -668,11 +667,16 @@ class Core(commands.Cog, CoreLogic):
locale = await ctx.bot.db.locale() locale = await ctx.bot.db.locale()
prefix_string = " ".join(prefixes) prefix_string = " ".join(prefixes)
settings = ( settings = _(
f"{ctx.bot.user.name} Settings:\n\n" "{bot_name} Settings:\n\n"
f"Prefixes: {prefix_string}\n" "Prefixes: {prefixes}\n"
f"{guild_settings}" "{guild_settings}"
f"Locale: {locale}" "Locale: {locale}"
).format(
bot_name=ctx.bot.user.name,
prefixes=prefix_string,
guild_settings=guild_settings,
locale=locale,
) )
await ctx.send(box(settings)) await ctx.send(box(settings))
@ -905,7 +909,7 @@ class Core(commands.Cog, CoreLogic):
except discord.Forbidden: except discord.Forbidden:
await ctx.send(_("I lack the permissions to change my own nickname.")) await ctx.send(_("I lack the permissions to change my own nickname."))
else: else:
await ctx.send("Done.") await ctx.send(_("Done."))
@_set.command(aliases=["prefixes"]) @_set.command(aliases=["prefixes"])
@checks.is_owner() @checks.is_owner()
@ -942,11 +946,17 @@ class Core(commands.Cog, CoreLogic):
for i in range(length): for i in range(length):
token += random.choice(chars) token += random.choice(chars)
log.info("{0} ({0.id}) requested to be set as owner.".format(ctx.author)) log.info(_("{0} ({0.id}) requested to be set as owner.").format(ctx.author))
print(_("\nVerification token:")) print(_("\nVerification token:"))
print(token) print(token)
await ctx.send(_("Remember:\n") + OWNER_DISCLAIMER) owner_disclaimer = _(
"⚠ **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.** ⚠"
)
await ctx.send(_("Remember:\n") + owner_disclaimer)
await asyncio.sleep(5) await asyncio.sleep(5)
await ctx.send( await ctx.send(
@ -997,7 +1007,7 @@ class Core(commands.Cog, CoreLogic):
return return
await ctx.bot.db.token.set(token) await ctx.bot.db.token.set(token)
await ctx.send("Token set. Restart me.") await ctx.send(_("Token set. Restart me."))
@_set.command() @_set.command()
@checks.is_owner() @checks.is_owner()
@ -1147,7 +1157,7 @@ class Core(commands.Cog, CoreLogic):
locale_list.append("en-US") locale_list.append("en-US")
locale_list = sorted(set(locale_list)) locale_list = sorted(set(locale_list))
if not locale_list: if not locale_list:
await ctx.send("No languages found.") await ctx.send(_("No languages found."))
return return
pages = pagify("\n".join(locale_list), shorten_by=26) pages = pagify("\n".join(locale_list), shorten_by=26)
@ -1157,6 +1167,12 @@ class Core(commands.Cog, CoreLogic):
@checks.is_owner() @checks.is_owner()
async def backup(self, ctx: commands.Context, *, backup_path: str = None): async def backup(self, ctx: commands.Context, *, backup_path: str = None):
"""Creates a backup of all data for the instance.""" """Creates a backup of all data for the instance."""
if backup_path:
path = pathlib.Path(backup_path)
if not (path.exists() and path.is_dir()):
return await ctx.send(
_("That path doesn't seem to exist. Please provide a valid path.")
)
from redbot.core.data_manager import basic_config, instance_name from redbot.core.data_manager import basic_config, instance_name
from redbot.core.drivers.red_json import JSON from redbot.core.drivers.red_json import JSON
@ -1220,7 +1236,7 @@ class Core(commands.Cog, CoreLogic):
_("A backup has been made of this instance. It is at {}.").format(backup_file) _("A backup has been made of this instance. It is at {}.").format(backup_file)
) )
if backup_file.stat().st_size > 8_000_000: if backup_file.stat().st_size > 8_000_000:
await ctx.send(_("This backup is to large to send via DM.")) await ctx.send(_("This backup is too large to send via DM."))
return return
await ctx.send(_("Would you like to receive a copy via DM? (y/n)")) await ctx.send(_("Would you like to receive a copy via DM? (y/n)"))
@ -1364,6 +1380,16 @@ class Core(commands.Cog, CoreLogic):
else: else:
await ctx.send(_("Message delivered to {}").format(destination)) await ctx.send(_("Message delivered to {}").format(destination))
@commands.command(hidden=True)
@checks.is_owner()
async def datapath(self, ctx: commands.Context):
"""Prints the bot's data path."""
from redbot.core.data_manager import basic_config
data_dir = Path(basic_config["DATA_PATH"])
msg = _("Data path: {path}").format(path=data_dir)
await ctx.send(box(msg))
@commands.group() @commands.group()
@checks.is_owner() @checks.is_owner()
async def whitelist(self, ctx: commands.Context): async def whitelist(self, ctx: commands.Context):
@ -1452,7 +1478,7 @@ class Core(commands.Cog, CoreLogic):
""" """
curr_list = await ctx.bot.db.blacklist() curr_list = await ctx.bot.db.blacklist()
msg = _("blacklisted Users:") msg = _("Blacklisted Users:")
for user in curr_list: for user in curr_list:
msg += "\n\t- {}".format(user) msg += "\n\t- {}".format(user)
@ -1482,7 +1508,7 @@ class Core(commands.Cog, CoreLogic):
Clears the blacklist. Clears the blacklist.
""" """
await ctx.bot.db.blacklist.set([]) await ctx.bot.db.blacklist.set([])
await ctx.send(_("blacklist has been cleared.")) await ctx.send(_("Blacklist has been cleared."))
@commands.group() @commands.group()
@commands.guild_only() @commands.guild_only()
@ -1596,7 +1622,7 @@ class Core(commands.Cog, CoreLogic):
""" """
curr_list = await ctx.bot.db.guild(ctx.guild).blacklist() curr_list = await ctx.bot.db.guild(ctx.guild).blacklist()
msg = _("blacklisted Users and Roles:") msg = _("Blacklisted Users and Roles:")
for obj in curr_list: for obj in curr_list:
msg += "\n\t- {}".format(obj) msg += "\n\t- {}".format(obj)
@ -1635,7 +1661,7 @@ class Core(commands.Cog, CoreLogic):
Clears the blacklist. Clears the blacklist.
""" """
await ctx.bot.db.guild(ctx.guild).blacklist.set([]) await ctx.bot.db.guild(ctx.guild).blacklist.set([])
await ctx.send(_("blacklist has been cleared.")) await ctx.send(_("Blacklist has been cleared."))
@checks.guildowner_or_permissions(administrator=True) @checks.guildowner_or_permissions(administrator=True)
@commands.group(name="command") @commands.group(name="command")

View File

@ -119,7 +119,19 @@ def init_events(bot, cli_flags):
"Outdated version! {} is available " "Outdated version! {} is available "
"but you're using {}".format(data["info"]["version"], red_version) "but you're using {}".format(data["info"]["version"], red_version)
) )
owner = await bot.fetch_user(bot.owner_id)
owners = []
owner = bot.get_user(bot.owner_id)
if owner is not None:
owners.append(owner)
for co_owner in bot._co_owners:
co_owner = await bot.get_user(co_owner)
if co_owner is not None:
owners.append(co_owner)
for owner in owners:
with contextlib.suppress(discord.HTTPException):
await owner.send( await owner.send(
"Your Red instance is out of date! {} is the current " "Your Red instance is out of date! {} is the current "
"version, however you are using {}!".format( "version, however you are using {}!".format(

View File

@ -83,7 +83,8 @@ def bot_repo(event_loop):
# Installable # Installable
INFO_JSON = { INFO_JSON = {
"author": ("tekulvw",), "author": ("tekulvw",),
"bot_version": (3, 0, 0), "min_bot_version": "3.0.0",
"max_bot_version": "3.0.2",
"description": "A long description", "description": "A long description",
"hidden": False, "hidden": False,
"install_msg": "A post-installation message", "install_msg": "A post-installation message",
@ -96,7 +97,8 @@ INFO_JSON = {
LIBRARY_INFO_JSON = { LIBRARY_INFO_JSON = {
"author": ("seputaes",), "author": ("seputaes",),
"bot_version": (3, 0, 0), "min_bot_version": "3.0.0",
"max_bot_version": "3.0.2",
"description": "A long library description", "description": "A long library description",
"hidden": False, # libraries are always hidden, this tests it will be flipped "hidden": False, # libraries are always hidden, this tests it will be flipped
"install_msg": "A library install message", "install_msg": "A library install message",

View File

@ -41,7 +41,7 @@ install_requires =
multidict==4.5.2 multidict==4.5.2
python-levenshtein-wheels==0.13.1 python-levenshtein-wheels==0.13.1
pyyaml==3.13 pyyaml==3.13
red-lavalink==0.2.3 red-lavalink>=0.3.0,<0.4
schema==0.6.8 schema==0.6.8
yarl==1.3.0 yarl==1.3.0
discord.py==1.0.1 discord.py==1.0.1

View File

@ -5,12 +5,15 @@ import pytest
from redbot.pytest.downloader import * from redbot.pytest.downloader import *
from redbot.cogs.downloader.installable import Installable, InstallableType from redbot.cogs.downloader.installable import Installable, InstallableType
from redbot.core import VersionInfo
def test_process_info_file(installable): def test_process_info_file(installable):
for k, v in INFO_JSON.items(): for k, v in INFO_JSON.items():
if k == "type": if k == "type":
assert installable.type == InstallableType.COG assert installable.type == InstallableType.COG
elif k in ("min_bot_version", "max_bot_version"):
assert getattr(installable, k) == VersionInfo.from_str(v)
else: else:
assert getattr(installable, k) == v assert getattr(installable, k) == v
@ -19,6 +22,8 @@ def test_process_lib_info_file(library_installable):
for k, v in LIBRARY_INFO_JSON.items(): for k, v in LIBRARY_INFO_JSON.items():
if k == "type": if k == "type":
assert library_installable.type == InstallableType.SHARED_LIBRARY assert library_installable.type == InstallableType.SHARED_LIBRARY
elif k in ("min_bot_version", "max_bot_version"):
assert getattr(library_installable, k) == VersionInfo.from_str(v)
elif k == "hidden": elif k == "hidden":
# libraries are always hidden, even if False # libraries are always hidden, even if False
assert library_installable.hidden is True assert library_installable.hidden is True