mirror of
https://github.com/Cog-Creators/Red-DiscordBot.git
synced 2025-11-10 13:18:54 -05:00
Merge remote-tracking branch 'release/V3/develop' into V3/develop
This commit is contained in:
commit
71955becb1
@ -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/).
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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),
|
||||
}
|
||||
|
||||
|
||||
@ -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.
|
||||
|
||||
|
||||
@ -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.
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
----------------
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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))
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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)
|
||||
|
||||
12
redbot/cogs/cleanup/converters.py
Normal file
12
redbot/cogs/cleanup/converters.py
Normal 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))
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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]))
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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",
|
||||
},
|
||||
]
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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"])
|
||||
|
||||
@ -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"""
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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.
|
||||
"""
|
||||
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user