diff --git a/README.md b/README.md index 776e402c5..bc7b1e5a6 100644 --- a/README.md +++ b/README.md @@ -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. -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 [Super Giant Games](https://www.supergiantgames.com/games/transistor/). diff --git a/docs/changelog_3_1_0.rst b/docs/changelog_3_1_0.rst index a5fda5c34..3727e27d1 100644 --- a/docs/changelog_3_1_0.rst +++ b/docs/changelog_3_1_0.rst @@ -1,8 +1,41 @@ .. 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 json`` + + Converting to Mongo: ``redbot-setup convert mongo`` + +################ v3.1.0 Changelog -================ +################ ----- Audio @@ -33,8 +66,15 @@ Audio Core ---- + * New Event dispatch: ``on_message_without_command`` (`#2338`_) + * Improve output format of cooldown messages (`#2412`_) * 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`_) + * Default locale changed from ``en`` to ``en-US`` (`#2642`_) + * New command ``[p]datapath`` that prints the bot's datapath (`#2652`_) ------ Config @@ -45,24 +85,50 @@ Config * We now record custom group primary key lengths in the core config object (`#2550`_) * Migrated internal UUIDs to maintain cross platform consistency (`#2604`_) +------------- +DataConverter +------------- + + * It's dead jim (Removal) (`#2554`_) + ---------- discord.py ---------- + * No longer vendoring discord.py (`#2587`_) + * Upgraded discord.py dependency to version 1.0.1 (`#2587`_) + ---------- Downloader ---------- * ``[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`_) * ``[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 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 --- * 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 @@ -72,6 +138,13 @@ Setup Scripts * ``redbot-setup convert`` now used to convert between libraries (`#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 ----- @@ -88,15 +161,20 @@ Trivia 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`` - 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 +.. _#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 .. _#2457: https://github.com/Cog-Creators/Red-DiscordBot/pull/2457 .. _#2461: https://github.com/Cog-Creators/Red-DiscordBot/pull/2461 .. _#2462: https://github.com/Cog-Creators/Red-DiscordBot/pull/2462 .. _#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 .. _#2470: https://github.com/Cog-Creators/Red-DiscordBot/pull/2470 .. _#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 .. _#2496: https://github.com/Cog-Creators/Red-DiscordBot/pull/2496 .. _#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 .. _#2521: https://github.com/Cog-Creators/Red-DiscordBot/pull/2521 .. _#2523: https://github.com/Cog-Creators/Red-DiscordBot/pull/2523 .. _#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 .. _#2536: https://github.com/Cog-Creators/Red-DiscordBot/pull/2536 .. _#2540: https://github.com/Cog-Creators/Red-DiscordBot/pull/2540 .. _#2545: https://github.com/Cog-Creators/Red-DiscordBot/pull/2545 .. _#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 .. _#2557: https://github.com/Cog-Creators/Red-DiscordBot/pull/2557 .. _#2565: https://github.com/Cog-Creators/Red-DiscordBot/pull/2565 .. _#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 .. _#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 .. _#2591: https://github.com/Cog-Creators/Red-DiscordBot/pull/2591 .. _#2592: https://github.com/Cog-Creators/Red-DiscordBot/pull/2592 .. _#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 +.. _#2605: https://github.com/Cog-Creators/Red-DiscordBot/pull/2605 .. _#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 diff --git a/docs/conf.py b/docs/conf.py index 3b04dffd5..29afe6380 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -203,7 +203,7 @@ linkcheck_ignore = [r"https://java.com*", r"https://chocolatey.org*"] # Intersphinx intersphinx_mapping = { "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), } diff --git a/docs/framework_apikeys.rst b/docs/framework_apikeys.rst index 5cb89e6b5..86a0a2e2d 100644 --- a/docs/framework_apikeys.rst +++ b/docs/framework_apikeys.rst @@ -18,7 +18,7 @@ and when accessed in the code it should be done by .. 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. diff --git a/docs/framework_downloader.rst b/docs/framework_downloader.rst index 45d75e459..6d4a5a45b 100644 --- a/docs/framework_downloader.rst +++ b/docs/framework_downloader.rst @@ -30,7 +30,10 @@ Keys common to both repo and 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. diff --git a/docs/guide_migration.rst b/docs/guide_migration.rst index a550ea7ea..c41009013 100644 --- a/docs/guide_migration.rst +++ b/docs/guide_migration.rst @@ -7,7 +7,7 @@ Migrating Cogs to V3 ==================== -First, be sure to read `discord.py's migration guide `_ +First, be sure to read `discord.py's migration guide `_ as that covers all of the changes to discord.py that will affect the migration process ---------------- diff --git a/docs/install_linux_mac.rst b/docs/install_linux_mac.rst index cb2759949..edab2a99b 100644 --- a/docs/install_linux_mac.rst +++ b/docs/install_linux_mac.rst @@ -92,7 +92,7 @@ one-by-one: brew install python --with-brewed-openssl brew install git 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`` @@ -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. You can find out how to obtain a token with -`this guide `_, +`this guide `_, section "Creating a Bot Account". You may also run Red via the launcher, which allows you to restart the bot diff --git a/docs/install_windows.rst b/docs/install_windows.rst index 875338ce3..076f83abd 100644 --- a/docs/install_windows.rst +++ b/docs/install_windows.rst @@ -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. You can find out how to obtain a token with -`this guide `_, +`this guide `_, section "Creating a Bot Account". You may also run Red via the launcher, which allows you to restart the bot diff --git a/redbot/__init__.py b/redbot/__init__.py index fb4b6c087..d2cd6b687 100644 --- a/redbot/__init__.py +++ b/redbot/__init__.py @@ -119,8 +119,14 @@ class VersionInfo: "dev_release": self.dev_release, } - def __lt__(self, other: "VersionInfo") -> bool: - tups: _List[_Tuple[int, int, int, int, int, int, int]] = [] + def _generate_comparison_tuples( + 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): tups.append( ( @@ -133,8 +139,20 @@ class VersionInfo: 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] + 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: ret = f"{self.major}.{self.minor}.{self.micro}" if self.releaselevel != self.FINAL: diff --git a/redbot/__main__.py b/redbot/__main__.py index 6460c211c..92793ab56 100644 --- a/redbot/__main__.py +++ b/redbot/__main__.py @@ -104,7 +104,9 @@ def main(): log.debug("Data Path: %s", data_manager._base_data_path()) 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_events(red, cli_flags) red.add_cog(Core(red)) diff --git a/redbot/cogs/audio/__init__.py b/redbot/cogs/audio/__init__.py index 3f912df3d..d36ffc7e3 100644 --- a/redbot/cogs/audio/__init__.py +++ b/redbot/cogs/audio/__init__.py @@ -1,31 +1,9 @@ -from pathlib import Path -import logging +from redbot.core import commands 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): 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() - bot.add_cog(cog) diff --git a/redbot/cogs/audio/audio.py b/redbot/cogs/audio/audio.py index 78f91045a..02e3f0f0b 100644 --- a/redbot/cogs/audio/audio.py +++ b/redbot/cogs/audio/audio.py @@ -14,6 +14,7 @@ import os import random import re import time +from typing import Optional import redbot.core from redbot.core import Config, commands, checks, bank 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 urllib.parse import urlparse -from .manager import shutdown_lavalink_server, start_lavalink_server, maybe_download_lavalink +from .manager import ServerManager _ = Translator("Audio", __file__) -__version__ = "0.0.8b" +__version__ = "0.0.9" __author__ = ["aikaterna"] log = logging.getLogger("red.audio") @@ -43,40 +44,45 @@ log = logging.getLogger("red.audio") class Audio(commands.Cog): """Play audio through voice channels.""" + _default_lavalink_settings = { + "host": "localhost", + "rest_port": 2333, + "ws_port": 2333, + "password": "youshallnotpass", + } + def __init__(self, bot): super().__init__() self.bot = bot self.config = Config.get_conf(self, 2711759130, force_registration=True) - default_global = { - "host": "localhost", - "rest_port": "2333", - "ws_port": "2332", - "password": "youshallnotpass", - "status": False, - "current_version": redbot.core.VersionInfo.from_str("3.0.0a0").to_json(), - "use_external_lavalink": False, - "restrict": True, - } + default_global = dict( + status=False, + use_external_lavalink=False, + restrict=True, + current_version=redbot.core.VersionInfo.from_str("3.0.0a0").to_json(), + localpath=str(cog_data_path(raw_name="Audio")), + **self._default_lavalink_settings, + ) - default_guild = { - "disconnect": False, - "dj_enabled": False, - "dj_role": None, - "emptydc_enabled": False, - "emptydc_timer": 0, - "jukebox": False, - "jukebox_price": 0, - "maxlength": 0, - "playlists": {}, - "notify": False, - "repeat": False, - "shuffle": False, - "thumbnail": False, - "volume": 100, - "vote_enabled": False, - "vote_percent": 0, - } + default_guild = dict( + disconnect=False, + dj_enabled=False, + dj_role=None, + emptydc_enabled=False, + emptydc_timer=0, + jukebox=False, + jukebox_price=0, + maxlength=0, + playlists={}, + notify=False, + repeat=False, + shuffle=False, + thumbnail=False, + volume=100, + vote_enabled=False, + vote_percent=0, + ) self.config.register_guild(**default_guild) self.config.register_global(**default_global) @@ -85,9 +91,24 @@ class Audio(commands.Cog): self._connect_task = None self._disconnect_task = None self._cleaned_up = False + self.spotify_token = None 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): self._restart_connect() 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): while True: # run until success external = await self.config.use_external_lavalink() - if not external: - shutdown_lavalink_server() - await maybe_download_lavalink(self.bot.loop, self) - await start_lavalink_server(self.bot.loop) - try: + if external is False: + settings = self._default_lavalink_settings + host = settings["host"] + 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: + 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() password = await self.config.password() rest_port = await self.config.rest_port() ws_port = await self.config.ws_port() - + try: await lavalink.initialize( bot=self.bot, host=host, @@ -121,9 +159,10 @@ class Audio(commands.Cog): timeout=timeout, ) return # break infinite loop - except Exception: - if not external: - shutdown_lavalink_server() + except asyncio.TimeoutError: + log.error("Connecting to Lavalink server timed out, retrying...") + if external is False and self._manager is not None: + await self._manager.shutdown() await asyncio.sleep(1) # prevent busylooping async def event_handler(self, player, event_type, extra): @@ -133,19 +172,19 @@ class Audio(commands.Cog): async def _players_check(): try: - get_players = [p for p in lavalink.players if p.current is not None] - get_single_title = get_players[0].current.title + get_single_title = lavalink.active_players()[0].current.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"): 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_players[0].current.author, get_players[0].current.title + lavalink.active_players()[0].current.author, + lavalink.active_players()[0].current.title, ) else: - get_single_title = get_players[0].current.title - playing_servers = len(get_players) + get_single_title = lavalink.active_players()[0].current.title + playing_servers = len(lavalink.active_players()) except IndexError: get_single_title = None 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.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() @checks.mod_or_permissions(administrator=True) async def maxlength(self, ctx, seconds): @@ -412,6 +532,7 @@ class Audio(commands.Cog): @audioset.command() async def settings(self, ctx): """Show the current settings.""" + is_owner = ctx.author.id == self.bot.owner_id data = await self.config.guild(ctx.guild).all() global_data = await self.config.all() dj_role_obj = ctx.guild.get_role(data["dj_role"]) @@ -458,8 +579,10 @@ class Audio(commands.Cog): "---Lavalink Settings--- \n" "Cog version: [{version}]\n" "Jar build: [{jarbuild}]\n" - "External server: [{use_external_lavalink}]" + "External server: [{use_external_lavalink}]\n" ).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")) return await ctx.send(embed=embed) @@ -545,11 +668,11 @@ class Audio(commands.Cog): @commands.guild_only() async def audiostats(self, ctx): """Audio stats.""" - server_num = len([p for p in lavalink.players if p.current is not None]) - total_num = len([p for p in lavalink.players]) + server_num = len(lavalink.active_players()) + total_num = len(lavalink.all_players()) msg = "" - for p in lavalink.players: + for p in lavalink.all_players(): connect_start = p.fetch("connect") connect_dur = self._dynamic_time( int((datetime.datetime.utcnow() - connect_start).total_seconds()) @@ -778,11 +901,10 @@ class Audio(commands.Cog): ) track_listing = [] if ctx.invoked_with == "search": + local_path = await self.config.localpath() for localtrack_location in folder_list: track_listing.append( - localtrack_location.replace( - "{}/localtracks/".format(cog_data_path(raw_name="Audio")), "" - ) + localtrack_location.replace("{}/localtracks/".format(local_path), "") ) else: for localtrack_location in folder_list: @@ -808,15 +930,18 @@ class Audio(commands.Cog): await ctx.invoke(self.search, query=("folder:" + folder)) 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: os.chdir(audio_data) localtracks_folder = any( f for f in os.listdir(os.getcwd()) if not os.path.isfile(f) if f == "localtracks" ) if not localtracks_folder: - await self._embed_msg(ctx, _("No localtracks folder.")) - return False + if ctx.invoked_with == "start": + return False + else: + await self._embed_msg(ctx, _("No localtracks folder.")) + return False else: return True @@ -1072,10 +1197,9 @@ class Audio(commands.Cog): return await self._get_spotify_tracks(ctx, query) if query.startswith("localtrack:"): + local_path = await self.config.localpath() await self._localtracks_check(ctx) - query = query.replace("localtrack:", "").replace( - (str(cog_data_path(raw_name="Audio")) + "/"), "" - ) + query = query.replace("localtrack:", "").replace(((local_path) + "/"), "") allowed_files = (".mp3", ".flac", ".ogg") if not self._match_url(query) and not (query.lower().endswith(allowed_files)): query = "ytsearch:{}".format(query) @@ -1333,17 +1457,13 @@ class Audio(commands.Cog): song_info = "{} {}".format(i["track"]["name"], i["track"]["artists"][0]["name"]) try: track_url = await self._youtube_api_search(yt_key, song_info) - except: + except (RuntimeError, aiohttp.client_exceptions.ServerDisconnectedError): error_embed = discord.Embed( colour=await ctx.embed_colour(), - title=_( - "The YouTube API key has not been set properly.\n" - "Use `{prefix}audioset youtubeapi` for instructions." - ).format(prefix=ctx.prefix), + title=_("The connection was reset while loading the playlist."), ) await playlist_msg.edit(embed=error_embed) return None - # let's complain about errors pass try: yt_track = await player.get_tracks(track_url) @@ -1831,6 +1951,8 @@ class Audio(commands.Cog): player = lavalink.get_player(ctx.guild.id) for track in playlists[playlist_name]["tracks"]: if track["info"]["uri"].startswith("localtracks/"): + if not await self._localtracks_check(ctx): + pass if not os.path.isfile(track["info"]["uri"]): continue if maxlength > 0: @@ -1914,7 +2036,10 @@ class Audio(commands.Cog): ) playlist_msg = await ctx.send(embed=embed1) for song_url in v2_playlist["playlist"]: - track = await player.get_tracks(song_url) + try: + track = await player.get_tracks(song_url) + except RuntimeError: + pass try: track_obj = self._track_creator(player, other_track=track[0]) track_list.append(track_obj) @@ -2251,9 +2376,8 @@ class Audio(commands.Cog): ): track_idx = i + 1 if command == "search": - track_location = track.replace( - "localtrack:{}/localtracks/".format(cog_data_path(raw_name="Audio")), "" - ) + local_path = await self.config.localpath() + track_location = track.replace("localtrack:{}/localtracks/".format(local_path), "") track_match += "`{}.` **{}**\n".format(track_idx, track_location) else: track_match += "`{}.` **{}**\n".format(track[0], track[1]) @@ -2271,11 +2395,13 @@ class Audio(commands.Cog): @commands.guild_only() async def _queue_clear(self, ctx): """Clears the queue.""" - player = lavalink.get_player(ctx.guild.id) + try: + 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() if not self._player_check(ctx) or not player.queue: return await self._embed_msg(ctx, _("There's nothing in the queue.")) - player = lavalink.get_player(ctx.guild.id) if dj_enabled: if not await self._can_instaskip(ctx, ctx.author) and not await self._is_alone( ctx, ctx.author @@ -2288,7 +2414,10 @@ class Audio(commands.Cog): @commands.guild_only() async def _queue_clean(self, ctx): """Removes songs from the queue if the requester is not in the voice channel.""" - player = lavalink.get_player(ctx.guild.id) + try: + 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() if not self._player_check(ctx) or not player.queue: return await self._embed_msg(ctx, _("There's nothing in the queue.")) @@ -2572,7 +2701,8 @@ class Audio(commands.Cog): if command == "search": return await ctx.invoke(self.play, query=("localtracks/{}".format(search_choice))) 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( self.search, query=("localfolder:{}".format(search_choice)) ) @@ -2633,14 +2763,10 @@ class Audio(commands.Cog): search_list += "`{}.` **{}**\n".format(search_track_num, track) folder = False else: + local_path = await self.config.localpath() search_list += "`{}.` **{}**\n".format( search_track_num, - track.replace( - "localtrack:{}/localtracks/".format( - str(cog_data_path(raw_name="Audio")) - ), - "", - ), + track.replace("localtrack:{}/localtracks/".format(local_path), ""), ) folder = False try: @@ -2773,8 +2899,8 @@ class Audio(commands.Cog): @commands.command() @commands.guild_only() - async def skip(self, ctx): - """Skip to the next track.""" + async def skip(self, ctx, skip_to_track: int = None): + """Skip to the next track, or to a given track number.""" if not self._player_check(ctx): return await self._embed_msg(ctx, _("Nothing playing.")) 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.")) if vote_enabled: 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]: self.skip_votes[ctx.message.guild].remove(ctx.author.id) reply = _("I removed your vote to skip.") @@ -2823,9 +2953,9 @@ class Audio(commands.Cog): ) return await self._embed_msg(ctx, reply) else: - return await self._skip_action(ctx) + return await self._skip_action(ctx, skip_to_track) else: - return await self._skip_action(ctx) + return await self._skip_action(ctx, skip_to_track) async def _can_instaskip(self, ctx, member): mod_role = await ctx.bot.db.guild(ctx.guild).mod_role() @@ -2884,7 +3014,7 @@ class Audio(commands.Cog): else: 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) if not player.queue: try: @@ -2909,23 +3039,57 @@ class Audio(commands.Cog): ) ) return await ctx.send(embed=embed) + queue_to_append = [] + if skip_to_track is not None and skip_to_track != 1: + if skip_to_track < 1: + return await self._embed_msg( + ctx, _("Track number must be equal to or greater than 1.") + ) + elif skip_to_track > len(player.queue): + return await self._embed_msg( + ctx, + _( + "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( + colour=await ctx.embed_colour(), + title=_("{skip_to_track} Tracks Skipped".format(skip_to_track=skip_to_track)), + ) + await ctx.send(embed=embed) + 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) - if "localtracks" in player.current.uri: - if not player.current.title == "Unknown title": - description = "**{} - {}**\n{}".format( - player.current.author, - player.current.title, - player.current.uri.replace("localtracks/", ""), + 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: - description = "{}".format(player.current.uri.replace("localtracks/", "")) + return "{}".format(track.uri.replace("localtracks/", "")) else: - description = "**[{}]({})**".format(player.current.title, player.current.uri) - embed = discord.Embed( - colour=await ctx.embed_colour(), title=_("Track Skipped"), description=description - ) - await ctx.send(embed=embed) - await player.skip() + return "**[{}]({})**".format(track.title, track.uri) @commands.command() @commands.guild_only() @@ -3020,19 +3184,16 @@ class Audio(commands.Cog): await self.config.use_external_lavalink.set(not 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( colour=await ctx.embed_colour(), title=_("External lavalink server: {true_or_false}.").format( true_or_false=not external ), ) - embed.set_footer(text=_("Defaults reset.")) await ctx.send(embed=embed) else: + if self._manager is not None: + await self._manager.shutdown() await self._embed_msg( ctx, _("External lavalink server: {true_or_false}.").format(true_or_false=not external), @@ -3106,7 +3267,10 @@ class Audio(commands.Cog): self._restart_connect() async def _channel_check(self, ctx): - player = lavalink.get_player(ctx.guild.id) + try: + player = lavalink.get_player(ctx.guild.id) + except KeyError: + return False try: in_channel = sum( 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): external = await self.config.use_external_lavalink() if not external: + if self._manager is not None: + await self._manager.shutdown() await self.config.use_external_lavalink.set(True) return True else: @@ -3191,7 +3357,7 @@ class Audio(commands.Cog): stop_times = {} while True: - for p in lavalink.players: + for p in lavalink.all_players(): server = p.channel.guild if [self.bot.user] == p.channel.members: @@ -3266,13 +3432,6 @@ class Audio(commands.Cog): else: 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): if not await self._localtracks_check(ctx): return @@ -3315,6 +3474,8 @@ class Audio(commands.Cog): try: lavalink.get_player(ctx.guild.id) return True + except IndexError: + return False except KeyError: return False @@ -3422,11 +3583,14 @@ class Audio(commands.Cog): async def _youtube_api_search(self, yt_key, query): params = {"q": query, "part": "id", "key": yt_key, "maxResults": 1, "type": "video"} yt_url = "https://www.googleapis.com/youtube/v3/search" - async with self.session.request("GET", yt_url, params=params) as r: - if r.status == 400: - return None - else: - search_response = await r.json() + try: + async with self.session.request("GET", yt_url, params=params) as r: + if r.status == 400: + return None + else: + search_response = await r.json() + except RuntimeError: + return None for search_result in search_response.get("items", []): if search_result["id"]["kind"] == "youtube#video": return "https://www.youtube.com/watch?v={}".format(search_result["id"]["videoId"]) @@ -3503,7 +3667,7 @@ class Audio(commands.Cog): def cog_unload(self): if not self._cleaned_up: - self.session.detach() + self.bot.loop.create_task(self.session.close()) if self._disconnect_task: self._disconnect_task.cancel() @@ -3513,25 +3677,8 @@ class Audio(commands.Cog): lavalink.unregister_event_listener(self.event_handler) 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 __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() diff --git a/redbot/cogs/audio/data/application.yml b/redbot/cogs/audio/data/application.yml index 9b8d7fe33..2c7e586c9 100644 --- a/redbot/cogs/audio/data/application.yml +++ b/redbot/cogs/audio/data/application.yml @@ -1,11 +1,9 @@ server: + host: "localhost" port: 2333 # REST server lavalink: server: password: "youshallnotpass" - ws: - host: "localhost" - port: 2332 sources: youtube: true bandcamp: true diff --git a/redbot/cogs/audio/manager.py b/redbot/cogs/audio/manager.py index 10cc7d4d9..dfcd46e62 100644 --- a/redbot/cogs/audio/manager.py +++ b/redbot/cogs/audio/manager.py @@ -1,172 +1,243 @@ -import shlex +import itertools +import pathlib +import platform import shutil import asyncio import asyncio.subprocess -import os import logging import re -from subprocess import Popen, DEVNULL -from typing import Optional, Tuple +import tempfile +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\d+)") log = logging.getLogger("red.audio.manager") -proc = None -shutdown = False +class ServerManager: -def has_java_error(pid): - from . import LAVALINK_DOWNLOAD_DIR + _java_available: ClassVar[Optional[bool]] = None + _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) - return poss_error_file.exists() + _blacklisted_archs = ["armv6l", "aarch32", "aarch64"] + def __init__(self) -> None: + self.ready = asyncio.Event() -async def monitor_lavalink_server(loop): - global shutdown - while shutdown is False: - if proc.poll() is not None: - break - await asyncio.sleep(0.5) + self._proc: Optional[asyncio.subprocess.Process] = None + self._monitor_task: Optional[asyncio.Task] = None + self._shutdown: bool = False - if shutdown is False: - # Lavalink was shut down by something else - log.info("Lavalink jar shutdown.") - shutdown = True - if not has_java_error(proc.pid): - 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) + async def start(self) -> None: + arch_name = platform.machine() + if arch_name in self._blacklisted_archs: + raise asyncio.CancelledError( + "You are attempting to run Lavalink audio on an unsupported machine architecture." ) + 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]]: - java_available = shutil.which("java") is not None - if not java_available: - return False, None + await self.maybe_download_jar() - version = await get_java_version(loop) - return (2, 0) > version >= (1, 8) or version >= (8, 0), version + # 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, + ) -async def get_java_version(loop) -> _JavaVersion: - """ - This assumes we've already checked that java exists. - """ - _proc: asyncio.subprocess.Process = await asyncio.create_subprocess_exec( - "java", - "-version", - stdout=asyncio.subprocess.PIPE, - stderr=asyncio.subprocess.PIPE, - loop=loop, - ) - # java -version outputs to stderr - _, err = await _proc.communicate() + log.info("Internal Lavalink server started. PID: %s", self._proc.pid) - version_info: str = err.decode("utf-8") - # We expect the output to look something like: - # $ java -version - # ... - # ... version "MAJOR.MINOR.PATCH[_BUILD]" ... - # ... - # We only care about the major and minor parts though. - version_line_re = re.compile( - r'version "(?P\d+).(?P\d+).\d+(?:_\d+)?(?:-[A-Za-z0-9]+)?"' - ) - short_version_re = re.compile(r'version "(?P\d+)"') + 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") - lines = version_info.splitlines() - for line in lines: - match = version_line_re.search(line) - short_match = short_version_re.search(line) - if match: - return int(match["major"]), int(match["minor"]) - elif short_match: - return int(short_match["major"]), 0 + self._monitor_task = asyncio.create_task(self._monitor()) - raise RuntimeError( - "The output of `java -version` was unexpected. Please report this issue on Red's " - "issue tracker." - ) + @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 = [] -async def start_lavalink_server(loop): - java_available, java_version = await has_java(loop) - if not java_available: - raise RuntimeError("You must install Java 1.8+ for Lavalink to run.") + return ["java", *extra_flags, "-jar", str(LAVALINK_JAR_FILE)] - 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 = "" + @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 + if not java_available: + 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 - from . import LAVALINK_DOWNLOAD_DIR, LAVALINK_JAR_FILE + @staticmethod + async def _get_java_version() -> Tuple[int, int]: + """ + This assumes we've already checked that java exists. + """ + _proc: asyncio.subprocess.Process = await asyncio.create_subprocess_exec( + "java", "-version", stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE + ) + # java -version outputs to stderr + _, err = await _proc.communicate() - start_cmd = "java {} -jar {}".format(extra_flags, LAVALINK_JAR_FILE.resolve()) + version_info: str = err.decode("utf-8") + # We expect the output to look something like: + # $ java -version + # ... + # ... version "MAJOR.MINOR.PATCH[_BUILD]" ... + # ... + # We only care about the major and minor parts though. + version_line_re = re.compile( + r'version "(?P\d+).(?P\d+).\d+(?:_\d+)?(?:-[A-Za-z0-9]+)?"' + ) + short_version_re = re.compile(r'version "(?P\d+)"') - global proc + lines = version_info.splitlines() + for line in lines: + match = version_line_re.search(line) + short_match = short_version_re.search(line) + if match: + return int(match["major"]), int(match["minor"]) + elif short_match: + return int(short_match["major"]), 0 - if proc and proc.poll() is None: - return # already running + raise RuntimeError( + "The output of `java -version` was unexpected. Please report this issue on Red's " + "issue tracker." + ) - proc = Popen( - shlex.split(start_cmd, posix=os.name == "posix"), - cwd=str(LAVALINK_DOWNLOAD_DIR), - stdout=DEVNULL, - stderr=DEVNULL, - ) + 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) - log.info("Lavalink jar started. PID: {}".format(proc.pid)) - global shutdown - shutdown = False + async def _monitor(self) -> None: + while self._proc.returncode is None: + await asyncio.sleep(0.5) - loop.create_task(monitor_lavalink_server(loop)) + # This task hasn't been cancelled - Lavalink was shut down by something else + log.info("Internal Lavalink jar shutdown unexpectedly") + if not self._has_java_error(): + log.info("Restarting internal Lavalink server") + await self.start() + else: + log.critical( + "Your Java is borked. Please find the hs_err_pid{}.log file" + " in the Audio data folder and report this issue.", + self._proc.pid, + ) + def _has_java_error(self) -> bool: + poss_error_file = LAVALINK_DOWNLOAD_DIR / "hs_err_pid{}.log".format(self._proc.pid) + return poss_error_file.exists() -def shutdown_lavalink_server(): - global shutdown - shutdown = True - global proc - if proc is not None: - log.info("Shutting down lavalink server.") - proc.terminate() - proc.wait() - proc = None + 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) -async def download_lavalink(session): - from . import LAVALINK_DOWNLOAD_URL, LAVALINK_JAR_FILE + @classmethod + async def _is_up_to_date(cls): + if cls._up_to_date is True: + # Return cached value if we've checked this before + return True + args = await cls._get_jar_args() + args.append("--version") + _proc = await asyncio.subprocess.create_subprocess_exec( + *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 - 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)) + @classmethod + async def maybe_download_jar(cls): + if not (LAVALINK_JAR_FILE.exists() and await cls._is_up_to_date()): + await cls._download_jar() diff --git a/redbot/cogs/cleanup/cleanup.py b/redbot/cogs/cleanup/cleanup.py index d76295b00..60796e314 100644 --- a/redbot/cogs/cleanup/cleanup.py +++ b/redbot/cogs/cleanup/cleanup.py @@ -10,6 +10,7 @@ from redbot.core.i18n import Translator, cog_i18n from redbot.core.utils.mod import slow_deletion, mass_purge from redbot.cogs.mod.log import log from redbot.core.utils.predicates import MessagePredicate +from .converters import RawMessageIds _ = Translator("Cleanup", __file__) @@ -211,7 +212,9 @@ class Cleanup(commands.Cog): @cleanup.command() @commands.guild_only() @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. To get a message id, enable developer mode in Discord's @@ -242,7 +245,11 @@ class Cleanup(commands.Cog): @commands.guild_only() @commands.bot_has_permissions(manage_messages=True) 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. @@ -255,7 +262,7 @@ class Cleanup(commands.Cog): author = ctx.author try: - before = await channel.get_message(message_id) + before = await channel.fetch_message(message_id) except discord.NotFound: return await ctx.send(_("Message not found.")) @@ -271,6 +278,48 @@ class Cleanup(commands.Cog): 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() @commands.guild_only() @commands.bot_has_permissions(manage_messages=True) diff --git a/redbot/cogs/cleanup/converters.py b/redbot/cogs/cleanup/converters.py new file mode 100644 index 000000000..ad271b64c --- /dev/null +++ b/redbot/cogs/cleanup/converters.py @@ -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)) diff --git a/redbot/cogs/customcom/customcom.py b/redbot/cogs/customcom/customcom.py index 66de7ac24..09b8ef26a 100644 --- a/redbot/cogs/customcom/customcom.py +++ b/redbot/cogs/customcom/customcom.py @@ -466,7 +466,7 @@ class CustomCommands(commands.Cog): return # 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) ctx.command = fake_cc diff --git a/redbot/cogs/downloader/downloader.py b/redbot/cogs/downloader/downloader.py index 8df78cbb9..adce10923 100644 --- a/redbot/cogs/downloader/downloader.py +++ b/redbot/cogs/downloader/downloader.py @@ -8,7 +8,7 @@ from sys import path as syspath from typing import Tuple, Union, Iterable 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.data_manager import cog_data_path from redbot.core.i18n import Translator, cog_i18n @@ -303,6 +303,26 @@ class Downloader(commands.Cog): ) ) 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): libraries = humanize_list(tuple(map(inline, cog.requirements))) @@ -344,7 +364,8 @@ class Downloader(commands.Cog): poss_installed_path = (await self.cog_install_path()) / real_name if poss_installed_path.exists(): - ctx.bot.unload_extension(real_name) + with contextlib.suppress(commands.ExtensionNotLoaded): + ctx.bot.unload_extension(real_name) await self._delete_cog(poss_installed_path) uninstalled_cogs.append(inline(real_name)) else: diff --git a/redbot/cogs/downloader/installable.py b/redbot/cogs/downloader/installable.py index a06e069ca..143701a6a 100644 --- a/redbot/cogs/downloader/installable.py +++ b/redbot/cogs/downloader/installable.py @@ -8,6 +8,8 @@ from typing import MutableMapping, Any, TYPE_CHECKING from .log import log from .json_mixins import RepoJSONMixin +from redbot.core import __version__, version_info as red_version_info, VersionInfo + if TYPE_CHECKING: from .repo_manager import RepoManager @@ -72,7 +74,8 @@ class Installable(RepoJSONMixin): self.repo_name = self._location.parent.stem 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.hidden = False self.disabled = False @@ -157,10 +160,16 @@ class Installable(RepoJSONMixin): self.author = author 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: - bot_version = self.bot_version - self.bot_version = bot_version + min_bot_version = self.min_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: min_python_version = tuple(info.get("min_python_version", [3, 5, 1])) diff --git a/redbot/cogs/economy/economy.py b/redbot/cogs/economy/economy.py index f9232dfbd..fb73d4c24 100644 --- a/redbot/cogs/economy/economy.py +++ b/redbot/cogs/economy/economy.py @@ -355,34 +355,56 @@ class Economy(commands.Cog): author = ctx.author if top < 1: top = 10 - if ( - await bank.is_global() and show_global - ): # show_global is only applicable if bank is global - 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) + if await bank.is_global() and show_global: + # show_global is only applicable if bank is global + bank_sorted = await bank.get_leaderboard(positions=top, guild=None) 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() @guild_only_check() diff --git a/redbot/cogs/mod/abc.py b/redbot/cogs/mod/abc.py index ce0905e8d..9f00dffea 100644 --- a/redbot/cogs/mod/abc.py +++ b/redbot/cogs/mod/abc.py @@ -1,8 +1,8 @@ from abc import ABC, abstractmethod -from typing import List, Tuple +from typing import List, Tuple, Optional import discord -from redbot.core import Config +from redbot.core import Config, commands from redbot.core.bot import Red @@ -20,6 +20,13 @@ class MixinMeta(ABC): self.ban_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 @abstractmethod async def get_audit_entry_info( diff --git a/redbot/cogs/mod/casetypes.py b/redbot/cogs/mod/casetypes.py index e5aad3ca4..3696aeb2e 100644 --- a/redbot/cogs/mod/casetypes.py +++ b/redbot/cogs/mod/casetypes.py @@ -97,4 +97,10 @@ CASETYPES = [ "case_str": "Server Unmute", "audit_type": "overwrite_update", }, + { + "name": "vkick", + "default_setting": False, + "image": "\N{SPEAKER WITH CANCELLATION STROKE}", + "case_str": "Voice Kick", + }, ] diff --git a/redbot/cogs/mod/kickban.py b/redbot/cogs/mod/kickban.py index df829d1d7..883ca4fec 100644 --- a/redbot/cogs/mod/kickban.py +++ b/redbot/cogs/mod/kickban.py @@ -495,6 +495,56 @@ class KickBanMixin(MixinMeta): await ctx.send(e) 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.guild_only() @commands.bot_has_permissions(ban_members=True) @@ -508,8 +558,9 @@ class KickBanMixin(MixinMeta): click the user and select 'Copy ID'.""" guild = ctx.guild author = ctx.author - user = await self.bot.fetch_user(user_id) - if not user: + try: + user = await self.bot.fetch_user(user_id) + except discord.errors.NotFound: await ctx.send(_("Couldn't find a user with that ID!")) return audit_reason = get_audit_reason(ctx.author, reason) diff --git a/redbot/cogs/streams/streams.py b/redbot/cogs/streams/streams.py index a1a6a5f71..9ba5c398e 100644 --- a/redbot/cogs/streams/streams.py +++ b/redbot/cogs/streams/streams.py @@ -44,6 +44,7 @@ class Streams(commands.Cog): "mention_here": False, "live_message_mention": False, "live_message_nomention": False, + "ignore_reruns": False, } role_defaults = {"mention": False} @@ -461,6 +462,19 @@ class Streams(commands.Cog): else: 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): if ctx.channel.id not in stream.channels: stream.channels.append(ctx.channel.id) @@ -524,7 +538,7 @@ class Streams(commands.Cog): for stream in self.streams: with contextlib.suppress(Exception): try: - embed = await stream.is_online() + embed, is_rerun = await stream.is_online() except OfflineStream: if not stream._messages_cache: continue @@ -540,6 +554,9 @@ class Streams(commands.Cog): continue for channel_id in stream.channels: 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) if mention_str: diff --git a/redbot/cogs/streams/streamtypes.py b/redbot/cogs/streams/streamtypes.py index b4d2a3cd4..d0ce0e462 100644 --- a/redbot/cogs/streams/streamtypes.py +++ b/redbot/cogs/streams/streamtypes.py @@ -174,7 +174,8 @@ class TwitchStream(Stream): # self.already_online = True # In case of rename 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: raise InvalidTwitchCredentials() elif r.status == 404: @@ -204,6 +205,7 @@ class TwitchStream(Stream): def make_embed(self, data): channel = data["stream"]["channel"] + is_rerun = data["stream"]["stream_type"] == "rerun" url = channel["url"] logo = channel["logo"] if logo is None: @@ -211,6 +213,8 @@ class TwitchStream(Stream): status = channel["status"] if not status: status = "Untitled broadcast" + if is_rerun: + status += " - Rerun" embed = discord.Embed(title=status, url=url) embed.set_author(name=channel["display_name"]) embed.add_field(name="Followers", value=channel["followers"]) diff --git a/redbot/core/bot.py b/redbot/core/bot.py index 004b00432..1f1f9d7d0 100644 --- a/redbot/core/bot.py +++ b/redbot/core/bot.py @@ -45,7 +45,7 @@ class RedBase(commands.GroupMixin, commands.bot.BotBase, RPCMixin): owner=None, whitelist=[], blacklist=[], - locale="en", + locale="en-US", embeds=True, color=15158332, fuzzy=False, @@ -190,6 +190,21 @@ class RedBase(commands.GroupMixin, commands.bot.BotBase, RPCMixin): async def get_context(self, message, *, cls=commands.Context): 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 def list_packages(): """Lists packages present in the cogs the folder""" diff --git a/redbot/core/commands/commands.py b/redbot/core/commands/commands.py index b5d54c439..796a7942a 100644 --- a/redbot/core/commands/commands.py +++ b/redbot/core/commands/commands.py @@ -156,6 +156,13 @@ class Command(CogCommandMixin, commands.Command): self._help_override = kwargs.pop("help_override", 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 def help(self): """Help string for this command. diff --git a/redbot/core/commands/converter.py b/redbot/core/commands/converter.py index b3412b45f..308c442c9 100644 --- a/redbot/core/commands/converter.py +++ b/redbot/core/commands/converter.py @@ -47,9 +47,9 @@ class APIToken(discord.ext.commands.Converter): This will parse the input argument separating the key value pairs into a format to be used for the core bots API token storage. - This will split the argument by 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, - 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. """ diff --git a/redbot/core/core_commands.py b/redbot/core/core_commands.py index 5604a0106..97f1bbcc3 100644 --- a/redbot/core/core_commands.py +++ b/redbot/core/core_commands.py @@ -6,6 +6,7 @@ import itertools import json import logging import os +import pathlib import sys import tarfile import traceback @@ -38,13 +39,6 @@ __all__ = ["Core"] 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__) @@ -301,42 +295,41 @@ class Core(commands.Cog, CoreLogic): async with session.get("{}/json".format(red_pypi)) as r: data = await r.json() outdated = VersionInfo.from_str(data["info"]["version"]) > red_version_info - about = ( + about = _( "This is an instance of [Red, an open source Discord bot]({}) " "created by [Twentysix]({}) and [improved by many]({}).\n\n" "Red is backed by a passionate community who contributes and " "creates content for everyone to enjoy. [Join us today]({}) " "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.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="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: 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: - 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 this instance"), value=custom_info, inline=False) + embed.add_field(name=_("About Red"), value=about, inline=False) 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: await ctx.send(embed=embed) 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() async def uptime(self, ctx: commands.Context): """Shows Red's uptime""" since = ctx.bot.uptime.strftime("%Y-%m-%d %H:%M:%S") 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): # Courtesy of Danny @@ -348,13 +341,13 @@ class Core(commands.Cog, CoreLogic): if not brief: if days: - fmt = "{d} days, {h} hours, {m} minutes, and {s} seconds" + fmt = _("{d} days, {h} hours, {m} minutes, and {s} seconds") else: - fmt = "{h} hours, {m} minutes, and {s} seconds" + fmt = _("{h} hours, {m} minutes, and {s} seconds") else: - fmt = "{h}h {m}m {s}s" + fmt = _("{h}h {m}m {s}s") if days: - fmt = "{d}d " + fmt + fmt = _("{d}d ") + fmt return fmt.format(d=days, h=hours, m=minutes, s=seconds) @@ -369,14 +362,14 @@ class Core(commands.Cog, CoreLogic): use embeds. """ if ctx.invoked_subcommand is None: - text = "Embed settings:\n\n" + text = _("Embed settings:\n\n") global_default = await self.bot.db.embeds() - text += "Global default: {}\n".format(global_default) + text += _("Global default: {}\n").format(global_default) if ctx.guild: 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() - text += "User setting: {}".format(user_setting) + text += _("User setting: {}").format(user_setting) await ctx.send(box(text)) @embedset.command(name="global") @@ -392,7 +385,7 @@ class Core(commands.Cog, CoreLogic): current = await self.bot.db.embeds() await self.bot.db.embeds.set(not current) 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") @@ -415,7 +408,9 @@ class Core(commands.Cog, CoreLogic): await ctx.send(_("Embeds will now fall back to the global setting.")) else: 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") @@ -436,7 +431,7 @@ class Core(commands.Cog, CoreLogic): await ctx.send(_("Embeds will now fall back to the global setting.")) else: 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() @@ -454,7 +449,7 @@ class Core(commands.Cog, CoreLogic): for page in pagify(self.bot._last_exception, shorten_by=10): await destination.send(box(page, lang="py")) else: - await ctx.send("No exception has occurred yet") + await ctx.send(_("No exception has occurred yet")) @commands.command() @checks.is_owner() @@ -467,21 +462,21 @@ class Core(commands.Cog, CoreLogic): @checks.is_owner() async def leave(self, ctx: commands.Context): """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) try: await self.bot.wait_for("message", check=pred) except asyncio.TimeoutError: - await ctx.send("Response timed out.") + await ctx.send(_("Response timed out.")) return else: if pred.result is True: - await ctx.send("Alright. Bye :wave:") - log.debug("Leaving guild '{}'".format(ctx.guild.name)) + await ctx.send(_("Alright. Bye :wave:")) + log.debug(_("Leaving guild '{}'").format(ctx.guild.name)) await ctx.guild.leave() else: - await ctx.send("Alright, I'll stay then :)") + await ctx.send(_("Alright, I'll stay then :)")) @commands.command() @checks.is_owner() @@ -497,7 +492,7 @@ class Core(commands.Cog, CoreLogic): for page in pagify(msg, ["\n"]): 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) try: @@ -512,21 +507,21 @@ class Core(commands.Cog, CoreLogic): async def leave_confirmation(self, guild, ctx): 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 - 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) try: await self.bot.wait_for("message", check=pred, timeout=15) if pred.result is True: await guild.leave() if guild != ctx.guild: - await ctx.send("Done.") + await ctx.send(_("Done.")) else: - await ctx.send("Alright then.") + await ctx.send(_("Alright then.")) except asyncio.TimeoutError: - await ctx.send("Response timed out.") + await ctx.send(_("Response timed out.")) @commands.command() @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) if loaded: - fmt = "Loaded {packs}." + fmt = _("Loaded {packs}.") formed = self._get_package_strings(loaded, fmt) await ctx.send(formed) if already_loaded: - fmt = "The package{plural} {packs} {other} already loaded." - formed = self._get_package_strings(already_loaded, fmt, ("is", "are")) + fmt = _("The package{plural} {packs} {other} already loaded.") + formed = self._get_package_strings(already_loaded, fmt, (_("is"), _("are"))) await ctx.send(formed) if failed: - fmt = ( + fmt = _( "Failed to load package{plural} {packs}. Check your console or " "logs for details." ) @@ -556,17 +551,17 @@ class Core(commands.Cog, CoreLogic): await ctx.send(formed) if not_found: - fmt = "The package{plural} {packs} {other} not found in any cog path." - formed = self._get_package_strings(not_found, fmt, ("was", "were")) + fmt = _("The package{plural} {packs} {other} not found in any cog path.") + formed = self._get_package_strings(not_found, fmt, (_("was"), _("were"))) await ctx.send(formed) if failed_with_reason: - fmt = ( + fmt = _( "{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]) 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) @@ -579,13 +574,13 @@ class Core(commands.Cog, CoreLogic): unloaded, failed = await self._unload(cogs) if unloaded: - fmt = "Package{plural} {packs} {other} unloaded." - formed = self._get_package_strings(unloaded, fmt, ("was", "were")) + fmt = _("Package{plural} {packs} {other} unloaded.") + formed = self._get_package_strings(unloaded, fmt, (_("was"), _("were"))) await ctx.send(formed) if failed: - fmt = "The package{plural} {packs} {other} not loaded." - formed = self._get_package_strings(failed, fmt, ("is", "are")) + fmt = _("The package{plural} {packs} {other} not loaded.") + formed = self._get_package_strings(failed, fmt, (_("is"), _("are"))) await ctx.send(formed) @commands.command(name="reload") @@ -600,25 +595,27 @@ class Core(commands.Cog, CoreLogic): ) if loaded: - fmt = "Package{plural} {packs} {other} reloaded." - formed = self._get_package_strings(loaded, fmt, ("was", "were")) + fmt = _("Package{plural} {packs} {other} reloaded.") + formed = self._get_package_strings(loaded, fmt, (_("was"), _("were"))) await ctx.send(formed) 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) await ctx.send(formed) if not_found: - fmt = "The package{plural} {packs} {other} not found in any cog path." - formed = self._get_package_strings(not_found, fmt, ("was", "were")) + fmt = _("The package{plural} {packs} {other} not found in any cog path.") + formed = self._get_package_strings(not_found, fmt, (_("was"), _("were"))) await ctx.send(formed) 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]) 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) @@ -659,7 +656,9 @@ class Core(commands.Cog, CoreLogic): guild.get_role(await ctx.bot.db.guild(ctx.guild).mod_role()) or "Not set" ) 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: guild_settings = "" 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() prefix_string = " ".join(prefixes) - settings = ( - f"{ctx.bot.user.name} Settings:\n\n" - f"Prefixes: {prefix_string}\n" - f"{guild_settings}" - f"Locale: {locale}" + settings = _( + "{bot_name} Settings:\n\n" + "Prefixes: {prefixes}\n" + "{guild_settings}" + "Locale: {locale}" + ).format( + bot_name=ctx.bot.user.name, + prefixes=prefix_string, + guild_settings=guild_settings, + locale=locale, ) await ctx.send(box(settings)) @@ -905,7 +909,7 @@ class Core(commands.Cog, CoreLogic): except discord.Forbidden: await ctx.send(_("I lack the permissions to change my own nickname.")) else: - await ctx.send("Done.") + await ctx.send(_("Done.")) @_set.command(aliases=["prefixes"]) @checks.is_owner() @@ -942,11 +946,17 @@ class Core(commands.Cog, CoreLogic): for i in range(length): 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(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 ctx.send( @@ -997,7 +1007,7 @@ class Core(commands.Cog, CoreLogic): return await ctx.bot.db.token.set(token) - await ctx.send("Token set. Restart me.") + await ctx.send(_("Token set. Restart me.")) @_set.command() @checks.is_owner() @@ -1147,7 +1157,7 @@ class Core(commands.Cog, CoreLogic): locale_list.append("en-US") locale_list = sorted(set(locale_list)) if not locale_list: - await ctx.send("No languages found.") + await ctx.send(_("No languages found.")) return pages = pagify("\n".join(locale_list), shorten_by=26) @@ -1157,6 +1167,12 @@ class Core(commands.Cog, CoreLogic): @checks.is_owner() async def backup(self, ctx: commands.Context, *, backup_path: str = None): """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.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) ) 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 await ctx.send(_("Would you like to receive a copy via DM? (y/n)")) @@ -1364,6 +1380,16 @@ class Core(commands.Cog, CoreLogic): else: 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() @checks.is_owner() async def whitelist(self, ctx: commands.Context): @@ -1452,7 +1478,7 @@ class Core(commands.Cog, CoreLogic): """ curr_list = await ctx.bot.db.blacklist() - msg = _("blacklisted Users:") + msg = _("Blacklisted Users:") for user in curr_list: msg += "\n\t- {}".format(user) @@ -1482,7 +1508,7 @@ class Core(commands.Cog, CoreLogic): Clears the blacklist. """ await ctx.bot.db.blacklist.set([]) - await ctx.send(_("blacklist has been cleared.")) + await ctx.send(_("Blacklist has been cleared.")) @commands.group() @commands.guild_only() @@ -1596,7 +1622,7 @@ class Core(commands.Cog, CoreLogic): """ 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: msg += "\n\t- {}".format(obj) @@ -1635,7 +1661,7 @@ class Core(commands.Cog, CoreLogic): Clears the blacklist. """ 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) @commands.group(name="command") diff --git a/redbot/core/events.py b/redbot/core/events.py index 0331cb795..165d51aa7 100644 --- a/redbot/core/events.py +++ b/redbot/core/events.py @@ -119,13 +119,25 @@ def init_events(bot, cli_flags): "Outdated version! {} is available " "but you're using {}".format(data["info"]["version"], red_version) ) - owner = await bot.fetch_user(bot.owner_id) - await owner.send( - "Your Red instance is out of date! {} is the current " - "version, however you are using {}!".format( - data["info"]["version"], red_version - ) - ) + + 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( + "Your Red instance is out of date! {} is the current " + "version, however you are using {}!".format( + data["info"]["version"], red_version + ) + ) INFO2 = [] mongo_enabled = storage_type() != "JSON" diff --git a/redbot/pytest/downloader.py b/redbot/pytest/downloader.py index 591441bf3..4c6e7d342 100644 --- a/redbot/pytest/downloader.py +++ b/redbot/pytest/downloader.py @@ -83,7 +83,8 @@ def bot_repo(event_loop): # Installable INFO_JSON = { "author": ("tekulvw",), - "bot_version": (3, 0, 0), + "min_bot_version": "3.0.0", + "max_bot_version": "3.0.2", "description": "A long description", "hidden": False, "install_msg": "A post-installation message", @@ -96,7 +97,8 @@ INFO_JSON = { LIBRARY_INFO_JSON = { "author": ("seputaes",), - "bot_version": (3, 0, 0), + "min_bot_version": "3.0.0", + "max_bot_version": "3.0.2", "description": "A long library description", "hidden": False, # libraries are always hidden, this tests it will be flipped "install_msg": "A library install message", diff --git a/setup.cfg b/setup.cfg index bb5eebfd6..a65267f9b 100644 --- a/setup.cfg +++ b/setup.cfg @@ -41,7 +41,7 @@ install_requires = multidict==4.5.2 python-levenshtein-wheels==0.13.1 pyyaml==3.13 - red-lavalink==0.2.3 + red-lavalink>=0.3.0,<0.4 schema==0.6.8 yarl==1.3.0 discord.py==1.0.1 diff --git a/tests/cogs/downloader/test_installable.py b/tests/cogs/downloader/test_installable.py index 5f1489e86..730ae9bcb 100644 --- a/tests/cogs/downloader/test_installable.py +++ b/tests/cogs/downloader/test_installable.py @@ -5,12 +5,15 @@ import pytest from redbot.pytest.downloader import * from redbot.cogs.downloader.installable import Installable, InstallableType +from redbot.core import VersionInfo def test_process_info_file(installable): for k, v in INFO_JSON.items(): if k == "type": assert installable.type == InstallableType.COG + elif k in ("min_bot_version", "max_bot_version"): + assert getattr(installable, k) == VersionInfo.from_str(v) else: 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(): if k == "type": 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": # libraries are always hidden, even if False assert library_installable.hidden is True