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.
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/).

View File

@ -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 <instance_name> json``
Converting to Mongo: ``redbot-setup convert <instance_name> 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

View File

@ -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),
}

View File

@ -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.

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)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
- ``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.

View File

@ -7,7 +7,7 @@
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
----------------

View File

@ -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 <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".
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.
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".
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,
}
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:

View File

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

View File

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

View File

@ -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()

View File

@ -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

View File

@ -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<build>\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<major>\d+).(?P<minor>\d+).\d+(?:_\d+)?(?:-[A-Za-z0-9]+)?"'
)
short_version_re = re.compile(r'version "(?P<major>\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<major>\d+).(?P<minor>\d+).\d+(?:_\d+)?(?:-[A-Za-z0-9]+)?"'
)
short_version_re = re.compile(r'version "(?P<major>\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()

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

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
# 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

View File

@ -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:

View File

@ -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]))

View File

@ -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()

View File

@ -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(

View File

@ -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",
},
]

View File

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

View File

@ -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:

View File

@ -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"])

View File

@ -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"""

View File

@ -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.

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
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.
"""

View File

@ -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")

View File

@ -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"

View File

@ -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",

View File

@ -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

View File

@ -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