mirror of
https://github.com/Cog-Creators/Red-DiscordBot.git
synced 2025-11-08 20:28:55 -05:00
Merge remote-tracking branch 'release/V3/develop' into V3/develop
This commit is contained in:
commit
62f15e52a0
28
LICENSE
28
LICENSE
@ -672,3 +672,31 @@ may consider it more useful to permit linking proprietary applications with
|
|||||||
the library. If this is what you want to do, use the GNU Lesser General
|
the library. If this is what you want to do, use the GNU Lesser General
|
||||||
Public License instead of this License. But first, please read
|
Public License instead of this License. But first, please read
|
||||||
<http://www.gnu.org/philosophy/why-not-lgpl.html>.
|
<http://www.gnu.org/philosophy/why-not-lgpl.html>.
|
||||||
|
|
||||||
|
The Red-DiscordBot project contains subcomponents in audio.py that have a
|
||||||
|
separate copyright notice and license terms. Your use of the source code for
|
||||||
|
these subcomponents is subject to the terms and conditions of the following
|
||||||
|
licenses.
|
||||||
|
|
||||||
|
This product bundles methods from https://github.com/Just-Some-Bots/MusicBot/
|
||||||
|
blob/master/musicbot/spotify.py which are available under an MIT license.
|
||||||
|
|
||||||
|
Copyright (c) 2015-2018 Just-Some-Bots (https://github.com/Just-Some-Bots)
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in
|
||||||
|
all copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||||
|
THE SOFTWARE.
|
||||||
|
|||||||
@ -91,8 +91,6 @@ community of cog repositories.**
|
|||||||
- [Arch Linux](https://red-discordbot.readthedocs.io/en/v3-develop/install_linux_mac.html)
|
- [Arch Linux](https://red-discordbot.readthedocs.io/en/v3-develop/install_linux_mac.html)
|
||||||
- [Raspbian Stretch](https://red-discordbot.readthedocs.io/en/v3-develop/install_linux_mac.html)
|
- [Raspbian Stretch](https://red-discordbot.readthedocs.io/en/v3-develop/install_linux_mac.html)
|
||||||
|
|
||||||
Already using **Red** V2? Take a look at the [Data Converter](https://red-discordbot.readthedocs.io/en/v3-develop/cog_dataconverter.html)
|
|
||||||
to import your data to V3.
|
|
||||||
|
|
||||||
If after reading the guide you are still experiencing issues, feel free to join the
|
If after reading the guide you are still experiencing issues, feel free to join the
|
||||||
[Official Discord Server](https://discord.gg/red) and ask in the **#support** channel for help.
|
[Official Discord Server](https://discord.gg/red) and ask in the **#support** channel for help.
|
||||||
|
|||||||
@ -1,62 +0,0 @@
|
|||||||
.. Importing data from a V2 install
|
|
||||||
|
|
||||||
================================
|
|
||||||
Importing data from a V2 install
|
|
||||||
================================
|
|
||||||
|
|
||||||
----------------
|
|
||||||
What you'll need
|
|
||||||
----------------
|
|
||||||
|
|
||||||
1. A Running V3 bot
|
|
||||||
2. The path where your V2 bot is installed
|
|
||||||
|
|
||||||
--------------
|
|
||||||
Importing data
|
|
||||||
--------------
|
|
||||||
|
|
||||||
.. important::
|
|
||||||
|
|
||||||
Unless otherwise specified, the V2 data will take priority over V3 data for the same entires
|
|
||||||
|
|
||||||
.. important::
|
|
||||||
|
|
||||||
For the purposes of this guide, your prefix will be denoted as
|
|
||||||
[p]
|
|
||||||
|
|
||||||
You should swap whatever you made your prefix in for this.
|
|
||||||
All of the below are commands to be entered in discord where the bot can
|
|
||||||
see them.
|
|
||||||
|
|
||||||
The dataconverter cog is not loaded by default. To start, load it with
|
|
||||||
|
|
||||||
.. code-block:: none
|
|
||||||
|
|
||||||
[p]load dataconverter
|
|
||||||
|
|
||||||
Next, you'll need to give it the path where your V2 install is.
|
|
||||||
|
|
||||||
On linux and OSX, it may look something like:
|
|
||||||
|
|
||||||
.. code-block:: none
|
|
||||||
|
|
||||||
/home/username/Red-DiscordBot/
|
|
||||||
|
|
||||||
On Windows it will look something like:
|
|
||||||
|
|
||||||
.. code-block:: none
|
|
||||||
|
|
||||||
C:\Users\yourusername\Red-DiscordBot
|
|
||||||
|
|
||||||
Once you have that path, give it to the bot with the following command
|
|
||||||
(make sure to swap your own path in)
|
|
||||||
|
|
||||||
.. code-block:: none
|
|
||||||
|
|
||||||
[p]convertdata /home/username/Red-DiscordBot/
|
|
||||||
|
|
||||||
|
|
||||||
From here, if the path is correct, you will be prompted with an interactive menu asking you
|
|
||||||
what data you would like to import
|
|
||||||
|
|
||||||
You can select an entry by number, or quit with any of 'quit', 'exit', 'q', '-1', or 'cancel'
|
|
||||||
@ -40,12 +40,6 @@ Mod Helpers
|
|||||||
.. automodule:: redbot.core.utils.mod
|
.. automodule:: redbot.core.utils.mod
|
||||||
:members:
|
:members:
|
||||||
|
|
||||||
V2 Data Conversion
|
|
||||||
==================
|
|
||||||
|
|
||||||
.. automodule:: redbot.core.utils.data_converter
|
|
||||||
:members: DataConverter
|
|
||||||
|
|
||||||
Tunnel
|
Tunnel
|
||||||
======
|
======
|
||||||
|
|
||||||
|
|||||||
@ -1,154 +0,0 @@
|
|||||||
.. Converting Data from a V2 cog
|
|
||||||
|
|
||||||
.. role:: python(code)
|
|
||||||
:language: python
|
|
||||||
|
|
||||||
============================
|
|
||||||
Importing Data From a V2 Cog
|
|
||||||
============================
|
|
||||||
|
|
||||||
This guide serves as a tutorial on using the DataConverter class
|
|
||||||
to import settings from a V2 cog.
|
|
||||||
|
|
||||||
------------------
|
|
||||||
Things you'll need
|
|
||||||
------------------
|
|
||||||
|
|
||||||
1. The path where each file holding related settings in v2 is
|
|
||||||
2. A conversion function to take the data and transform it to conform to Config
|
|
||||||
|
|
||||||
-----------------------
|
|
||||||
Getting your file paths
|
|
||||||
-----------------------
|
|
||||||
|
|
||||||
You should probably not try to find the files manually.
|
|
||||||
Asking the user for the base install path and using a relative path to where the
|
|
||||||
data should be, then testing that the file exists there is safer. This is especially
|
|
||||||
True if your cog has multiple settings files
|
|
||||||
|
|
||||||
Example
|
|
||||||
|
|
||||||
.. code-block:: python
|
|
||||||
|
|
||||||
from discord.ext import commands
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
@commands.command(name="filefinder")
|
|
||||||
async def file_finding_command(self, ctx, filepath):
|
|
||||||
"""
|
|
||||||
this finds a file based on a user provided input and a known relative path
|
|
||||||
"""
|
|
||||||
|
|
||||||
base_path = Path(filepath)
|
|
||||||
fp = base_path / 'data' / 'mycog' / 'settings.json'
|
|
||||||
if not fp.is_file():
|
|
||||||
pass
|
|
||||||
# fail, prompting user
|
|
||||||
else:
|
|
||||||
pass
|
|
||||||
# do something with the file
|
|
||||||
|
|
||||||
---------------
|
|
||||||
Converting data
|
|
||||||
---------------
|
|
||||||
|
|
||||||
Once you've gotten your v2 settings file, you'll want to be able to import it
|
|
||||||
There are a couple options available depending on how you would like to convert
|
|
||||||
the data.
|
|
||||||
|
|
||||||
The first one takes a data path, and a conversion function and does the rest for you.
|
|
||||||
This is great for simple data that just needs to quickly be imported without much
|
|
||||||
modification.
|
|
||||||
|
|
||||||
|
|
||||||
Here's an example of that in use:
|
|
||||||
|
|
||||||
.. code-block:: python
|
|
||||||
|
|
||||||
from pathlib import Path
|
|
||||||
from discord.ext import commands
|
|
||||||
|
|
||||||
from redbot.core.utils.data_converter import DataConverter as dc
|
|
||||||
from redbot.core.config import Config
|
|
||||||
|
|
||||||
...
|
|
||||||
|
|
||||||
|
|
||||||
async def import_v2(self, file_path: Path):
|
|
||||||
"""
|
|
||||||
to be called from a command limited to owner
|
|
||||||
|
|
||||||
This should be a coroutine as the convert function will
|
|
||||||
need to be awaited
|
|
||||||
"""
|
|
||||||
|
|
||||||
# First we give the converter our cog's Config instance.
|
|
||||||
converter = dc(self.config)
|
|
||||||
|
|
||||||
# next we design a way to get all of the data into Config's internal
|
|
||||||
# format. This should be a generator, but you can also return a single
|
|
||||||
# list with identical results outside of memory usage
|
|
||||||
def conversion_spec(v2data):
|
|
||||||
for guild_id in v2.data.keys():
|
|
||||||
yield {(Config.GUILD, guild_id): {('blacklisted',): True}}
|
|
||||||
# This is yielding a dictionary that is designed for config's set_raw.
|
|
||||||
# The keys should be a tuple of Config scopes + the needed Identifiers. The
|
|
||||||
# values should be another dictionary whose keys are tuples representing
|
|
||||||
# config settings, the value should be the value to set for that.
|
|
||||||
|
|
||||||
# Then we pass the file and the conversion function
|
|
||||||
await converter.convert(file_path, conversion_spec)
|
|
||||||
# From here, our data should be imported
|
|
||||||
|
|
||||||
|
|
||||||
You can also choose to convert all of your data and pass it as a single dict
|
|
||||||
This can be useful if you want finer control over the dataconversion or want to
|
|
||||||
preserve any data from v3 that may share the same entry and set it aside to prompt
|
|
||||||
a user
|
|
||||||
|
|
||||||
.. code-block:: python
|
|
||||||
|
|
||||||
from pathlib import Path
|
|
||||||
from discord.ext import commands
|
|
||||||
|
|
||||||
from redbot.core.utils.data_converter import DataConverter as dc
|
|
||||||
from redbot.core.config import Config
|
|
||||||
|
|
||||||
...
|
|
||||||
|
|
||||||
await dc(config_instance).dict_import(some_processed_dict)
|
|
||||||
|
|
||||||
|
|
||||||
The format of the items of the dict is the same as in the above example
|
|
||||||
|
|
||||||
|
|
||||||
-----------------------------------
|
|
||||||
Config Scopes and their Identifiers
|
|
||||||
-----------------------------------
|
|
||||||
|
|
||||||
This section is provided as a quick reference for the identifiers for default
|
|
||||||
scopes available in Config. This does not cover usage of custom scopes, though the
|
|
||||||
data converter is compatible with those as well.
|
|
||||||
|
|
||||||
Global::
|
|
||||||
:code:`(Config.GLOBAL,)`
|
|
||||||
Guild::
|
|
||||||
:code:`(Config.GUILD, guild_id)`
|
|
||||||
Channel::
|
|
||||||
:code:`(Config.CHANNEL, channel_id)`
|
|
||||||
User::
|
|
||||||
:code:`(Config.USER, user_id)`
|
|
||||||
Member::
|
|
||||||
:code:`(Config.MEMBER, guild_id, user_id)`
|
|
||||||
Role::
|
|
||||||
:code:`(Config.ROLE, role_id)`
|
|
||||||
|
|
||||||
|
|
||||||
-----------------------------
|
|
||||||
More information and Examples
|
|
||||||
-----------------------------
|
|
||||||
|
|
||||||
For a more in depth look at how all of these commands function
|
|
||||||
You may want to take a look at how core data is being imported
|
|
||||||
|
|
||||||
:code:`redbot/cogs/dataconverter/core_specs.py`
|
|
||||||
@ -13,7 +13,6 @@ Welcome to Red - Discord Bot's documentation!
|
|||||||
install_windows
|
install_windows
|
||||||
install_linux_mac
|
install_linux_mac
|
||||||
venv_guide
|
venv_guide
|
||||||
cog_dataconverter
|
|
||||||
autostart_systemd
|
autostart_systemd
|
||||||
|
|
||||||
.. toctree::
|
.. toctree::
|
||||||
@ -30,7 +29,6 @@ Welcome to Red - Discord Bot's documentation!
|
|||||||
|
|
||||||
guide_migration
|
guide_migration
|
||||||
guide_cog_creation
|
guide_cog_creation
|
||||||
guide_data_conversion
|
|
||||||
framework_bank
|
framework_bank
|
||||||
framework_bot
|
framework_bot
|
||||||
framework_checks
|
framework_checks
|
||||||
|
|||||||
@ -2,29 +2,31 @@
|
|||||||
|
|
||||||
# Discord Version check
|
# Discord Version check
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
import discord
|
import discord
|
||||||
|
|
||||||
|
import redbot.logging
|
||||||
from redbot.core.bot import Red, ExitCodes
|
from redbot.core.bot import Red, ExitCodes
|
||||||
from redbot.core.cog_manager import CogManagerUI
|
from redbot.core.cog_manager import CogManagerUI
|
||||||
from redbot.core.data_manager import create_temp_config, load_basic_configuration, config_file
|
|
||||||
from redbot.core.json_io import JsonIO
|
from redbot.core.json_io import JsonIO
|
||||||
from redbot.core.global_checks import init_global_checks
|
from redbot.core.global_checks import init_global_checks
|
||||||
from redbot.core.events import init_events
|
from redbot.core.events import init_events
|
||||||
from redbot.core.cli import interactive_config, confirm, parse_cli_flags
|
from redbot.core.cli import interactive_config, confirm, parse_cli_flags
|
||||||
from redbot.core.core_commands import Core
|
from redbot.core.core_commands import Core
|
||||||
from redbot.core.dev_commands import Dev
|
from redbot.core.dev_commands import Dev
|
||||||
from redbot.core import modlog, bank
|
from redbot.core import __version__, modlog, bank, data_manager
|
||||||
from signal import SIGTERM
|
from signal import SIGTERM
|
||||||
import asyncio
|
|
||||||
import logging.handlers
|
|
||||||
import logging
|
|
||||||
import os
|
|
||||||
|
|
||||||
# Let's not force this dependency, uvloop is much faster on cpython
|
# Let's not force this dependency, uvloop is much faster on cpython
|
||||||
if sys.implementation.name == "cpython":
|
if sys.implementation.name == "cpython":
|
||||||
try:
|
try:
|
||||||
import uvloop
|
import uvloop
|
||||||
except ImportError:
|
except ImportError:
|
||||||
|
uvloop = None
|
||||||
pass
|
pass
|
||||||
else:
|
else:
|
||||||
asyncio.set_event_loop_policy(uvloop.EventLoopPolicy())
|
asyncio.set_event_loop_policy(uvloop.EventLoopPolicy())
|
||||||
@ -32,6 +34,7 @@ if sys.implementation.name == "cpython":
|
|||||||
if sys.platform == "win32":
|
if sys.platform == "win32":
|
||||||
asyncio.set_event_loop(asyncio.ProactorEventLoop())
|
asyncio.set_event_loop(asyncio.ProactorEventLoop())
|
||||||
|
|
||||||
|
log = logging.getLogger("red.main")
|
||||||
|
|
||||||
#
|
#
|
||||||
# Red - Discord Bot v3
|
# Red - Discord Bot v3
|
||||||
@ -40,46 +43,6 @@ if sys.platform == "win32":
|
|||||||
#
|
#
|
||||||
|
|
||||||
|
|
||||||
def init_loggers(cli_flags):
|
|
||||||
# d.py stuff
|
|
||||||
dpy_logger = logging.getLogger("discord")
|
|
||||||
dpy_logger.setLevel(logging.WARNING)
|
|
||||||
console = logging.StreamHandler()
|
|
||||||
console.setLevel(logging.WARNING)
|
|
||||||
dpy_logger.addHandler(console)
|
|
||||||
|
|
||||||
# Red stuff
|
|
||||||
|
|
||||||
logger = logging.getLogger("red")
|
|
||||||
|
|
||||||
red_format = logging.Formatter(
|
|
||||||
"%(asctime)s %(levelname)s %(module)s %(funcName)s %(lineno)d: %(message)s",
|
|
||||||
datefmt="[%d/%m/%Y %H:%M]",
|
|
||||||
)
|
|
||||||
|
|
||||||
stdout_handler = logging.StreamHandler(sys.stdout)
|
|
||||||
stdout_handler.setFormatter(red_format)
|
|
||||||
|
|
||||||
if cli_flags.debug:
|
|
||||||
os.environ["PYTHONASYNCIODEBUG"] = "1"
|
|
||||||
logger.setLevel(logging.DEBUG)
|
|
||||||
else:
|
|
||||||
logger.setLevel(logging.INFO)
|
|
||||||
|
|
||||||
from redbot.core.data_manager import core_data_path
|
|
||||||
|
|
||||||
logfile_path = core_data_path() / "red.log"
|
|
||||||
fhandler = logging.handlers.RotatingFileHandler(
|
|
||||||
filename=str(logfile_path), encoding="utf-8", mode="a", maxBytes=10 ** 7, backupCount=5
|
|
||||||
)
|
|
||||||
fhandler.setFormatter(red_format)
|
|
||||||
|
|
||||||
logger.addHandler(fhandler)
|
|
||||||
logger.addHandler(stdout_handler)
|
|
||||||
|
|
||||||
return logger
|
|
||||||
|
|
||||||
|
|
||||||
async def _get_prefix_and_token(red, indict):
|
async def _get_prefix_and_token(red, indict):
|
||||||
"""
|
"""
|
||||||
Again, please blame <@269933075037814786> for this.
|
Again, please blame <@269933075037814786> for this.
|
||||||
@ -91,14 +54,14 @@ async def _get_prefix_and_token(red, indict):
|
|||||||
|
|
||||||
|
|
||||||
def list_instances():
|
def list_instances():
|
||||||
if not config_file.exists():
|
if not data_manager.config_file.exists():
|
||||||
print(
|
print(
|
||||||
"No instances have been configured! Configure one "
|
"No instances have been configured! Configure one "
|
||||||
"using `redbot-setup` before trying to run the bot!"
|
"using `redbot-setup` before trying to run the bot!"
|
||||||
)
|
)
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
else:
|
else:
|
||||||
data = JsonIO(config_file)._load_json()
|
data = JsonIO(data_manager.config_file)._load_json()
|
||||||
text = "Configured Instances:\n\n"
|
text = "Configured Instances:\n\n"
|
||||||
for instance_name in sorted(data.keys()):
|
for instance_name in sorted(data.keys()):
|
||||||
text += "{}\n".format(instance_name)
|
text += "{}\n".format(instance_name)
|
||||||
@ -118,6 +81,7 @@ def main():
|
|||||||
list_instances()
|
list_instances()
|
||||||
elif cli_flags.version:
|
elif cli_flags.version:
|
||||||
print(description)
|
print(description)
|
||||||
|
print("Current Version: {}".format(__version__))
|
||||||
sys.exit(0)
|
sys.exit(0)
|
||||||
elif not cli_flags.instance_name and not cli_flags.no_instance:
|
elif not cli_flags.instance_name and not cli_flags.no_instance:
|
||||||
print("Error: No instance name was provided!")
|
print("Error: No instance name was provided!")
|
||||||
@ -125,13 +89,21 @@ def main():
|
|||||||
if cli_flags.no_instance:
|
if cli_flags.no_instance:
|
||||||
print(
|
print(
|
||||||
"\033[1m"
|
"\033[1m"
|
||||||
"Warning: The data will be placed in a temporary folder and removed on next system reboot."
|
"Warning: The data will be placed in a temporary folder and removed on next system "
|
||||||
|
"reboot."
|
||||||
"\033[0m"
|
"\033[0m"
|
||||||
)
|
)
|
||||||
cli_flags.instance_name = "temporary_red"
|
cli_flags.instance_name = "temporary_red"
|
||||||
create_temp_config()
|
data_manager.create_temp_config()
|
||||||
load_basic_configuration(cli_flags.instance_name)
|
data_manager.load_basic_configuration(cli_flags.instance_name)
|
||||||
log = init_loggers(cli_flags)
|
redbot.logging.init_logging(
|
||||||
|
level=cli_flags.logging_level, location=data_manager.core_data_path() / "logs"
|
||||||
|
)
|
||||||
|
|
||||||
|
log.debug("====Basic Config====")
|
||||||
|
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, pm_help=None)
|
red = Red(cli_flags=cli_flags, description=description, pm_help=None)
|
||||||
init_global_checks(red)
|
init_global_checks(red)
|
||||||
init_events(red, cli_flags)
|
init_events(red, cli_flags)
|
||||||
|
|||||||
@ -1,8 +1,10 @@
|
|||||||
from copy import copy
|
from copy import copy
|
||||||
from re import search
|
from re import findall, search
|
||||||
|
from string import Formatter
|
||||||
from typing import Generator, Tuple, Iterable, Optional
|
from typing import Generator, Tuple, Iterable, Optional
|
||||||
|
|
||||||
import discord
|
import discord
|
||||||
|
from discord.ext.commands.view import StringView, quoted_word
|
||||||
from redbot.core import Config, commands, checks
|
from redbot.core import Config, commands, checks
|
||||||
from redbot.core.i18n import Translator, cog_i18n
|
from redbot.core.i18n import Translator, cog_i18n
|
||||||
from redbot.core.utils.chat_formatting import box
|
from redbot.core.utils.chat_formatting import box
|
||||||
@ -13,6 +15,21 @@ from .alias_entry import AliasEntry
|
|||||||
_ = Translator("Alias", __file__)
|
_ = Translator("Alias", __file__)
|
||||||
|
|
||||||
|
|
||||||
|
class _TrackingFormatter(Formatter):
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__()
|
||||||
|
self.max = -1
|
||||||
|
|
||||||
|
def get_value(self, key, args, kwargs):
|
||||||
|
if isinstance(key, int):
|
||||||
|
self.max = max((key, self.max))
|
||||||
|
return super().get_value(key, args, kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
class ArgParseError(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
@cog_i18n(_)
|
@cog_i18n(_)
|
||||||
class Alias(commands.Cog):
|
class Alias(commands.Cog):
|
||||||
"""Create aliases for commands.
|
"""Create aliases for commands.
|
||||||
@ -80,8 +97,25 @@ class Alias(commands.Cog):
|
|||||||
return not bool(search(r"\s", alias_name)) and alias_name.isprintable()
|
return not bool(search(r"\s", alias_name)) and alias_name.isprintable()
|
||||||
|
|
||||||
async def add_alias(
|
async def add_alias(
|
||||||
self, ctx: commands.Context, alias_name: str, command: Tuple[str], global_: bool = False
|
self, ctx: commands.Context, alias_name: str, command: str, global_: bool = False
|
||||||
) -> AliasEntry:
|
) -> AliasEntry:
|
||||||
|
indices = findall(r"{(\d*)}", command)
|
||||||
|
if indices:
|
||||||
|
try:
|
||||||
|
indices = [int(a[0]) for a in indices]
|
||||||
|
except IndexError:
|
||||||
|
raise ArgParseError(_("Arguments must be specified with a number."))
|
||||||
|
low = min(indices)
|
||||||
|
indices = [a - low for a in indices]
|
||||||
|
high = max(indices)
|
||||||
|
gaps = set(indices).symmetric_difference(range(high + 1))
|
||||||
|
if gaps:
|
||||||
|
raise ArgParseError(
|
||||||
|
_("Arguments must be sequential. Missing arguments: ")
|
||||||
|
+ ", ".join(str(i + low) for i in gaps)
|
||||||
|
)
|
||||||
|
command = command.format(*(f"{{{i}}}" for i in range(-low, high + low + 1)))
|
||||||
|
|
||||||
alias = AliasEntry(alias_name, command, ctx.author, global_=global_)
|
alias = AliasEntry(alias_name, command, ctx.author, global_=global_)
|
||||||
|
|
||||||
if global_:
|
if global_:
|
||||||
@ -142,7 +176,17 @@ class Alias(commands.Cog):
|
|||||||
:return:
|
:return:
|
||||||
"""
|
"""
|
||||||
known_content_length = len(prefix) + len(alias.name)
|
known_content_length = len(prefix) + len(alias.name)
|
||||||
extra = message.content[known_content_length:].strip()
|
extra = message.content[known_content_length:]
|
||||||
|
view = StringView(extra)
|
||||||
|
view.skip_ws()
|
||||||
|
extra = []
|
||||||
|
while not view.eof:
|
||||||
|
prev = view.index
|
||||||
|
word = quoted_word(view)
|
||||||
|
if len(word) < view.index - prev:
|
||||||
|
word = "".join((view.buffer[prev], word, view.buffer[view.index - 1]))
|
||||||
|
extra.append(word)
|
||||||
|
view.skip_ws()
|
||||||
return extra
|
return extra
|
||||||
|
|
||||||
async def maybe_call_alias(
|
async def maybe_call_alias(
|
||||||
@ -167,10 +211,18 @@ class Alias(commands.Cog):
|
|||||||
|
|
||||||
async def call_alias(self, message: discord.Message, prefix: str, alias: AliasEntry):
|
async def call_alias(self, message: discord.Message, prefix: str, alias: AliasEntry):
|
||||||
new_message = copy(message)
|
new_message = copy(message)
|
||||||
|
try:
|
||||||
args = self.get_extra_args_from_alias(message, prefix, alias)
|
args = self.get_extra_args_from_alias(message, prefix, alias)
|
||||||
|
except commands.BadArgument as bae:
|
||||||
|
return
|
||||||
|
|
||||||
|
trackform = _TrackingFormatter()
|
||||||
|
command = trackform.format(alias.command, *args)
|
||||||
|
|
||||||
# noinspection PyDunderSlots
|
# noinspection PyDunderSlots
|
||||||
new_message.content = "{}{} {}".format(prefix, alias.command, args)
|
new_message.content = "{}{} {}".format(
|
||||||
|
prefix, command, " ".join(args[trackform.max + 1 :])
|
||||||
|
)
|
||||||
await self.bot.process_commands(new_message)
|
await self.bot.process_commands(new_message)
|
||||||
|
|
||||||
@commands.group()
|
@commands.group()
|
||||||
@ -228,7 +280,10 @@ class Alias(commands.Cog):
|
|||||||
# At this point we know we need to make a new alias
|
# At this point we know we need to make a new alias
|
||||||
# and that the alias name is valid.
|
# and that the alias name is valid.
|
||||||
|
|
||||||
|
try:
|
||||||
await self.add_alias(ctx, alias_name, command)
|
await self.add_alias(ctx, alias_name, command)
|
||||||
|
except ArgParseError as e:
|
||||||
|
return await ctx.send(" ".join(e.args))
|
||||||
|
|
||||||
await ctx.send(
|
await ctx.send(
|
||||||
_("A new alias with the trigger `{name}` has been created.").format(name=alias_name)
|
_("A new alias with the trigger `{name}` has been created.").format(name=alias_name)
|
||||||
@ -274,7 +329,10 @@ class Alias(commands.Cog):
|
|||||||
return
|
return
|
||||||
# endregion
|
# endregion
|
||||||
|
|
||||||
|
try:
|
||||||
await self.add_alias(ctx, alias_name, command, global_=True)
|
await self.add_alias(ctx, alias_name, command, global_=True)
|
||||||
|
except ArgParseError as e:
|
||||||
|
return await ctx.send(" ".join(e.args))
|
||||||
|
|
||||||
await ctx.send(
|
await ctx.send(
|
||||||
_("A new global alias with the trigger `{name}` has been created.").format(
|
_("A new global alias with the trigger `{name}` has been created.").format(
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import aiohttp
|
import aiohttp
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import base64
|
||||||
import datetime
|
import datetime
|
||||||
import discord
|
import discord
|
||||||
from fuzzywuzzy import process
|
from fuzzywuzzy import process
|
||||||
@ -17,7 +18,7 @@ import redbot.core
|
|||||||
from redbot.core import Config, commands, checks, bank
|
from redbot.core import Config, commands, checks, bank
|
||||||
from redbot.core.data_manager import cog_data_path
|
from redbot.core.data_manager import cog_data_path
|
||||||
from redbot.core.i18n import Translator, cog_i18n
|
from redbot.core.i18n import Translator, cog_i18n
|
||||||
from redbot.core.utils.chat_formatting import bold, box
|
from redbot.core.utils.chat_formatting import bold, box, pagify
|
||||||
from redbot.core.utils.menus import (
|
from redbot.core.utils.menus import (
|
||||||
menu,
|
menu,
|
||||||
DEFAULT_CONTROLS,
|
DEFAULT_CONTROLS,
|
||||||
@ -32,8 +33,8 @@ from .manager import shutdown_lavalink_server, start_lavalink_server, maybe_down
|
|||||||
|
|
||||||
_ = Translator("Audio", __file__)
|
_ = Translator("Audio", __file__)
|
||||||
|
|
||||||
__version__ = "0.0.8"
|
__version__ = "0.0.8b"
|
||||||
__author__ = ["aikaterna", "billy/bollo/ati"]
|
__author__ = ["aikaterna"]
|
||||||
|
|
||||||
log = logging.getLogger("red.audio")
|
log = logging.getLogger("red.audio")
|
||||||
|
|
||||||
@ -84,6 +85,8 @@ class Audio(commands.Cog):
|
|||||||
self._connect_task = None
|
self._connect_task = None
|
||||||
self._disconnect_task = None
|
self._disconnect_task = None
|
||||||
self._cleaned_up = False
|
self._cleaned_up = False
|
||||||
|
self.spotify_token = None
|
||||||
|
self.play_lock = {}
|
||||||
|
|
||||||
async def initialize(self):
|
async def initialize(self):
|
||||||
self._restart_connect()
|
self._restart_connect()
|
||||||
@ -331,6 +334,27 @@ class Audio(commands.Cog):
|
|||||||
await self.config.guild(ctx.guild).emptydc_timer.set(seconds)
|
await self.config.guild(ctx.guild).emptydc_timer.set(seconds)
|
||||||
await self.config.guild(ctx.guild).emptydc_enabled.set(enabled)
|
await self.config.guild(ctx.guild).emptydc_enabled.set(enabled)
|
||||||
|
|
||||||
|
@audioset.command()
|
||||||
|
@checks.mod_or_permissions(administrator=True)
|
||||||
|
async def jukebox(self, ctx, price: int):
|
||||||
|
"""Set a price for queueing tracks for non-mods. 0 to disable."""
|
||||||
|
if price < 0:
|
||||||
|
return await self._embed_msg(ctx, _("Can't be less than zero."))
|
||||||
|
if price == 0:
|
||||||
|
jukebox = False
|
||||||
|
await self._embed_msg(ctx, _("Jukebox mode disabled."))
|
||||||
|
else:
|
||||||
|
jukebox = True
|
||||||
|
await self._embed_msg(
|
||||||
|
ctx,
|
||||||
|
_("Track queueing command price set to {price} {currency}.").format(
|
||||||
|
price=price, currency=await bank.get_currency_name(ctx.guild)
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
await self.config.guild(ctx.guild).jukebox_price.set(price)
|
||||||
|
await self.config.guild(ctx.guild).jukebox.set(jukebox)
|
||||||
|
|
||||||
@audioset.command()
|
@audioset.command()
|
||||||
@checks.mod_or_permissions(administrator=True)
|
@checks.mod_or_permissions(administrator=True)
|
||||||
async def maxlength(self, ctx, seconds):
|
async def maxlength(self, ctx, seconds):
|
||||||
@ -354,35 +378,6 @@ class Audio(commands.Cog):
|
|||||||
|
|
||||||
await self.config.guild(ctx.guild).maxlength.set(seconds)
|
await self.config.guild(ctx.guild).maxlength.set(seconds)
|
||||||
|
|
||||||
@audioset.command()
|
|
||||||
@checks.admin_or_permissions(manage_roles=True)
|
|
||||||
async def role(self, ctx, role_name: discord.Role):
|
|
||||||
"""Set the role to use for DJ mode."""
|
|
||||||
await self.config.guild(ctx.guild).dj_role.set(role_name.id)
|
|
||||||
dj_role_obj = ctx.guild.get_role(await self.config.guild(ctx.guild).dj_role())
|
|
||||||
await self._embed_msg(ctx, _("DJ role set to: {role.name}.").format(role=dj_role_obj))
|
|
||||||
|
|
||||||
@audioset.command()
|
|
||||||
@checks.mod_or_permissions(administrator=True)
|
|
||||||
async def jukebox(self, ctx, price: int):
|
|
||||||
"""Set a price for queueing tracks for non-mods. 0 to disable."""
|
|
||||||
if price < 0:
|
|
||||||
return await self._embed_msg(ctx, _("Can't be less than zero."))
|
|
||||||
if price == 0:
|
|
||||||
jukebox = False
|
|
||||||
await self._embed_msg(ctx, _("Jukebox mode disabled."))
|
|
||||||
else:
|
|
||||||
jukebox = True
|
|
||||||
await self._embed_msg(
|
|
||||||
ctx,
|
|
||||||
_("Track queueing command price set to {price} {currency}.").format(
|
|
||||||
price=price, currency=await bank.get_currency_name(ctx.guild)
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
await self.config.guild(ctx.guild).jukebox_price.set(price)
|
|
||||||
await self.config.guild(ctx.guild).jukebox.set(jukebox)
|
|
||||||
|
|
||||||
@audioset.command()
|
@audioset.command()
|
||||||
@checks.mod_or_permissions(manage_messages=True)
|
@checks.mod_or_permissions(manage_messages=True)
|
||||||
async def notify(self, ctx):
|
async def notify(self, ctx):
|
||||||
@ -406,6 +401,14 @@ class Audio(commands.Cog):
|
|||||||
ctx, _("Commercial links only: {true_or_false}.").format(true_or_false=not restrict)
|
ctx, _("Commercial links only: {true_or_false}.").format(true_or_false=not restrict)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@audioset.command()
|
||||||
|
@checks.admin_or_permissions(manage_roles=True)
|
||||||
|
async def role(self, ctx, role_name: discord.Role):
|
||||||
|
"""Set the role to use for DJ mode."""
|
||||||
|
await self.config.guild(ctx.guild).dj_role.set(role_name.id)
|
||||||
|
dj_role_obj = ctx.guild.get_role(await self.config.guild(ctx.guild).dj_role())
|
||||||
|
await self._embed_msg(ctx, _("DJ role set to: {role.name}.").format(role=dj_role_obj))
|
||||||
|
|
||||||
@audioset.command()
|
@audioset.command()
|
||||||
async def settings(self, ctx):
|
async def settings(self, ctx):
|
||||||
"""Show the current settings."""
|
"""Show the current settings."""
|
||||||
@ -461,6 +464,33 @@ class Audio(commands.Cog):
|
|||||||
embed = discord.Embed(colour=await ctx.embed_colour(), description=box(msg, lang="ini"))
|
embed = discord.Embed(colour=await ctx.embed_colour(), description=box(msg, lang="ini"))
|
||||||
return await ctx.send(embed=embed)
|
return await ctx.send(embed=embed)
|
||||||
|
|
||||||
|
@audioset.command()
|
||||||
|
@checks.is_owner()
|
||||||
|
async def spotifyapi(self, ctx):
|
||||||
|
"""Instructions to set the Spotify API tokens."""
|
||||||
|
message = _(
|
||||||
|
f"1. Go to Spotify developers and log in with your Spotify account\n"
|
||||||
|
"(https://developer.spotify.com/dashboard/applications)\n"
|
||||||
|
'2. Click "Create An App"\n'
|
||||||
|
"3. Fill out the form provided with your app name, etc.\n"
|
||||||
|
'4. When asked if you\'re developing commercial integration select "No"\n'
|
||||||
|
"5. Accept the terms and conditions.\n"
|
||||||
|
"6. Copy your client ID and your client secret into:\n"
|
||||||
|
"`{prefix}set api spotify client_id,your_client_id "
|
||||||
|
"client_secret,your_client_secret`"
|
||||||
|
).format(prefix=ctx.prefix)
|
||||||
|
await ctx.maybe_send_embed(message)
|
||||||
|
|
||||||
|
@checks.is_owner()
|
||||||
|
@audioset.command()
|
||||||
|
async def status(self, ctx):
|
||||||
|
"""Enable/disable tracks' titles as status."""
|
||||||
|
status = await self.config.status()
|
||||||
|
await self.config.status.set(not status)
|
||||||
|
await self._embed_msg(
|
||||||
|
ctx, _("Song titles as status: {true_or_false}.").format(true_or_false=not status)
|
||||||
|
)
|
||||||
|
|
||||||
@audioset.command()
|
@audioset.command()
|
||||||
@checks.mod_or_permissions(administrator=True)
|
@checks.mod_or_permissions(administrator=True)
|
||||||
async def thumbnail(self, ctx):
|
async def thumbnail(self, ctx):
|
||||||
@ -493,21 +523,30 @@ class Audio(commands.Cog):
|
|||||||
await self.config.guild(ctx.guild).vote_percent.set(percent)
|
await self.config.guild(ctx.guild).vote_percent.set(percent)
|
||||||
await self.config.guild(ctx.guild).vote_enabled.set(enabled)
|
await self.config.guild(ctx.guild).vote_enabled.set(enabled)
|
||||||
|
|
||||||
@checks.is_owner()
|
|
||||||
@audioset.command()
|
@audioset.command()
|
||||||
async def status(self, ctx):
|
@checks.is_owner()
|
||||||
"""Enable/disable tracks' titles as status."""
|
async def youtubeapi(self, ctx):
|
||||||
status = await self.config.status()
|
"""Instructions to set the YouTube API key."""
|
||||||
await self.config.status.set(not status)
|
message = _(
|
||||||
await self._embed_msg(
|
f"1. Go to Google Developers Console and log in with your Google account.\n"
|
||||||
ctx, _("Song titles as status: {true_or_false}.").format(true_or_false=not status)
|
"(https://console.developers.google.com/)\n"
|
||||||
)
|
"2. You should be prompted to create a new project (name does not matter).\n"
|
||||||
|
"3. Click on Enable APIs and Services at the top.\n"
|
||||||
|
"4. In the list of APIs choose or search for YouTube Data API v3 and click on it. Choose Enable.\n"
|
||||||
|
"5. Click on Credentials on the left navigation bar.\n"
|
||||||
|
"6. Click on Create Credential at the top.\n"
|
||||||
|
'7. At the top click the link for "API key".\n'
|
||||||
|
"8. No application restrictions are needed. Click Create at the bottom.\n"
|
||||||
|
"9. You now have a key to add to `{prefix}set api youtube api_key,your_api_key`"
|
||||||
|
).format(prefix=ctx.prefix)
|
||||||
|
await ctx.maybe_send_embed(message)
|
||||||
|
|
||||||
@commands.command()
|
@commands.command()
|
||||||
@commands.guild_only()
|
@commands.guild_only()
|
||||||
async def audiostats(self, ctx):
|
async def audiostats(self, ctx):
|
||||||
"""Audio stats."""
|
"""Audio stats."""
|
||||||
server_num = len([p for p in lavalink.players if p.current is not None])
|
server_num = len([p for p in lavalink.players if p.current is not None])
|
||||||
|
total_num = len([p for p in lavalink.players])
|
||||||
server_list = []
|
server_list = []
|
||||||
|
|
||||||
for p in lavalink.players:
|
for p in lavalink.players:
|
||||||
@ -549,7 +588,7 @@ class Audio(commands.Cog):
|
|||||||
servers = "\n".join(server_list)
|
servers = "\n".join(server_list)
|
||||||
embed = discord.Embed(
|
embed = discord.Embed(
|
||||||
colour=await ctx.embed_colour(),
|
colour=await ctx.embed_colour(),
|
||||||
title=_("Connected in {num} servers:").format(num=server_num),
|
title=_("Playing in {num}/{total} servers:").format(num=server_num, total=total_num),
|
||||||
description=servers,
|
description=servers,
|
||||||
)
|
)
|
||||||
await ctx.send(embed=embed)
|
await ctx.send(embed=embed)
|
||||||
@ -605,6 +644,7 @@ class Audio(commands.Cog):
|
|||||||
):
|
):
|
||||||
return await self._embed_msg(ctx, _("There are other people listening to music."))
|
return await self._embed_msg(ctx, _("There are other people listening to music."))
|
||||||
else:
|
else:
|
||||||
|
self._play_lock(ctx, False)
|
||||||
await lavalink.get_player(ctx.guild.id).stop()
|
await lavalink.get_player(ctx.guild.id).stop()
|
||||||
await lavalink.get_player(ctx.guild.id).disconnect()
|
await lavalink.get_player(ctx.guild.id).disconnect()
|
||||||
|
|
||||||
@ -977,6 +1017,7 @@ class Audio(commands.Cog):
|
|||||||
@commands.guild_only()
|
@commands.guild_only()
|
||||||
async def play(self, ctx, *, query):
|
async def play(self, ctx, *, query):
|
||||||
"""Play a URL or search for a track."""
|
"""Play a URL or search for a track."""
|
||||||
|
|
||||||
guild_data = await self.config.guild(ctx.guild).all()
|
guild_data = await self.config.guild(ctx.guild).all()
|
||||||
restrict = await self.config.restrict()
|
restrict = await self.config.restrict()
|
||||||
if restrict:
|
if restrict:
|
||||||
@ -986,8 +1027,10 @@ class Audio(commands.Cog):
|
|||||||
return await self._embed_msg(ctx, _("That URL is not allowed."))
|
return await self._embed_msg(ctx, _("That URL is not allowed."))
|
||||||
if not self._player_check(ctx):
|
if not self._player_check(ctx):
|
||||||
try:
|
try:
|
||||||
if not ctx.author.voice.channel.permissions_for(ctx.me).connect or self._userlimit(
|
if (
|
||||||
ctx.author.voice.channel
|
not ctx.author.voice.channel.permissions_for(ctx.me).connect
|
||||||
|
or not ctx.author.voice.channel.permissions_for(ctx.me).move_members
|
||||||
|
and self._userlimit(ctx.author.voice.channel)
|
||||||
):
|
):
|
||||||
return await self._embed_msg(
|
return await self._embed_msg(
|
||||||
ctx, _("I don't have permission to connect to your channel.")
|
ctx, _("I don't have permission to connect to your channel.")
|
||||||
@ -1021,6 +1064,13 @@ class Audio(commands.Cog):
|
|||||||
return await self._embed_msg(ctx, _("No tracks to play."))
|
return await self._embed_msg(ctx, _("No tracks to play."))
|
||||||
query = query.strip("<>")
|
query = query.strip("<>")
|
||||||
|
|
||||||
|
if "open.spotify.com" in query:
|
||||||
|
query = "spotify:{}".format(
|
||||||
|
re.sub("(http[s]?:\/\/)?(open.spotify.com)\/", "", query).replace("/", ":")
|
||||||
|
)
|
||||||
|
if query.startswith("spotify:"):
|
||||||
|
return await self._get_spotify_tracks(ctx, query)
|
||||||
|
|
||||||
if query.startswith("localtrack:"):
|
if query.startswith("localtrack:"):
|
||||||
await self._localtracks_check(ctx)
|
await self._localtracks_check(ctx)
|
||||||
query = query.replace("localtrack:", "").replace(
|
query = query.replace("localtrack:", "").replace(
|
||||||
@ -1030,9 +1080,116 @@ class Audio(commands.Cog):
|
|||||||
if not self._match_url(query) and not (query.lower().endswith(allowed_files)):
|
if not self._match_url(query) and not (query.lower().endswith(allowed_files)):
|
||||||
query = "ytsearch:{}".format(query)
|
query = "ytsearch:{}".format(query)
|
||||||
|
|
||||||
|
await self._enqueue_tracks(ctx, query)
|
||||||
|
|
||||||
|
async def _get_spotify_tracks(self, ctx, query):
|
||||||
|
if ctx.invoked_with == "play":
|
||||||
|
enqueue_tracks = True
|
||||||
|
else:
|
||||||
|
enqueue_tracks = False
|
||||||
|
player = lavalink.get_player(ctx.guild.id)
|
||||||
|
api_data = await self._check_api_tokens()
|
||||||
|
guild_data = await self.config.guild(ctx.guild).all()
|
||||||
|
if "open.spotify.com" in query:
|
||||||
|
query = "spotify:{}".format(
|
||||||
|
re.sub("(http[s]?:\/\/)?(open.spotify.com)\/", "", query).replace("/", ":")
|
||||||
|
)
|
||||||
|
if query.startswith("spotify:"):
|
||||||
|
if (
|
||||||
|
not api_data["spotify_client_id"]
|
||||||
|
or not api_data["spotify_client_secret"]
|
||||||
|
or not api_data["youtube_api"]
|
||||||
|
):
|
||||||
|
return await self._embed_msg(
|
||||||
|
ctx,
|
||||||
|
_(
|
||||||
|
"The owner needs to set the Spotify client ID, Spotify client secret, "
|
||||||
|
"and YouTube API key before Spotify URLs or codes can be used. "
|
||||||
|
"\nSee `{prefix}audioset youtubeapi` and `{prefix}audioset spotifyapi` "
|
||||||
|
"for instructions."
|
||||||
|
).format(prefix=ctx.prefix),
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
if self.play_lock[ctx.message.guild.id]:
|
||||||
|
return await self._embed_msg(
|
||||||
|
ctx, _("Wait until the playlist has finished loading.")
|
||||||
|
)
|
||||||
|
except KeyError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
parts = query.split(":")
|
||||||
|
if "track" in parts:
|
||||||
|
res = await self._make_spotify_req(
|
||||||
|
"https://api.spotify.com/v1/tracks/{0}".format(parts[-1])
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
query = "{} {}".format(res["artists"][0]["name"], res["name"])
|
||||||
|
if enqueue_tracks:
|
||||||
|
return await self._enqueue_tracks(ctx, query)
|
||||||
|
else:
|
||||||
|
tracks = await player.get_tracks(f"ytsearch:{query}")
|
||||||
|
if not tracks:
|
||||||
|
return await self._embed_msg(ctx, _("Nothing found."))
|
||||||
|
single_track = []
|
||||||
|
single_track.append(tracks[0])
|
||||||
|
return single_track
|
||||||
|
|
||||||
|
except KeyError:
|
||||||
|
return await self._embed_msg(
|
||||||
|
ctx,
|
||||||
|
_(
|
||||||
|
"The Spotify API key or client secret has not been set properly. "
|
||||||
|
"\nUse `{prefix}audioset spotifyapi` for instructions."
|
||||||
|
).format(prefix=ctx.prefix),
|
||||||
|
)
|
||||||
|
elif "album" in parts:
|
||||||
|
query = parts[-1]
|
||||||
|
self._play_lock(ctx, True)
|
||||||
|
track_list = await self._spotify_playlist(
|
||||||
|
ctx, "album", api_data["youtube_api"], query
|
||||||
|
)
|
||||||
|
if not track_list:
|
||||||
|
self._play_lock(ctx, False)
|
||||||
|
return
|
||||||
|
if enqueue_tracks:
|
||||||
|
return await self._enqueue_tracks(ctx, track_list)
|
||||||
|
else:
|
||||||
|
return track_list
|
||||||
|
elif "playlist" in parts:
|
||||||
|
query = parts[-1]
|
||||||
|
self._play_lock(ctx, True)
|
||||||
|
if "user" in parts:
|
||||||
|
track_list = await self._spotify_playlist(
|
||||||
|
ctx, "user_playlist", api_data["youtube_api"], query
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
track_list = await self._spotify_playlist(
|
||||||
|
ctx, "playlist", api_data["youtube_api"], query
|
||||||
|
)
|
||||||
|
if not track_list:
|
||||||
|
self._play_lock(ctx, False)
|
||||||
|
return
|
||||||
|
if enqueue_tracks:
|
||||||
|
return await self._enqueue_tracks(ctx, track_list)
|
||||||
|
else:
|
||||||
|
return track_list
|
||||||
|
|
||||||
|
else:
|
||||||
|
return await self._embed_msg(
|
||||||
|
ctx, _("This doesn't seem to be a valid Spotify URL or code.")
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _enqueue_tracks(self, ctx, query):
|
||||||
|
player = lavalink.get_player(ctx.guild.id)
|
||||||
|
guild_data = await self.config.guild(ctx.guild).all()
|
||||||
|
if type(query) is not list:
|
||||||
|
if not (query.startswith("http") or query.startswith("localtracks")):
|
||||||
|
query = f"ytsearch:{query}"
|
||||||
tracks = await player.get_tracks(query)
|
tracks = await player.get_tracks(query)
|
||||||
if not tracks:
|
if not tracks:
|
||||||
return await self._embed_msg(ctx, _("Nothing found."))
|
return await self._embed_msg(ctx, _("Nothing found."))
|
||||||
|
else:
|
||||||
|
tracks = query
|
||||||
|
|
||||||
queue_duration = await self._queue_duration(ctx)
|
queue_duration = await self._queue_duration(ctx)
|
||||||
queue_total_duration = lavalink.utils.format_time(queue_duration)
|
queue_total_duration = lavalink.utils.format_time(queue_duration)
|
||||||
@ -1071,14 +1228,20 @@ class Audio(commands.Cog):
|
|||||||
if not player.current:
|
if not player.current:
|
||||||
await player.play()
|
await player.play()
|
||||||
else:
|
else:
|
||||||
|
try:
|
||||||
single_track = tracks[0]
|
single_track = tracks[0]
|
||||||
if guild_data["maxlength"] > 0:
|
if guild_data["maxlength"] > 0:
|
||||||
if self._track_limit(ctx, single_track, guild_data["maxlength"]):
|
if self._track_limit(ctx, single_track, guild_data["maxlength"]):
|
||||||
player.add(ctx.author, single_track)
|
player.add(ctx.author, single_track)
|
||||||
else:
|
else:
|
||||||
return await self._embed_msg(ctx, _("Track exceeds maximum length."))
|
return await self._embed_msg(ctx, _("Track exceeds maximum length."))
|
||||||
|
|
||||||
else:
|
else:
|
||||||
player.add(ctx.author, single_track)
|
player.add(ctx.author, single_track)
|
||||||
|
except IndexError:
|
||||||
|
return await self._embed_msg(
|
||||||
|
ctx, _("Nothing found. Check your Lavalink logs for details.")
|
||||||
|
)
|
||||||
|
|
||||||
if "localtracks" in single_track.uri:
|
if "localtracks" in single_track.uri:
|
||||||
if not single_track.title == "Unknown title":
|
if not single_track.title == "Unknown title":
|
||||||
@ -1105,6 +1268,131 @@ class Audio(commands.Cog):
|
|||||||
if not player.current:
|
if not player.current:
|
||||||
await player.play()
|
await player.play()
|
||||||
await ctx.send(embed=embed)
|
await ctx.send(embed=embed)
|
||||||
|
if type(query) is list:
|
||||||
|
self._play_lock(ctx, False)
|
||||||
|
|
||||||
|
async def _spotify_playlist(self, ctx, stype, yt_key, query):
|
||||||
|
player = lavalink.get_player(ctx.guild.id)
|
||||||
|
spotify_info = []
|
||||||
|
if stype == "album":
|
||||||
|
r = await self._make_spotify_req("https://api.spotify.com/v1/albums/{0}".format(query))
|
||||||
|
else:
|
||||||
|
r = await self._make_spotify_req(
|
||||||
|
"https://api.spotify.com/v1/playlists/{0}/tracks".format(query)
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
if r["error"]["status"] == 401:
|
||||||
|
return await self._embed_msg(
|
||||||
|
ctx,
|
||||||
|
_(
|
||||||
|
"The Spotify API key or client secret has not been set properly. "
|
||||||
|
"\nUse `{prefix}audioset spotifyapi` for instructions."
|
||||||
|
).format(prefix=ctx.prefix),
|
||||||
|
)
|
||||||
|
except KeyError:
|
||||||
|
pass
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
try:
|
||||||
|
spotify_info.extend(r["tracks"]["items"])
|
||||||
|
except KeyError:
|
||||||
|
spotify_info.extend(r["items"])
|
||||||
|
except KeyError:
|
||||||
|
return await self._embed_msg(
|
||||||
|
ctx, _("This doesn't seem to be a valid Spotify URL or code.")
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
if r["next"] is not None:
|
||||||
|
r = await self._make_spotify_req(r["next"])
|
||||||
|
continue
|
||||||
|
else:
|
||||||
|
break
|
||||||
|
except KeyError:
|
||||||
|
if r["tracks"]["next"] is not None:
|
||||||
|
r = await self._make_spotify_req(r["tracks"]["next"])
|
||||||
|
continue
|
||||||
|
else:
|
||||||
|
break
|
||||||
|
|
||||||
|
embed1 = discord.Embed(
|
||||||
|
colour=await ctx.embed_colour(), title=_("Please wait, adding tracks...")
|
||||||
|
)
|
||||||
|
playlist_msg = await ctx.send(embed=embed1)
|
||||||
|
track_list = []
|
||||||
|
track_count = 0
|
||||||
|
now = int(time.time())
|
||||||
|
for i in spotify_info:
|
||||||
|
if stype == "album":
|
||||||
|
song_info = "{} {}".format(i["name"], i["artists"][0]["name"])
|
||||||
|
else:
|
||||||
|
song_info = "{} {}".format(i["track"]["name"], i["track"]["artists"][0]["name"])
|
||||||
|
try:
|
||||||
|
track_url = await self._youtube_api_search(yt_key, song_info)
|
||||||
|
except:
|
||||||
|
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),
|
||||||
|
)
|
||||||
|
await playlist_msg.edit(embed=error_embed)
|
||||||
|
return None
|
||||||
|
# let's complain about errors
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
yt_track = await player.get_tracks(track_url)
|
||||||
|
except (RuntimeError, aiohttp.client_exceptions.ServerDisconnectedError):
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
track_list.append(yt_track[0])
|
||||||
|
except IndexError:
|
||||||
|
pass
|
||||||
|
track_count += 1
|
||||||
|
if (track_count % 5 == 0) or (track_count == len(spotify_info)):
|
||||||
|
embed2 = discord.Embed(
|
||||||
|
colour=await ctx.embed_colour(),
|
||||||
|
title=_("Loading track {num}/{total}...").format(
|
||||||
|
num=track_count, total=len(spotify_info)
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if track_count == 5:
|
||||||
|
five_time = int(time.time()) - now
|
||||||
|
if track_count >= 5:
|
||||||
|
remain_tracks = len(spotify_info) - track_count
|
||||||
|
time_remain = (remain_tracks / 5) * five_time
|
||||||
|
if track_count < len(spotify_info):
|
||||||
|
seconds = self._dynamic_time(int(time_remain))
|
||||||
|
if track_count == len(spotify_info):
|
||||||
|
seconds = "0s"
|
||||||
|
embed2.set_footer(
|
||||||
|
text=_("Approximate time remaining: {seconds}").format(seconds=seconds)
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
await playlist_msg.edit(embed=embed2)
|
||||||
|
except discord.errors.NotFound:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if len(track_list) == 0:
|
||||||
|
embed3 = discord.Embed(
|
||||||
|
colour=await ctx.embed_colour(),
|
||||||
|
title=_(
|
||||||
|
"Nothing found.\nThe YouTube API key may be invalid "
|
||||||
|
"or you may be rate limited on YouTube's search service.\n"
|
||||||
|
"Check the YouTube API key again and follow the instructions "
|
||||||
|
"at `{prefix}audioset youtubeapi`."
|
||||||
|
).format(prefix=ctx.prefix),
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
return await playlist_msg.edit(embed=embed3)
|
||||||
|
except discord.errors.NotFound:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
await playlist_msg.delete()
|
||||||
|
except discord.errors.NotFound:
|
||||||
|
pass
|
||||||
|
return track_list
|
||||||
|
|
||||||
@commands.group()
|
@commands.group()
|
||||||
@commands.guild_only()
|
@commands.guild_only()
|
||||||
@ -1319,25 +1607,46 @@ class Audio(commands.Cog):
|
|||||||
author_id = playlists[playlist_name]["author"]
|
author_id = playlists[playlist_name]["author"]
|
||||||
except KeyError:
|
except KeyError:
|
||||||
return await self._embed_msg(ctx, _("No playlist with that name."))
|
return await self._embed_msg(ctx, _("No playlist with that name."))
|
||||||
author_obj = self.bot.get_user(author_id)
|
|
||||||
playlist_url = playlists[playlist_name]["playlist_url"]
|
|
||||||
try:
|
try:
|
||||||
track_len = len(playlists[playlist_name]["tracks"])
|
track_len = len(playlists[playlist_name]["tracks"])
|
||||||
except TypeError:
|
except TypeError:
|
||||||
track_len = 0
|
track_len = 0
|
||||||
if playlist_url is None:
|
|
||||||
playlist_url = _("**Custom playlist.**")
|
msg = ""
|
||||||
else:
|
track_idx = 0
|
||||||
playlist_url = _("URL: <{url}>").format(url=playlist_url)
|
if track_len > 0:
|
||||||
embed = discord.Embed(
|
for track in playlists[playlist_name]["tracks"]:
|
||||||
colour=await ctx.embed_colour(),
|
track_idx = track_idx + 1
|
||||||
title=_("Playlist info for {playlist_name}:").format(playlist_name=playlist_name),
|
spaces = abs(len(str(track_idx)) - 5)
|
||||||
description=_("Author: **{author_name}**\n{url}").format(
|
msg += "`{}.` **[{}]({})**\n".format(
|
||||||
author_name=author_obj, url=playlist_url
|
track_idx, track["info"]["title"], track["info"]["uri"]
|
||||||
),
|
|
||||||
)
|
)
|
||||||
embed.set_footer(text=_("{num} track(s)").format(num=track_len))
|
else:
|
||||||
await ctx.send(embed=embed)
|
msg = "No tracks."
|
||||||
|
playlist_url = playlists[playlist_name]["playlist_url"]
|
||||||
|
if not playlist_url:
|
||||||
|
embed_title = _("Playlist info for {playlist_name}:\n").format(
|
||||||
|
playlist_name=playlist_name
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
embed_title = _("Playlist info for {playlist_name}:\nURL: {url}").format(
|
||||||
|
playlist_name=playlist_name, url=playlist_url
|
||||||
|
)
|
||||||
|
|
||||||
|
page_list = []
|
||||||
|
for page in pagify(msg, delims=["\n"], page_length=1000):
|
||||||
|
embed = discord.Embed(
|
||||||
|
colour=await ctx.embed_colour(), title=embed_title, description=page
|
||||||
|
)
|
||||||
|
author_obj = self.bot.get_user(author_id)
|
||||||
|
embed.set_footer(
|
||||||
|
text=_("Author: {author_name} | {num} track(s)").format(
|
||||||
|
author_name=author_obj, num=track_len
|
||||||
|
)
|
||||||
|
)
|
||||||
|
page_list.append(embed)
|
||||||
|
await menu(ctx, page_list, DEFAULT_CONTROLS)
|
||||||
|
|
||||||
@playlist.command(name="list")
|
@playlist.command(name="list")
|
||||||
async def _playlist_list(self, ctx):
|
async def _playlist_list(self, ctx):
|
||||||
@ -1645,8 +1954,10 @@ class Audio(commands.Cog):
|
|||||||
return False
|
return False
|
||||||
if not self._player_check(ctx):
|
if not self._player_check(ctx):
|
||||||
try:
|
try:
|
||||||
if not ctx.author.voice.channel.permissions_for(ctx.me).connect or self._userlimit(
|
if (
|
||||||
ctx.author.voice.channel
|
not ctx.author.voice.channel.permissions_for(ctx.me).connect
|
||||||
|
or not ctx.author.voice.channel.permissions_for(ctx.me).move_members
|
||||||
|
and self._userlimit(ctx.author.voice.channel)
|
||||||
):
|
):
|
||||||
return await self._embed_msg(
|
return await self._embed_msg(
|
||||||
ctx, _("I don't have permission to connect to your channel.")
|
ctx, _("I don't have permission to connect to your channel.")
|
||||||
@ -1680,21 +1991,42 @@ class Audio(commands.Cog):
|
|||||||
|
|
||||||
async def _playlist_tracks(self, ctx, player, query):
|
async def _playlist_tracks(self, ctx, player, query):
|
||||||
search = False
|
search = False
|
||||||
|
tracklist = []
|
||||||
if type(query) is tuple:
|
if type(query) is tuple:
|
||||||
query = " ".join(query)
|
query = " ".join(query)
|
||||||
if not query.startswith("http"):
|
if "open.spotify.com" in query:
|
||||||
|
query = "spotify:{}".format(
|
||||||
|
re.sub("(http[s]?:\/\/)?(open.spotify.com)\/", "", query).replace("/", ":")
|
||||||
|
)
|
||||||
|
if query.startswith("spotify:"):
|
||||||
|
try:
|
||||||
|
if self.play_lock[ctx.message.guild.id]:
|
||||||
|
return await self._embed_msg(
|
||||||
|
ctx, _("Wait until the playlist has finished loading.")
|
||||||
|
)
|
||||||
|
except KeyError:
|
||||||
|
pass
|
||||||
|
tracks = await self._get_spotify_tracks(ctx, query)
|
||||||
|
if not tracks:
|
||||||
|
return await self._embed_msg(ctx, _("Nothing found."))
|
||||||
|
for track in tracks:
|
||||||
|
track_obj = self._track_creator(player, other_track=track)
|
||||||
|
tracklist.append(track_obj)
|
||||||
|
self._play_lock(ctx, False)
|
||||||
|
elif not query.startswith("http"):
|
||||||
query = " ".join(query)
|
query = " ".join(query)
|
||||||
query = "ytsearch:{}".format(query)
|
query = "ytsearch:{}".format(query)
|
||||||
search = True
|
search = True
|
||||||
tracks = await player.get_tracks(query)
|
tracks = await player.get_tracks(query)
|
||||||
if not tracks:
|
if not tracks:
|
||||||
return await self._embed_msg(ctx, _("Nothing found."))
|
return await self._embed_msg(ctx, _("Nothing found."))
|
||||||
tracklist = []
|
else:
|
||||||
if not search:
|
tracks = await player.get_tracks(query)
|
||||||
|
if not search and len(tracklist) == 0:
|
||||||
for track in tracks:
|
for track in tracks:
|
||||||
track_obj = self._track_creator(player, other_track=track)
|
track_obj = self._track_creator(player, other_track=track)
|
||||||
tracklist.append(track_obj)
|
tracklist.append(track_obj)
|
||||||
else:
|
elif len(tracklist) == 0:
|
||||||
track_obj = self._track_creator(player, other_track=tracks[0])
|
track_obj = self._track_creator(player, other_track=tracks[0])
|
||||||
tracklist.append(track_obj)
|
tracklist.append(track_obj)
|
||||||
return tracklist
|
return tracklist
|
||||||
@ -1736,7 +2068,7 @@ class Audio(commands.Cog):
|
|||||||
player.current.title, player.current.uri.replace("localtracks/", "")
|
player.current.title, player.current.uri.replace("localtracks/", "")
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
description = f"**[{player.current.title}]({player.current.title})**"
|
description = f"**[{player.current.title}]({player.current.uri})**"
|
||||||
embed = discord.Embed(
|
embed = discord.Embed(
|
||||||
colour=await ctx.embed_colour(),
|
colour=await ctx.embed_colour(),
|
||||||
title=_("Replaying Track"),
|
title=_("Replaying Track"),
|
||||||
@ -1744,7 +2076,7 @@ class Audio(commands.Cog):
|
|||||||
)
|
)
|
||||||
await ctx.send(embed=embed)
|
await ctx.send(embed=embed)
|
||||||
|
|
||||||
@commands.command()
|
@commands.group(invoke_without_command=True)
|
||||||
@commands.guild_only()
|
@commands.guild_only()
|
||||||
async def queue(self, ctx, *, page="1"):
|
async def queue(self, ctx, *, page="1"):
|
||||||
"""List the queue.
|
"""List the queue.
|
||||||
@ -1930,6 +2262,55 @@ class Audio(commands.Cog):
|
|||||||
)
|
)
|
||||||
return embed
|
return embed
|
||||||
|
|
||||||
|
@queue.command(name="clear")
|
||||||
|
@commands.guild_only()
|
||||||
|
async def _queue_clear(self, ctx):
|
||||||
|
"""Clears the queue."""
|
||||||
|
player = lavalink.get_player(ctx.guild.id)
|
||||||
|
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
|
||||||
|
):
|
||||||
|
return await self._embed_msg(ctx, _("You need the DJ role to clear the queue."))
|
||||||
|
player.queue.clear()
|
||||||
|
await self._embed_msg(ctx, _("The queue has been cleared."))
|
||||||
|
|
||||||
|
@queue.command(name="clean")
|
||||||
|
@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)
|
||||||
|
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."))
|
||||||
|
if dj_enabled:
|
||||||
|
if not await self._can_instaskip(ctx, ctx.author) and not await self._is_alone(
|
||||||
|
ctx, ctx.author
|
||||||
|
):
|
||||||
|
return await self._embed_msg(ctx, _("You need the DJ role to clean the queue."))
|
||||||
|
clean_tracks = []
|
||||||
|
removed_tracks = 0
|
||||||
|
listeners = player.channel.members
|
||||||
|
for track in player.queue:
|
||||||
|
if track.requester in listeners:
|
||||||
|
clean_tracks.append(track)
|
||||||
|
else:
|
||||||
|
removed_tracks += 1
|
||||||
|
player.queue = clean_tracks
|
||||||
|
if removed_tracks == 0:
|
||||||
|
await self._embed_msg(ctx, _("Removed 0 tracks."))
|
||||||
|
else:
|
||||||
|
await self._embed_msg(
|
||||||
|
ctx,
|
||||||
|
_(
|
||||||
|
"Removed {removed_tracks} tracks queued by members outside of the voice channel."
|
||||||
|
).format(removed_tracks=removed_tracks),
|
||||||
|
)
|
||||||
|
|
||||||
@commands.command()
|
@commands.command()
|
||||||
@commands.guild_only()
|
@commands.guild_only()
|
||||||
async def repeat(self, ctx):
|
async def repeat(self, ctx):
|
||||||
@ -2029,8 +2410,10 @@ class Audio(commands.Cog):
|
|||||||
|
|
||||||
if not self._player_check(ctx):
|
if not self._player_check(ctx):
|
||||||
try:
|
try:
|
||||||
if not ctx.author.voice.channel.permissions_for(ctx.me).connect or self._userlimit(
|
if (
|
||||||
ctx.author.voice.channel
|
not ctx.author.voice.channel.permissions_for(ctx.me).connect
|
||||||
|
or not ctx.author.voice.channel.permissions_for(ctx.me).move_members
|
||||||
|
and self._userlimit(ctx.author.voice.channel)
|
||||||
):
|
):
|
||||||
return await self._embed_msg(
|
return await self._embed_msg(
|
||||||
ctx, _("I don't have permission to connect to your channel.")
|
ctx, _("I don't have permission to connect to your channel.")
|
||||||
@ -2456,6 +2839,7 @@ class Audio(commands.Cog):
|
|||||||
)
|
)
|
||||||
is_mod = discord.utils.get(ctx.guild.get_member(member.id).roles, id=mod_role) is not None
|
is_mod = discord.utils.get(ctx.guild.get_member(member.id).roles, id=mod_role) is not None
|
||||||
is_bot = member.bot is True
|
is_bot = member.bot is True
|
||||||
|
is_other_channel = await self._channel_check(ctx)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
is_active_dj
|
is_active_dj
|
||||||
@ -2465,6 +2849,7 @@ class Audio(commands.Cog):
|
|||||||
or is_admin
|
or is_admin
|
||||||
or is_mod
|
or is_mod
|
||||||
or is_bot
|
or is_bot
|
||||||
|
or is_other_channel
|
||||||
)
|
)
|
||||||
|
|
||||||
async def _is_alone(self, ctx, member):
|
async def _is_alone(self, ctx, member):
|
||||||
@ -2715,6 +3100,43 @@ class Audio(commands.Cog):
|
|||||||
|
|
||||||
self._restart_connect()
|
self._restart_connect()
|
||||||
|
|
||||||
|
async def _channel_check(self, ctx):
|
||||||
|
player = lavalink.get_player(ctx.guild.id)
|
||||||
|
try:
|
||||||
|
in_channel = sum(
|
||||||
|
not m.bot for m in ctx.guild.get_member(self.bot.user.id).voice.channel.members
|
||||||
|
)
|
||||||
|
except AttributeError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
if not ctx.author.voice:
|
||||||
|
user_channel = None
|
||||||
|
else:
|
||||||
|
user_channel = ctx.author.voice.channel
|
||||||
|
|
||||||
|
if in_channel == 0 and user_channel:
|
||||||
|
if (
|
||||||
|
(player.channel != user_channel)
|
||||||
|
and not player.current
|
||||||
|
and player.position == 0
|
||||||
|
and len(player.queue) == 0
|
||||||
|
):
|
||||||
|
await player.move_to(user_channel)
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def _check_api_tokens(self):
|
||||||
|
spotify = await self.bot.db.api_tokens.get_raw(
|
||||||
|
"spotify", default={"client_id": "", "client_secret": ""}
|
||||||
|
)
|
||||||
|
youtube = await self.bot.db.api_tokens.get_raw("youtube", default={"api_key": ""})
|
||||||
|
return {
|
||||||
|
"spotify_client_id": spotify["client_id"],
|
||||||
|
"spotify_client_secret": spotify["client_secret"],
|
||||||
|
"youtube_api": youtube["api_key"],
|
||||||
|
}
|
||||||
|
|
||||||
async def _check_external(self):
|
async def _check_external(self):
|
||||||
external = await self.config.use_external_lavalink()
|
external = await self.config.use_external_lavalink()
|
||||||
if not external:
|
if not external:
|
||||||
@ -2881,6 +3303,12 @@ class Audio(commands.Cog):
|
|||||||
return True
|
return True
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
def _play_lock(self, ctx, tf):
|
||||||
|
if tf:
|
||||||
|
self.play_lock[ctx.message.guild.id] = True
|
||||||
|
else:
|
||||||
|
self.play_lock[ctx.message.guild.id] = False
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _player_check(ctx):
|
def _player_check(ctx):
|
||||||
try:
|
try:
|
||||||
@ -2972,6 +3400,7 @@ class Audio(commands.Cog):
|
|||||||
"vimeo.com",
|
"vimeo.com",
|
||||||
"mixer.com",
|
"mixer.com",
|
||||||
"twitch.tv",
|
"twitch.tv",
|
||||||
|
"spotify.com",
|
||||||
"localtracks",
|
"localtracks",
|
||||||
]
|
]
|
||||||
query_url = urlparse(url)
|
query_url = urlparse(url)
|
||||||
@ -2989,6 +3418,80 @@ class Audio(commands.Cog):
|
|||||||
else:
|
else:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
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()
|
||||||
|
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"])
|
||||||
|
|
||||||
|
# Spotify-related methods below are originally from: https://github.com/Just-Some-Bots/MusicBot/blob/master/musicbot/spotify.py
|
||||||
|
|
||||||
|
async def _check_token(self, token):
|
||||||
|
now = int(time.time())
|
||||||
|
return token["expires_at"] - now < 60
|
||||||
|
|
||||||
|
async def _get_spotify_token(self):
|
||||||
|
if self.spotify_token and not await self._check_token(self.spotify_token):
|
||||||
|
return self.spotify_token["access_token"]
|
||||||
|
token = await self._request_token()
|
||||||
|
if token is None:
|
||||||
|
log.debug("Requested a token from Spotify, did not end up getting one.")
|
||||||
|
try:
|
||||||
|
token["expires_at"] = int(time.time()) + token["expires_in"]
|
||||||
|
except KeyError:
|
||||||
|
return
|
||||||
|
self.spotify_token = token
|
||||||
|
log.debug("Created a new access token for Spotify: {0}".format(token))
|
||||||
|
return self.spotify_token["access_token"]
|
||||||
|
|
||||||
|
async def _make_get(self, url, headers=None):
|
||||||
|
async with self.session.request("GET", url, headers=headers) as r:
|
||||||
|
if r.status != 200:
|
||||||
|
log.debug(
|
||||||
|
"Issue making GET request to {0}: [{1.status}] {2}".format(
|
||||||
|
url, r, await r.json()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return await r.json()
|
||||||
|
|
||||||
|
async def _make_post(self, url, payload, headers=None):
|
||||||
|
async with self.session.post(url, data=payload, headers=headers) as r:
|
||||||
|
if r.status != 200:
|
||||||
|
log.debug(
|
||||||
|
"Issue making POST request to {0}: [{1.status}] {2}".format(
|
||||||
|
url, r, await r.json()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return await r.json()
|
||||||
|
|
||||||
|
async def _make_spotify_req(self, url):
|
||||||
|
token = await self._get_spotify_token()
|
||||||
|
return await self._make_get(url, headers={"Authorization": "Bearer {0}".format(token)})
|
||||||
|
|
||||||
|
def _make_token_auth(self, client_id, client_secret):
|
||||||
|
auth_header = base64.b64encode((client_id + ":" + client_secret).encode("ascii"))
|
||||||
|
return {"Authorization": "Basic %s" % auth_header.decode("ascii")}
|
||||||
|
|
||||||
|
async def _request_token(self):
|
||||||
|
self.client_id = await self.bot.db.api_tokens.get_raw("spotify", default={"client_id": ""})
|
||||||
|
self.client_secret = await self.bot.db.api_tokens.get_raw(
|
||||||
|
"spotify", default={"client_secret": ""}
|
||||||
|
)
|
||||||
|
payload = {"grant_type": "client_credentials"}
|
||||||
|
headers = self._make_token_auth(
|
||||||
|
self.client_id["client_id"], self.client_secret["client_secret"]
|
||||||
|
)
|
||||||
|
r = await self._make_post(
|
||||||
|
"https://accounts.spotify.com/api/token", payload=payload, headers=headers
|
||||||
|
)
|
||||||
|
return r
|
||||||
|
|
||||||
async def on_voice_state_update(self, member, before, after):
|
async def on_voice_state_update(self, member, before, after):
|
||||||
if after.channel != before.channel:
|
if after.channel != before.channel:
|
||||||
try:
|
try:
|
||||||
|
|||||||
@ -1,6 +0,0 @@
|
|||||||
from redbot.core.bot import Red
|
|
||||||
from .dataconverter import DataConverter
|
|
||||||
|
|
||||||
|
|
||||||
def setup(bot: Red):
|
|
||||||
bot.add_cog(DataConverter(bot))
|
|
||||||
@ -1,174 +0,0 @@
|
|||||||
from itertools import chain, starmap
|
|
||||||
from pathlib import Path
|
|
||||||
from datetime import datetime
|
|
||||||
|
|
||||||
from redbot.core.bot import Red
|
|
||||||
from redbot.core.utils.data_converter import DataConverter as dc
|
|
||||||
from redbot.core.config import Config
|
|
||||||
|
|
||||||
|
|
||||||
class SpecResolver(object):
|
|
||||||
"""
|
|
||||||
Resolves Certain things for DataConverter
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, path: Path):
|
|
||||||
self.v2path = path
|
|
||||||
self.resolved = set()
|
|
||||||
self.available_core_conversions = {
|
|
||||||
"Bank Accounts": {
|
|
||||||
"cfg": ("Bank", None, 384734293238749),
|
|
||||||
"file": self.v2path / "data" / "economy" / "bank.json",
|
|
||||||
"converter": self.bank_accounts_conv_spec,
|
|
||||||
},
|
|
||||||
"Economy Settings": {
|
|
||||||
"cfg": ("Economy", "config", 1256844281),
|
|
||||||
"file": self.v2path / "data" / "economy" / "settings.json",
|
|
||||||
"converter": self.economy_conv_spec,
|
|
||||||
},
|
|
||||||
"Mod Log Cases": {
|
|
||||||
"cfg": ("ModLog", None, 1354799444),
|
|
||||||
"file": self.v2path / "data" / "mod" / "modlog.json",
|
|
||||||
"converter": None, # prevents from showing as available
|
|
||||||
},
|
|
||||||
"Filter": {
|
|
||||||
"cfg": ("Filter", "settings", 4766951341),
|
|
||||||
"file": self.v2path / "data" / "mod" / "filter.json",
|
|
||||||
"converter": self.filter_conv_spec,
|
|
||||||
},
|
|
||||||
"Past Names": {
|
|
||||||
"cfg": ("Mod", "settings", 4961522000),
|
|
||||||
"file": self.v2path / "data" / "mod" / "past_names.json",
|
|
||||||
"converter": self.past_names_conv_spec,
|
|
||||||
},
|
|
||||||
"Past Nicknames": {
|
|
||||||
"cfg": ("Mod", "settings", 4961522000),
|
|
||||||
"file": self.v2path / "data" / "mod" / "past_nicknames.json",
|
|
||||||
"converter": self.past_nicknames_conv_spec,
|
|
||||||
},
|
|
||||||
"Custom Commands": {
|
|
||||||
"cfg": ("CustomCommands", "config", 414589031223512),
|
|
||||||
"file": self.v2path / "data" / "customcom" / "commands.json",
|
|
||||||
"converter": self.customcom_conv_spec,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
@property
|
|
||||||
def available(self):
|
|
||||||
return sorted(
|
|
||||||
k
|
|
||||||
for k, v in self.available_core_conversions.items()
|
|
||||||
if v["file"].is_file() and v["converter"] is not None and k not in self.resolved
|
|
||||||
)
|
|
||||||
|
|
||||||
def unpack(self, parent_key, parent_value):
|
|
||||||
"""Unpack one level of nesting in a dictionary"""
|
|
||||||
try:
|
|
||||||
items = parent_value.items()
|
|
||||||
except AttributeError:
|
|
||||||
yield (parent_key, parent_value)
|
|
||||||
else:
|
|
||||||
for key, value in items:
|
|
||||||
yield (parent_key + (key,), value)
|
|
||||||
|
|
||||||
def flatten_dict(self, dictionary: dict):
|
|
||||||
"""Flatten a nested dictionary structure"""
|
|
||||||
dictionary = {(key,): value for key, value in dictionary.items()}
|
|
||||||
while True:
|
|
||||||
dictionary = dict(chain.from_iterable(starmap(self.unpack, dictionary.items())))
|
|
||||||
if not any(isinstance(value, dict) for value in dictionary.values()):
|
|
||||||
break
|
|
||||||
return dictionary
|
|
||||||
|
|
||||||
def apply_scope(self, scope: str, data: dict):
|
|
||||||
return {(scope,) + k: v for k, v in data.items()}
|
|
||||||
|
|
||||||
def bank_accounts_conv_spec(self, data: dict):
|
|
||||||
flatscoped = self.apply_scope(Config.MEMBER, self.flatten_dict(data))
|
|
||||||
ret = {}
|
|
||||||
for k, v in flatscoped.items():
|
|
||||||
outerkey, innerkey = tuple(k[:-1]), (k[-1],)
|
|
||||||
if outerkey not in ret:
|
|
||||||
ret[outerkey] = {}
|
|
||||||
if innerkey[0] == "created_at":
|
|
||||||
x = int(datetime.strptime(v, "%Y-%m-%d %H:%M:%S").timestamp())
|
|
||||||
ret[outerkey].update({innerkey: x})
|
|
||||||
else:
|
|
||||||
ret[outerkey].update({innerkey: v})
|
|
||||||
return ret
|
|
||||||
|
|
||||||
def economy_conv_spec(self, data: dict):
|
|
||||||
flatscoped = self.apply_scope(Config.GUILD, self.flatten_dict(data))
|
|
||||||
ret = {}
|
|
||||||
for k, v in flatscoped.items():
|
|
||||||
outerkey, innerkey = (*k[:-1],), (k[-1],)
|
|
||||||
if outerkey not in ret:
|
|
||||||
ret[outerkey] = {}
|
|
||||||
ret[outerkey].update({innerkey: v})
|
|
||||||
return ret
|
|
||||||
|
|
||||||
def mod_log_cases(self, data: dict):
|
|
||||||
raise NotImplementedError("This one isn't ready yet")
|
|
||||||
|
|
||||||
def filter_conv_spec(self, data: dict):
|
|
||||||
return {(Config.GUILD, k): {("filter",): v} for k, v in data.items()}
|
|
||||||
|
|
||||||
def past_names_conv_spec(self, data: dict):
|
|
||||||
return {(Config.USER, k): {("past_names",): v} for k, v in data.items()}
|
|
||||||
|
|
||||||
def past_nicknames_conv_spec(self, data: dict):
|
|
||||||
flatscoped = self.apply_scope(Config.MEMBER, self.flatten_dict(data))
|
|
||||||
ret = {}
|
|
||||||
for config_identifiers, v2data in flatscoped.items():
|
|
||||||
if config_identifiers not in ret:
|
|
||||||
ret[config_identifiers] = {}
|
|
||||||
ret[config_identifiers].update({("past_nicks",): v2data})
|
|
||||||
return ret
|
|
||||||
|
|
||||||
def customcom_conv_spec(self, data: dict):
|
|
||||||
flatscoped = self.apply_scope(Config.GUILD, self.flatten_dict(data))
|
|
||||||
ret = {}
|
|
||||||
for k, v in flatscoped.items():
|
|
||||||
outerkey, innerkey = (*k[:-1],), ("commands", k[-1])
|
|
||||||
if outerkey not in ret:
|
|
||||||
ret[outerkey] = {}
|
|
||||||
|
|
||||||
ccinfo = {
|
|
||||||
"author": {"id": 42, "name": "Converted from a v2 instance"},
|
|
||||||
"command": k[-1],
|
|
||||||
"created_at": "{:%d/%m/%Y %H:%M:%S}".format(datetime.utcnow()),
|
|
||||||
"editors": [],
|
|
||||||
"response": v,
|
|
||||||
}
|
|
||||||
ret[outerkey].update({innerkey: ccinfo})
|
|
||||||
return ret
|
|
||||||
|
|
||||||
def get_config_object(self, bot, cogname, attr, _id):
|
|
||||||
try:
|
|
||||||
config = getattr(bot.get_cog(cogname), attr)
|
|
||||||
except (TypeError, AttributeError):
|
|
||||||
config = Config.get_conf(None, _id, cog_name=cogname)
|
|
||||||
|
|
||||||
return config
|
|
||||||
|
|
||||||
def get_conversion_info(self, prettyname: str):
|
|
||||||
info = self.available_core_conversions[prettyname]
|
|
||||||
filepath, converter = info["file"], info["converter"]
|
|
||||||
(cogname, attr, _id) = info["cfg"]
|
|
||||||
return filepath, converter, cogname, attr, _id
|
|
||||||
|
|
||||||
async def convert(self, bot: Red, prettyname: str, config=None):
|
|
||||||
if prettyname not in self.available:
|
|
||||||
raise NotImplementedError("No Conversion Specs for this")
|
|
||||||
|
|
||||||
filepath, converter, cogname, attr, _id = self.get_conversion_info(prettyname)
|
|
||||||
if config is None:
|
|
||||||
config = self.get_config_object(bot, cogname, attr, _id)
|
|
||||||
|
|
||||||
try:
|
|
||||||
items = converter(dc.json_load(filepath))
|
|
||||||
await dc(config).dict_import(items)
|
|
||||||
except Exception:
|
|
||||||
raise
|
|
||||||
else:
|
|
||||||
self.resolved.add(prettyname)
|
|
||||||
@ -1,73 +0,0 @@
|
|||||||
from pathlib import Path
|
|
||||||
import asyncio
|
|
||||||
|
|
||||||
from redbot.core import checks, commands
|
|
||||||
from redbot.core.bot import Red
|
|
||||||
from redbot.core.i18n import Translator, cog_i18n
|
|
||||||
from redbot.cogs.dataconverter.core_specs import SpecResolver
|
|
||||||
from redbot.core.utils.chat_formatting import box
|
|
||||||
from redbot.core.utils.predicates import MessagePredicate
|
|
||||||
|
|
||||||
_ = Translator("DataConverter", __file__)
|
|
||||||
|
|
||||||
|
|
||||||
@cog_i18n(_)
|
|
||||||
class DataConverter(commands.Cog):
|
|
||||||
"""Import Red V2 data to your V3 instance."""
|
|
||||||
|
|
||||||
def __init__(self, bot: Red):
|
|
||||||
super().__init__()
|
|
||||||
self.bot = bot
|
|
||||||
|
|
||||||
@checks.is_owner()
|
|
||||||
@commands.command(name="convertdata")
|
|
||||||
async def dataconversioncommand(self, ctx: commands.Context, v2path: str):
|
|
||||||
"""Interactive prompt for importing data from Red V2.
|
|
||||||
|
|
||||||
Takes the path where the V2 install is, and overwrites
|
|
||||||
values which have entries in both V2 and v3; use with caution.
|
|
||||||
"""
|
|
||||||
resolver = SpecResolver(Path(v2path.strip()))
|
|
||||||
|
|
||||||
if not resolver.available:
|
|
||||||
return await ctx.send(
|
|
||||||
_(
|
|
||||||
"There don't seem to be any data files I know how to "
|
|
||||||
"handle here. Are you sure you gave me the base "
|
|
||||||
"installation path?"
|
|
||||||
)
|
|
||||||
)
|
|
||||||
while resolver.available:
|
|
||||||
menu = _("Please select a set of data to import by number, or 'exit' to exit")
|
|
||||||
for index, entry in enumerate(resolver.available, 1):
|
|
||||||
menu += "\n{}. {}".format(index, entry)
|
|
||||||
|
|
||||||
menu_message = await ctx.send(box(menu))
|
|
||||||
|
|
||||||
try:
|
|
||||||
message = await self.bot.wait_for(
|
|
||||||
"message", check=MessagePredicate.same_context(ctx), timeout=60
|
|
||||||
)
|
|
||||||
except asyncio.TimeoutError:
|
|
||||||
return await ctx.send(_("Try this again when you are ready."))
|
|
||||||
else:
|
|
||||||
if message.content.strip().lower() in ["quit", "exit", "-1", "q", "cancel"]:
|
|
||||||
return await ctx.tick()
|
|
||||||
try:
|
|
||||||
message = int(message.content.strip())
|
|
||||||
to_conv = resolver.available[message - 1]
|
|
||||||
except (ValueError, IndexError):
|
|
||||||
await ctx.send(_("That wasn't a valid choice."))
|
|
||||||
continue
|
|
||||||
else:
|
|
||||||
async with ctx.typing():
|
|
||||||
await resolver.convert(self.bot, to_conv)
|
|
||||||
await ctx.send(_("{} converted.").format(to_conv))
|
|
||||||
await menu_message.delete()
|
|
||||||
else:
|
|
||||||
return await ctx.send(
|
|
||||||
_(
|
|
||||||
"There isn't anything else I know how to convert here.\n"
|
|
||||||
"There might be more things I can convert in the future."
|
|
||||||
)
|
|
||||||
)
|
|
||||||
@ -1,56 +0,0 @@
|
|||||||
msgid ""
|
|
||||||
msgstr ""
|
|
||||||
"Project-Id-Version: red-discordbot\n"
|
|
||||||
"POT-Creation-Date: 2019-01-11 02:18+0000\n"
|
|
||||||
"PO-Revision-Date: 2019-02-25 03:06\n"
|
|
||||||
"Last-Translator: Kowlin <boxedpp@gmail.com>\n"
|
|
||||||
"Language-Team: Arabic\n"
|
|
||||||
"MIME-Version: 1.0\n"
|
|
||||||
"Content-Type: text/plain; charset=UTF-8\n"
|
|
||||||
"Content-Transfer-Encoding: 8bit\n"
|
|
||||||
"Generated-By: redgettext 2.2\n"
|
|
||||||
"Plural-Forms: nplurals=6; plural=(n==0 ? 0 : n==1 ? 1 : n==2 ? 2 : n%100>=3 && n%100<=10 ? 3 : n%100>=11 && n%100<=99 ? 4 : 5);\n"
|
|
||||||
"X-Generator: crowdin.com\n"
|
|
||||||
"X-Crowdin-Project: red-discordbot\n"
|
|
||||||
"X-Crowdin-Language: ar\n"
|
|
||||||
"X-Crowdin-File: /cogs/dataconverter/locales/messages.pot\n"
|
|
||||||
"Language: ar_SA\n"
|
|
||||||
|
|
||||||
#: redbot/cogs/dataconverter/dataconverter.py:16
|
|
||||||
#, docstring
|
|
||||||
msgid "Import Red V2 data to your V3 instance."
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: redbot/cogs/dataconverter/dataconverter.py:25
|
|
||||||
#, docstring
|
|
||||||
msgid "Interactive prompt for importing data from Red V2.\n\n"
|
|
||||||
" Takes the path where the V2 install is, and overwrites\n"
|
|
||||||
" values which have entries in both V2 and v3; use with caution.\n"
|
|
||||||
" "
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: redbot/cogs/dataconverter/dataconverter.py:34
|
|
||||||
msgid "There don't seem to be any data files I know how to handle here. Are you sure you gave me the base installation path?"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: redbot/cogs/dataconverter/dataconverter.py:41
|
|
||||||
msgid "Please select a set of data to import by number, or 'exit' to exit"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: redbot/cogs/dataconverter/dataconverter.py:52
|
|
||||||
msgid "Try this again when you are ready."
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: redbot/cogs/dataconverter/dataconverter.py:60
|
|
||||||
msgid "That wasn't a valid choice."
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: redbot/cogs/dataconverter/dataconverter.py:65
|
|
||||||
msgid "{} converted."
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: redbot/cogs/dataconverter/dataconverter.py:69
|
|
||||||
msgid "There isn't anything else I know how to convert here.\n"
|
|
||||||
"There might be more things I can convert in the future."
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
@ -1,56 +0,0 @@
|
|||||||
msgid ""
|
|
||||||
msgstr ""
|
|
||||||
"Project-Id-Version: red-discordbot\n"
|
|
||||||
"POT-Creation-Date: 2019-01-11 02:18+0000\n"
|
|
||||||
"PO-Revision-Date: 2019-02-25 03:06\n"
|
|
||||||
"Last-Translator: Kowlin <boxedpp@gmail.com>\n"
|
|
||||||
"Language-Team: Bulgarian\n"
|
|
||||||
"MIME-Version: 1.0\n"
|
|
||||||
"Content-Type: text/plain; charset=UTF-8\n"
|
|
||||||
"Content-Transfer-Encoding: 8bit\n"
|
|
||||||
"Generated-By: redgettext 2.2\n"
|
|
||||||
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
|
|
||||||
"X-Generator: crowdin.com\n"
|
|
||||||
"X-Crowdin-Project: red-discordbot\n"
|
|
||||||
"X-Crowdin-Language: bg\n"
|
|
||||||
"X-Crowdin-File: /cogs/dataconverter/locales/messages.pot\n"
|
|
||||||
"Language: bg_BG\n"
|
|
||||||
|
|
||||||
#: redbot/cogs/dataconverter/dataconverter.py:16
|
|
||||||
#, docstring
|
|
||||||
msgid "Import Red V2 data to your V3 instance."
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: redbot/cogs/dataconverter/dataconverter.py:25
|
|
||||||
#, docstring
|
|
||||||
msgid "Interactive prompt for importing data from Red V2.\n\n"
|
|
||||||
" Takes the path where the V2 install is, and overwrites\n"
|
|
||||||
" values which have entries in both V2 and v3; use with caution.\n"
|
|
||||||
" "
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: redbot/cogs/dataconverter/dataconverter.py:34
|
|
||||||
msgid "There don't seem to be any data files I know how to handle here. Are you sure you gave me the base installation path?"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: redbot/cogs/dataconverter/dataconverter.py:41
|
|
||||||
msgid "Please select a set of data to import by number, or 'exit' to exit"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: redbot/cogs/dataconverter/dataconverter.py:52
|
|
||||||
msgid "Try this again when you are ready."
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: redbot/cogs/dataconverter/dataconverter.py:60
|
|
||||||
msgid "That wasn't a valid choice."
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: redbot/cogs/dataconverter/dataconverter.py:65
|
|
||||||
msgid "{} converted."
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: redbot/cogs/dataconverter/dataconverter.py:69
|
|
||||||
msgid "There isn't anything else I know how to convert here.\n"
|
|
||||||
"There might be more things I can convert in the future."
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
@ -1,56 +0,0 @@
|
|||||||
msgid ""
|
|
||||||
msgstr ""
|
|
||||||
"Project-Id-Version: red-discordbot\n"
|
|
||||||
"POT-Creation-Date: 2019-01-11 02:18+0000\n"
|
|
||||||
"PO-Revision-Date: 2019-02-25 03:07\n"
|
|
||||||
"Last-Translator: Kowlin <boxedpp@gmail.com>\n"
|
|
||||||
"Language-Team: Danish\n"
|
|
||||||
"MIME-Version: 1.0\n"
|
|
||||||
"Content-Type: text/plain; charset=UTF-8\n"
|
|
||||||
"Content-Transfer-Encoding: 8bit\n"
|
|
||||||
"Generated-By: redgettext 2.2\n"
|
|
||||||
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
|
|
||||||
"X-Generator: crowdin.com\n"
|
|
||||||
"X-Crowdin-Project: red-discordbot\n"
|
|
||||||
"X-Crowdin-Language: da\n"
|
|
||||||
"X-Crowdin-File: /cogs/dataconverter/locales/messages.pot\n"
|
|
||||||
"Language: da_DK\n"
|
|
||||||
|
|
||||||
#: redbot/cogs/dataconverter/dataconverter.py:16
|
|
||||||
#, docstring
|
|
||||||
msgid "Import Red V2 data to your V3 instance."
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: redbot/cogs/dataconverter/dataconverter.py:25
|
|
||||||
#, docstring
|
|
||||||
msgid "Interactive prompt for importing data from Red V2.\n\n"
|
|
||||||
" Takes the path where the V2 install is, and overwrites\n"
|
|
||||||
" values which have entries in both V2 and v3; use with caution.\n"
|
|
||||||
" "
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: redbot/cogs/dataconverter/dataconverter.py:34
|
|
||||||
msgid "There don't seem to be any data files I know how to handle here. Are you sure you gave me the base installation path?"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: redbot/cogs/dataconverter/dataconverter.py:41
|
|
||||||
msgid "Please select a set of data to import by number, or 'exit' to exit"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: redbot/cogs/dataconverter/dataconverter.py:52
|
|
||||||
msgid "Try this again when you are ready."
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: redbot/cogs/dataconverter/dataconverter.py:60
|
|
||||||
msgid "That wasn't a valid choice."
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: redbot/cogs/dataconverter/dataconverter.py:65
|
|
||||||
msgid "{} converted."
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: redbot/cogs/dataconverter/dataconverter.py:69
|
|
||||||
msgid "There isn't anything else I know how to convert here.\n"
|
|
||||||
"There might be more things I can convert in the future."
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
@ -1,60 +0,0 @@
|
|||||||
msgid ""
|
|
||||||
msgstr ""
|
|
||||||
"Project-Id-Version: red-discordbot\n"
|
|
||||||
"POT-Creation-Date: 2019-01-11 02:18+0000\n"
|
|
||||||
"PO-Revision-Date: 2019-02-25 03:07\n"
|
|
||||||
"Last-Translator: Kowlin <boxedpp@gmail.com>\n"
|
|
||||||
"Language-Team: German\n"
|
|
||||||
"MIME-Version: 1.0\n"
|
|
||||||
"Content-Type: text/plain; charset=UTF-8\n"
|
|
||||||
"Content-Transfer-Encoding: 8bit\n"
|
|
||||||
"Generated-By: redgettext 2.2\n"
|
|
||||||
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
|
|
||||||
"X-Generator: crowdin.com\n"
|
|
||||||
"X-Crowdin-Project: red-discordbot\n"
|
|
||||||
"X-Crowdin-Language: de\n"
|
|
||||||
"X-Crowdin-File: /cogs/dataconverter/locales/messages.pot\n"
|
|
||||||
"Language: de_DE\n"
|
|
||||||
|
|
||||||
#: redbot/cogs/dataconverter/dataconverter.py:16
|
|
||||||
#, docstring
|
|
||||||
msgid "Import Red V2 data to your V3 instance."
|
|
||||||
msgstr "Importiere Red V2 Daten in deine V3 Instanz."
|
|
||||||
|
|
||||||
#: redbot/cogs/dataconverter/dataconverter.py:25
|
|
||||||
#, docstring
|
|
||||||
msgid "Interactive prompt for importing data from Red V2.\n\n"
|
|
||||||
" Takes the path where the V2 install is, and overwrites\n"
|
|
||||||
" values which have entries in both V2 and v3; use with caution.\n"
|
|
||||||
" "
|
|
||||||
msgstr "Interaktive Eingabeaufforderung um Daten aus Red V2 zu importieren.\n\n"
|
|
||||||
" Nimmt den Pfad der V2 Installation und überschreibt\n"
|
|
||||||
" Werte die Einträge in V2 und V3 haben; vorsichtig benutzen.\n"
|
|
||||||
" "
|
|
||||||
|
|
||||||
#: redbot/cogs/dataconverter/dataconverter.py:34
|
|
||||||
msgid "There don't seem to be any data files I know how to handle here. Are you sure you gave me the base installation path?"
|
|
||||||
msgstr "Es scheint keine Dateien zu geben, die ich nutzen kann. Bist du sicher, dass du dem Basis-Installationspfad gefolgt bist?"
|
|
||||||
|
|
||||||
#: redbot/cogs/dataconverter/dataconverter.py:41
|
|
||||||
msgid "Please select a set of data to import by number, or 'exit' to exit"
|
|
||||||
msgstr "Wähle einen Datensatz zum importieren per Nummer oder `exit` zum beenden"
|
|
||||||
|
|
||||||
#: redbot/cogs/dataconverter/dataconverter.py:52
|
|
||||||
msgid "Try this again when you are ready."
|
|
||||||
msgstr "Versuche dies erneut wenn du bereit bist."
|
|
||||||
|
|
||||||
#: redbot/cogs/dataconverter/dataconverter.py:60
|
|
||||||
msgid "That wasn't a valid choice."
|
|
||||||
msgstr "Das war keine valide Auswahl."
|
|
||||||
|
|
||||||
#: redbot/cogs/dataconverter/dataconverter.py:65
|
|
||||||
msgid "{} converted."
|
|
||||||
msgstr "{} konvertiert."
|
|
||||||
|
|
||||||
#: redbot/cogs/dataconverter/dataconverter.py:69
|
|
||||||
msgid "There isn't anything else I know how to convert here.\n"
|
|
||||||
"There might be more things I can convert in the future."
|
|
||||||
msgstr "Es gibt nichts mehr, was ich konvertieren könnte.\n"
|
|
||||||
"Es könnte in Zukunft mehr geben, was ich konvertieren kann."
|
|
||||||
|
|
||||||
@ -1,56 +0,0 @@
|
|||||||
msgid ""
|
|
||||||
msgstr ""
|
|
||||||
"Project-Id-Version: red-discordbot\n"
|
|
||||||
"POT-Creation-Date: 2019-01-11 02:18+0000\n"
|
|
||||||
"PO-Revision-Date: 2019-02-25 03:07\n"
|
|
||||||
"Last-Translator: Kowlin <boxedpp@gmail.com>\n"
|
|
||||||
"Language-Team: Greek\n"
|
|
||||||
"MIME-Version: 1.0\n"
|
|
||||||
"Content-Type: text/plain; charset=UTF-8\n"
|
|
||||||
"Content-Transfer-Encoding: 8bit\n"
|
|
||||||
"Generated-By: redgettext 2.2\n"
|
|
||||||
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
|
|
||||||
"X-Generator: crowdin.com\n"
|
|
||||||
"X-Crowdin-Project: red-discordbot\n"
|
|
||||||
"X-Crowdin-Language: el\n"
|
|
||||||
"X-Crowdin-File: /cogs/dataconverter/locales/messages.pot\n"
|
|
||||||
"Language: el_GR\n"
|
|
||||||
|
|
||||||
#: redbot/cogs/dataconverter/dataconverter.py:16
|
|
||||||
#, docstring
|
|
||||||
msgid "Import Red V2 data to your V3 instance."
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: redbot/cogs/dataconverter/dataconverter.py:25
|
|
||||||
#, docstring
|
|
||||||
msgid "Interactive prompt for importing data from Red V2.\n\n"
|
|
||||||
" Takes the path where the V2 install is, and overwrites\n"
|
|
||||||
" values which have entries in both V2 and v3; use with caution.\n"
|
|
||||||
" "
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: redbot/cogs/dataconverter/dataconverter.py:34
|
|
||||||
msgid "There don't seem to be any data files I know how to handle here. Are you sure you gave me the base installation path?"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: redbot/cogs/dataconverter/dataconverter.py:41
|
|
||||||
msgid "Please select a set of data to import by number, or 'exit' to exit"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: redbot/cogs/dataconverter/dataconverter.py:52
|
|
||||||
msgid "Try this again when you are ready."
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: redbot/cogs/dataconverter/dataconverter.py:60
|
|
||||||
msgid "That wasn't a valid choice."
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: redbot/cogs/dataconverter/dataconverter.py:65
|
|
||||||
msgid "{} converted."
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: redbot/cogs/dataconverter/dataconverter.py:69
|
|
||||||
msgid "There isn't anything else I know how to convert here.\n"
|
|
||||||
"There might be more things I can convert in the future."
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
@ -1,56 +0,0 @@
|
|||||||
msgid ""
|
|
||||||
msgstr ""
|
|
||||||
"Project-Id-Version: red-discordbot\n"
|
|
||||||
"POT-Creation-Date: 2019-01-11 02:18+0000\n"
|
|
||||||
"PO-Revision-Date: 2019-02-25 03:08\n"
|
|
||||||
"Last-Translator: Kowlin <boxedpp@gmail.com>\n"
|
|
||||||
"Language-Team: Pirate English\n"
|
|
||||||
"MIME-Version: 1.0\n"
|
|
||||||
"Content-Type: text/plain; charset=UTF-8\n"
|
|
||||||
"Content-Transfer-Encoding: 8bit\n"
|
|
||||||
"Generated-By: redgettext 2.2\n"
|
|
||||||
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
|
|
||||||
"X-Generator: crowdin.com\n"
|
|
||||||
"X-Crowdin-Project: red-discordbot\n"
|
|
||||||
"X-Crowdin-Language: en-PT\n"
|
|
||||||
"X-Crowdin-File: /cogs/dataconverter/locales/messages.pot\n"
|
|
||||||
"Language: en_PT\n"
|
|
||||||
|
|
||||||
#: redbot/cogs/dataconverter/dataconverter.py:16
|
|
||||||
#, docstring
|
|
||||||
msgid "Import Red V2 data to your V3 instance."
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: redbot/cogs/dataconverter/dataconverter.py:25
|
|
||||||
#, docstring
|
|
||||||
msgid "Interactive prompt for importing data from Red V2.\n\n"
|
|
||||||
" Takes the path where the V2 install is, and overwrites\n"
|
|
||||||
" values which have entries in both V2 and v3; use with caution.\n"
|
|
||||||
" "
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: redbot/cogs/dataconverter/dataconverter.py:34
|
|
||||||
msgid "There don't seem to be any data files I know how to handle here. Are you sure you gave me the base installation path?"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: redbot/cogs/dataconverter/dataconverter.py:41
|
|
||||||
msgid "Please select a set of data to import by number, or 'exit' to exit"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: redbot/cogs/dataconverter/dataconverter.py:52
|
|
||||||
msgid "Try this again when you are ready."
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: redbot/cogs/dataconverter/dataconverter.py:60
|
|
||||||
msgid "That wasn't a valid choice."
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: redbot/cogs/dataconverter/dataconverter.py:65
|
|
||||||
msgid "{} converted."
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: redbot/cogs/dataconverter/dataconverter.py:69
|
|
||||||
msgid "There isn't anything else I know how to convert here.\n"
|
|
||||||
"There might be more things I can convert in the future."
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
@ -1,57 +0,0 @@
|
|||||||
msgid ""
|
|
||||||
msgstr ""
|
|
||||||
"Project-Id-Version: red-discordbot\n"
|
|
||||||
"POT-Creation-Date: 2019-01-11 02:18+0000\n"
|
|
||||||
"PO-Revision-Date: 2019-02-25 03:06\n"
|
|
||||||
"Last-Translator: Kowlin <boxedpp@gmail.com>\n"
|
|
||||||
"Language-Team: Spanish\n"
|
|
||||||
"MIME-Version: 1.0\n"
|
|
||||||
"Content-Type: text/plain; charset=UTF-8\n"
|
|
||||||
"Content-Transfer-Encoding: 8bit\n"
|
|
||||||
"Generated-By: redgettext 2.2\n"
|
|
||||||
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
|
|
||||||
"X-Generator: crowdin.com\n"
|
|
||||||
"X-Crowdin-Project: red-discordbot\n"
|
|
||||||
"X-Crowdin-Language: es-ES\n"
|
|
||||||
"X-Crowdin-File: /cogs/dataconverter/locales/messages.pot\n"
|
|
||||||
"Language: es_ES\n"
|
|
||||||
|
|
||||||
#: redbot/cogs/dataconverter/dataconverter.py:16
|
|
||||||
#, docstring
|
|
||||||
msgid "Import Red V2 data to your V3 instance."
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: redbot/cogs/dataconverter/dataconverter.py:25
|
|
||||||
#, docstring
|
|
||||||
msgid "Interactive prompt for importing data from Red V2.\n\n"
|
|
||||||
" Takes the path where the V2 install is, and overwrites\n"
|
|
||||||
" values which have entries in both V2 and v3; use with caution.\n"
|
|
||||||
" "
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: redbot/cogs/dataconverter/dataconverter.py:34
|
|
||||||
msgid "There don't seem to be any data files I know how to handle here. Are you sure you gave me the base installation path?"
|
|
||||||
msgstr "No parece que haya aquí ningún archivo de datos que yo sepa manejar. ¿Estás seguro que me has dado la ruta de instalación base?"
|
|
||||||
|
|
||||||
#: redbot/cogs/dataconverter/dataconverter.py:41
|
|
||||||
msgid "Please select a set of data to import by number, or 'exit' to exit"
|
|
||||||
msgstr "Por favor seleccione un conjunto de datos para importar por número, o 'salir' para salir"
|
|
||||||
|
|
||||||
#: redbot/cogs/dataconverter/dataconverter.py:52
|
|
||||||
msgid "Try this again when you are ready."
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: redbot/cogs/dataconverter/dataconverter.py:60
|
|
||||||
msgid "That wasn't a valid choice."
|
|
||||||
msgstr "Esa no era una opción válida."
|
|
||||||
|
|
||||||
#: redbot/cogs/dataconverter/dataconverter.py:65
|
|
||||||
msgid "{} converted."
|
|
||||||
msgstr "{} convertido."
|
|
||||||
|
|
||||||
#: redbot/cogs/dataconverter/dataconverter.py:69
|
|
||||||
msgid "There isn't anything else I know how to convert here.\n"
|
|
||||||
"There might be more things I can convert in the future."
|
|
||||||
msgstr "Aquí no hay nada mas que yo sepa como convertir.\n"
|
|
||||||
"Aquí podrá haber cosas que yo pueda convertir en el futuro."
|
|
||||||
|
|
||||||
@ -1,56 +0,0 @@
|
|||||||
msgid ""
|
|
||||||
msgstr ""
|
|
||||||
"Project-Id-Version: red-discordbot\n"
|
|
||||||
"POT-Creation-Date: 2019-01-11 02:18+0000\n"
|
|
||||||
"PO-Revision-Date: 2019-02-25 03:07\n"
|
|
||||||
"Last-Translator: Kowlin <boxedpp@gmail.com>\n"
|
|
||||||
"Language-Team: Finnish\n"
|
|
||||||
"MIME-Version: 1.0\n"
|
|
||||||
"Content-Type: text/plain; charset=UTF-8\n"
|
|
||||||
"Content-Transfer-Encoding: 8bit\n"
|
|
||||||
"Generated-By: redgettext 2.2\n"
|
|
||||||
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
|
|
||||||
"X-Generator: crowdin.com\n"
|
|
||||||
"X-Crowdin-Project: red-discordbot\n"
|
|
||||||
"X-Crowdin-Language: fi\n"
|
|
||||||
"X-Crowdin-File: /cogs/dataconverter/locales/messages.pot\n"
|
|
||||||
"Language: fi_FI\n"
|
|
||||||
|
|
||||||
#: redbot/cogs/dataconverter/dataconverter.py:16
|
|
||||||
#, docstring
|
|
||||||
msgid "Import Red V2 data to your V3 instance."
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: redbot/cogs/dataconverter/dataconverter.py:25
|
|
||||||
#, docstring
|
|
||||||
msgid "Interactive prompt for importing data from Red V2.\n\n"
|
|
||||||
" Takes the path where the V2 install is, and overwrites\n"
|
|
||||||
" values which have entries in both V2 and v3; use with caution.\n"
|
|
||||||
" "
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: redbot/cogs/dataconverter/dataconverter.py:34
|
|
||||||
msgid "There don't seem to be any data files I know how to handle here. Are you sure you gave me the base installation path?"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: redbot/cogs/dataconverter/dataconverter.py:41
|
|
||||||
msgid "Please select a set of data to import by number, or 'exit' to exit"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: redbot/cogs/dataconverter/dataconverter.py:52
|
|
||||||
msgid "Try this again when you are ready."
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: redbot/cogs/dataconverter/dataconverter.py:60
|
|
||||||
msgid "That wasn't a valid choice."
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: redbot/cogs/dataconverter/dataconverter.py:65
|
|
||||||
msgid "{} converted."
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: redbot/cogs/dataconverter/dataconverter.py:69
|
|
||||||
msgid "There isn't anything else I know how to convert here.\n"
|
|
||||||
"There might be more things I can convert in the future."
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
@ -1,57 +0,0 @@
|
|||||||
msgid ""
|
|
||||||
msgstr ""
|
|
||||||
"Project-Id-Version: red-discordbot\n"
|
|
||||||
"POT-Creation-Date: 2019-01-11 02:18+0000\n"
|
|
||||||
"PO-Revision-Date: 2019-02-25 03:06\n"
|
|
||||||
"Last-Translator: Kowlin <boxedpp@gmail.com>\n"
|
|
||||||
"Language-Team: French\n"
|
|
||||||
"MIME-Version: 1.0\n"
|
|
||||||
"Content-Type: text/plain; charset=UTF-8\n"
|
|
||||||
"Content-Transfer-Encoding: 8bit\n"
|
|
||||||
"Generated-By: redgettext 2.2\n"
|
|
||||||
"Plural-Forms: nplurals=2; plural=(n > 1);\n"
|
|
||||||
"X-Generator: crowdin.com\n"
|
|
||||||
"X-Crowdin-Project: red-discordbot\n"
|
|
||||||
"X-Crowdin-Language: fr\n"
|
|
||||||
"X-Crowdin-File: /cogs/dataconverter/locales/messages.pot\n"
|
|
||||||
"Language: fr_FR\n"
|
|
||||||
|
|
||||||
#: redbot/cogs/dataconverter/dataconverter.py:16
|
|
||||||
#, docstring
|
|
||||||
msgid "Import Red V2 data to your V3 instance."
|
|
||||||
msgstr "Importe des données venant de la V2 de Red vers votre instance Red V3."
|
|
||||||
|
|
||||||
#: redbot/cogs/dataconverter/dataconverter.py:25
|
|
||||||
#, docstring
|
|
||||||
msgid "Interactive prompt for importing data from Red V2.\n\n"
|
|
||||||
" Takes the path where the V2 install is, and overwrites\n"
|
|
||||||
" values which have entries in both V2 and v3; use with caution.\n"
|
|
||||||
" "
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: redbot/cogs/dataconverter/dataconverter.py:34
|
|
||||||
msgid "There don't seem to be any data files I know how to handle here. Are you sure you gave me the base installation path?"
|
|
||||||
msgstr "Il ne semble pas y avoir de fichiers de données que je puisse gérer ici. Êtes-vous sûr de m'avoir donné le chemin d'installation de base?"
|
|
||||||
|
|
||||||
#: redbot/cogs/dataconverter/dataconverter.py:41
|
|
||||||
msgid "Please select a set of data to import by number, or 'exit' to exit"
|
|
||||||
msgstr "Veuillez sélectionner un ensemble de données à importer par numéro ou \"exit\" pour quitter"
|
|
||||||
|
|
||||||
#: redbot/cogs/dataconverter/dataconverter.py:52
|
|
||||||
msgid "Try this again when you are ready."
|
|
||||||
msgstr "Essayez à nouveau quand vous êtes prêt."
|
|
||||||
|
|
||||||
#: redbot/cogs/dataconverter/dataconverter.py:60
|
|
||||||
msgid "That wasn't a valid choice."
|
|
||||||
msgstr "Ce n’était pas un choix valide."
|
|
||||||
|
|
||||||
#: redbot/cogs/dataconverter/dataconverter.py:65
|
|
||||||
msgid "{} converted."
|
|
||||||
msgstr "{} converti."
|
|
||||||
|
|
||||||
#: redbot/cogs/dataconverter/dataconverter.py:69
|
|
||||||
msgid "There isn't anything else I know how to convert here.\n"
|
|
||||||
"There might be more things I can convert in the future."
|
|
||||||
msgstr "Il n'y a rien d'autre que je puisse convertir ici.\n"
|
|
||||||
"Il pourrait y avoir beaucoup plus de choses que je puisse convertir à l'avenir."
|
|
||||||
|
|
||||||
@ -1,56 +0,0 @@
|
|||||||
msgid ""
|
|
||||||
msgstr ""
|
|
||||||
"Project-Id-Version: red-discordbot\n"
|
|
||||||
"POT-Creation-Date: 2019-01-11 02:18+0000\n"
|
|
||||||
"PO-Revision-Date: 2019-02-25 03:07\n"
|
|
||||||
"Last-Translator: Kowlin <boxedpp@gmail.com>\n"
|
|
||||||
"Language-Team: Hungarian\n"
|
|
||||||
"MIME-Version: 1.0\n"
|
|
||||||
"Content-Type: text/plain; charset=UTF-8\n"
|
|
||||||
"Content-Transfer-Encoding: 8bit\n"
|
|
||||||
"Generated-By: redgettext 2.2\n"
|
|
||||||
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
|
|
||||||
"X-Generator: crowdin.com\n"
|
|
||||||
"X-Crowdin-Project: red-discordbot\n"
|
|
||||||
"X-Crowdin-Language: hu\n"
|
|
||||||
"X-Crowdin-File: /cogs/dataconverter/locales/messages.pot\n"
|
|
||||||
"Language: hu_HU\n"
|
|
||||||
|
|
||||||
#: redbot/cogs/dataconverter/dataconverter.py:16
|
|
||||||
#, docstring
|
|
||||||
msgid "Import Red V2 data to your V3 instance."
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: redbot/cogs/dataconverter/dataconverter.py:25
|
|
||||||
#, docstring
|
|
||||||
msgid "Interactive prompt for importing data from Red V2.\n\n"
|
|
||||||
" Takes the path where the V2 install is, and overwrites\n"
|
|
||||||
" values which have entries in both V2 and v3; use with caution.\n"
|
|
||||||
" "
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: redbot/cogs/dataconverter/dataconverter.py:34
|
|
||||||
msgid "There don't seem to be any data files I know how to handle here. Are you sure you gave me the base installation path?"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: redbot/cogs/dataconverter/dataconverter.py:41
|
|
||||||
msgid "Please select a set of data to import by number, or 'exit' to exit"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: redbot/cogs/dataconverter/dataconverter.py:52
|
|
||||||
msgid "Try this again when you are ready."
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: redbot/cogs/dataconverter/dataconverter.py:60
|
|
||||||
msgid "That wasn't a valid choice."
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: redbot/cogs/dataconverter/dataconverter.py:65
|
|
||||||
msgid "{} converted."
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: redbot/cogs/dataconverter/dataconverter.py:69
|
|
||||||
msgid "There isn't anything else I know how to convert here.\n"
|
|
||||||
"There might be more things I can convert in the future."
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
@ -1,56 +0,0 @@
|
|||||||
msgid ""
|
|
||||||
msgstr ""
|
|
||||||
"Project-Id-Version: red-discordbot\n"
|
|
||||||
"POT-Creation-Date: 2019-01-11 02:18+0000\n"
|
|
||||||
"PO-Revision-Date: 2019-02-25 03:08\n"
|
|
||||||
"Last-Translator: Kowlin <boxedpp@gmail.com>\n"
|
|
||||||
"Language-Team: Indonesian\n"
|
|
||||||
"MIME-Version: 1.0\n"
|
|
||||||
"Content-Type: text/plain; charset=UTF-8\n"
|
|
||||||
"Content-Transfer-Encoding: 8bit\n"
|
|
||||||
"Generated-By: redgettext 2.2\n"
|
|
||||||
"Plural-Forms: nplurals=1; plural=0;\n"
|
|
||||||
"X-Generator: crowdin.com\n"
|
|
||||||
"X-Crowdin-Project: red-discordbot\n"
|
|
||||||
"X-Crowdin-Language: id\n"
|
|
||||||
"X-Crowdin-File: /cogs/dataconverter/locales/messages.pot\n"
|
|
||||||
"Language: id_ID\n"
|
|
||||||
|
|
||||||
#: redbot/cogs/dataconverter/dataconverter.py:16
|
|
||||||
#, docstring
|
|
||||||
msgid "Import Red V2 data to your V3 instance."
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: redbot/cogs/dataconverter/dataconverter.py:25
|
|
||||||
#, docstring
|
|
||||||
msgid "Interactive prompt for importing data from Red V2.\n\n"
|
|
||||||
" Takes the path where the V2 install is, and overwrites\n"
|
|
||||||
" values which have entries in both V2 and v3; use with caution.\n"
|
|
||||||
" "
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: redbot/cogs/dataconverter/dataconverter.py:34
|
|
||||||
msgid "There don't seem to be any data files I know how to handle here. Are you sure you gave me the base installation path?"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: redbot/cogs/dataconverter/dataconverter.py:41
|
|
||||||
msgid "Please select a set of data to import by number, or 'exit' to exit"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: redbot/cogs/dataconverter/dataconverter.py:52
|
|
||||||
msgid "Try this again when you are ready."
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: redbot/cogs/dataconverter/dataconverter.py:60
|
|
||||||
msgid "That wasn't a valid choice."
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: redbot/cogs/dataconverter/dataconverter.py:65
|
|
||||||
msgid "{} converted."
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: redbot/cogs/dataconverter/dataconverter.py:69
|
|
||||||
msgid "There isn't anything else I know how to convert here.\n"
|
|
||||||
"There might be more things I can convert in the future."
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
@ -1,56 +0,0 @@
|
|||||||
msgid ""
|
|
||||||
msgstr ""
|
|
||||||
"Project-Id-Version: red-discordbot\n"
|
|
||||||
"POT-Creation-Date: 2019-01-11 02:18+0000\n"
|
|
||||||
"PO-Revision-Date: 2019-02-25 03:07\n"
|
|
||||||
"Last-Translator: Kowlin <boxedpp@gmail.com>\n"
|
|
||||||
"Language-Team: Italian\n"
|
|
||||||
"MIME-Version: 1.0\n"
|
|
||||||
"Content-Type: text/plain; charset=UTF-8\n"
|
|
||||||
"Content-Transfer-Encoding: 8bit\n"
|
|
||||||
"Generated-By: redgettext 2.2\n"
|
|
||||||
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
|
|
||||||
"X-Generator: crowdin.com\n"
|
|
||||||
"X-Crowdin-Project: red-discordbot\n"
|
|
||||||
"X-Crowdin-Language: it\n"
|
|
||||||
"X-Crowdin-File: /cogs/dataconverter/locales/messages.pot\n"
|
|
||||||
"Language: it_IT\n"
|
|
||||||
|
|
||||||
#: redbot/cogs/dataconverter/dataconverter.py:16
|
|
||||||
#, docstring
|
|
||||||
msgid "Import Red V2 data to your V3 instance."
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: redbot/cogs/dataconverter/dataconverter.py:25
|
|
||||||
#, docstring
|
|
||||||
msgid "Interactive prompt for importing data from Red V2.\n\n"
|
|
||||||
" Takes the path where the V2 install is, and overwrites\n"
|
|
||||||
" values which have entries in both V2 and v3; use with caution.\n"
|
|
||||||
" "
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: redbot/cogs/dataconverter/dataconverter.py:34
|
|
||||||
msgid "There don't seem to be any data files I know how to handle here. Are you sure you gave me the base installation path?"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: redbot/cogs/dataconverter/dataconverter.py:41
|
|
||||||
msgid "Please select a set of data to import by number, or 'exit' to exit"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: redbot/cogs/dataconverter/dataconverter.py:52
|
|
||||||
msgid "Try this again when you are ready."
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: redbot/cogs/dataconverter/dataconverter.py:60
|
|
||||||
msgid "That wasn't a valid choice."
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: redbot/cogs/dataconverter/dataconverter.py:65
|
|
||||||
msgid "{} converted."
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: redbot/cogs/dataconverter/dataconverter.py:69
|
|
||||||
msgid "There isn't anything else I know how to convert here.\n"
|
|
||||||
"There might be more things I can convert in the future."
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
@ -1,56 +0,0 @@
|
|||||||
msgid ""
|
|
||||||
msgstr ""
|
|
||||||
"Project-Id-Version: red-discordbot\n"
|
|
||||||
"POT-Creation-Date: 2019-01-11 02:18+0000\n"
|
|
||||||
"PO-Revision-Date: 2019-02-25 03:07\n"
|
|
||||||
"Last-Translator: Kowlin <boxedpp@gmail.com>\n"
|
|
||||||
"Language-Team: Japanese\n"
|
|
||||||
"MIME-Version: 1.0\n"
|
|
||||||
"Content-Type: text/plain; charset=UTF-8\n"
|
|
||||||
"Content-Transfer-Encoding: 8bit\n"
|
|
||||||
"Generated-By: redgettext 2.2\n"
|
|
||||||
"Plural-Forms: nplurals=1; plural=0;\n"
|
|
||||||
"X-Generator: crowdin.com\n"
|
|
||||||
"X-Crowdin-Project: red-discordbot\n"
|
|
||||||
"X-Crowdin-Language: ja\n"
|
|
||||||
"X-Crowdin-File: /cogs/dataconverter/locales/messages.pot\n"
|
|
||||||
"Language: ja_JP\n"
|
|
||||||
|
|
||||||
#: redbot/cogs/dataconverter/dataconverter.py:16
|
|
||||||
#, docstring
|
|
||||||
msgid "Import Red V2 data to your V3 instance."
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: redbot/cogs/dataconverter/dataconverter.py:25
|
|
||||||
#, docstring
|
|
||||||
msgid "Interactive prompt for importing data from Red V2.\n\n"
|
|
||||||
" Takes the path where the V2 install is, and overwrites\n"
|
|
||||||
" values which have entries in both V2 and v3; use with caution.\n"
|
|
||||||
" "
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: redbot/cogs/dataconverter/dataconverter.py:34
|
|
||||||
msgid "There don't seem to be any data files I know how to handle here. Are you sure you gave me the base installation path?"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: redbot/cogs/dataconverter/dataconverter.py:41
|
|
||||||
msgid "Please select a set of data to import by number, or 'exit' to exit"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: redbot/cogs/dataconverter/dataconverter.py:52
|
|
||||||
msgid "Try this again when you are ready."
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: redbot/cogs/dataconverter/dataconverter.py:60
|
|
||||||
msgid "That wasn't a valid choice."
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: redbot/cogs/dataconverter/dataconverter.py:65
|
|
||||||
msgid "{} converted."
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: redbot/cogs/dataconverter/dataconverter.py:69
|
|
||||||
msgid "There isn't anything else I know how to convert here.\n"
|
|
||||||
"There might be more things I can convert in the future."
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
@ -1,56 +0,0 @@
|
|||||||
msgid ""
|
|
||||||
msgstr ""
|
|
||||||
"Project-Id-Version: red-discordbot\n"
|
|
||||||
"POT-Creation-Date: 2019-01-11 02:18+0000\n"
|
|
||||||
"PO-Revision-Date: 2019-02-25 03:07\n"
|
|
||||||
"Last-Translator: Kowlin <boxedpp@gmail.com>\n"
|
|
||||||
"Language-Team: Korean\n"
|
|
||||||
"MIME-Version: 1.0\n"
|
|
||||||
"Content-Type: text/plain; charset=UTF-8\n"
|
|
||||||
"Content-Transfer-Encoding: 8bit\n"
|
|
||||||
"Generated-By: redgettext 2.2\n"
|
|
||||||
"Plural-Forms: nplurals=1; plural=0;\n"
|
|
||||||
"X-Generator: crowdin.com\n"
|
|
||||||
"X-Crowdin-Project: red-discordbot\n"
|
|
||||||
"X-Crowdin-Language: ko\n"
|
|
||||||
"X-Crowdin-File: /cogs/dataconverter/locales/messages.pot\n"
|
|
||||||
"Language: ko_KR\n"
|
|
||||||
|
|
||||||
#: redbot/cogs/dataconverter/dataconverter.py:16
|
|
||||||
#, docstring
|
|
||||||
msgid "Import Red V2 data to your V3 instance."
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: redbot/cogs/dataconverter/dataconverter.py:25
|
|
||||||
#, docstring
|
|
||||||
msgid "Interactive prompt for importing data from Red V2.\n\n"
|
|
||||||
" Takes the path where the V2 install is, and overwrites\n"
|
|
||||||
" values which have entries in both V2 and v3; use with caution.\n"
|
|
||||||
" "
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: redbot/cogs/dataconverter/dataconverter.py:34
|
|
||||||
msgid "There don't seem to be any data files I know how to handle here. Are you sure you gave me the base installation path?"
|
|
||||||
msgstr "제가 처리해야 하는 데이터 파일이 없는 것 같습니다. 기본 설치 경로를 저에게 준 것이 확실한가요?"
|
|
||||||
|
|
||||||
#: redbot/cogs/dataconverter/dataconverter.py:41
|
|
||||||
msgid "Please select a set of data to import by number, or 'exit' to exit"
|
|
||||||
msgstr "중요한 데이터를 설정합니다. (오직 숫자만 입력 가능합니다. 또는 'exit' 를 입력하여 종료하실 수 있습니다)"
|
|
||||||
|
|
||||||
#: redbot/cogs/dataconverter/dataconverter.py:52
|
|
||||||
msgid "Try this again when you are ready."
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: redbot/cogs/dataconverter/dataconverter.py:60
|
|
||||||
msgid "That wasn't a valid choice."
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: redbot/cogs/dataconverter/dataconverter.py:65
|
|
||||||
msgid "{} converted."
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: redbot/cogs/dataconverter/dataconverter.py:69
|
|
||||||
msgid "There isn't anything else I know how to convert here.\n"
|
|
||||||
"There might be more things I can convert in the future."
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
@ -1,56 +0,0 @@
|
|||||||
msgid ""
|
|
||||||
msgstr ""
|
|
||||||
"Project-Id-Version: red-discordbot\n"
|
|
||||||
"POT-Creation-Date: 2019-01-11 02:18+0000\n"
|
|
||||||
"PO-Revision-Date: 2019-02-25 03:08\n"
|
|
||||||
"Last-Translator: Kowlin <boxedpp@gmail.com>\n"
|
|
||||||
"Language-Team: LOLCAT\n"
|
|
||||||
"MIME-Version: 1.0\n"
|
|
||||||
"Content-Type: text/plain; charset=UTF-8\n"
|
|
||||||
"Content-Transfer-Encoding: 8bit\n"
|
|
||||||
"Generated-By: redgettext 2.2\n"
|
|
||||||
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
|
|
||||||
"X-Generator: crowdin.com\n"
|
|
||||||
"X-Crowdin-Project: red-discordbot\n"
|
|
||||||
"X-Crowdin-Language: lol\n"
|
|
||||||
"X-Crowdin-File: /cogs/dataconverter/locales/messages.pot\n"
|
|
||||||
"Language: lol_US\n"
|
|
||||||
|
|
||||||
#: redbot/cogs/dataconverter/dataconverter.py:16
|
|
||||||
#, docstring
|
|
||||||
msgid "Import Red V2 data to your V3 instance."
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: redbot/cogs/dataconverter/dataconverter.py:25
|
|
||||||
#, docstring
|
|
||||||
msgid "Interactive prompt for importing data from Red V2.\n\n"
|
|
||||||
" Takes the path where the V2 install is, and overwrites\n"
|
|
||||||
" values which have entries in both V2 and v3; use with caution.\n"
|
|
||||||
" "
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: redbot/cogs/dataconverter/dataconverter.py:34
|
|
||||||
msgid "There don't seem to be any data files I know how to handle here. Are you sure you gave me the base installation path?"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: redbot/cogs/dataconverter/dataconverter.py:41
|
|
||||||
msgid "Please select a set of data to import by number, or 'exit' to exit"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: redbot/cogs/dataconverter/dataconverter.py:52
|
|
||||||
msgid "Try this again when you are ready."
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: redbot/cogs/dataconverter/dataconverter.py:60
|
|
||||||
msgid "That wasn't a valid choice."
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: redbot/cogs/dataconverter/dataconverter.py:65
|
|
||||||
msgid "{} converted."
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: redbot/cogs/dataconverter/dataconverter.py:69
|
|
||||||
msgid "There isn't anything else I know how to convert here.\n"
|
|
||||||
"There might be more things I can convert in the future."
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
@ -1,60 +0,0 @@
|
|||||||
msgid ""
|
|
||||||
msgstr ""
|
|
||||||
"Project-Id-Version: red-discordbot\n"
|
|
||||||
"POT-Creation-Date: 2019-01-11 02:18+0000\n"
|
|
||||||
"PO-Revision-Date: 2019-02-25 03:07\n"
|
|
||||||
"Last-Translator: Kowlin <boxedpp@gmail.com>\n"
|
|
||||||
"Language-Team: Dutch\n"
|
|
||||||
"MIME-Version: 1.0\n"
|
|
||||||
"Content-Type: text/plain; charset=UTF-8\n"
|
|
||||||
"Content-Transfer-Encoding: 8bit\n"
|
|
||||||
"Generated-By: redgettext 2.2\n"
|
|
||||||
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
|
|
||||||
"X-Generator: crowdin.com\n"
|
|
||||||
"X-Crowdin-Project: red-discordbot\n"
|
|
||||||
"X-Crowdin-Language: nl\n"
|
|
||||||
"X-Crowdin-File: /cogs/dataconverter/locales/messages.pot\n"
|
|
||||||
"Language: nl_NL\n"
|
|
||||||
|
|
||||||
#: redbot/cogs/dataconverter/dataconverter.py:16
|
|
||||||
#, docstring
|
|
||||||
msgid "Import Red V2 data to your V3 instance."
|
|
||||||
msgstr "Importeer de data van V2 naar je V3 instantie."
|
|
||||||
|
|
||||||
#: redbot/cogs/dataconverter/dataconverter.py:25
|
|
||||||
#, docstring
|
|
||||||
msgid "Interactive prompt for importing data from Red V2.\n\n"
|
|
||||||
" Takes the path where the V2 install is, and overwrites\n"
|
|
||||||
" values which have entries in both V2 and v3; use with caution.\n"
|
|
||||||
" "
|
|
||||||
msgstr "Interactieve prompt voor het importeren van gegevens van Red V2.\n\n"
|
|
||||||
" Neemt het pad waar de V2-installatie zich bevindt en overschrijft\n"
|
|
||||||
" waarden met vermeldingen in zowel V2 als v3; voorzichtig gebruiken.\n"
|
|
||||||
" "
|
|
||||||
|
|
||||||
#: redbot/cogs/dataconverter/dataconverter.py:34
|
|
||||||
msgid "There don't seem to be any data files I know how to handle here. Are you sure you gave me the base installation path?"
|
|
||||||
msgstr "Er lijken geen gegevensbestanden te zijn die ik hier weet aan te wijzen. Weet je zeker dat je me het basisinstallatiepad hebt gegeven?"
|
|
||||||
|
|
||||||
#: redbot/cogs/dataconverter/dataconverter.py:41
|
|
||||||
msgid "Please select a set of data to import by number, or 'exit' to exit"
|
|
||||||
msgstr "Selecteer een set gegevens om te importeren op nummer of typ 'exit' om af te sluiten"
|
|
||||||
|
|
||||||
#: redbot/cogs/dataconverter/dataconverter.py:52
|
|
||||||
msgid "Try this again when you are ready."
|
|
||||||
msgstr "Probeer dit opnieuw als je klaar bent."
|
|
||||||
|
|
||||||
#: redbot/cogs/dataconverter/dataconverter.py:60
|
|
||||||
msgid "That wasn't a valid choice."
|
|
||||||
msgstr "Dat was geen geldige keuze."
|
|
||||||
|
|
||||||
#: redbot/cogs/dataconverter/dataconverter.py:65
|
|
||||||
msgid "{} converted."
|
|
||||||
msgstr "{} geconverteerd."
|
|
||||||
|
|
||||||
#: redbot/cogs/dataconverter/dataconverter.py:69
|
|
||||||
msgid "There isn't anything else I know how to convert here.\n"
|
|
||||||
"There might be more things I can convert in the future."
|
|
||||||
msgstr "Er is niets anders dat ik hier weet te converteren.\n"
|
|
||||||
"Er kunnen in de toekomst meer dingen zijn die ik kan omzetten."
|
|
||||||
|
|
||||||
@ -1,56 +0,0 @@
|
|||||||
msgid ""
|
|
||||||
msgstr ""
|
|
||||||
"Project-Id-Version: red-discordbot\n"
|
|
||||||
"POT-Creation-Date: 2019-01-11 02:18+0000\n"
|
|
||||||
"PO-Revision-Date: 2019-02-25 03:07\n"
|
|
||||||
"Last-Translator: Kowlin <boxedpp@gmail.com>\n"
|
|
||||||
"Language-Team: Norwegian\n"
|
|
||||||
"MIME-Version: 1.0\n"
|
|
||||||
"Content-Type: text/plain; charset=UTF-8\n"
|
|
||||||
"Content-Transfer-Encoding: 8bit\n"
|
|
||||||
"Generated-By: redgettext 2.2\n"
|
|
||||||
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
|
|
||||||
"X-Generator: crowdin.com\n"
|
|
||||||
"X-Crowdin-Project: red-discordbot\n"
|
|
||||||
"X-Crowdin-Language: no\n"
|
|
||||||
"X-Crowdin-File: /cogs/dataconverter/locales/messages.pot\n"
|
|
||||||
"Language: no_NO\n"
|
|
||||||
|
|
||||||
#: redbot/cogs/dataconverter/dataconverter.py:16
|
|
||||||
#, docstring
|
|
||||||
msgid "Import Red V2 data to your V3 instance."
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: redbot/cogs/dataconverter/dataconverter.py:25
|
|
||||||
#, docstring
|
|
||||||
msgid "Interactive prompt for importing data from Red V2.\n\n"
|
|
||||||
" Takes the path where the V2 install is, and overwrites\n"
|
|
||||||
" values which have entries in both V2 and v3; use with caution.\n"
|
|
||||||
" "
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: redbot/cogs/dataconverter/dataconverter.py:34
|
|
||||||
msgid "There don't seem to be any data files I know how to handle here. Are you sure you gave me the base installation path?"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: redbot/cogs/dataconverter/dataconverter.py:41
|
|
||||||
msgid "Please select a set of data to import by number, or 'exit' to exit"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: redbot/cogs/dataconverter/dataconverter.py:52
|
|
||||||
msgid "Try this again when you are ready."
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: redbot/cogs/dataconverter/dataconverter.py:60
|
|
||||||
msgid "That wasn't a valid choice."
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: redbot/cogs/dataconverter/dataconverter.py:65
|
|
||||||
msgid "{} converted."
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: redbot/cogs/dataconverter/dataconverter.py:69
|
|
||||||
msgid "There isn't anything else I know how to convert here.\n"
|
|
||||||
"There might be more things I can convert in the future."
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
@ -1,56 +0,0 @@
|
|||||||
msgid ""
|
|
||||||
msgstr ""
|
|
||||||
"Project-Id-Version: red-discordbot\n"
|
|
||||||
"POT-Creation-Date: 2019-01-11 02:18+0000\n"
|
|
||||||
"PO-Revision-Date: 2019-02-25 03:07\n"
|
|
||||||
"Last-Translator: Kowlin <boxedpp@gmail.com>\n"
|
|
||||||
"Language-Team: Polish\n"
|
|
||||||
"MIME-Version: 1.0\n"
|
|
||||||
"Content-Type: text/plain; charset=UTF-8\n"
|
|
||||||
"Content-Transfer-Encoding: 8bit\n"
|
|
||||||
"Generated-By: redgettext 2.2\n"
|
|
||||||
"Plural-Forms: nplurals=4; plural=(n==1 ? 0 : (n%10>=2 && n%10<=4) && (n%100<12 || n%100>14) ? 1 : n!=1 && (n%10>=0 && n%10<=1) || (n%10>=5 && n%10<=9) || (n%100>=12 && n%100<=14) ? 2 : 3);\n"
|
|
||||||
"X-Generator: crowdin.com\n"
|
|
||||||
"X-Crowdin-Project: red-discordbot\n"
|
|
||||||
"X-Crowdin-Language: pl\n"
|
|
||||||
"X-Crowdin-File: /cogs/dataconverter/locales/messages.pot\n"
|
|
||||||
"Language: pl_PL\n"
|
|
||||||
|
|
||||||
#: redbot/cogs/dataconverter/dataconverter.py:16
|
|
||||||
#, docstring
|
|
||||||
msgid "Import Red V2 data to your V3 instance."
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: redbot/cogs/dataconverter/dataconverter.py:25
|
|
||||||
#, docstring
|
|
||||||
msgid "Interactive prompt for importing data from Red V2.\n\n"
|
|
||||||
" Takes the path where the V2 install is, and overwrites\n"
|
|
||||||
" values which have entries in both V2 and v3; use with caution.\n"
|
|
||||||
" "
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: redbot/cogs/dataconverter/dataconverter.py:34
|
|
||||||
msgid "There don't seem to be any data files I know how to handle here. Are you sure you gave me the base installation path?"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: redbot/cogs/dataconverter/dataconverter.py:41
|
|
||||||
msgid "Please select a set of data to import by number, or 'exit' to exit"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: redbot/cogs/dataconverter/dataconverter.py:52
|
|
||||||
msgid "Try this again when you are ready."
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: redbot/cogs/dataconverter/dataconverter.py:60
|
|
||||||
msgid "That wasn't a valid choice."
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: redbot/cogs/dataconverter/dataconverter.py:65
|
|
||||||
msgid "{} converted."
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: redbot/cogs/dataconverter/dataconverter.py:69
|
|
||||||
msgid "There isn't anything else I know how to convert here.\n"
|
|
||||||
"There might be more things I can convert in the future."
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
@ -1,56 +0,0 @@
|
|||||||
msgid ""
|
|
||||||
msgstr ""
|
|
||||||
"Project-Id-Version: red-discordbot\n"
|
|
||||||
"POT-Creation-Date: 2019-01-11 02:18+0000\n"
|
|
||||||
"PO-Revision-Date: 2019-02-25 03:08\n"
|
|
||||||
"Last-Translator: Kowlin <boxedpp@gmail.com>\n"
|
|
||||||
"Language-Team: Portuguese, Brazilian\n"
|
|
||||||
"MIME-Version: 1.0\n"
|
|
||||||
"Content-Type: text/plain; charset=UTF-8\n"
|
|
||||||
"Content-Transfer-Encoding: 8bit\n"
|
|
||||||
"Generated-By: redgettext 2.2\n"
|
|
||||||
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
|
|
||||||
"X-Generator: crowdin.com\n"
|
|
||||||
"X-Crowdin-Project: red-discordbot\n"
|
|
||||||
"X-Crowdin-Language: pt-BR\n"
|
|
||||||
"X-Crowdin-File: /cogs/dataconverter/locales/messages.pot\n"
|
|
||||||
"Language: pt_BR\n"
|
|
||||||
|
|
||||||
#: redbot/cogs/dataconverter/dataconverter.py:16
|
|
||||||
#, docstring
|
|
||||||
msgid "Import Red V2 data to your V3 instance."
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: redbot/cogs/dataconverter/dataconverter.py:25
|
|
||||||
#, docstring
|
|
||||||
msgid "Interactive prompt for importing data from Red V2.\n\n"
|
|
||||||
" Takes the path where the V2 install is, and overwrites\n"
|
|
||||||
" values which have entries in both V2 and v3; use with caution.\n"
|
|
||||||
" "
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: redbot/cogs/dataconverter/dataconverter.py:34
|
|
||||||
msgid "There don't seem to be any data files I know how to handle here. Are you sure you gave me the base installation path?"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: redbot/cogs/dataconverter/dataconverter.py:41
|
|
||||||
msgid "Please select a set of data to import by number, or 'exit' to exit"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: redbot/cogs/dataconverter/dataconverter.py:52
|
|
||||||
msgid "Try this again when you are ready."
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: redbot/cogs/dataconverter/dataconverter.py:60
|
|
||||||
msgid "That wasn't a valid choice."
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: redbot/cogs/dataconverter/dataconverter.py:65
|
|
||||||
msgid "{} converted."
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: redbot/cogs/dataconverter/dataconverter.py:69
|
|
||||||
msgid "There isn't anything else I know how to convert here.\n"
|
|
||||||
"There might be more things I can convert in the future."
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
@ -1,56 +0,0 @@
|
|||||||
msgid ""
|
|
||||||
msgstr ""
|
|
||||||
"Project-Id-Version: red-discordbot\n"
|
|
||||||
"POT-Creation-Date: 2019-01-11 02:18+0000\n"
|
|
||||||
"PO-Revision-Date: 2019-02-25 03:07\n"
|
|
||||||
"Last-Translator: Kowlin <boxedpp@gmail.com>\n"
|
|
||||||
"Language-Team: Portuguese\n"
|
|
||||||
"MIME-Version: 1.0\n"
|
|
||||||
"Content-Type: text/plain; charset=UTF-8\n"
|
|
||||||
"Content-Transfer-Encoding: 8bit\n"
|
|
||||||
"Generated-By: redgettext 2.2\n"
|
|
||||||
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
|
|
||||||
"X-Generator: crowdin.com\n"
|
|
||||||
"X-Crowdin-Project: red-discordbot\n"
|
|
||||||
"X-Crowdin-Language: pt-PT\n"
|
|
||||||
"X-Crowdin-File: /cogs/dataconverter/locales/messages.pot\n"
|
|
||||||
"Language: pt_PT\n"
|
|
||||||
|
|
||||||
#: redbot/cogs/dataconverter/dataconverter.py:16
|
|
||||||
#, docstring
|
|
||||||
msgid "Import Red V2 data to your V3 instance."
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: redbot/cogs/dataconverter/dataconverter.py:25
|
|
||||||
#, docstring
|
|
||||||
msgid "Interactive prompt for importing data from Red V2.\n\n"
|
|
||||||
" Takes the path where the V2 install is, and overwrites\n"
|
|
||||||
" values which have entries in both V2 and v3; use with caution.\n"
|
|
||||||
" "
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: redbot/cogs/dataconverter/dataconverter.py:34
|
|
||||||
msgid "There don't seem to be any data files I know how to handle here. Are you sure you gave me the base installation path?"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: redbot/cogs/dataconverter/dataconverter.py:41
|
|
||||||
msgid "Please select a set of data to import by number, or 'exit' to exit"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: redbot/cogs/dataconverter/dataconverter.py:52
|
|
||||||
msgid "Try this again when you are ready."
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: redbot/cogs/dataconverter/dataconverter.py:60
|
|
||||||
msgid "That wasn't a valid choice."
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: redbot/cogs/dataconverter/dataconverter.py:65
|
|
||||||
msgid "{} converted."
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: redbot/cogs/dataconverter/dataconverter.py:69
|
|
||||||
msgid "There isn't anything else I know how to convert here.\n"
|
|
||||||
"There might be more things I can convert in the future."
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
@ -1,61 +0,0 @@
|
|||||||
msgid ""
|
|
||||||
msgstr ""
|
|
||||||
"Project-Id-Version: red-discordbot\n"
|
|
||||||
"POT-Creation-Date: 2019-01-11 02:18+0000\n"
|
|
||||||
"PO-Revision-Date: 2019-02-25 05:52\n"
|
|
||||||
"Last-Translator: Kowlin <boxedpp@gmail.com>\n"
|
|
||||||
"Language-Team: Russian\n"
|
|
||||||
"MIME-Version: 1.0\n"
|
|
||||||
"Content-Type: text/plain; charset=UTF-8\n"
|
|
||||||
"Content-Transfer-Encoding: 8bit\n"
|
|
||||||
"Generated-By: redgettext 2.2\n"
|
|
||||||
"Plural-Forms: nplurals=4; plural=((n%10==1 && n%100!=11) ? 0 : ((n%10 >= 2 && n%10 <=4 && (n%100 < 12 || n%100 > 14)) ? 1 : ((n%10 == 0 || (n%10 >= 5 && n%10 <=9)) || (n%100 >= 11 && n%100 <= 14)) ? 2 : 3));\n"
|
|
||||||
"X-Generator: crowdin.com\n"
|
|
||||||
"X-Crowdin-Project: red-discordbot\n"
|
|
||||||
"X-Crowdin-Language: ru\n"
|
|
||||||
"X-Crowdin-File: /cogs/dataconverter/locales/messages.pot\n"
|
|
||||||
"Language: ru_RU\n"
|
|
||||||
|
|
||||||
#: redbot/cogs/dataconverter/dataconverter.py:16
|
|
||||||
#, docstring
|
|
||||||
msgid "Import Red V2 data to your V3 instance."
|
|
||||||
msgstr "Импортировать данные Red V2 в вашу сборку V3."
|
|
||||||
|
|
||||||
#: redbot/cogs/dataconverter/dataconverter.py:25
|
|
||||||
#, docstring
|
|
||||||
msgid "Interactive prompt for importing data from Red V2.\n\n"
|
|
||||||
" Takes the path where the V2 install is, and overwrites\n"
|
|
||||||
" values which have entries in both V2 and v3; use with caution.\n"
|
|
||||||
" "
|
|
||||||
msgstr "Интерактивная подсказка для импорта данных из Red V2.\n\n"
|
|
||||||
" Принимает путь, по которому устанавливается V2, и\n"
|
|
||||||
" перезаписывает значения, которые имеют записи как в V2,\n"
|
|
||||||
" так и в v3; используйте с осторожностью.\n"
|
|
||||||
" "
|
|
||||||
|
|
||||||
#: redbot/cogs/dataconverter/dataconverter.py:34
|
|
||||||
msgid "There don't seem to be any data files I know how to handle here. Are you sure you gave me the base installation path?"
|
|
||||||
msgstr "Кажется, здесь нет файлов данных, с которыми я знаю как обращаться. Вы уверены, что дали мне путь базовой установки?"
|
|
||||||
|
|
||||||
#: redbot/cogs/dataconverter/dataconverter.py:41
|
|
||||||
msgid "Please select a set of data to import by number, or 'exit' to exit"
|
|
||||||
msgstr "Пожалуйста, выберите набор данных для импорта по номеру, или 'exit', чтобы выйти"
|
|
||||||
|
|
||||||
#: redbot/cogs/dataconverter/dataconverter.py:52
|
|
||||||
msgid "Try this again when you are ready."
|
|
||||||
msgstr "Повторите попытку, когда вы будете готовы."
|
|
||||||
|
|
||||||
#: redbot/cogs/dataconverter/dataconverter.py:60
|
|
||||||
msgid "That wasn't a valid choice."
|
|
||||||
msgstr "Это был неправильный выбор."
|
|
||||||
|
|
||||||
#: redbot/cogs/dataconverter/dataconverter.py:65
|
|
||||||
msgid "{} converted."
|
|
||||||
msgstr "{} преобразован."
|
|
||||||
|
|
||||||
#: redbot/cogs/dataconverter/dataconverter.py:69
|
|
||||||
msgid "There isn't anything else I know how to convert here.\n"
|
|
||||||
"There might be more things I can convert in the future."
|
|
||||||
msgstr "Нет ничего, что я знаю, как конвертировать здесь.\n"
|
|
||||||
"Возможно, в будущем я смогу конвертировать еще больше вещей."
|
|
||||||
|
|
||||||
@ -1,56 +0,0 @@
|
|||||||
msgid ""
|
|
||||||
msgstr ""
|
|
||||||
"Project-Id-Version: red-discordbot\n"
|
|
||||||
"POT-Creation-Date: 2019-01-11 02:18+0000\n"
|
|
||||||
"PO-Revision-Date: 2019-02-25 03:07\n"
|
|
||||||
"Last-Translator: Kowlin <boxedpp@gmail.com>\n"
|
|
||||||
"Language-Team: Slovak\n"
|
|
||||||
"MIME-Version: 1.0\n"
|
|
||||||
"Content-Type: text/plain; charset=UTF-8\n"
|
|
||||||
"Content-Transfer-Encoding: 8bit\n"
|
|
||||||
"Generated-By: redgettext 2.2\n"
|
|
||||||
"Plural-Forms: nplurals=4; plural=(n==1) ? 0 : (n>=2 && n<=4) ? 1 : 3;\n"
|
|
||||||
"X-Generator: crowdin.com\n"
|
|
||||||
"X-Crowdin-Project: red-discordbot\n"
|
|
||||||
"X-Crowdin-Language: sk\n"
|
|
||||||
"X-Crowdin-File: /cogs/dataconverter/locales/messages.pot\n"
|
|
||||||
"Language: sk_SK\n"
|
|
||||||
|
|
||||||
#: redbot/cogs/dataconverter/dataconverter.py:16
|
|
||||||
#, docstring
|
|
||||||
msgid "Import Red V2 data to your V3 instance."
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: redbot/cogs/dataconverter/dataconverter.py:25
|
|
||||||
#, docstring
|
|
||||||
msgid "Interactive prompt for importing data from Red V2.\n\n"
|
|
||||||
" Takes the path where the V2 install is, and overwrites\n"
|
|
||||||
" values which have entries in both V2 and v3; use with caution.\n"
|
|
||||||
" "
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: redbot/cogs/dataconverter/dataconverter.py:34
|
|
||||||
msgid "There don't seem to be any data files I know how to handle here. Are you sure you gave me the base installation path?"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: redbot/cogs/dataconverter/dataconverter.py:41
|
|
||||||
msgid "Please select a set of data to import by number, or 'exit' to exit"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: redbot/cogs/dataconverter/dataconverter.py:52
|
|
||||||
msgid "Try this again when you are ready."
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: redbot/cogs/dataconverter/dataconverter.py:60
|
|
||||||
msgid "That wasn't a valid choice."
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: redbot/cogs/dataconverter/dataconverter.py:65
|
|
||||||
msgid "{} converted."
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: redbot/cogs/dataconverter/dataconverter.py:69
|
|
||||||
msgid "There isn't anything else I know how to convert here.\n"
|
|
||||||
"There might be more things I can convert in the future."
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
@ -1,56 +0,0 @@
|
|||||||
msgid ""
|
|
||||||
msgstr ""
|
|
||||||
"Project-Id-Version: red-discordbot\n"
|
|
||||||
"POT-Creation-Date: 2019-01-11 02:18+0000\n"
|
|
||||||
"PO-Revision-Date: 2019-02-25 03:08\n"
|
|
||||||
"Last-Translator: Kowlin <boxedpp@gmail.com>\n"
|
|
||||||
"Language-Team: Swedish\n"
|
|
||||||
"MIME-Version: 1.0\n"
|
|
||||||
"Content-Type: text/plain; charset=UTF-8\n"
|
|
||||||
"Content-Transfer-Encoding: 8bit\n"
|
|
||||||
"Generated-By: redgettext 2.2\n"
|
|
||||||
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
|
|
||||||
"X-Generator: crowdin.com\n"
|
|
||||||
"X-Crowdin-Project: red-discordbot\n"
|
|
||||||
"X-Crowdin-Language: sv-SE\n"
|
|
||||||
"X-Crowdin-File: /cogs/dataconverter/locales/messages.pot\n"
|
|
||||||
"Language: sv_SE\n"
|
|
||||||
|
|
||||||
#: redbot/cogs/dataconverter/dataconverter.py:16
|
|
||||||
#, docstring
|
|
||||||
msgid "Import Red V2 data to your V3 instance."
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: redbot/cogs/dataconverter/dataconverter.py:25
|
|
||||||
#, docstring
|
|
||||||
msgid "Interactive prompt for importing data from Red V2.\n\n"
|
|
||||||
" Takes the path where the V2 install is, and overwrites\n"
|
|
||||||
" values which have entries in both V2 and v3; use with caution.\n"
|
|
||||||
" "
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: redbot/cogs/dataconverter/dataconverter.py:34
|
|
||||||
msgid "There don't seem to be any data files I know how to handle here. Are you sure you gave me the base installation path?"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: redbot/cogs/dataconverter/dataconverter.py:41
|
|
||||||
msgid "Please select a set of data to import by number, or 'exit' to exit"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: redbot/cogs/dataconverter/dataconverter.py:52
|
|
||||||
msgid "Try this again when you are ready."
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: redbot/cogs/dataconverter/dataconverter.py:60
|
|
||||||
msgid "That wasn't a valid choice."
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: redbot/cogs/dataconverter/dataconverter.py:65
|
|
||||||
msgid "{} converted."
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: redbot/cogs/dataconverter/dataconverter.py:69
|
|
||||||
msgid "There isn't anything else I know how to convert here.\n"
|
|
||||||
"There might be more things I can convert in the future."
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
@ -1,56 +0,0 @@
|
|||||||
msgid ""
|
|
||||||
msgstr ""
|
|
||||||
"Project-Id-Version: red-discordbot\n"
|
|
||||||
"POT-Creation-Date: 2019-01-11 02:18+0000\n"
|
|
||||||
"PO-Revision-Date: 2019-02-25 03:08\n"
|
|
||||||
"Last-Translator: Kowlin <boxedpp@gmail.com>\n"
|
|
||||||
"Language-Team: Turkish\n"
|
|
||||||
"MIME-Version: 1.0\n"
|
|
||||||
"Content-Type: text/plain; charset=UTF-8\n"
|
|
||||||
"Content-Transfer-Encoding: 8bit\n"
|
|
||||||
"Generated-By: redgettext 2.2\n"
|
|
||||||
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
|
|
||||||
"X-Generator: crowdin.com\n"
|
|
||||||
"X-Crowdin-Project: red-discordbot\n"
|
|
||||||
"X-Crowdin-Language: tr\n"
|
|
||||||
"X-Crowdin-File: /cogs/dataconverter/locales/messages.pot\n"
|
|
||||||
"Language: tr_TR\n"
|
|
||||||
|
|
||||||
#: redbot/cogs/dataconverter/dataconverter.py:16
|
|
||||||
#, docstring
|
|
||||||
msgid "Import Red V2 data to your V3 instance."
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: redbot/cogs/dataconverter/dataconverter.py:25
|
|
||||||
#, docstring
|
|
||||||
msgid "Interactive prompt for importing data from Red V2.\n\n"
|
|
||||||
" Takes the path where the V2 install is, and overwrites\n"
|
|
||||||
" values which have entries in both V2 and v3; use with caution.\n"
|
|
||||||
" "
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: redbot/cogs/dataconverter/dataconverter.py:34
|
|
||||||
msgid "There don't seem to be any data files I know how to handle here. Are you sure you gave me the base installation path?"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: redbot/cogs/dataconverter/dataconverter.py:41
|
|
||||||
msgid "Please select a set of data to import by number, or 'exit' to exit"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: redbot/cogs/dataconverter/dataconverter.py:52
|
|
||||||
msgid "Try this again when you are ready."
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: redbot/cogs/dataconverter/dataconverter.py:60
|
|
||||||
msgid "That wasn't a valid choice."
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: redbot/cogs/dataconverter/dataconverter.py:65
|
|
||||||
msgid "{} converted."
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: redbot/cogs/dataconverter/dataconverter.py:69
|
|
||||||
msgid "There isn't anything else I know how to convert here.\n"
|
|
||||||
"There might be more things I can convert in the future."
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
@ -1,56 +0,0 @@
|
|||||||
msgid ""
|
|
||||||
msgstr ""
|
|
||||||
"Project-Id-Version: red-discordbot\n"
|
|
||||||
"POT-Creation-Date: 2019-01-11 02:18+0000\n"
|
|
||||||
"PO-Revision-Date: 2019-02-25 03:08\n"
|
|
||||||
"Last-Translator: Kowlin <boxedpp@gmail.com>\n"
|
|
||||||
"Language-Team: Chinese Simplified\n"
|
|
||||||
"MIME-Version: 1.0\n"
|
|
||||||
"Content-Type: text/plain; charset=UTF-8\n"
|
|
||||||
"Content-Transfer-Encoding: 8bit\n"
|
|
||||||
"Generated-By: redgettext 2.2\n"
|
|
||||||
"Plural-Forms: nplurals=1; plural=0;\n"
|
|
||||||
"X-Generator: crowdin.com\n"
|
|
||||||
"X-Crowdin-Project: red-discordbot\n"
|
|
||||||
"X-Crowdin-Language: zh-CN\n"
|
|
||||||
"X-Crowdin-File: /cogs/dataconverter/locales/messages.pot\n"
|
|
||||||
"Language: zh_CN\n"
|
|
||||||
|
|
||||||
#: redbot/cogs/dataconverter/dataconverter.py:16
|
|
||||||
#, docstring
|
|
||||||
msgid "Import Red V2 data to your V3 instance."
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: redbot/cogs/dataconverter/dataconverter.py:25
|
|
||||||
#, docstring
|
|
||||||
msgid "Interactive prompt for importing data from Red V2.\n\n"
|
|
||||||
" Takes the path where the V2 install is, and overwrites\n"
|
|
||||||
" values which have entries in both V2 and v3; use with caution.\n"
|
|
||||||
" "
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: redbot/cogs/dataconverter/dataconverter.py:34
|
|
||||||
msgid "There don't seem to be any data files I know how to handle here. Are you sure you gave me the base installation path?"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: redbot/cogs/dataconverter/dataconverter.py:41
|
|
||||||
msgid "Please select a set of data to import by number, or 'exit' to exit"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: redbot/cogs/dataconverter/dataconverter.py:52
|
|
||||||
msgid "Try this again when you are ready."
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: redbot/cogs/dataconverter/dataconverter.py:60
|
|
||||||
msgid "That wasn't a valid choice."
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: redbot/cogs/dataconverter/dataconverter.py:65
|
|
||||||
msgid "{} converted."
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: redbot/cogs/dataconverter/dataconverter.py:69
|
|
||||||
msgid "There isn't anything else I know how to convert here.\n"
|
|
||||||
"There might be more things I can convert in the future."
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
@ -318,7 +318,11 @@ class Downloader(commands.Cog):
|
|||||||
|
|
||||||
await repo.install_libraries(target_dir=self.SHAREDLIB_PATH, req_target_dir=self.LIB_PATH)
|
await repo.install_libraries(target_dir=self.SHAREDLIB_PATH, req_target_dir=self.LIB_PATH)
|
||||||
|
|
||||||
await ctx.send(_("Cog `{cog_name}` successfully installed.").format(cog_name=cog_name))
|
await ctx.send(
|
||||||
|
_(
|
||||||
|
"Cog `{cog_name}` successfully installed. You can load it with `{prefix}load {cog_name}`"
|
||||||
|
).format(cog_name=cog_name, prefix=ctx.prefix)
|
||||||
|
)
|
||||||
if cog.install_msg is not None:
|
if cog.install_msg is not None:
|
||||||
await ctx.send(cog.install_msg.replace("[p]", ctx.prefix))
|
await ctx.send(cog.install_msg.replace("[p]", ctx.prefix))
|
||||||
|
|
||||||
|
|||||||
@ -265,7 +265,7 @@ class Economy(commands.Cog):
|
|||||||
await bank.set_balance(author, exc.max_balance)
|
await bank.set_balance(author, exc.max_balance)
|
||||||
await ctx.send(
|
await ctx.send(
|
||||||
_(
|
_(
|
||||||
"You've reached the maximum amount of {currency}! (**{balance:,}**) "
|
"You've reached the maximum amount of {currency}!"
|
||||||
"Please spend some more \N{GRIMACING FACE}\n\n"
|
"Please spend some more \N{GRIMACING FACE}\n\n"
|
||||||
"You currently have {new_balance} {currency}."
|
"You currently have {new_balance} {currency}."
|
||||||
).format(currency=credits_name, new_balance=exc.max_balance)
|
).format(currency=credits_name, new_balance=exc.max_balance)
|
||||||
|
|||||||
@ -7,7 +7,6 @@ from redbot.core.bot import Red
|
|||||||
from redbot.core.i18n import Translator, cog_i18n
|
from redbot.core.i18n import Translator, cog_i18n
|
||||||
from redbot.core.utils.chat_formatting import pagify
|
from redbot.core.utils.chat_formatting import pagify
|
||||||
|
|
||||||
RE_WORD_SPLIT = re.compile(r"[^\w]")
|
|
||||||
_ = Translator("Filter", __file__)
|
_ = Translator("Filter", __file__)
|
||||||
|
|
||||||
|
|
||||||
@ -32,6 +31,7 @@ class Filter(commands.Cog):
|
|||||||
self.settings.register_member(**default_member_settings)
|
self.settings.register_member(**default_member_settings)
|
||||||
self.settings.register_channel(**default_channel_settings)
|
self.settings.register_channel(**default_channel_settings)
|
||||||
self.register_task = self.bot.loop.create_task(self.register_filterban())
|
self.register_task = self.bot.loop.create_task(self.register_filterban())
|
||||||
|
self.pattern_cache = {}
|
||||||
|
|
||||||
def __unload(self):
|
def __unload(self):
|
||||||
self.register_task.cancel()
|
self.register_task.cancel()
|
||||||
@ -165,6 +165,7 @@ class Filter(commands.Cog):
|
|||||||
tmp += word + " "
|
tmp += word + " "
|
||||||
added = await self.add_to_filter(channel, word_list)
|
added = await self.add_to_filter(channel, word_list)
|
||||||
if added:
|
if added:
|
||||||
|
self.invalidate_cache(ctx.guild, ctx.channel)
|
||||||
await ctx.send(_("Words added to filter."))
|
await ctx.send(_("Words added to filter."))
|
||||||
else:
|
else:
|
||||||
await ctx.send(_("Words already in the filter."))
|
await ctx.send(_("Words already in the filter."))
|
||||||
@ -198,6 +199,7 @@ class Filter(commands.Cog):
|
|||||||
removed = await self.remove_from_filter(channel, word_list)
|
removed = await self.remove_from_filter(channel, word_list)
|
||||||
if removed:
|
if removed:
|
||||||
await ctx.send(_("Words removed from filter."))
|
await ctx.send(_("Words removed from filter."))
|
||||||
|
self.invalidate_cache(ctx.guild, ctx.channel)
|
||||||
else:
|
else:
|
||||||
await ctx.send(_("Those words weren't in the filter."))
|
await ctx.send(_("Those words weren't in the filter."))
|
||||||
|
|
||||||
@ -229,6 +231,7 @@ class Filter(commands.Cog):
|
|||||||
tmp += word + " "
|
tmp += word + " "
|
||||||
added = await self.add_to_filter(server, word_list)
|
added = await self.add_to_filter(server, word_list)
|
||||||
if added:
|
if added:
|
||||||
|
self.invalidate_cache(ctx.guild)
|
||||||
await ctx.send(_("Words successfully added to filter."))
|
await ctx.send(_("Words successfully added to filter."))
|
||||||
else:
|
else:
|
||||||
await ctx.send(_("Those words were already in the filter."))
|
await ctx.send(_("Those words were already in the filter."))
|
||||||
@ -261,6 +264,7 @@ class Filter(commands.Cog):
|
|||||||
tmp += word + " "
|
tmp += word + " "
|
||||||
removed = await self.remove_from_filter(server, word_list)
|
removed = await self.remove_from_filter(server, word_list)
|
||||||
if removed:
|
if removed:
|
||||||
|
self.invalidate_cache(ctx.guild)
|
||||||
await ctx.send(_("Words successfully removed from filter."))
|
await ctx.send(_("Words successfully removed from filter."))
|
||||||
else:
|
else:
|
||||||
await ctx.send(_("Those words weren't in the filter."))
|
await ctx.send(_("Those words weren't in the filter."))
|
||||||
@ -279,6 +283,10 @@ class Filter(commands.Cog):
|
|||||||
else:
|
else:
|
||||||
await ctx.send(_("Names and nicknames will now be filtered."))
|
await ctx.send(_("Names and nicknames will now be filtered."))
|
||||||
|
|
||||||
|
def invalidate_cache(self, guild: discord.Guild, channel: discord.TextChannel = None):
|
||||||
|
""" Invalidate a cached pattern"""
|
||||||
|
self.pattern_cache.pop((guild, channel), None)
|
||||||
|
|
||||||
async def add_to_filter(
|
async def add_to_filter(
|
||||||
self, server_or_channel: Union[discord.Guild, discord.TextChannel], words: list
|
self, server_or_channel: Union[discord.Guild, discord.TextChannel], words: list
|
||||||
) -> bool:
|
) -> bool:
|
||||||
@ -322,24 +330,34 @@ class Filter(commands.Cog):
|
|||||||
async def filter_hits(
|
async def filter_hits(
|
||||||
self, text: str, server_or_channel: Union[discord.Guild, discord.TextChannel]
|
self, text: str, server_or_channel: Union[discord.Guild, discord.TextChannel]
|
||||||
) -> Set[str]:
|
) -> Set[str]:
|
||||||
if isinstance(server_or_channel, discord.Guild):
|
|
||||||
word_list = set(await self.settings.guild(server_or_channel).filter())
|
try:
|
||||||
elif isinstance(server_or_channel, discord.TextChannel):
|
guild = server_or_channel.guild
|
||||||
word_list = set(
|
channel = server_or_channel
|
||||||
await self.settings.guild(server_or_channel.guild).filter()
|
except AttributeError:
|
||||||
+ await self.settings.channel(server_or_channel).filter()
|
guild = server_or_channel
|
||||||
|
channel = None
|
||||||
|
|
||||||
|
hits: Set[str] = set()
|
||||||
|
|
||||||
|
try:
|
||||||
|
pattern = self.pattern_cache[(guild, channel)]
|
||||||
|
except KeyError:
|
||||||
|
word_list = set(await self.settings.guild(guild).filter())
|
||||||
|
if channel:
|
||||||
|
word_list |= set(await self.settings.channel(channel).filter())
|
||||||
|
|
||||||
|
if word_list:
|
||||||
|
pattern = re.compile(
|
||||||
|
"|".join(rf"\b{re.escape(w)}\b" for w in word_list), flags=re.I
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
raise TypeError("%r should be Guild or TextChannel" % server_or_channel)
|
pattern = None
|
||||||
|
|
||||||
content = text.lower()
|
self.pattern_cache[(guild, channel)] = pattern
|
||||||
msg_words = set(RE_WORD_SPLIT.split(content))
|
|
||||||
|
|
||||||
filtered_phrases = {x for x in word_list if len(RE_WORD_SPLIT.split(x)) > 1}
|
if pattern:
|
||||||
filtered_words = word_list - filtered_phrases
|
hits |= set(pattern.findall(text))
|
||||||
|
|
||||||
hits = {p for p in filtered_phrases if p in content}
|
|
||||||
hits |= filtered_words & msg_words
|
|
||||||
return hits
|
return hits
|
||||||
|
|
||||||
async def check_filter(self, message: discord.Message):
|
async def check_filter(self, message: discord.Message):
|
||||||
|
|||||||
33
redbot/cogs/mod/abc.py
Normal file
33
redbot/cogs/mod/abc.py
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
from abc import ABC, abstractmethod
|
||||||
|
from typing import List, Tuple
|
||||||
|
|
||||||
|
import discord
|
||||||
|
from redbot.core import Config
|
||||||
|
from redbot.core.bot import Red
|
||||||
|
|
||||||
|
|
||||||
|
class MixinMeta(ABC):
|
||||||
|
"""
|
||||||
|
Metaclass for well behaved type hint detection with composite class.
|
||||||
|
|
||||||
|
Basically, to keep developers sane when not all attributes are defined in each mixin.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, *_args):
|
||||||
|
self.settings: Config
|
||||||
|
self.bot: Red
|
||||||
|
self.cache: dict
|
||||||
|
self.ban_queue: List[Tuple[int, int]]
|
||||||
|
self.unban_queue: List[Tuple[int, int]]
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
@abstractmethod
|
||||||
|
async def get_audit_entry_info(
|
||||||
|
cls, guild: discord.Guild, action: discord.AuditLogAction, target
|
||||||
|
):
|
||||||
|
raise NotImplementedError()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
@abstractmethod
|
||||||
|
async def get_audit_log_entry(guild: discord.Guild, action: discord.AuditLogAction, target):
|
||||||
|
raise NotImplementedError()
|
||||||
100
redbot/cogs/mod/casetypes.py
Normal file
100
redbot/cogs/mod/casetypes.py
Normal file
@ -0,0 +1,100 @@
|
|||||||
|
CASETYPES = [
|
||||||
|
{
|
||||||
|
"name": "ban",
|
||||||
|
"default_setting": True,
|
||||||
|
"image": "\N{HAMMER}",
|
||||||
|
"case_str": "Ban",
|
||||||
|
"audit_type": "ban",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "kick",
|
||||||
|
"default_setting": True,
|
||||||
|
"image": "\N{WOMANS BOOTS}",
|
||||||
|
"case_str": "Kick",
|
||||||
|
"audit_type": "kick",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "hackban",
|
||||||
|
"default_setting": True,
|
||||||
|
"image": "\N{BUST IN SILHOUETTE}\N{HAMMER}",
|
||||||
|
"case_str": "Hackban",
|
||||||
|
"audit_type": "ban",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "tempban",
|
||||||
|
"default_setting": True,
|
||||||
|
"image": "\N{ALARM CLOCK}\N{HAMMER}",
|
||||||
|
"case_str": "Tempban",
|
||||||
|
"audit_type": "ban",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "softban",
|
||||||
|
"default_setting": True,
|
||||||
|
"image": "\N{DASH SYMBOL}\N{HAMMER}",
|
||||||
|
"case_str": "Softban",
|
||||||
|
"audit_type": "ban",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "unban",
|
||||||
|
"default_setting": True,
|
||||||
|
"image": "\N{DOVE OF PEACE}",
|
||||||
|
"case_str": "Unban",
|
||||||
|
"audit_type": "unban",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "voiceban",
|
||||||
|
"default_setting": True,
|
||||||
|
"image": "\N{SPEAKER WITH CANCELLATION STROKE}",
|
||||||
|
"case_str": "Voice Ban",
|
||||||
|
"audit_type": "member_update",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "voiceunban",
|
||||||
|
"default_setting": True,
|
||||||
|
"image": "\N{SPEAKER}",
|
||||||
|
"case_str": "Voice Unban",
|
||||||
|
"audit_type": "member_update",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "vmute",
|
||||||
|
"default_setting": False,
|
||||||
|
"image": "\N{SPEAKER WITH CANCELLATION STROKE}",
|
||||||
|
"case_str": "Voice Mute",
|
||||||
|
"audit_type": "overwrite_update",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "cmute",
|
||||||
|
"default_setting": False,
|
||||||
|
"image": "\N{SPEAKER WITH CANCELLATION STROKE}",
|
||||||
|
"case_str": "Channel Mute",
|
||||||
|
"audit_type": "overwrite_update",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "smute",
|
||||||
|
"default_setting": True,
|
||||||
|
"image": "\N{SPEAKER WITH CANCELLATION STROKE}",
|
||||||
|
"case_str": "Server Mute",
|
||||||
|
"audit_type": "overwrite_update",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "vunmute",
|
||||||
|
"default_setting": False,
|
||||||
|
"image": "\N{SPEAKER}",
|
||||||
|
"case_str": "Voice Unmute",
|
||||||
|
"audit_type": "overwrite_update",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "cunmute",
|
||||||
|
"default_setting": False,
|
||||||
|
"image": "\N{SPEAKER}",
|
||||||
|
"case_str": "Channel Unmute",
|
||||||
|
"audit_type": "overwrite_update",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "sunmute",
|
||||||
|
"default_setting": True,
|
||||||
|
"image": "\N{SPEAKER}",
|
||||||
|
"case_str": "Server Unmute",
|
||||||
|
"audit_type": "overwrite_update",
|
||||||
|
},
|
||||||
|
]
|
||||||
16
redbot/cogs/mod/converters.py
Normal file
16
redbot/cogs/mod/converters.py
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
from redbot.core.commands import Converter, BadArgument
|
||||||
|
from redbot.core.i18n import Translator
|
||||||
|
|
||||||
|
_ = Translator("Mod", __file__)
|
||||||
|
|
||||||
|
|
||||||
|
class RawUserIds(Converter):
|
||||||
|
async def convert(self, ctx, argument):
|
||||||
|
# This is for the hackban command, where we receive IDs that
|
||||||
|
# are most likely not in the guild.
|
||||||
|
# As long as it's numeric and long enough, it makes a good candidate
|
||||||
|
# to attempt a ban on
|
||||||
|
if argument.isnumeric() and len(argument) >= 17:
|
||||||
|
return int(argument)
|
||||||
|
|
||||||
|
raise BadArgument(_("{} doesn't look like a valid user ID.").format(argument))
|
||||||
176
redbot/cogs/mod/events.py
Normal file
176
redbot/cogs/mod/events.py
Normal file
@ -0,0 +1,176 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
import discord
|
||||||
|
from redbot.core import i18n, modlog
|
||||||
|
from redbot.core.utils.mod import is_mod_or_superior
|
||||||
|
from . import log
|
||||||
|
from .abc import MixinMeta
|
||||||
|
|
||||||
|
_ = i18n.Translator("Mod", __file__)
|
||||||
|
|
||||||
|
|
||||||
|
class Events(MixinMeta):
|
||||||
|
"""
|
||||||
|
This is a mixin for the core mod cog
|
||||||
|
Has a bunch of things split off to here.
|
||||||
|
"""
|
||||||
|
|
||||||
|
async def check_duplicates(self, message):
|
||||||
|
guild = message.guild
|
||||||
|
author = message.author
|
||||||
|
|
||||||
|
if await self.settings.guild(guild).delete_repeats():
|
||||||
|
if not message.content:
|
||||||
|
return False
|
||||||
|
self.cache[author].append(message)
|
||||||
|
msgs = self.cache[author]
|
||||||
|
if len(msgs) == 3 and msgs[0].content == msgs[1].content == msgs[2].content:
|
||||||
|
try:
|
||||||
|
await message.delete()
|
||||||
|
return True
|
||||||
|
except discord.HTTPException:
|
||||||
|
pass
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def check_mention_spam(self, message):
|
||||||
|
guild = message.guild
|
||||||
|
author = message.author
|
||||||
|
|
||||||
|
max_mentions = await self.settings.guild(guild).ban_mention_spam()
|
||||||
|
if max_mentions:
|
||||||
|
mentions = set(message.mentions)
|
||||||
|
if len(mentions) >= max_mentions:
|
||||||
|
try:
|
||||||
|
await guild.ban(author, reason=_("Mention spam (Autoban)"))
|
||||||
|
except discord.HTTPException:
|
||||||
|
log.info(
|
||||||
|
"Failed to ban member for mention spam in server {}.".format(guild.id)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
await modlog.create_case(
|
||||||
|
self.bot,
|
||||||
|
guild,
|
||||||
|
message.created_at,
|
||||||
|
"ban",
|
||||||
|
author,
|
||||||
|
guild.me,
|
||||||
|
_("Mention spam (Autoban)"),
|
||||||
|
until=None,
|
||||||
|
channel=None,
|
||||||
|
)
|
||||||
|
except RuntimeError as e:
|
||||||
|
print(e)
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def on_message(self, message):
|
||||||
|
author = message.author
|
||||||
|
if message.guild is None or self.bot.user == author:
|
||||||
|
return
|
||||||
|
valid_user = isinstance(author, discord.Member) and not author.bot
|
||||||
|
if not valid_user:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Bots and mods or superior are ignored from the filter
|
||||||
|
mod_or_superior = await is_mod_or_superior(self.bot, obj=author)
|
||||||
|
if mod_or_superior:
|
||||||
|
return
|
||||||
|
# As are anyone configured to be
|
||||||
|
if await self.bot.is_automod_immune(message):
|
||||||
|
return
|
||||||
|
deleted = await self.check_duplicates(message)
|
||||||
|
if not deleted:
|
||||||
|
await self.check_mention_spam(message)
|
||||||
|
|
||||||
|
async def on_member_ban(self, guild: discord.Guild, member: discord.Member):
|
||||||
|
if (guild.id, member.id) in self.ban_queue:
|
||||||
|
self.ban_queue.remove((guild.id, member.id))
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
await modlog.get_modlog_channel(guild)
|
||||||
|
except RuntimeError:
|
||||||
|
return # No modlog channel so no point in continuing
|
||||||
|
mod, reason, date = await self.get_audit_entry_info(
|
||||||
|
guild, discord.AuditLogAction.ban, member
|
||||||
|
)
|
||||||
|
if date is None:
|
||||||
|
date = datetime.now()
|
||||||
|
try:
|
||||||
|
await modlog.create_case(
|
||||||
|
self.bot, guild, date, "ban", member, mod, reason if reason else None
|
||||||
|
)
|
||||||
|
except RuntimeError as e:
|
||||||
|
print(e)
|
||||||
|
|
||||||
|
async def on_member_unban(self, guild: discord.Guild, user: discord.User):
|
||||||
|
if (guild.id, user.id) in self.unban_queue:
|
||||||
|
self.unban_queue.remove((guild.id, user.id))
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
await modlog.get_modlog_channel(guild)
|
||||||
|
except RuntimeError:
|
||||||
|
return # No modlog channel so no point in continuing
|
||||||
|
mod, reason, date = await self.get_audit_entry_info(
|
||||||
|
guild, discord.AuditLogAction.unban, user
|
||||||
|
)
|
||||||
|
if date is None:
|
||||||
|
date = datetime.now()
|
||||||
|
try:
|
||||||
|
await modlog.create_case(self.bot, guild, date, "unban", user, mod, reason)
|
||||||
|
except RuntimeError as e:
|
||||||
|
print(e)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def on_modlog_case_create(case: modlog.Case):
|
||||||
|
"""
|
||||||
|
An event for modlog case creation
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
mod_channel = await modlog.get_modlog_channel(case.guild)
|
||||||
|
except RuntimeError:
|
||||||
|
return
|
||||||
|
use_embeds = await case.bot.embed_requested(mod_channel, case.guild.me)
|
||||||
|
case_content = await case.message_content(use_embeds)
|
||||||
|
if use_embeds:
|
||||||
|
msg = await mod_channel.send(embed=case_content)
|
||||||
|
else:
|
||||||
|
msg = await mod_channel.send(case_content)
|
||||||
|
await case.edit({"message": msg})
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def on_modlog_case_edit(case: modlog.Case):
|
||||||
|
"""
|
||||||
|
Event for modlog case edits
|
||||||
|
"""
|
||||||
|
if not case.message:
|
||||||
|
return
|
||||||
|
use_embed = await case.bot.embed_requested(case.message.channel, case.guild.me)
|
||||||
|
case_content = await case.message_content(use_embed)
|
||||||
|
if use_embed:
|
||||||
|
await case.message.edit(embed=case_content)
|
||||||
|
else:
|
||||||
|
await case.message.edit(content=case_content)
|
||||||
|
|
||||||
|
async def on_member_update(self, before: discord.Member, after: discord.Member):
|
||||||
|
if before.name != after.name:
|
||||||
|
async with self.settings.user(before).past_names() as name_list:
|
||||||
|
while None in name_list: # clean out null entries from a bug
|
||||||
|
name_list.remove(None)
|
||||||
|
if after.name in name_list:
|
||||||
|
# Ensure order is maintained without duplicates occuring
|
||||||
|
name_list.remove(after.name)
|
||||||
|
name_list.append(after.name)
|
||||||
|
while len(name_list) > 20:
|
||||||
|
name_list.pop(0)
|
||||||
|
|
||||||
|
if before.nick != after.nick and after.nick is not None:
|
||||||
|
async with self.settings.member(before).past_nicks() as nick_list:
|
||||||
|
while None in nick_list: # clean out null entries from a bug
|
||||||
|
nick_list.remove(None)
|
||||||
|
if after.nick in nick_list:
|
||||||
|
nick_list.remove(after.nick)
|
||||||
|
nick_list.append(after.nick)
|
||||||
|
while len(nick_list) > 20:
|
||||||
|
nick_list.pop(0)
|
||||||
565
redbot/cogs/mod/kickban.py
Normal file
565
redbot/cogs/mod/kickban.py
Normal file
@ -0,0 +1,565 @@
|
|||||||
|
import asyncio
|
||||||
|
import contextlib
|
||||||
|
from collections import namedtuple
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from typing import cast, Optional, Union
|
||||||
|
|
||||||
|
import discord
|
||||||
|
from redbot.core import commands, i18n, checks, modlog
|
||||||
|
from redbot.core.utils.chat_formatting import pagify
|
||||||
|
from redbot.core.utils.mod import is_allowed_by_hierarchy, get_audit_reason
|
||||||
|
from .abc import MixinMeta
|
||||||
|
from .converters import RawUserIds
|
||||||
|
from .log import log
|
||||||
|
|
||||||
|
_ = i18n.Translator("Mod", __file__)
|
||||||
|
|
||||||
|
|
||||||
|
class KickBanMixin(MixinMeta):
|
||||||
|
"""
|
||||||
|
Kick and ban commands and tasks go here.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def get_invite_for_reinvite(ctx: commands.Context, max_age: int = 86400):
|
||||||
|
"""Handles the reinvite logic for getting an invite
|
||||||
|
to send the newly unbanned user
|
||||||
|
:returns: :class:`Invite`"""
|
||||||
|
guild = ctx.guild
|
||||||
|
my_perms: discord.Permissions = guild.me.guild_permissions
|
||||||
|
if my_perms.manage_guild or my_perms.administrator:
|
||||||
|
if "VANITY_URL" in guild.features:
|
||||||
|
# guild has a vanity url so use it as the one to send
|
||||||
|
return await guild.vanity_invite()
|
||||||
|
invites = await guild.invites()
|
||||||
|
else:
|
||||||
|
invites = []
|
||||||
|
for inv in invites: # Loop through the invites for the guild
|
||||||
|
if not (inv.max_uses or inv.max_age or inv.temporary):
|
||||||
|
# Invite is for the guild's default channel,
|
||||||
|
# has unlimited uses, doesn't expire, and
|
||||||
|
# doesn't grant temporary membership
|
||||||
|
# (i.e. they won't be kicked on disconnect)
|
||||||
|
return inv
|
||||||
|
else: # No existing invite found that is valid
|
||||||
|
channels_and_perms = zip(
|
||||||
|
guild.text_channels, map(guild.me.permissions_in, guild.text_channels)
|
||||||
|
)
|
||||||
|
channel = next(
|
||||||
|
(channel for channel, perms in channels_and_perms if perms.create_instant_invite),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
if channel is None:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
# Create invite that expires after max_age
|
||||||
|
return await channel.create_invite(max_age=max_age)
|
||||||
|
except discord.HTTPException:
|
||||||
|
return
|
||||||
|
|
||||||
|
async def ban_user(
|
||||||
|
self,
|
||||||
|
user: discord.Member,
|
||||||
|
ctx: commands.Context,
|
||||||
|
days: int = 0,
|
||||||
|
reason: str = None,
|
||||||
|
create_modlog_case=False,
|
||||||
|
) -> Union[str, bool]:
|
||||||
|
author = ctx.author
|
||||||
|
guild = ctx.guild
|
||||||
|
|
||||||
|
if author == user:
|
||||||
|
return _("I cannot let you do that. Self-harm is bad {}").format("\N{PENSIVE FACE}")
|
||||||
|
elif not await is_allowed_by_hierarchy(self.bot, self.settings, guild, author, user):
|
||||||
|
return _(
|
||||||
|
"I cannot let you do that. You are "
|
||||||
|
"not higher than the user in the role "
|
||||||
|
"hierarchy."
|
||||||
|
)
|
||||||
|
elif guild.me.top_role <= user.top_role or user == guild.owner:
|
||||||
|
return _("I cannot do that due to discord hierarchy rules")
|
||||||
|
elif not (0 <= days <= 7):
|
||||||
|
return _("Invalid days. Must be between 0 and 7.")
|
||||||
|
|
||||||
|
audit_reason = get_audit_reason(author, reason)
|
||||||
|
|
||||||
|
queue_entry = (guild.id, user.id)
|
||||||
|
self.ban_queue.append(queue_entry)
|
||||||
|
try:
|
||||||
|
await guild.ban(user, reason=audit_reason, delete_message_days=days)
|
||||||
|
log.info(
|
||||||
|
"{}({}) banned {}({}), deleting {} days worth of messages".format(
|
||||||
|
author.name, author.id, user.name, user.id, str(days)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
except discord.Forbidden:
|
||||||
|
self.ban_queue.remove(queue_entry)
|
||||||
|
return _("I'm not allowed to do that.")
|
||||||
|
except Exception as e:
|
||||||
|
self.ban_queue.remove(queue_entry)
|
||||||
|
return e # TODO: impproper return type? Is this intended to be re-raised?
|
||||||
|
|
||||||
|
if create_modlog_case:
|
||||||
|
try:
|
||||||
|
await modlog.create_case(
|
||||||
|
self.bot,
|
||||||
|
guild,
|
||||||
|
ctx.message.created_at,
|
||||||
|
"ban",
|
||||||
|
user,
|
||||||
|
author,
|
||||||
|
reason,
|
||||||
|
until=None,
|
||||||
|
channel=None,
|
||||||
|
)
|
||||||
|
except RuntimeError as e:
|
||||||
|
return _(
|
||||||
|
"The user was banned but an error occurred when trying to "
|
||||||
|
"create the modlog entry: {reason}"
|
||||||
|
).format(reason=e)
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
async def check_tempban_expirations(self):
|
||||||
|
member = namedtuple("Member", "id guild")
|
||||||
|
while self == self.bot.get_cog("Mod"):
|
||||||
|
for guild in self.bot.guilds:
|
||||||
|
async with self.settings.guild(guild).current_tempbans() as guild_tempbans:
|
||||||
|
for uid in guild_tempbans.copy():
|
||||||
|
unban_time = datetime.utcfromtimestamp(
|
||||||
|
await self.settings.member(member(uid, guild)).banned_until()
|
||||||
|
)
|
||||||
|
now = datetime.utcnow()
|
||||||
|
if now > unban_time: # Time to unban the user
|
||||||
|
user = await self.bot.get_user_info(uid)
|
||||||
|
queue_entry = (guild.id, user.id)
|
||||||
|
self.unban_queue.append(queue_entry)
|
||||||
|
try:
|
||||||
|
await guild.unban(user, reason=_("Tempban finished"))
|
||||||
|
guild_tempbans.remove(uid)
|
||||||
|
except discord.Forbidden:
|
||||||
|
self.unban_queue.remove(queue_entry)
|
||||||
|
log.info("Failed to unban member due to permissions")
|
||||||
|
except discord.HTTPException:
|
||||||
|
self.unban_queue.remove(queue_entry)
|
||||||
|
await asyncio.sleep(60)
|
||||||
|
|
||||||
|
@commands.command()
|
||||||
|
@commands.guild_only()
|
||||||
|
@commands.bot_has_permissions(kick_members=True)
|
||||||
|
@checks.admin_or_permissions(kick_members=True)
|
||||||
|
async def kick(self, ctx: commands.Context, user: discord.Member, *, reason: str = None):
|
||||||
|
"""Kick a user.
|
||||||
|
|
||||||
|
If a reason is specified, it will be the reason that shows up
|
||||||
|
in the audit log.
|
||||||
|
"""
|
||||||
|
author = ctx.author
|
||||||
|
guild = ctx.guild
|
||||||
|
|
||||||
|
if author == user:
|
||||||
|
await ctx.send(
|
||||||
|
_("I cannot let you do that. Self-harm is bad {emoji}").format(
|
||||||
|
emoji="\N{PENSIVE FACE}"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return
|
||||||
|
elif not await is_allowed_by_hierarchy(self.bot, self.settings, guild, author, user):
|
||||||
|
await ctx.send(
|
||||||
|
_(
|
||||||
|
"I cannot let you do that. You are "
|
||||||
|
"not higher than the user in the role "
|
||||||
|
"hierarchy."
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return
|
||||||
|
elif ctx.guild.me.top_role <= user.top_role or user == ctx.guild.owner:
|
||||||
|
await ctx.send(_("I cannot do that due to discord hierarchy rules"))
|
||||||
|
return
|
||||||
|
audit_reason = get_audit_reason(author, reason)
|
||||||
|
try:
|
||||||
|
await guild.kick(user, reason=audit_reason)
|
||||||
|
log.info("{}({}) kicked {}({})".format(author.name, author.id, user.name, user.id))
|
||||||
|
except discord.errors.Forbidden:
|
||||||
|
await ctx.send(_("I'm not allowed to do that."))
|
||||||
|
except Exception as e:
|
||||||
|
print(e)
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
await modlog.create_case(
|
||||||
|
self.bot,
|
||||||
|
guild,
|
||||||
|
ctx.message.created_at,
|
||||||
|
"kick",
|
||||||
|
user,
|
||||||
|
author,
|
||||||
|
reason,
|
||||||
|
until=None,
|
||||||
|
channel=None,
|
||||||
|
)
|
||||||
|
except RuntimeError as e:
|
||||||
|
await ctx.send(e)
|
||||||
|
await ctx.send(_("Done. That felt good."))
|
||||||
|
|
||||||
|
@commands.command()
|
||||||
|
@commands.guild_only()
|
||||||
|
@commands.bot_has_permissions(ban_members=True)
|
||||||
|
@checks.admin_or_permissions(ban_members=True)
|
||||||
|
async def ban(
|
||||||
|
self, ctx: commands.Context, user: discord.Member, days: int = 0, *, reason: str = None
|
||||||
|
):
|
||||||
|
"""Ban a user from this server.
|
||||||
|
|
||||||
|
If days is not a number, it's treated as the first word of the reason.
|
||||||
|
Minimum 0 days, maximum 7. Defaults to 0."""
|
||||||
|
|
||||||
|
result = await self.ban_user(
|
||||||
|
user=user, ctx=ctx, days=days, reason=reason, create_modlog_case=True
|
||||||
|
)
|
||||||
|
|
||||||
|
if result is True:
|
||||||
|
await ctx.send(_("Done. It was about time."))
|
||||||
|
elif isinstance(result, str):
|
||||||
|
await ctx.send(result)
|
||||||
|
|
||||||
|
@commands.command()
|
||||||
|
@commands.guild_only()
|
||||||
|
@commands.bot_has_permissions(ban_members=True)
|
||||||
|
@checks.admin_or_permissions(ban_members=True)
|
||||||
|
async def hackban(
|
||||||
|
self,
|
||||||
|
ctx: commands.Context,
|
||||||
|
user_ids: commands.Greedy[RawUserIds],
|
||||||
|
days: Optional[int] = 0,
|
||||||
|
*,
|
||||||
|
reason: str = None,
|
||||||
|
):
|
||||||
|
"""Preemptively bans user(s) from the server
|
||||||
|
|
||||||
|
User IDs need to be provided in order to ban
|
||||||
|
using this command"""
|
||||||
|
days = cast(int, days)
|
||||||
|
banned = []
|
||||||
|
errors = {}
|
||||||
|
|
||||||
|
async def show_results():
|
||||||
|
text = _("Banned {num} users from the server.".format(num=len(banned)))
|
||||||
|
if errors:
|
||||||
|
text += _("\nErrors:\n")
|
||||||
|
text += "\n".join(errors.values())
|
||||||
|
|
||||||
|
for p in pagify(text):
|
||||||
|
await ctx.send(p)
|
||||||
|
|
||||||
|
def remove_processed(ids):
|
||||||
|
return [_id for _id in ids if _id not in banned and _id not in errors]
|
||||||
|
|
||||||
|
user_ids = list(set(user_ids)) # No dupes
|
||||||
|
|
||||||
|
author = ctx.author
|
||||||
|
guild = ctx.guild
|
||||||
|
|
||||||
|
if not user_ids:
|
||||||
|
await ctx.send_help()
|
||||||
|
return
|
||||||
|
|
||||||
|
if not (0 <= days <= 7):
|
||||||
|
await ctx.send(_("Invalid days. Must be between 0 and 7."))
|
||||||
|
return
|
||||||
|
|
||||||
|
if not guild.me.guild_permissions.ban_members:
|
||||||
|
return await ctx.send(_("I lack the permissions to do this."))
|
||||||
|
|
||||||
|
ban_list = await guild.bans()
|
||||||
|
for entry in ban_list:
|
||||||
|
for user_id in user_ids:
|
||||||
|
if entry.user.id == user_id:
|
||||||
|
errors[user_id] = _("User {user_id} is already banned.").format(
|
||||||
|
user_id=user_id
|
||||||
|
)
|
||||||
|
|
||||||
|
user_ids = remove_processed(user_ids)
|
||||||
|
|
||||||
|
if not user_ids:
|
||||||
|
await show_results()
|
||||||
|
return
|
||||||
|
|
||||||
|
for user_id in user_ids:
|
||||||
|
user = guild.get_member(user_id)
|
||||||
|
if user is not None:
|
||||||
|
# Instead of replicating all that handling... gets attr from decorator
|
||||||
|
try:
|
||||||
|
result = await self.ban_user(
|
||||||
|
user=user, ctx=ctx, days=days, reason=reason, create_modlog_case=True
|
||||||
|
)
|
||||||
|
if result is True:
|
||||||
|
banned.append(user_id)
|
||||||
|
else:
|
||||||
|
errors[user_id] = _("Failed to ban user {user_id}: {reason}").format(
|
||||||
|
user_id=user_id, reason=result
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
errors[user_id] = _("Failed to ban user {user_id}: {reason}").format(
|
||||||
|
user_id=user_id, reason=e
|
||||||
|
)
|
||||||
|
|
||||||
|
user_ids = remove_processed(user_ids)
|
||||||
|
|
||||||
|
if not user_ids:
|
||||||
|
await show_results()
|
||||||
|
return
|
||||||
|
|
||||||
|
for user_id in user_ids:
|
||||||
|
user = discord.Object(id=user_id)
|
||||||
|
audit_reason = get_audit_reason(author, reason)
|
||||||
|
queue_entry = (guild.id, user_id)
|
||||||
|
self.ban_queue.append(queue_entry)
|
||||||
|
try:
|
||||||
|
await guild.ban(user, reason=audit_reason, delete_message_days=days)
|
||||||
|
log.info("{}({}) hackbanned {}".format(author.name, author.id, user_id))
|
||||||
|
except discord.NotFound:
|
||||||
|
self.ban_queue.remove(queue_entry)
|
||||||
|
errors[user_id] = _("User {user_id} does not exist.").format(user_id=user_id)
|
||||||
|
continue
|
||||||
|
except discord.Forbidden:
|
||||||
|
self.ban_queue.remove(queue_entry)
|
||||||
|
errors[user_id] = _("Could not ban {user_id}: missing permissions.").format(
|
||||||
|
user_id=user_id
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
else:
|
||||||
|
banned.append(user_id)
|
||||||
|
|
||||||
|
user_info = await self.bot.get_user_info(user_id)
|
||||||
|
|
||||||
|
try:
|
||||||
|
await modlog.create_case(
|
||||||
|
self.bot,
|
||||||
|
guild,
|
||||||
|
ctx.message.created_at,
|
||||||
|
"hackban",
|
||||||
|
user_info,
|
||||||
|
author,
|
||||||
|
reason,
|
||||||
|
until=None,
|
||||||
|
channel=None,
|
||||||
|
)
|
||||||
|
except RuntimeError as e:
|
||||||
|
errors["0"] = _("Failed to create modlog case: {reason}").format(reason=e)
|
||||||
|
await show_results()
|
||||||
|
|
||||||
|
@commands.command()
|
||||||
|
@commands.guild_only()
|
||||||
|
@commands.bot_has_permissions(ban_members=True)
|
||||||
|
@checks.admin_or_permissions(ban_members=True)
|
||||||
|
async def tempban(
|
||||||
|
self, ctx: commands.Context, user: discord.Member, days: int = 1, *, reason: str = None
|
||||||
|
):
|
||||||
|
"""Temporarily ban a user from this server."""
|
||||||
|
guild = ctx.guild
|
||||||
|
author = ctx.author
|
||||||
|
days_delta = timedelta(days=int(days))
|
||||||
|
unban_time = datetime.utcnow() + days_delta
|
||||||
|
|
||||||
|
invite = await self.get_invite_for_reinvite(ctx, int(days_delta.total_seconds() + 86400))
|
||||||
|
if invite is None:
|
||||||
|
invite = ""
|
||||||
|
|
||||||
|
queue_entry = (guild.id, user.id)
|
||||||
|
await self.settings.member(user).banned_until.set(unban_time.timestamp())
|
||||||
|
cur_tbans = await self.settings.guild(guild).current_tempbans()
|
||||||
|
cur_tbans.append(user.id)
|
||||||
|
await self.settings.guild(guild).current_tempbans.set(cur_tbans)
|
||||||
|
|
||||||
|
with contextlib.suppress(discord.HTTPException):
|
||||||
|
# We don't want blocked DMs preventing us from banning
|
||||||
|
await user.send(
|
||||||
|
_(
|
||||||
|
"You have been temporarily banned from {server_name} until {date}. "
|
||||||
|
"Here is an invite for when your ban expires: {invite_link}"
|
||||||
|
).format(
|
||||||
|
server_name=guild.name,
|
||||||
|
date=unban_time.strftime("%m-%d-%Y %H:%M:%S"),
|
||||||
|
invite_link=invite,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self.ban_queue.append(queue_entry)
|
||||||
|
try:
|
||||||
|
await guild.ban(user)
|
||||||
|
except discord.Forbidden:
|
||||||
|
await ctx.send(_("I can't do that for some reason."))
|
||||||
|
except discord.HTTPException:
|
||||||
|
await ctx.send(_("Something went wrong while banning"))
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
await modlog.create_case(
|
||||||
|
self.bot,
|
||||||
|
guild,
|
||||||
|
ctx.message.created_at,
|
||||||
|
"tempban",
|
||||||
|
user,
|
||||||
|
author,
|
||||||
|
reason,
|
||||||
|
unban_time,
|
||||||
|
)
|
||||||
|
except RuntimeError as e:
|
||||||
|
await ctx.send(e)
|
||||||
|
await ctx.send(_("Done. Enough chaos for now"))
|
||||||
|
|
||||||
|
@commands.command()
|
||||||
|
@commands.guild_only()
|
||||||
|
@commands.bot_has_permissions(ban_members=True)
|
||||||
|
@checks.admin_or_permissions(ban_members=True)
|
||||||
|
async def softban(self, ctx: commands.Context, user: discord.Member, *, reason: str = None):
|
||||||
|
"""Kick a user and delete 1 day's worth of their messages."""
|
||||||
|
guild = ctx.guild
|
||||||
|
author = ctx.author
|
||||||
|
|
||||||
|
if author == user:
|
||||||
|
await ctx.send(
|
||||||
|
_("I cannot let you do that. Self-harm is bad {emoji}").format(
|
||||||
|
emoji="\N{PENSIVE FACE}"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return
|
||||||
|
elif not await is_allowed_by_hierarchy(self.bot, self.settings, guild, author, user):
|
||||||
|
await ctx.send(
|
||||||
|
_(
|
||||||
|
"I cannot let you do that. You are "
|
||||||
|
"not higher than the user in the role "
|
||||||
|
"hierarchy."
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
audit_reason = get_audit_reason(author, reason)
|
||||||
|
|
||||||
|
invite = await self.get_invite_for_reinvite(ctx)
|
||||||
|
if invite is None:
|
||||||
|
invite = ""
|
||||||
|
|
||||||
|
queue_entry = (guild.id, user.id)
|
||||||
|
try: # We don't want blocked DMs preventing us from banning
|
||||||
|
msg = await user.send(
|
||||||
|
_(
|
||||||
|
"You have been banned and "
|
||||||
|
"then unbanned as a quick way to delete your messages.\n"
|
||||||
|
"You can now join the server again. {invite_link}"
|
||||||
|
).format(invite_link=invite)
|
||||||
|
)
|
||||||
|
except discord.HTTPException:
|
||||||
|
msg = None
|
||||||
|
self.ban_queue.append(queue_entry)
|
||||||
|
try:
|
||||||
|
await guild.ban(user, reason=audit_reason, delete_message_days=1)
|
||||||
|
except discord.errors.Forbidden:
|
||||||
|
self.ban_queue.remove(queue_entry)
|
||||||
|
await ctx.send(_("My role is not high enough to softban that user."))
|
||||||
|
if msg is not None:
|
||||||
|
await msg.delete()
|
||||||
|
return
|
||||||
|
except discord.HTTPException as e:
|
||||||
|
self.ban_queue.remove(queue_entry)
|
||||||
|
print(e)
|
||||||
|
return
|
||||||
|
self.unban_queue.append(queue_entry)
|
||||||
|
try:
|
||||||
|
await guild.unban(user)
|
||||||
|
except discord.HTTPException as e:
|
||||||
|
self.unban_queue.remove(queue_entry)
|
||||||
|
print(e)
|
||||||
|
return
|
||||||
|
else:
|
||||||
|
log.info(
|
||||||
|
"{}({}) softbanned {}({}), deleting 1 day worth "
|
||||||
|
"of messages".format(author.name, author.id, user.name, user.id)
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
await modlog.create_case(
|
||||||
|
self.bot,
|
||||||
|
guild,
|
||||||
|
ctx.message.created_at,
|
||||||
|
"softban",
|
||||||
|
user,
|
||||||
|
author,
|
||||||
|
reason,
|
||||||
|
until=None,
|
||||||
|
channel=None,
|
||||||
|
)
|
||||||
|
except RuntimeError as e:
|
||||||
|
await ctx.send(e)
|
||||||
|
await ctx.send(_("Done. Enough chaos."))
|
||||||
|
|
||||||
|
@commands.command()
|
||||||
|
@commands.guild_only()
|
||||||
|
@commands.bot_has_permissions(ban_members=True)
|
||||||
|
@checks.admin_or_permissions(ban_members=True)
|
||||||
|
async def unban(self, ctx: commands.Context, user_id: int, *, reason: str = None):
|
||||||
|
"""Unban a user from this server.
|
||||||
|
|
||||||
|
Requires specifying the target user's ID. To find this, you may either:
|
||||||
|
1. Copy it from the mod log case (if one was created), or
|
||||||
|
2. enable developer mode, go to Bans in this server's settings, right-
|
||||||
|
click the user and select 'Copy ID'."""
|
||||||
|
guild = ctx.guild
|
||||||
|
author = ctx.author
|
||||||
|
user = await self.bot.get_user_info(user_id)
|
||||||
|
if not user:
|
||||||
|
await ctx.send(_("Couldn't find a user with that ID!"))
|
||||||
|
return
|
||||||
|
audit_reason = get_audit_reason(ctx.author, reason)
|
||||||
|
bans = await guild.bans()
|
||||||
|
bans = [be.user for be in bans]
|
||||||
|
if user not in bans:
|
||||||
|
await ctx.send(_("It seems that user isn't banned!"))
|
||||||
|
return
|
||||||
|
queue_entry = (guild.id, user.id)
|
||||||
|
self.unban_queue.append(queue_entry)
|
||||||
|
try:
|
||||||
|
await guild.unban(user, reason=audit_reason)
|
||||||
|
except discord.HTTPException:
|
||||||
|
self.unban_queue.remove(queue_entry)
|
||||||
|
await ctx.send(_("Something went wrong while attempting to unban that user"))
|
||||||
|
return
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
await modlog.create_case(
|
||||||
|
self.bot,
|
||||||
|
guild,
|
||||||
|
ctx.message.created_at,
|
||||||
|
"unban",
|
||||||
|
user,
|
||||||
|
author,
|
||||||
|
reason,
|
||||||
|
until=None,
|
||||||
|
channel=None,
|
||||||
|
)
|
||||||
|
except RuntimeError as e:
|
||||||
|
await ctx.send(e)
|
||||||
|
await ctx.send(_("Unbanned that user from this server"))
|
||||||
|
|
||||||
|
if await self.settings.guild(guild).reinvite_on_unban():
|
||||||
|
invite = await self.get_invite_for_reinvite(ctx)
|
||||||
|
if invite:
|
||||||
|
try:
|
||||||
|
await user.send(
|
||||||
|
_(
|
||||||
|
"You've been unbanned from {server}.\n"
|
||||||
|
"Here is an invite for that server: {invite_link}"
|
||||||
|
).format(server=guild.name, invite_link=invite.url)
|
||||||
|
)
|
||||||
|
except discord.Forbidden:
|
||||||
|
await ctx.send(
|
||||||
|
_(
|
||||||
|
"I failed to send an invite to that user. "
|
||||||
|
"Perhaps you may be able to send it for me?\n"
|
||||||
|
"Here's the invite link: {invite_link}"
|
||||||
|
).format(invite_link=invite.url)
|
||||||
|
)
|
||||||
|
except discord.HTTPException:
|
||||||
|
await ctx.send(
|
||||||
|
_(
|
||||||
|
"Something went wrong when attempting to send that user"
|
||||||
|
"an invite. Here's the link so you can try: {invite_link}"
|
||||||
|
).format(invite_link=invite.url)
|
||||||
|
)
|
||||||
File diff suppressed because it is too large
Load Diff
125
redbot/cogs/mod/movetocore.py
Normal file
125
redbot/cogs/mod/movetocore.py
Normal file
@ -0,0 +1,125 @@
|
|||||||
|
import asyncio
|
||||||
|
import contextlib
|
||||||
|
|
||||||
|
import discord
|
||||||
|
from redbot.core import commands, checks, i18n
|
||||||
|
from redbot.core.utils.chat_formatting import box
|
||||||
|
from .abc import MixinMeta
|
||||||
|
from .log import log
|
||||||
|
|
||||||
|
_ = i18n.Translator("Mod", __file__)
|
||||||
|
|
||||||
|
|
||||||
|
# TODO: Empty this to core red.
|
||||||
|
class MoveToCore(MixinMeta):
|
||||||
|
"""
|
||||||
|
Mixin for things which should really not be in mod, but have not been moved out yet.
|
||||||
|
"""
|
||||||
|
|
||||||
|
async def on_command_completion(self, ctx: commands.Context):
|
||||||
|
await self._delete_delay(ctx)
|
||||||
|
|
||||||
|
# noinspection PyUnusedLocal
|
||||||
|
async def on_command_error(self, ctx: commands.Context, error):
|
||||||
|
await self._delete_delay(ctx)
|
||||||
|
|
||||||
|
async def _delete_delay(self, ctx: commands.Context):
|
||||||
|
"""Currently used for:
|
||||||
|
* delete delay"""
|
||||||
|
guild = ctx.guild
|
||||||
|
if guild is None:
|
||||||
|
return
|
||||||
|
message = ctx.message
|
||||||
|
delay = await self.settings.guild(guild).delete_delay()
|
||||||
|
|
||||||
|
if delay == -1:
|
||||||
|
return
|
||||||
|
|
||||||
|
async def _delete_helper(m):
|
||||||
|
with contextlib.suppress(discord.HTTPException):
|
||||||
|
await m.delete()
|
||||||
|
log.debug("Deleted command msg {}".format(m.id))
|
||||||
|
|
||||||
|
await asyncio.sleep(delay)
|
||||||
|
await _delete_helper(message)
|
||||||
|
|
||||||
|
# When the below are moved to core, the global check in .modcore needs to be moved as well.
|
||||||
|
@commands.group()
|
||||||
|
@commands.guild_only()
|
||||||
|
@checks.admin_or_permissions(manage_channels=True)
|
||||||
|
async def ignore(self, ctx: commands.Context):
|
||||||
|
"""Add servers or channels to the ignore list."""
|
||||||
|
if ctx.invoked_subcommand is None:
|
||||||
|
await ctx.send(await self.count_ignored())
|
||||||
|
|
||||||
|
@ignore.command(name="channel")
|
||||||
|
async def ignore_channel(self, ctx: commands.Context, channel: discord.TextChannel = None):
|
||||||
|
"""Ignore commands in the channel.
|
||||||
|
|
||||||
|
Defaults to the current channel.
|
||||||
|
"""
|
||||||
|
if not channel:
|
||||||
|
channel = ctx.channel
|
||||||
|
if not await self.settings.channel(channel).ignored():
|
||||||
|
await self.settings.channel(channel).ignored.set(True)
|
||||||
|
await ctx.send(_("Channel added to ignore list."))
|
||||||
|
else:
|
||||||
|
await ctx.send(_("Channel already in ignore list."))
|
||||||
|
|
||||||
|
@ignore.command(name="server", aliases=["guild"])
|
||||||
|
@checks.admin_or_permissions(manage_guild=True)
|
||||||
|
async def ignore_guild(self, ctx: commands.Context):
|
||||||
|
"""Ignore commands in this server."""
|
||||||
|
guild = ctx.guild
|
||||||
|
if not await self.settings.guild(guild).ignored():
|
||||||
|
await self.settings.guild(guild).ignored.set(True)
|
||||||
|
await ctx.send(_("This server has been added to the ignore list."))
|
||||||
|
else:
|
||||||
|
await ctx.send(_("This server is already being ignored."))
|
||||||
|
|
||||||
|
@commands.group()
|
||||||
|
@commands.guild_only()
|
||||||
|
@checks.admin_or_permissions(manage_channels=True)
|
||||||
|
async def unignore(self, ctx: commands.Context):
|
||||||
|
"""Remove servers or channels from the ignore list."""
|
||||||
|
if ctx.invoked_subcommand is None:
|
||||||
|
await ctx.send(await self.count_ignored())
|
||||||
|
|
||||||
|
@unignore.command(name="channel")
|
||||||
|
async def unignore_channel(self, ctx: commands.Context, channel: discord.TextChannel = None):
|
||||||
|
"""Remove a channel from ignore the list.
|
||||||
|
|
||||||
|
Defaults to the current channel.
|
||||||
|
"""
|
||||||
|
if not channel:
|
||||||
|
channel = ctx.channel
|
||||||
|
|
||||||
|
if await self.settings.channel(channel).ignored():
|
||||||
|
await self.settings.channel(channel).ignored.set(False)
|
||||||
|
await ctx.send(_("Channel removed from ignore list."))
|
||||||
|
else:
|
||||||
|
await ctx.send(_("That channel is not in the ignore list."))
|
||||||
|
|
||||||
|
@unignore.command(name="server", aliases=["guild"])
|
||||||
|
@checks.admin_or_permissions(manage_guild=True)
|
||||||
|
async def unignore_guild(self, ctx: commands.Context):
|
||||||
|
"""Remove this server from the ignore list."""
|
||||||
|
guild = ctx.message.guild
|
||||||
|
if await self.settings.guild(guild).ignored():
|
||||||
|
await self.settings.guild(guild).ignored.set(False)
|
||||||
|
await ctx.send(_("This server has been removed from the ignore list."))
|
||||||
|
else:
|
||||||
|
await ctx.send(_("This server is not in the ignore list."))
|
||||||
|
|
||||||
|
async def count_ignored(self):
|
||||||
|
ch_count = 0
|
||||||
|
svr_count = 0
|
||||||
|
for guild in self.bot.guilds:
|
||||||
|
if not await self.settings.guild(guild).ignored():
|
||||||
|
for channel in guild.text_channels:
|
||||||
|
if await self.settings.channel(channel).ignored():
|
||||||
|
ch_count += 1
|
||||||
|
else:
|
||||||
|
svr_count += 1
|
||||||
|
msg = _("Currently ignoring:\n{} channels\n{} guilds\n").format(ch_count, svr_count)
|
||||||
|
return box(msg)
|
||||||
465
redbot/cogs/mod/mutes.py
Normal file
465
redbot/cogs/mod/mutes.py
Normal file
@ -0,0 +1,465 @@
|
|||||||
|
import asyncio
|
||||||
|
from typing import cast, Optional
|
||||||
|
|
||||||
|
import discord
|
||||||
|
from redbot.core import commands, checks, i18n, modlog
|
||||||
|
from redbot.core.utils.chat_formatting import format_perms_list
|
||||||
|
from redbot.core.utils.mod import get_audit_reason, is_allowed_by_hierarchy
|
||||||
|
from .abc import MixinMeta
|
||||||
|
|
||||||
|
T_ = i18n.Translator("Mod", __file__)
|
||||||
|
|
||||||
|
_ = lambda s: s
|
||||||
|
mute_unmute_issues = {
|
||||||
|
"already_muted": _("That user can't send messages in this channel."),
|
||||||
|
"already_unmuted": _("That user isn't muted in this channel."),
|
||||||
|
"hierarchy_problem": _(
|
||||||
|
"I cannot let you do that. You are not higher than the user in the role hierarchy."
|
||||||
|
),
|
||||||
|
"is_admin": _("That user cannot be muted, as they have the Administrator permission."),
|
||||||
|
"permissions_issue": _(
|
||||||
|
"Failed to mute user. I need the manage roles "
|
||||||
|
"permission and the user I'm muting must be "
|
||||||
|
"lower than myself in the role hierarchy."
|
||||||
|
),
|
||||||
|
}
|
||||||
|
_ = T_
|
||||||
|
|
||||||
|
|
||||||
|
class MuteMixin(MixinMeta):
|
||||||
|
"""
|
||||||
|
Stuff for mutes goes here
|
||||||
|
"""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def _voice_perm_check(
|
||||||
|
ctx: commands.Context, user_voice_state: Optional[discord.VoiceState], **perms: bool
|
||||||
|
) -> bool:
|
||||||
|
"""Check if the bot and user have sufficient permissions for voicebans.
|
||||||
|
|
||||||
|
This also verifies that the user's voice state and connected
|
||||||
|
channel are not ``None``.
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
bool
|
||||||
|
``True`` if the permissions are sufficient and the user has
|
||||||
|
a valid voice state.
|
||||||
|
|
||||||
|
"""
|
||||||
|
if user_voice_state is None or user_voice_state.channel is None:
|
||||||
|
await ctx.send(_("That user is not in a voice channel."))
|
||||||
|
return False
|
||||||
|
voice_channel: discord.VoiceChannel = user_voice_state.channel
|
||||||
|
required_perms = discord.Permissions()
|
||||||
|
required_perms.update(**perms)
|
||||||
|
if not voice_channel.permissions_for(ctx.me) >= required_perms:
|
||||||
|
await ctx.send(
|
||||||
|
_("I require the {perms} permission(s) in that user's channel to do that.").format(
|
||||||
|
perms=format_perms_list(required_perms)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
if (
|
||||||
|
ctx.permission_state is commands.PermState.NORMAL
|
||||||
|
and not voice_channel.permissions_for(ctx.author) >= required_perms
|
||||||
|
):
|
||||||
|
await ctx.send(
|
||||||
|
_(
|
||||||
|
"You must have the {perms} permission(s) in that user's channel to use this "
|
||||||
|
"command."
|
||||||
|
).format(perms=format_perms_list(required_perms))
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
@commands.command()
|
||||||
|
@commands.guild_only()
|
||||||
|
@checks.admin_or_permissions(mute_members=True, deafen_members=True)
|
||||||
|
async def voiceunban(self, ctx: commands.Context, user: discord.Member, *, reason: str = None):
|
||||||
|
"""Unban a user from speaking and listening in the server's voice channels."""
|
||||||
|
user_voice_state = user.voice
|
||||||
|
if (
|
||||||
|
await self._voice_perm_check(
|
||||||
|
ctx, user_voice_state, deafen_members=True, mute_members=True
|
||||||
|
)
|
||||||
|
is False
|
||||||
|
):
|
||||||
|
return
|
||||||
|
needs_unmute = True if user_voice_state.mute else False
|
||||||
|
needs_undeafen = True if user_voice_state.deaf else False
|
||||||
|
audit_reason = get_audit_reason(ctx.author, reason)
|
||||||
|
if needs_unmute and needs_undeafen:
|
||||||
|
await user.edit(mute=False, deafen=False, reason=audit_reason)
|
||||||
|
elif needs_unmute:
|
||||||
|
await user.edit(mute=False, reason=audit_reason)
|
||||||
|
elif needs_undeafen:
|
||||||
|
await user.edit(deafen=False, reason=audit_reason)
|
||||||
|
else:
|
||||||
|
await ctx.send(_("That user isn't muted or deafened by the server!"))
|
||||||
|
return
|
||||||
|
|
||||||
|
guild = ctx.guild
|
||||||
|
author = ctx.author
|
||||||
|
try:
|
||||||
|
await modlog.create_case(
|
||||||
|
self.bot,
|
||||||
|
guild,
|
||||||
|
ctx.message.created_at,
|
||||||
|
"voiceunban",
|
||||||
|
user,
|
||||||
|
author,
|
||||||
|
reason,
|
||||||
|
until=None,
|
||||||
|
channel=None,
|
||||||
|
)
|
||||||
|
except RuntimeError as e:
|
||||||
|
await ctx.send(e)
|
||||||
|
await ctx.send(_("User is now allowed to speak and listen in voice channels"))
|
||||||
|
|
||||||
|
@commands.command()
|
||||||
|
@commands.guild_only()
|
||||||
|
@checks.admin_or_permissions(mute_members=True, deafen_members=True)
|
||||||
|
async def voiceban(self, ctx: commands.Context, user: discord.Member, *, reason: str = None):
|
||||||
|
"""Ban a user from speaking and listening in the server's voice channels."""
|
||||||
|
user_voice_state: discord.VoiceState = user.voice
|
||||||
|
if (
|
||||||
|
await self._voice_perm_check(
|
||||||
|
ctx, user_voice_state, deafen_members=True, mute_members=True
|
||||||
|
)
|
||||||
|
is False
|
||||||
|
):
|
||||||
|
return
|
||||||
|
needs_mute = True if user_voice_state.mute is False else False
|
||||||
|
needs_deafen = True if user_voice_state.deaf is False else False
|
||||||
|
audit_reason = get_audit_reason(ctx.author, reason)
|
||||||
|
author = ctx.author
|
||||||
|
guild = ctx.guild
|
||||||
|
if needs_mute and needs_deafen:
|
||||||
|
await user.edit(mute=True, deafen=True, reason=audit_reason)
|
||||||
|
elif needs_mute:
|
||||||
|
await user.edit(mute=True, reason=audit_reason)
|
||||||
|
elif needs_deafen:
|
||||||
|
await user.edit(deafen=True, reason=audit_reason)
|
||||||
|
else:
|
||||||
|
await ctx.send(_("That user is already muted and deafened server-wide!"))
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
await modlog.create_case(
|
||||||
|
self.bot,
|
||||||
|
guild,
|
||||||
|
ctx.message.created_at,
|
||||||
|
"voiceban",
|
||||||
|
user,
|
||||||
|
author,
|
||||||
|
reason,
|
||||||
|
until=None,
|
||||||
|
channel=None,
|
||||||
|
)
|
||||||
|
except RuntimeError as e:
|
||||||
|
await ctx.send(e)
|
||||||
|
await ctx.send(_("User has been banned from speaking or listening in voice channels"))
|
||||||
|
|
||||||
|
@commands.group()
|
||||||
|
@commands.guild_only()
|
||||||
|
@checks.mod_or_permissions(manage_channels=True)
|
||||||
|
async def mute(self, ctx: commands.Context):
|
||||||
|
"""Mute users."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@mute.command(name="voice")
|
||||||
|
@commands.guild_only()
|
||||||
|
async def voice_mute(self, ctx: commands.Context, user: discord.Member, *, reason: str = None):
|
||||||
|
"""Mute a user in their current voice channel."""
|
||||||
|
user_voice_state = user.voice
|
||||||
|
if (
|
||||||
|
await self._voice_perm_check(
|
||||||
|
ctx, user_voice_state, mute_members=True, manage_channels=True
|
||||||
|
)
|
||||||
|
is False
|
||||||
|
):
|
||||||
|
return
|
||||||
|
guild = ctx.guild
|
||||||
|
author = ctx.author
|
||||||
|
channel = user_voice_state.channel
|
||||||
|
audit_reason = get_audit_reason(author, reason)
|
||||||
|
|
||||||
|
success, issue = await self.mute_user(guild, channel, author, user, audit_reason)
|
||||||
|
|
||||||
|
if success:
|
||||||
|
try:
|
||||||
|
await modlog.create_case(
|
||||||
|
self.bot,
|
||||||
|
guild,
|
||||||
|
ctx.message.created_at,
|
||||||
|
"vmute",
|
||||||
|
user,
|
||||||
|
author,
|
||||||
|
reason,
|
||||||
|
until=None,
|
||||||
|
channel=channel,
|
||||||
|
)
|
||||||
|
except RuntimeError as e:
|
||||||
|
await ctx.send(e)
|
||||||
|
await ctx.send(
|
||||||
|
_("Muted {user} in channel {channel.name}").format(user=user, channel=channel)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
await ctx.send(issue)
|
||||||
|
|
||||||
|
@mute.command(name="channel")
|
||||||
|
@commands.guild_only()
|
||||||
|
@commands.bot_has_permissions(manage_roles=True)
|
||||||
|
@checks.mod_or_permissions(administrator=True)
|
||||||
|
async def channel_mute(
|
||||||
|
self, ctx: commands.Context, user: discord.Member, *, reason: str = None
|
||||||
|
):
|
||||||
|
"""Mute a user in the current text channel."""
|
||||||
|
author = ctx.message.author
|
||||||
|
channel = ctx.message.channel
|
||||||
|
guild = ctx.guild
|
||||||
|
audit_reason = get_audit_reason(author, reason)
|
||||||
|
|
||||||
|
success, issue = await self.mute_user(guild, channel, author, user, audit_reason)
|
||||||
|
|
||||||
|
if success:
|
||||||
|
try:
|
||||||
|
await modlog.create_case(
|
||||||
|
self.bot,
|
||||||
|
guild,
|
||||||
|
ctx.message.created_at,
|
||||||
|
"cmute",
|
||||||
|
user,
|
||||||
|
author,
|
||||||
|
reason,
|
||||||
|
until=None,
|
||||||
|
channel=channel,
|
||||||
|
)
|
||||||
|
except RuntimeError as e:
|
||||||
|
await ctx.send(e)
|
||||||
|
await channel.send(_("User has been muted in this channel."))
|
||||||
|
else:
|
||||||
|
await channel.send(issue)
|
||||||
|
|
||||||
|
@mute.command(name="server", aliases=["guild"])
|
||||||
|
@commands.guild_only()
|
||||||
|
@commands.bot_has_permissions(manage_roles=True)
|
||||||
|
@checks.mod_or_permissions(administrator=True)
|
||||||
|
async def guild_mute(self, ctx: commands.Context, user: discord.Member, *, reason: str = None):
|
||||||
|
"""Mutes user in the server"""
|
||||||
|
author = ctx.message.author
|
||||||
|
guild = ctx.guild
|
||||||
|
audit_reason = get_audit_reason(author, reason)
|
||||||
|
|
||||||
|
mute_success = []
|
||||||
|
for channel in guild.channels:
|
||||||
|
success, issue = await self.mute_user(guild, channel, author, user, audit_reason)
|
||||||
|
mute_success.append((success, issue))
|
||||||
|
await asyncio.sleep(0.1)
|
||||||
|
try:
|
||||||
|
await modlog.create_case(
|
||||||
|
self.bot,
|
||||||
|
guild,
|
||||||
|
ctx.message.created_at,
|
||||||
|
"smute",
|
||||||
|
user,
|
||||||
|
author,
|
||||||
|
reason,
|
||||||
|
until=None,
|
||||||
|
channel=None,
|
||||||
|
)
|
||||||
|
except RuntimeError as e:
|
||||||
|
await ctx.send(e)
|
||||||
|
await ctx.send(_("User has been muted in this server."))
|
||||||
|
|
||||||
|
@commands.group()
|
||||||
|
@commands.guild_only()
|
||||||
|
@commands.bot_has_permissions(manage_roles=True)
|
||||||
|
@checks.mod_or_permissions(manage_channels=True)
|
||||||
|
async def unmute(self, ctx: commands.Context):
|
||||||
|
"""Unmute users."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@unmute.command(name="voice")
|
||||||
|
@commands.guild_only()
|
||||||
|
async def unmute_voice(
|
||||||
|
self, ctx: commands.Context, user: discord.Member, *, reason: str = None
|
||||||
|
):
|
||||||
|
"""Unmute a user in their current voice channel."""
|
||||||
|
user_voice_state = user.voice
|
||||||
|
if (
|
||||||
|
await self._voice_perm_check(
|
||||||
|
ctx, user_voice_state, mute_members=True, manage_channels=True
|
||||||
|
)
|
||||||
|
is False
|
||||||
|
):
|
||||||
|
return
|
||||||
|
guild = ctx.guild
|
||||||
|
author = ctx.author
|
||||||
|
channel = user_voice_state.channel
|
||||||
|
audit_reason = get_audit_reason(author, reason)
|
||||||
|
|
||||||
|
success, message = await self.unmute_user(guild, channel, author, user, audit_reason)
|
||||||
|
|
||||||
|
if success:
|
||||||
|
try:
|
||||||
|
await modlog.create_case(
|
||||||
|
self.bot,
|
||||||
|
guild,
|
||||||
|
ctx.message.created_at,
|
||||||
|
"vunmute",
|
||||||
|
user,
|
||||||
|
author,
|
||||||
|
reason,
|
||||||
|
until=None,
|
||||||
|
channel=channel,
|
||||||
|
)
|
||||||
|
except RuntimeError as e:
|
||||||
|
await ctx.send(e)
|
||||||
|
await ctx.send(
|
||||||
|
_("Unmuted {user} in channel {channel.name}").format(user=user, channel=channel)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
await ctx.send(_("Unmute failed. Reason: {}").format(message))
|
||||||
|
|
||||||
|
@checks.mod_or_permissions(administrator=True)
|
||||||
|
@unmute.command(name="channel")
|
||||||
|
@commands.bot_has_permissions(manage_roles=True)
|
||||||
|
@commands.guild_only()
|
||||||
|
async def unmute_channel(
|
||||||
|
self, ctx: commands.Context, user: discord.Member, *, reason: str = None
|
||||||
|
):
|
||||||
|
"""Unmute a user in this channel."""
|
||||||
|
channel = ctx.channel
|
||||||
|
author = ctx.author
|
||||||
|
guild = ctx.guild
|
||||||
|
audit_reason = get_audit_reason(author, reason)
|
||||||
|
|
||||||
|
success, message = await self.unmute_user(guild, channel, author, user, audit_reason)
|
||||||
|
|
||||||
|
if success:
|
||||||
|
try:
|
||||||
|
await modlog.create_case(
|
||||||
|
self.bot,
|
||||||
|
guild,
|
||||||
|
ctx.message.created_at,
|
||||||
|
"cunmute",
|
||||||
|
user,
|
||||||
|
author,
|
||||||
|
reason,
|
||||||
|
until=None,
|
||||||
|
channel=channel,
|
||||||
|
)
|
||||||
|
except RuntimeError as e:
|
||||||
|
await ctx.send(e)
|
||||||
|
await ctx.send(_("User unmuted in this channel."))
|
||||||
|
else:
|
||||||
|
await ctx.send(_("Unmute failed. Reason: {}").format(message))
|
||||||
|
|
||||||
|
@checks.mod_or_permissions(administrator=True)
|
||||||
|
@unmute.command(name="server", aliases=["guild"])
|
||||||
|
@commands.bot_has_permissions(manage_roles=True)
|
||||||
|
@commands.guild_only()
|
||||||
|
async def unmute_guild(
|
||||||
|
self, ctx: commands.Context, user: discord.Member, *, reason: str = None
|
||||||
|
):
|
||||||
|
"""Unmute a user in this server."""
|
||||||
|
guild = ctx.guild
|
||||||
|
author = ctx.author
|
||||||
|
audit_reason = get_audit_reason(author, reason)
|
||||||
|
|
||||||
|
unmute_success = []
|
||||||
|
for channel in guild.channels:
|
||||||
|
success, message = await self.unmute_user(guild, channel, author, user, audit_reason)
|
||||||
|
unmute_success.append((success, message))
|
||||||
|
await asyncio.sleep(0.1)
|
||||||
|
try:
|
||||||
|
await modlog.create_case(
|
||||||
|
self.bot,
|
||||||
|
guild,
|
||||||
|
ctx.message.created_at,
|
||||||
|
"sunmute",
|
||||||
|
user,
|
||||||
|
author,
|
||||||
|
reason,
|
||||||
|
until=None,
|
||||||
|
)
|
||||||
|
except RuntimeError as e:
|
||||||
|
await ctx.send(e)
|
||||||
|
await ctx.send(_("User has been unmuted in this server."))
|
||||||
|
|
||||||
|
async def mute_user(
|
||||||
|
self,
|
||||||
|
guild: discord.Guild,
|
||||||
|
channel: discord.abc.GuildChannel,
|
||||||
|
author: discord.Member,
|
||||||
|
user: discord.Member,
|
||||||
|
reason: str,
|
||||||
|
) -> (bool, str):
|
||||||
|
"""Mutes the specified user in the specified channel"""
|
||||||
|
overwrites = channel.overwrites_for(user)
|
||||||
|
permissions = channel.permissions_for(user)
|
||||||
|
|
||||||
|
if permissions.administrator:
|
||||||
|
return False, _(mute_unmute_issues["is_admin"])
|
||||||
|
|
||||||
|
new_overs = {}
|
||||||
|
if not isinstance(channel, discord.TextChannel):
|
||||||
|
new_overs.update(speak=False)
|
||||||
|
if not isinstance(channel, discord.VoiceChannel):
|
||||||
|
new_overs.update(send_messages=False, add_reactions=False)
|
||||||
|
|
||||||
|
if all(getattr(permissions, p) is False for p in new_overs.keys()):
|
||||||
|
return False, _(mute_unmute_issues["already_muted"])
|
||||||
|
|
||||||
|
elif not await is_allowed_by_hierarchy(self.bot, self.settings, guild, author, user):
|
||||||
|
return False, _(mute_unmute_issues["hierarchy_problem"])
|
||||||
|
|
||||||
|
old_overs = {k: getattr(overwrites, k) for k in new_overs}
|
||||||
|
overwrites.update(**new_overs)
|
||||||
|
try:
|
||||||
|
await channel.set_permissions(user, overwrite=overwrites, reason=reason)
|
||||||
|
except discord.Forbidden:
|
||||||
|
return False, _(mute_unmute_issues["permissions_issue"])
|
||||||
|
else:
|
||||||
|
await self.settings.member(user).set_raw(
|
||||||
|
"perms_cache", str(channel.id), value=old_overs
|
||||||
|
)
|
||||||
|
return True, None
|
||||||
|
|
||||||
|
async def unmute_user(
|
||||||
|
self,
|
||||||
|
guild: discord.Guild,
|
||||||
|
channel: discord.abc.GuildChannel,
|
||||||
|
author: discord.Member,
|
||||||
|
user: discord.Member,
|
||||||
|
reason: str,
|
||||||
|
) -> (bool, str):
|
||||||
|
overwrites = channel.overwrites_for(user)
|
||||||
|
perms_cache = await self.settings.member(user).perms_cache()
|
||||||
|
|
||||||
|
if channel.id in perms_cache:
|
||||||
|
old_values = perms_cache[channel.id]
|
||||||
|
else:
|
||||||
|
old_values = {"send_messages": None, "add_reactions": None, "speak": None}
|
||||||
|
|
||||||
|
if all(getattr(overwrites, k) == v for k, v in old_values.items()):
|
||||||
|
return False, _(mute_unmute_issues["already_unmuted"])
|
||||||
|
|
||||||
|
elif not await is_allowed_by_hierarchy(self.bot, self.settings, guild, author, user):
|
||||||
|
return False, _(mute_unmute_issues["hierarchy_problem"])
|
||||||
|
|
||||||
|
overwrites.update(**old_values)
|
||||||
|
try:
|
||||||
|
if overwrites.is_empty():
|
||||||
|
await channel.set_permissions(
|
||||||
|
user, overwrite=cast(discord.PermissionOverwrite, None), reason=reason
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
await channel.set_permissions(user, overwrite=overwrites, reason=reason)
|
||||||
|
except discord.Forbidden:
|
||||||
|
return False, _(mute_unmute_issues["permissions_issue"])
|
||||||
|
else:
|
||||||
|
await self.settings.member(user).clear_raw("perms_cache", str(channel.id))
|
||||||
|
return True, None
|
||||||
185
redbot/cogs/mod/names.py
Normal file
185
redbot/cogs/mod/names.py
Normal file
@ -0,0 +1,185 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
from typing import cast
|
||||||
|
|
||||||
|
import discord
|
||||||
|
from redbot.core import commands, i18n, checks
|
||||||
|
from redbot.core.utils.common_filters import (
|
||||||
|
filter_invites,
|
||||||
|
filter_various_mentions,
|
||||||
|
escape_spoilers_and_mass_mentions,
|
||||||
|
)
|
||||||
|
from redbot.core.utils.mod import get_audit_reason
|
||||||
|
from .abc import MixinMeta
|
||||||
|
|
||||||
|
_ = i18n.Translator("Mod", __file__)
|
||||||
|
|
||||||
|
|
||||||
|
class ModInfo(MixinMeta):
|
||||||
|
"""
|
||||||
|
Commands regarding names, userinfo, etc.
|
||||||
|
"""
|
||||||
|
|
||||||
|
async def get_names_and_nicks(self, user):
|
||||||
|
names = await self.settings.user(user).past_names()
|
||||||
|
nicks = await self.settings.member(user).past_nicks()
|
||||||
|
if names:
|
||||||
|
names = [escape_spoilers_and_mass_mentions(name) for name in names if name]
|
||||||
|
if nicks:
|
||||||
|
nicks = [escape_spoilers_and_mass_mentions(nick) for nick in nicks if nick]
|
||||||
|
return names, nicks
|
||||||
|
|
||||||
|
@commands.command()
|
||||||
|
@commands.guild_only()
|
||||||
|
@commands.bot_has_permissions(manage_nicknames=True)
|
||||||
|
@checks.admin_or_permissions(manage_nicknames=True)
|
||||||
|
async def rename(self, ctx: commands.Context, user: discord.Member, *, nickname: str = ""):
|
||||||
|
"""Change a user's nickname.
|
||||||
|
|
||||||
|
Leaving the nickname empty will remove it.
|
||||||
|
"""
|
||||||
|
nickname = nickname.strip()
|
||||||
|
me = cast(discord.Member, ctx.me)
|
||||||
|
if not nickname:
|
||||||
|
nickname = None
|
||||||
|
elif not 2 <= len(nickname) <= 32:
|
||||||
|
await ctx.send(_("Nicknames must be between 2 and 32 characters long."))
|
||||||
|
return
|
||||||
|
if not (
|
||||||
|
(me.guild_permissions.manage_nicknames or me.guild_permissions.administrator)
|
||||||
|
and me.top_role > user.top_role
|
||||||
|
and user != ctx.guild.owner
|
||||||
|
):
|
||||||
|
await ctx.send(
|
||||||
|
_(
|
||||||
|
"I do not have permission to rename that member. They may be higher than or "
|
||||||
|
"equal to me in the role hierarchy."
|
||||||
|
)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
await user.edit(reason=get_audit_reason(ctx.author, None), nick=nickname)
|
||||||
|
except discord.Forbidden:
|
||||||
|
# Just in case we missed something in the permissions check above
|
||||||
|
await ctx.send(_("I do not have permission to rename that member."))
|
||||||
|
except discord.HTTPException as exc:
|
||||||
|
if exc.status == 400: # BAD REQUEST
|
||||||
|
await ctx.send(_("That nickname is invalid."))
|
||||||
|
else:
|
||||||
|
await ctx.send(_("An unexpected error has occured."))
|
||||||
|
else:
|
||||||
|
await ctx.send(_("Done."))
|
||||||
|
|
||||||
|
@commands.command()
|
||||||
|
@commands.guild_only()
|
||||||
|
@commands.bot_has_permissions(embed_links=True)
|
||||||
|
async def userinfo(self, ctx, *, user: discord.Member = None):
|
||||||
|
"""Show information about a user.
|
||||||
|
|
||||||
|
This includes fields for status, discord join date, server
|
||||||
|
join date, voice state and previous names/nicknames.
|
||||||
|
|
||||||
|
If the user has no roles, previous names or previous nicknames,
|
||||||
|
these fields will be omitted.
|
||||||
|
"""
|
||||||
|
author = ctx.author
|
||||||
|
guild = ctx.guild
|
||||||
|
|
||||||
|
if not user:
|
||||||
|
user = author
|
||||||
|
|
||||||
|
# A special case for a special someone :^)
|
||||||
|
special_date = datetime(2016, 1, 10, 6, 8, 4, 443000)
|
||||||
|
is_special = user.id == 96130341705637888 and guild.id == 133049272517001216
|
||||||
|
|
||||||
|
roles = sorted(user.roles)[1:]
|
||||||
|
names, nicks = await self.get_names_and_nicks(user)
|
||||||
|
|
||||||
|
joined_at = user.joined_at if not is_special else special_date
|
||||||
|
since_created = (ctx.message.created_at - user.created_at).days
|
||||||
|
if joined_at is not None:
|
||||||
|
since_joined = (ctx.message.created_at - joined_at).days
|
||||||
|
user_joined = joined_at.strftime("%d %b %Y %H:%M")
|
||||||
|
else:
|
||||||
|
since_joined = "?"
|
||||||
|
user_joined = "Unknown"
|
||||||
|
user_created = user.created_at.strftime("%d %b %Y %H:%M")
|
||||||
|
voice_state = user.voice
|
||||||
|
member_number = (
|
||||||
|
sorted(guild.members, key=lambda m: m.joined_at or ctx.message.created_at).index(user)
|
||||||
|
+ 1
|
||||||
|
)
|
||||||
|
|
||||||
|
created_on = _("{}\n({} days ago)").format(user_created, since_created)
|
||||||
|
joined_on = _("{}\n({} days ago)").format(user_joined, since_joined)
|
||||||
|
|
||||||
|
activity = _("Chilling in {} status").format(user.status)
|
||||||
|
if user.activity is None: # Default status
|
||||||
|
pass
|
||||||
|
elif user.activity.type == discord.ActivityType.playing:
|
||||||
|
activity = _("Playing {}").format(user.activity.name)
|
||||||
|
elif user.activity.type == discord.ActivityType.streaming:
|
||||||
|
activity = _("Streaming [{}]({})").format(user.activity.name, user.activity.url)
|
||||||
|
elif user.activity.type == discord.ActivityType.listening:
|
||||||
|
activity = _("Listening to {}").format(user.activity.name)
|
||||||
|
elif user.activity.type == discord.ActivityType.watching:
|
||||||
|
activity = _("Watching {}").format(user.activity.name)
|
||||||
|
|
||||||
|
if roles:
|
||||||
|
roles = ", ".join([x.name for x in roles])
|
||||||
|
else:
|
||||||
|
roles = None
|
||||||
|
|
||||||
|
data = discord.Embed(description=activity, colour=user.colour)
|
||||||
|
data.add_field(name=_("Joined Discord on"), value=created_on)
|
||||||
|
data.add_field(name=_("Joined this server on"), value=joined_on)
|
||||||
|
if roles is not None:
|
||||||
|
data.add_field(name=_("Roles"), value=roles, inline=False)
|
||||||
|
if names:
|
||||||
|
# May need sanitizing later, but mentions do not ping in embeds currently
|
||||||
|
val = filter_invites(", ".join(names))
|
||||||
|
data.add_field(name=_("Previous Names"), value=val, inline=False)
|
||||||
|
if nicks:
|
||||||
|
# May need sanitizing later, but mentions do not ping in embeds currently
|
||||||
|
val = filter_invites(", ".join(nicks))
|
||||||
|
data.add_field(name=_("Previous Nicknames"), value=val, inline=False)
|
||||||
|
if voice_state and voice_state.channel:
|
||||||
|
data.add_field(
|
||||||
|
name=_("Current voice channel"),
|
||||||
|
value="{0.name} (ID {0.id})".format(voice_state.channel),
|
||||||
|
inline=False,
|
||||||
|
)
|
||||||
|
data.set_footer(text=_("Member #{} | User ID: {}").format(member_number, user.id))
|
||||||
|
|
||||||
|
name = str(user)
|
||||||
|
name = " ~ ".join((name, user.nick)) if user.nick else name
|
||||||
|
name = filter_invites(name)
|
||||||
|
|
||||||
|
if user.avatar:
|
||||||
|
avatar = user.avatar_url_as(static_format="png")
|
||||||
|
data.set_author(name=name, url=avatar)
|
||||||
|
data.set_thumbnail(url=avatar)
|
||||||
|
else:
|
||||||
|
data.set_author(name=name)
|
||||||
|
|
||||||
|
await ctx.send(embed=data)
|
||||||
|
|
||||||
|
@commands.command()
|
||||||
|
async def names(self, ctx: commands.Context, user: discord.Member):
|
||||||
|
"""Show previous names and nicknames of a user."""
|
||||||
|
names, nicks = await self.get_names_and_nicks(user)
|
||||||
|
msg = ""
|
||||||
|
if names:
|
||||||
|
msg += _("**Past 20 names**:")
|
||||||
|
msg += "\n"
|
||||||
|
msg += ", ".join(names)
|
||||||
|
if nicks:
|
||||||
|
if msg:
|
||||||
|
msg += "\n\n"
|
||||||
|
msg += _("**Past 20 nicknames**:")
|
||||||
|
msg += "\n"
|
||||||
|
msg += ", ".join(nicks)
|
||||||
|
if msg:
|
||||||
|
msg = filter_various_mentions(msg)
|
||||||
|
await ctx.send(msg)
|
||||||
|
else:
|
||||||
|
await ctx.send(_("That user doesn't have any recorded name or nickname change."))
|
||||||
168
redbot/cogs/mod/settings.py
Normal file
168
redbot/cogs/mod/settings.py
Normal file
@ -0,0 +1,168 @@
|
|||||||
|
from redbot.core import commands, i18n, checks
|
||||||
|
from redbot.core.utils.chat_formatting import box
|
||||||
|
|
||||||
|
from .abc import MixinMeta
|
||||||
|
|
||||||
|
_ = i18n.Translator("Mod", __file__)
|
||||||
|
|
||||||
|
|
||||||
|
class ModSettings(MixinMeta):
|
||||||
|
"""
|
||||||
|
This is a mixin for the mod cog containing all settings commands.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@commands.group()
|
||||||
|
@commands.guild_only()
|
||||||
|
@checks.guildowner_or_permissions(administrator=True)
|
||||||
|
async def modset(self, ctx: commands.Context):
|
||||||
|
"""Manage server administration settings."""
|
||||||
|
if ctx.invoked_subcommand is None:
|
||||||
|
guild = ctx.guild
|
||||||
|
# Display current settings
|
||||||
|
delete_repeats = await self.settings.guild(guild).delete_repeats()
|
||||||
|
ban_mention_spam = await self.settings.guild(guild).ban_mention_spam()
|
||||||
|
respect_hierarchy = await self.settings.guild(guild).respect_hierarchy()
|
||||||
|
delete_delay = await self.settings.guild(guild).delete_delay()
|
||||||
|
reinvite_on_unban = await self.settings.guild(guild).reinvite_on_unban()
|
||||||
|
msg = ""
|
||||||
|
msg += _("Delete repeats: {yes_or_no}\n").format(
|
||||||
|
yes_or_no=_("Yes") if delete_repeats else _("No")
|
||||||
|
)
|
||||||
|
msg += _("Ban mention spam: {num_mentions}\n").format(
|
||||||
|
num_mentions=_("{num} mentions").format(num=ban_mention_spam)
|
||||||
|
if ban_mention_spam
|
||||||
|
else _("No")
|
||||||
|
)
|
||||||
|
msg += _("Respects hierarchy: {yes_or_no}\n").format(
|
||||||
|
yes_or_no=_("Yes") if respect_hierarchy else _("No")
|
||||||
|
)
|
||||||
|
msg += _("Delete delay: {num_seconds}\n").format(
|
||||||
|
num_seconds=_("{num} seconds").format(num=delete_delay)
|
||||||
|
if delete_delay != -1
|
||||||
|
else _("None")
|
||||||
|
)
|
||||||
|
msg += _("Reinvite on unban: {yes_or_no}\n").format(
|
||||||
|
yes_or_no=_("Yes") if reinvite_on_unban else _("No")
|
||||||
|
)
|
||||||
|
await ctx.send(box(msg))
|
||||||
|
|
||||||
|
@modset.command()
|
||||||
|
@commands.guild_only()
|
||||||
|
async def hierarchy(self, ctx: commands.Context):
|
||||||
|
"""Toggle role hierarchy check for mods and admins.
|
||||||
|
|
||||||
|
**WARNING**: Disabling this setting will allow mods to take
|
||||||
|
actions on users above them in the role hierarchy!
|
||||||
|
|
||||||
|
This is enabled by default.
|
||||||
|
"""
|
||||||
|
guild = ctx.guild
|
||||||
|
toggled = await self.settings.guild(guild).respect_hierarchy()
|
||||||
|
if not toggled:
|
||||||
|
await self.settings.guild(guild).respect_hierarchy.set(True)
|
||||||
|
await ctx.send(
|
||||||
|
_("Role hierarchy will be checked when moderation commands are issued.")
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
await self.settings.guild(guild).respect_hierarchy.set(False)
|
||||||
|
await ctx.send(
|
||||||
|
_("Role hierarchy will be ignored when moderation commands are issued.")
|
||||||
|
)
|
||||||
|
|
||||||
|
@modset.command()
|
||||||
|
@commands.guild_only()
|
||||||
|
async def banmentionspam(self, ctx: commands.Context, max_mentions: int = 0):
|
||||||
|
"""Set the autoban conditions for mention spam.
|
||||||
|
|
||||||
|
Users will be banned if they send any message which contains more than
|
||||||
|
`<max_mentions>` mentions.
|
||||||
|
|
||||||
|
`<max_mentions>` must be at least 5. Set to 0 to disable.
|
||||||
|
"""
|
||||||
|
guild = ctx.guild
|
||||||
|
if max_mentions:
|
||||||
|
if max_mentions < 5:
|
||||||
|
max_mentions = 5
|
||||||
|
await self.settings.guild(guild).ban_mention_spam.set(max_mentions)
|
||||||
|
await ctx.send(
|
||||||
|
_(
|
||||||
|
"Autoban for mention spam enabled. "
|
||||||
|
"Anyone mentioning {max_mentions} or more different people "
|
||||||
|
"in a single message will be autobanned."
|
||||||
|
).format(max_mentions=max_mentions)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
cur_setting = await self.settings.guild(guild).ban_mention_spam()
|
||||||
|
if not cur_setting:
|
||||||
|
await ctx.send_help()
|
||||||
|
return
|
||||||
|
await self.settings.guild(guild).ban_mention_spam.set(False)
|
||||||
|
await ctx.send(_("Autoban for mention spam disabled."))
|
||||||
|
|
||||||
|
@modset.command()
|
||||||
|
@commands.guild_only()
|
||||||
|
async def deleterepeats(self, ctx: commands.Context):
|
||||||
|
"""Enable auto-deletion of repeated messages."""
|
||||||
|
guild = ctx.guild
|
||||||
|
cur_setting = await self.settings.guild(guild).delete_repeats()
|
||||||
|
if not cur_setting:
|
||||||
|
await self.settings.guild(guild).delete_repeats.set(True)
|
||||||
|
await ctx.send(_("Messages repeated up to 3 times will be deleted."))
|
||||||
|
else:
|
||||||
|
await self.settings.guild(guild).delete_repeats.set(False)
|
||||||
|
await ctx.send(_("Repeated messages will be ignored."))
|
||||||
|
|
||||||
|
@modset.command()
|
||||||
|
@commands.guild_only()
|
||||||
|
async def deletedelay(self, ctx: commands.Context, time: int = None):
|
||||||
|
"""Set the delay until the bot removes the command message.
|
||||||
|
|
||||||
|
Must be between -1 and 60.
|
||||||
|
|
||||||
|
Set to -1 to disable this feature.
|
||||||
|
"""
|
||||||
|
guild = ctx.guild
|
||||||
|
if time is not None:
|
||||||
|
time = min(max(time, -1), 60) # Enforces the time limits
|
||||||
|
await self.settings.guild(guild).delete_delay.set(time)
|
||||||
|
if time == -1:
|
||||||
|
await ctx.send(_("Command deleting disabled."))
|
||||||
|
else:
|
||||||
|
await ctx.send(_("Delete delay set to {num} seconds.").format(num=time))
|
||||||
|
else:
|
||||||
|
delay = await self.settings.guild(guild).delete_delay()
|
||||||
|
if delay != -1:
|
||||||
|
await ctx.send(
|
||||||
|
_(
|
||||||
|
"Bot will delete command messages after"
|
||||||
|
" {num} seconds. Set this value to -1 to"
|
||||||
|
" stop deleting messages"
|
||||||
|
).format(num=delay)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
await ctx.send(_("I will not delete command messages."))
|
||||||
|
|
||||||
|
@modset.command()
|
||||||
|
@commands.guild_only()
|
||||||
|
async def reinvite(self, ctx: commands.Context):
|
||||||
|
"""Toggle whether an invite will be sent to a user when unbanned.
|
||||||
|
|
||||||
|
If this is True, the bot will attempt to create and send a single-use invite
|
||||||
|
to the newly-unbanned user.
|
||||||
|
"""
|
||||||
|
guild = ctx.guild
|
||||||
|
cur_setting = await self.settings.guild(guild).reinvite_on_unban()
|
||||||
|
if not cur_setting:
|
||||||
|
await self.settings.guild(guild).reinvite_on_unban.set(True)
|
||||||
|
await ctx.send(
|
||||||
|
_("Users unbanned with {command} will be reinvited.").format(
|
||||||
|
command=f"{ctx.prefix}unban"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
await self.settings.guild(guild).reinvite_on_unban.set(False)
|
||||||
|
await ctx.send(
|
||||||
|
_("Users unbanned with {command} will not be reinvited.").format(
|
||||||
|
command=f"{ctx.prefix}unban"
|
||||||
|
)
|
||||||
|
)
|
||||||
@ -100,7 +100,9 @@ class Permissions(commands.Cog):
|
|||||||
# Note that GLOBAL rules are denoted by an ID of 0.
|
# Note that GLOBAL rules are denoted by an ID of 0.
|
||||||
self.config = config.Config.get_conf(self, identifier=78631113035100160)
|
self.config = config.Config.get_conf(self, identifier=78631113035100160)
|
||||||
self.config.register_global(version="")
|
self.config.register_global(version="")
|
||||||
|
self.config.init_custom(COG, 1)
|
||||||
self.config.register_custom(COG)
|
self.config.register_custom(COG)
|
||||||
|
self.config.init_custom(COMMAND, 1)
|
||||||
self.config.register_custom(COMMAND)
|
self.config.register_custom(COMMAND)
|
||||||
|
|
||||||
@commands.group()
|
@commands.group()
|
||||||
@ -278,7 +280,7 @@ class Permissions(commands.Cog):
|
|||||||
ctx: commands.Context,
|
ctx: commands.Context,
|
||||||
allow_or_deny: RuleType,
|
allow_or_deny: RuleType,
|
||||||
cog_or_command: CogOrCommand,
|
cog_or_command: CogOrCommand,
|
||||||
who_or_what: GlobalUniqueObjectFinder,
|
who_or_what: commands.Greedy[GlobalUniqueObjectFinder],
|
||||||
):
|
):
|
||||||
"""Add a global rule to a command.
|
"""Add a global rule to a command.
|
||||||
|
|
||||||
@ -287,13 +289,13 @@ class Permissions(commands.Cog):
|
|||||||
`<cog_or_command>` is the cog or command to add the rule to.
|
`<cog_or_command>` is the cog or command to add the rule to.
|
||||||
This is case sensitive.
|
This is case sensitive.
|
||||||
|
|
||||||
`<who_or_what>` is the user, channel, role or server the rule
|
`<who_or_what>` is one or more users, channels or roles the rule is for.
|
||||||
is for.
|
|
||||||
"""
|
"""
|
||||||
|
for w in who_or_what:
|
||||||
await self._add_rule(
|
await self._add_rule(
|
||||||
rule=cast(bool, allow_or_deny),
|
rule=cast(bool, allow_or_deny),
|
||||||
cog_or_cmd=cog_or_command,
|
cog_or_cmd=cog_or_command,
|
||||||
model_id=who_or_what.id,
|
model_id=w.id,
|
||||||
guild_id=0,
|
guild_id=0,
|
||||||
)
|
)
|
||||||
await ctx.send(_("Rule added."))
|
await ctx.send(_("Rule added."))
|
||||||
@ -306,7 +308,7 @@ class Permissions(commands.Cog):
|
|||||||
ctx: commands.Context,
|
ctx: commands.Context,
|
||||||
allow_or_deny: RuleType,
|
allow_or_deny: RuleType,
|
||||||
cog_or_command: CogOrCommand,
|
cog_or_command: CogOrCommand,
|
||||||
who_or_what: GuildUniqueObjectFinder,
|
who_or_what: commands.Greedy[GuildUniqueObjectFinder],
|
||||||
):
|
):
|
||||||
"""Add a rule to a command in this server.
|
"""Add a rule to a command in this server.
|
||||||
|
|
||||||
@ -315,12 +317,13 @@ class Permissions(commands.Cog):
|
|||||||
`<cog_or_command>` is the cog or command to add the rule to.
|
`<cog_or_command>` is the cog or command to add the rule to.
|
||||||
This is case sensitive.
|
This is case sensitive.
|
||||||
|
|
||||||
`<who_or_what>` is the user, channel or role the rule is for.
|
`<who_or_what>` is one or more users, channels or roles the rule is for.
|
||||||
"""
|
"""
|
||||||
|
for w in who_or_what:
|
||||||
await self._add_rule(
|
await self._add_rule(
|
||||||
rule=cast(bool, allow_or_deny),
|
rule=cast(bool, allow_or_deny),
|
||||||
cog_or_cmd=cog_or_command,
|
cog_or_cmd=cog_or_command,
|
||||||
model_id=who_or_what.id,
|
model_id=w.id,
|
||||||
guild_id=ctx.guild.id,
|
guild_id=ctx.guild.id,
|
||||||
)
|
)
|
||||||
await ctx.send(_("Rule added."))
|
await ctx.send(_("Rule added."))
|
||||||
@ -331,19 +334,17 @@ class Permissions(commands.Cog):
|
|||||||
self,
|
self,
|
||||||
ctx: commands.Context,
|
ctx: commands.Context,
|
||||||
cog_or_command: CogOrCommand,
|
cog_or_command: CogOrCommand,
|
||||||
who_or_what: GlobalUniqueObjectFinder,
|
who_or_what: commands.Greedy[GlobalUniqueObjectFinder],
|
||||||
):
|
):
|
||||||
"""Remove a global rule from a command.
|
"""Remove a global rule from a command.
|
||||||
|
|
||||||
`<cog_or_command>` is the cog or command to remove the rule
|
`<cog_or_command>` is the cog or command to remove the rule
|
||||||
from. This is case sensitive.
|
from. This is case sensitive.
|
||||||
|
|
||||||
`<who_or_what>` is the user, channel, role or server the rule
|
`<who_or_what>` is one or more users, channels or roles the rule is for.
|
||||||
is for.
|
|
||||||
"""
|
"""
|
||||||
await self._remove_rule(
|
for w in who_or_what:
|
||||||
cog_or_cmd=cog_or_command, model_id=who_or_what.id, guild_id=GLOBAL
|
await self._remove_rule(cog_or_cmd=cog_or_command, model_id=w.id, guild_id=GLOBAL)
|
||||||
)
|
|
||||||
await ctx.send(_("Rule removed."))
|
await ctx.send(_("Rule removed."))
|
||||||
|
|
||||||
@commands.guild_only()
|
@commands.guild_only()
|
||||||
@ -353,18 +354,18 @@ class Permissions(commands.Cog):
|
|||||||
self,
|
self,
|
||||||
ctx: commands.Context,
|
ctx: commands.Context,
|
||||||
cog_or_command: CogOrCommand,
|
cog_or_command: CogOrCommand,
|
||||||
*,
|
who_or_what: commands.Greedy[GlobalUniqueObjectFinder],
|
||||||
who_or_what: GuildUniqueObjectFinder,
|
|
||||||
):
|
):
|
||||||
"""Remove a server rule from a command.
|
"""Remove a server rule from a command.
|
||||||
|
|
||||||
`<cog_or_command>` is the cog or command to remove the rule
|
`<cog_or_command>` is the cog or command to remove the rule
|
||||||
from. This is case sensitive.
|
from. This is case sensitive.
|
||||||
|
|
||||||
`<who_or_what>` is the user, channel or role the rule is for.
|
`<who_or_what>` is one or more users, channels or roles the rule is for.
|
||||||
"""
|
"""
|
||||||
|
for w in who_or_what:
|
||||||
await self._remove_rule(
|
await self._remove_rule(
|
||||||
cog_or_cmd=cog_or_command, model_id=who_or_what.id, guild_id=ctx.guild.id
|
cog_or_cmd=cog_or_command, model_id=w.id, guild_id=ctx.guild.id
|
||||||
)
|
)
|
||||||
await ctx.send(_("Rule removed."))
|
await ctx.send(_("Rule removed."))
|
||||||
|
|
||||||
|
|||||||
@ -45,6 +45,7 @@ class Reports(commands.Cog):
|
|||||||
self.bot = bot
|
self.bot = bot
|
||||||
self.config = Config.get_conf(self, 78631113035100160, force_registration=True)
|
self.config = Config.get_conf(self, 78631113035100160, force_registration=True)
|
||||||
self.config.register_guild(**self.default_guild_settings)
|
self.config.register_guild(**self.default_guild_settings)
|
||||||
|
self.config.init_custom("REPORT", 2)
|
||||||
self.config.register_custom("REPORT", **self.default_report)
|
self.config.register_custom("REPORT", **self.default_report)
|
||||||
self.antispam = {}
|
self.antispam = {}
|
||||||
self.user_cache = []
|
self.user_cache = []
|
||||||
|
|||||||
@ -74,7 +74,7 @@ What is the capital of Bulgaria?:
|
|||||||
What is the capital of Burkina Faso?:
|
What is the capital of Burkina Faso?:
|
||||||
- Ouagadougou
|
- Ouagadougou
|
||||||
What is the capital of Burundi?:
|
What is the capital of Burundi?:
|
||||||
- Bujumbura
|
- Gitega
|
||||||
What is the capital of Cabo Verde?:
|
What is the capital of Cabo Verde?:
|
||||||
- Praia
|
- Praia
|
||||||
What is the capital of Cambodia?:
|
What is the capital of Cambodia?:
|
||||||
@ -235,8 +235,6 @@ What is the capital of Lithuania?:
|
|||||||
- Vilnius
|
- Vilnius
|
||||||
What is the capital of Luxembourg?:
|
What is the capital of Luxembourg?:
|
||||||
- Luxembourg
|
- Luxembourg
|
||||||
What is the capital of Macedonia?:
|
|
||||||
- Skopje
|
|
||||||
What is the capital of Madagascar?:
|
What is the capital of Madagascar?:
|
||||||
- Antananarivo
|
- Antananarivo
|
||||||
What is the capital of Malawi?:
|
What is the capital of Malawi?:
|
||||||
@ -292,6 +290,8 @@ What is the capital of Nigeria?:
|
|||||||
What is the capital of North Korea?:
|
What is the capital of North Korea?:
|
||||||
- Pyongyang
|
- Pyongyang
|
||||||
- pyong yang
|
- pyong yang
|
||||||
|
What is the capital of North Macedonia?:
|
||||||
|
- Skopje
|
||||||
What is the capital of Norway?:
|
What is the capital of Norway?:
|
||||||
- Oslo
|
- Oslo
|
||||||
What is the capital of Oman?:
|
What is the capital of Oman?:
|
||||||
@ -338,7 +338,7 @@ What is the capital of Samoa?:
|
|||||||
What is the capital of San Marino?:
|
What is the capital of San Marino?:
|
||||||
- San Marino
|
- San Marino
|
||||||
- sanmarino
|
- sanmarino
|
||||||
What is the capital of Sao Tome and Principe?:
|
What is the capital of São Tomé and Príncipe?:
|
||||||
- São Tomé
|
- São Tomé
|
||||||
- sao tome
|
- sao tome
|
||||||
- saotome
|
- saotome
|
||||||
@ -371,6 +371,7 @@ What is the capital of Spain?:
|
|||||||
What is the capital of Sri Lanka?:
|
What is the capital of Sri Lanka?:
|
||||||
- Sri Jayawardenepura Kotte
|
- Sri Jayawardenepura Kotte
|
||||||
- srijawawardenpurakotte
|
- srijawawardenpurakotte
|
||||||
|
- Kotte
|
||||||
What is the capital of Sudan?:
|
What is the capital of Sudan?:
|
||||||
- Khartoum
|
- Khartoum
|
||||||
What is the capital of Suriname?:
|
What is the capital of Suriname?:
|
||||||
|
|||||||
@ -54,7 +54,7 @@ What country is represented by this flag? https://i.imgur.com/6UkAfly.png:
|
|||||||
What country is represented by this flag? https://i.imgur.com/7qSsp7Z.png:
|
What country is represented by this flag? https://i.imgur.com/7qSsp7Z.png:
|
||||||
- Samoa
|
- Samoa
|
||||||
What country is represented by this flag? https://i.imgur.com/7weskDc.png:
|
What country is represented by this flag? https://i.imgur.com/7weskDc.png:
|
||||||
- Macedonia
|
- North Macedonia
|
||||||
What country is represented by this flag? https://i.imgur.com/8F5aqVG.png:
|
What country is represented by this flag? https://i.imgur.com/8F5aqVG.png:
|
||||||
- Germany
|
- Germany
|
||||||
What country is represented by this flag? https://i.imgur.com/8LFhQVn.png:
|
What country is represented by this flag? https://i.imgur.com/8LFhQVn.png:
|
||||||
@ -62,7 +62,8 @@ What country is represented by this flag? https://i.imgur.com/8LFhQVn.png:
|
|||||||
- People's Republic of Korea
|
- People's Republic of Korea
|
||||||
What country is represented by this flag? https://i.imgur.com/8OzbswS.png:
|
What country is represented by this flag? https://i.imgur.com/8OzbswS.png:
|
||||||
- Armenia
|
- Armenia
|
||||||
What country is represented by this flag? https://i.imgur.com/AKvyrB8.png:
|
What country is represented by this flag? https://i.imgur.com/T91TSYR.png:
|
||||||
|
- São Tomé and Príncipe
|
||||||
- Sao Tome and Principe
|
- Sao Tome and Principe
|
||||||
- Sao Tome
|
- Sao Tome
|
||||||
What country is represented by this flag? https://i.imgur.com/AMccj7Q.png:
|
What country is represented by this flag? https://i.imgur.com/AMccj7Q.png:
|
||||||
|
|||||||
@ -15,7 +15,9 @@ What country is highlighted on this map? https://i.imgur.com/19AMqVD.png:
|
|||||||
What country is highlighted on this map? https://i.imgur.com/1GT6807.png:
|
What country is highlighted on this map? https://i.imgur.com/1GT6807.png:
|
||||||
- Guinea
|
- Guinea
|
||||||
What country is highlighted on this map? https://i.imgur.com/1MHPIUv.png:
|
What country is highlighted on this map? https://i.imgur.com/1MHPIUv.png:
|
||||||
|
- São Tomé and Príncipe
|
||||||
- Sao Tome and Principe
|
- Sao Tome and Principe
|
||||||
|
- Sao Tome
|
||||||
What country is highlighted on this map? https://i.imgur.com/1xVJiLb.png:
|
What country is highlighted on this map? https://i.imgur.com/1xVJiLb.png:
|
||||||
- Nepal
|
- Nepal
|
||||||
What country is highlighted on this map? https://i.imgur.com/21wXDZ6.png:
|
What country is highlighted on this map? https://i.imgur.com/21wXDZ6.png:
|
||||||
@ -124,9 +126,7 @@ What country is highlighted on this map? https://i.imgur.com/E6lQFbg.png:
|
|||||||
What country is highlighted on this map? https://i.imgur.com/EE9jicV.png:
|
What country is highlighted on this map? https://i.imgur.com/EE9jicV.png:
|
||||||
- Tonga
|
- Tonga
|
||||||
What country is highlighted on this map? https://i.imgur.com/EQSChbH.png:
|
What country is highlighted on this map? https://i.imgur.com/EQSChbH.png:
|
||||||
- Macedonia
|
- North Macedonia
|
||||||
- FYROM
|
|
||||||
- the former Yugoslav Republic of Macedonia
|
|
||||||
What country is highlighted on this map? https://i.imgur.com/EdETzhx.png:
|
What country is highlighted on this map? https://i.imgur.com/EdETzhx.png:
|
||||||
- Paraguay
|
- Paraguay
|
||||||
What country is highlighted on this map? https://i.imgur.com/FHCjY5w.png:
|
What country is highlighted on this map? https://i.imgur.com/FHCjY5w.png:
|
||||||
@ -342,6 +342,7 @@ What country is highlighted on this map? https://i.imgur.com/kBdjnEv.png:
|
|||||||
What country is highlighted on this map? https://i.imgur.com/kHtsSx9.png:
|
What country is highlighted on this map? https://i.imgur.com/kHtsSx9.png:
|
||||||
- Cote d'Ivoire
|
- Cote d'Ivoire
|
||||||
- Ivory Coast
|
- Ivory Coast
|
||||||
|
- Cote Divoire
|
||||||
What country is highlighted on this map? https://i.imgur.com/lHMAntb.png:
|
What country is highlighted on this map? https://i.imgur.com/lHMAntb.png:
|
||||||
- Pakistan
|
- Pakistan
|
||||||
What country is highlighted on this map? https://i.imgur.com/lIIYtBD.png:
|
What country is highlighted on this map? https://i.imgur.com/lIIYtBD.png:
|
||||||
|
|||||||
@ -18,6 +18,8 @@ from .help_formatter import Help, help as help_
|
|||||||
from .rpc import RPCMixin
|
from .rpc import RPCMixin
|
||||||
from .utils import common_filters
|
from .utils import common_filters
|
||||||
|
|
||||||
|
CUSTOM_GROUPS = "CUSTOM_GROUPS"
|
||||||
|
|
||||||
|
|
||||||
def _is_submodule(parent, child):
|
def _is_submodule(parent, child):
|
||||||
return parent == child or child.startswith(parent + ".")
|
return parent == child or child.startswith(parent + ".")
|
||||||
@ -74,6 +76,9 @@ class RedBase(commands.GroupMixin, commands.bot.BotBase, RPCMixin):
|
|||||||
|
|
||||||
self.db.register_user(embeds=None)
|
self.db.register_user(embeds=None)
|
||||||
|
|
||||||
|
self.db.init_custom(CUSTOM_GROUPS, 2)
|
||||||
|
self.db.register_custom(CUSTOM_GROUPS)
|
||||||
|
|
||||||
async def prefix_manager(bot, message):
|
async def prefix_manager(bot, message):
|
||||||
if not cli_flags.prefix:
|
if not cli_flags.prefix:
|
||||||
global_prefix = await bot.db.prefix()
|
global_prefix = await bot.db.prefix()
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import argparse
|
import argparse
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import logging
|
||||||
|
|
||||||
|
|
||||||
def confirm(m=""):
|
def confirm(m=""):
|
||||||
@ -97,7 +98,14 @@ def parse_cli_flags(args):
|
|||||||
"login. This is useful for testing the boot "
|
"login. This is useful for testing the boot "
|
||||||
"process.",
|
"process.",
|
||||||
)
|
)
|
||||||
parser.add_argument("--debug", action="store_true", help="Sets the loggers level as debug")
|
parser.add_argument(
|
||||||
|
"--debug",
|
||||||
|
action="store_const",
|
||||||
|
dest="logging_level",
|
||||||
|
const=logging.DEBUG,
|
||||||
|
default=logging.INFO,
|
||||||
|
help="Sets the loggers level as debug",
|
||||||
|
)
|
||||||
parser.add_argument("--dev", action="store_true", help="Enables developer mode")
|
parser.add_argument("--dev", action="store_true", help="Enables developer mode")
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--mentionable",
|
"--mentionable",
|
||||||
|
|||||||
@ -2,19 +2,33 @@ import logging
|
|||||||
import collections
|
import collections
|
||||||
from copy import deepcopy
|
from copy import deepcopy
|
||||||
from typing import Any, Union, Tuple, Dict, Awaitable, AsyncContextManager, TypeVar, TYPE_CHECKING
|
from typing import Any, Union, Tuple, Dict, Awaitable, AsyncContextManager, TypeVar, TYPE_CHECKING
|
||||||
|
import weakref
|
||||||
|
|
||||||
import discord
|
import discord
|
||||||
|
|
||||||
from .data_manager import cog_data_path, core_data_path
|
from .data_manager import cog_data_path, core_data_path
|
||||||
from .drivers import get_driver
|
from .drivers import get_driver, IdentifierData
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from .drivers.red_base import BaseDriver
|
from .drivers.red_base import BaseDriver
|
||||||
|
|
||||||
|
__all__ = ["Config", "get_latest_confs"]
|
||||||
|
|
||||||
log = logging.getLogger("red.config")
|
log = logging.getLogger("red.config")
|
||||||
|
|
||||||
_T = TypeVar("_T")
|
_T = TypeVar("_T")
|
||||||
|
|
||||||
|
_config_cache = weakref.WeakValueDictionary()
|
||||||
|
_retrieved = weakref.WeakSet()
|
||||||
|
|
||||||
|
|
||||||
|
def get_latest_confs() -> Tuple["Config"]:
|
||||||
|
global _retrieved
|
||||||
|
ret = set(_config_cache.values()) - set(_retrieved)
|
||||||
|
_retrieved |= ret
|
||||||
|
# noinspection PyTypeChecker
|
||||||
|
return tuple(ret)
|
||||||
|
|
||||||
|
|
||||||
class _ValueCtxManager(Awaitable[_T], AsyncContextManager[_T]):
|
class _ValueCtxManager(Awaitable[_T], AsyncContextManager[_T]):
|
||||||
"""Context manager implementation of config values.
|
"""Context manager implementation of config values.
|
||||||
@ -72,14 +86,14 @@ class Value:
|
|||||||
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, identifiers: Tuple[str], default_value, driver):
|
def __init__(self, identifier_data: IdentifierData, default_value, driver):
|
||||||
self.identifiers = identifiers
|
self.identifier_data = identifier_data
|
||||||
self.default = default_value
|
self.default = default_value
|
||||||
self.driver = driver
|
self.driver = driver
|
||||||
|
|
||||||
async def _get(self, default=...):
|
async def _get(self, default=...):
|
||||||
try:
|
try:
|
||||||
ret = await self.driver.get(*self.identifiers)
|
ret = await self.driver.get(self.identifier_data)
|
||||||
except KeyError:
|
except KeyError:
|
||||||
return default if default is not ... else self.default
|
return default if default is not ... else self.default
|
||||||
return ret
|
return ret
|
||||||
@ -150,13 +164,13 @@ class Value:
|
|||||||
"""
|
"""
|
||||||
if isinstance(value, dict):
|
if isinstance(value, dict):
|
||||||
value = _str_key_dict(value)
|
value = _str_key_dict(value)
|
||||||
await self.driver.set(*self.identifiers, value=value)
|
await self.driver.set(self.identifier_data, value=value)
|
||||||
|
|
||||||
async def clear(self):
|
async def clear(self):
|
||||||
"""
|
"""
|
||||||
Clears the value from record for the data element pointed to by `identifiers`.
|
Clears the value from record for the data element pointed to by `identifiers`.
|
||||||
"""
|
"""
|
||||||
await self.driver.clear(*self.identifiers)
|
await self.driver.clear(self.identifier_data)
|
||||||
|
|
||||||
|
|
||||||
class Group(Value):
|
class Group(Value):
|
||||||
@ -178,13 +192,17 @@ class Group(Value):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self, identifiers: Tuple[str], defaults: dict, driver, force_registration: bool = False
|
self,
|
||||||
|
identifier_data: IdentifierData,
|
||||||
|
defaults: dict,
|
||||||
|
driver,
|
||||||
|
force_registration: bool = False,
|
||||||
):
|
):
|
||||||
self._defaults = defaults
|
self._defaults = defaults
|
||||||
self.force_registration = force_registration
|
self.force_registration = force_registration
|
||||||
self.driver = driver
|
self.driver = driver
|
||||||
|
|
||||||
super().__init__(identifiers, {}, self.driver)
|
super().__init__(identifier_data, {}, self.driver)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def defaults(self):
|
def defaults(self):
|
||||||
@ -225,22 +243,24 @@ class Group(Value):
|
|||||||
"""
|
"""
|
||||||
is_group = self.is_group(item)
|
is_group = self.is_group(item)
|
||||||
is_value = not is_group and self.is_value(item)
|
is_value = not is_group and self.is_value(item)
|
||||||
new_identifiers = self.identifiers + (item,)
|
new_identifiers = self.identifier_data.add_identifier(item)
|
||||||
if is_group:
|
if is_group:
|
||||||
return Group(
|
return Group(
|
||||||
identifiers=new_identifiers,
|
identifier_data=new_identifiers,
|
||||||
defaults=self._defaults[item],
|
defaults=self._defaults[item],
|
||||||
driver=self.driver,
|
driver=self.driver,
|
||||||
force_registration=self.force_registration,
|
force_registration=self.force_registration,
|
||||||
)
|
)
|
||||||
elif is_value:
|
elif is_value:
|
||||||
return Value(
|
return Value(
|
||||||
identifiers=new_identifiers, default_value=self._defaults[item], driver=self.driver
|
identifier_data=new_identifiers,
|
||||||
|
default_value=self._defaults[item],
|
||||||
|
driver=self.driver,
|
||||||
)
|
)
|
||||||
elif self.force_registration:
|
elif self.force_registration:
|
||||||
raise AttributeError("'{}' is not a valid registered Group or value.".format(item))
|
raise AttributeError("'{}' is not a valid registered Group or value.".format(item))
|
||||||
else:
|
else:
|
||||||
return Value(identifiers=new_identifiers, default_value=None, driver=self.driver)
|
return Value(identifier_data=new_identifiers, default_value=None, driver=self.driver)
|
||||||
|
|
||||||
async def clear_raw(self, *nested_path: Any):
|
async def clear_raw(self, *nested_path: Any):
|
||||||
"""
|
"""
|
||||||
@ -262,8 +282,9 @@ class Group(Value):
|
|||||||
Multiple arguments that mirror the arguments passed in for nested
|
Multiple arguments that mirror the arguments passed in for nested
|
||||||
dict access. These are casted to `str` for you.
|
dict access. These are casted to `str` for you.
|
||||||
"""
|
"""
|
||||||
path = [str(p) for p in nested_path]
|
path = tuple(str(p) for p in nested_path)
|
||||||
await self.driver.clear(*self.identifiers, *path)
|
identifier_data = self.identifier_data.add_identifier(*path)
|
||||||
|
await self.driver.clear(identifier_data)
|
||||||
|
|
||||||
def is_group(self, item: Any) -> bool:
|
def is_group(self, item: Any) -> bool:
|
||||||
"""A helper method for `__getattr__`. Most developers will have no need
|
"""A helper method for `__getattr__`. Most developers will have no need
|
||||||
@ -368,7 +389,7 @@ class Group(Value):
|
|||||||
If the value does not exist yet in Config's internal storage.
|
If the value does not exist yet in Config's internal storage.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
path = [str(p) for p in nested_path]
|
path = tuple(str(p) for p in nested_path)
|
||||||
|
|
||||||
if default is ...:
|
if default is ...:
|
||||||
poss_default = self.defaults
|
poss_default = self.defaults
|
||||||
@ -380,8 +401,9 @@ class Group(Value):
|
|||||||
else:
|
else:
|
||||||
default = poss_default
|
default = poss_default
|
||||||
|
|
||||||
|
identifier_data = self.identifier_data.add_identifier(*path)
|
||||||
try:
|
try:
|
||||||
raw = await self.driver.get(*self.identifiers, *path)
|
raw = await self.driver.get(identifier_data)
|
||||||
except KeyError:
|
except KeyError:
|
||||||
if default is not ...:
|
if default is not ...:
|
||||||
return default
|
return default
|
||||||
@ -456,10 +478,11 @@ class Group(Value):
|
|||||||
value
|
value
|
||||||
The value to store.
|
The value to store.
|
||||||
"""
|
"""
|
||||||
path = [str(p) for p in nested_path]
|
path = tuple(str(p) for p in nested_path)
|
||||||
|
identifier_data = self.identifier_data.add_identifier(*path)
|
||||||
if isinstance(value, dict):
|
if isinstance(value, dict):
|
||||||
value = _str_key_dict(value)
|
value = _str_key_dict(value)
|
||||||
await self.driver.set(*self.identifiers, *path, value=value)
|
await self.driver.set(identifier_data, value=value)
|
||||||
|
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
@ -505,6 +528,19 @@ class Config:
|
|||||||
USER = "USER"
|
USER = "USER"
|
||||||
MEMBER = "MEMBER"
|
MEMBER = "MEMBER"
|
||||||
|
|
||||||
|
def __new__(cls, cog_name, unique_identifier, *args, **kwargs):
|
||||||
|
key = (cog_name, unique_identifier)
|
||||||
|
|
||||||
|
if key[0] is None:
|
||||||
|
raise ValueError("You must provide either the cog instance or a cog name.")
|
||||||
|
|
||||||
|
if key in _config_cache:
|
||||||
|
conf = _config_cache[key]
|
||||||
|
else:
|
||||||
|
conf = object.__new__(cls)
|
||||||
|
_config_cache[key] = conf
|
||||||
|
return conf
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
cog_name: str,
|
cog_name: str,
|
||||||
@ -520,6 +556,8 @@ class Config:
|
|||||||
self.force_registration = force_registration
|
self.force_registration = force_registration
|
||||||
self._defaults = defaults or {}
|
self._defaults = defaults or {}
|
||||||
|
|
||||||
|
self.custom_groups = {}
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def defaults(self):
|
def defaults(self):
|
||||||
return deepcopy(self._defaults)
|
return deepcopy(self._defaults)
|
||||||
@ -569,13 +607,9 @@ class Config:
|
|||||||
# We have to import this here otherwise we have a circular dependency
|
# We have to import this here otherwise we have a circular dependency
|
||||||
from .data_manager import basic_config
|
from .data_manager import basic_config
|
||||||
|
|
||||||
log.debug("Basic config: \n\n{}".format(basic_config))
|
|
||||||
|
|
||||||
driver_name = basic_config.get("STORAGE_TYPE", "JSON")
|
driver_name = basic_config.get("STORAGE_TYPE", "JSON")
|
||||||
driver_details = basic_config.get("STORAGE_DETAILS", {})
|
driver_details = basic_config.get("STORAGE_DETAILS", {})
|
||||||
|
|
||||||
log.debug("Using driver: '{}'".format(driver_name))
|
|
||||||
|
|
||||||
driver = get_driver(
|
driver = get_driver(
|
||||||
driver_name, cog_name, uuid, data_path_override=cog_path_override, **driver_details
|
driver_name, cog_name, uuid, data_path_override=cog_path_override, **driver_details
|
||||||
)
|
)
|
||||||
@ -783,11 +817,36 @@ class Config:
|
|||||||
"""
|
"""
|
||||||
self._register_default(group_identifier, **kwargs)
|
self._register_default(group_identifier, **kwargs)
|
||||||
|
|
||||||
def _get_base_group(self, key: str, *identifiers: str) -> Group:
|
def init_custom(self, group_identifier: str, identifier_count: int):
|
||||||
|
"""
|
||||||
|
Initializes a custom group for usage. This method must be called first!
|
||||||
|
"""
|
||||||
|
if group_identifier in self.custom_groups:
|
||||||
|
raise ValueError(f"Group identifier already registered: {group_identifier}")
|
||||||
|
|
||||||
|
self.custom_groups[group_identifier] = identifier_count
|
||||||
|
|
||||||
|
def _get_base_group(self, category: str, *primary_keys: str) -> Group:
|
||||||
|
is_custom = category not in (
|
||||||
|
self.GLOBAL,
|
||||||
|
self.GUILD,
|
||||||
|
self.USER,
|
||||||
|
self.MEMBER,
|
||||||
|
self.ROLE,
|
||||||
|
self.CHANNEL,
|
||||||
|
)
|
||||||
# noinspection PyTypeChecker
|
# noinspection PyTypeChecker
|
||||||
|
identifier_data = IdentifierData(
|
||||||
|
uuid=self.unique_identifier,
|
||||||
|
category=category,
|
||||||
|
primary_key=primary_keys,
|
||||||
|
identifiers=(),
|
||||||
|
custom_group_data=self.custom_groups,
|
||||||
|
is_custom=is_custom,
|
||||||
|
)
|
||||||
return Group(
|
return Group(
|
||||||
identifiers=(key, *identifiers),
|
identifier_data=identifier_data,
|
||||||
defaults=self.defaults.get(key, {}),
|
defaults=self.defaults.get(category, {}),
|
||||||
driver=self.driver,
|
driver=self.driver,
|
||||||
force_registration=self.force_registration,
|
force_registration=self.force_registration,
|
||||||
)
|
)
|
||||||
@ -891,6 +950,8 @@ class Config:
|
|||||||
The custom group's Group object.
|
The custom group's Group object.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
if group_identifier not in self.custom_groups:
|
||||||
|
raise ValueError(f"Group identifier not initialized: {group_identifier}")
|
||||||
return self._get_base_group(str(group_identifier), *map(str, identifiers))
|
return self._get_base_group(str(group_identifier), *map(str, identifiers))
|
||||||
|
|
||||||
async def _all_from_scope(self, scope: str) -> Dict[int, Dict[Any, Any]]:
|
async def _all_from_scope(self, scope: str) -> Dict[int, Dict[Any, Any]]:
|
||||||
@ -908,7 +969,7 @@ class Config:
|
|||||||
ret = {}
|
ret = {}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
dict_ = await self.driver.get(*group.identifiers)
|
dict_ = await self.driver.get(group.identifier_data)
|
||||||
except KeyError:
|
except KeyError:
|
||||||
pass
|
pass
|
||||||
else:
|
else:
|
||||||
@ -1025,7 +1086,7 @@ class Config:
|
|||||||
if guild is None:
|
if guild is None:
|
||||||
group = self._get_base_group(self.MEMBER)
|
group = self._get_base_group(self.MEMBER)
|
||||||
try:
|
try:
|
||||||
dict_ = await self.driver.get(*group.identifiers)
|
dict_ = await self.driver.get(group.identifier_data)
|
||||||
except KeyError:
|
except KeyError:
|
||||||
pass
|
pass
|
||||||
else:
|
else:
|
||||||
@ -1034,7 +1095,7 @@ class Config:
|
|||||||
else:
|
else:
|
||||||
group = self._get_base_group(self.MEMBER, str(guild.id))
|
group = self._get_base_group(self.MEMBER, str(guild.id))
|
||||||
try:
|
try:
|
||||||
guild_data = await self.driver.get(*group.identifiers)
|
guild_data = await self.driver.get(group.identifier_data)
|
||||||
except KeyError:
|
except KeyError:
|
||||||
pass
|
pass
|
||||||
else:
|
else:
|
||||||
@ -1061,7 +1122,10 @@ class Config:
|
|||||||
"""
|
"""
|
||||||
if not scopes:
|
if not scopes:
|
||||||
# noinspection PyTypeChecker
|
# noinspection PyTypeChecker
|
||||||
group = Group(identifiers=(), defaults={}, driver=self.driver)
|
identifier_data = IdentifierData(
|
||||||
|
self.unique_identifier, "", (), (), self.custom_groups
|
||||||
|
)
|
||||||
|
group = Group(identifier_data, defaults={}, driver=self.driver)
|
||||||
else:
|
else:
|
||||||
group = self._get_base_group(*scopes)
|
group = self._get_base_group(*scopes)
|
||||||
await group.clear()
|
await group.clear()
|
||||||
|
|||||||
@ -1491,8 +1491,8 @@ class Core(commands.Cog, CoreLogic):
|
|||||||
"""
|
"""
|
||||||
user = isinstance(user_or_role, discord.Member)
|
user = isinstance(user_or_role, discord.Member)
|
||||||
async with ctx.bot.db.guild(ctx.guild).whitelist() as curr_list:
|
async with ctx.bot.db.guild(ctx.guild).whitelist() as curr_list:
|
||||||
if obj.id not in curr_list:
|
if user_or_role.id not in curr_list:
|
||||||
curr_list.append(obj.id)
|
curr_list.append(user_or_role.id)
|
||||||
|
|
||||||
if user:
|
if user:
|
||||||
await ctx.send(_("User added to whitelist."))
|
await ctx.send(_("User added to whitelist."))
|
||||||
@ -1524,9 +1524,9 @@ class Core(commands.Cog, CoreLogic):
|
|||||||
|
|
||||||
removed = False
|
removed = False
|
||||||
async with ctx.bot.db.guild(ctx.guild).whitelist() as curr_list:
|
async with ctx.bot.db.guild(ctx.guild).whitelist() as curr_list:
|
||||||
if obj.id in curr_list:
|
if user_or_role.id in curr_list:
|
||||||
removed = True
|
removed = True
|
||||||
curr_list.remove(obj.id)
|
curr_list.remove(user_or_role.id)
|
||||||
|
|
||||||
if removed:
|
if removed:
|
||||||
if user:
|
if user:
|
||||||
@ -1570,8 +1570,8 @@ class Core(commands.Cog, CoreLogic):
|
|||||||
return
|
return
|
||||||
|
|
||||||
async with ctx.bot.db.guild(ctx.guild).blacklist() as curr_list:
|
async with ctx.bot.db.guild(ctx.guild).blacklist() as curr_list:
|
||||||
if obj.id not in curr_list:
|
if user_or_role.id not in curr_list:
|
||||||
curr_list.append(obj.id)
|
curr_list.append(user_or_role.id)
|
||||||
|
|
||||||
if user:
|
if user:
|
||||||
await ctx.send(_("User added to blacklist."))
|
await ctx.send(_("User added to blacklist."))
|
||||||
@ -1603,9 +1603,9 @@ class Core(commands.Cog, CoreLogic):
|
|||||||
user = isinstance(user_or_role, discord.Member)
|
user = isinstance(user_or_role, discord.Member)
|
||||||
|
|
||||||
async with ctx.bot.db.guild(ctx.guild).blacklist() as curr_list:
|
async with ctx.bot.db.guild(ctx.guild).blacklist() as curr_list:
|
||||||
if obj.id in curr_list:
|
if user_or_role.id in curr_list:
|
||||||
removed = True
|
removed = True
|
||||||
curr_list.remove(obj.id)
|
curr_list.remove(user_or_role.id)
|
||||||
|
|
||||||
if removed:
|
if removed:
|
||||||
if user:
|
if user:
|
||||||
|
|||||||
@ -113,6 +113,7 @@ def cog_data_path(cog_instance=None, raw_name: str = None) -> Path:
|
|||||||
----------
|
----------
|
||||||
cog_instance
|
cog_instance
|
||||||
The instance of the cog you wish to get a data path for.
|
The instance of the cog you wish to get a data path for.
|
||||||
|
If calling from a command or method of your cog, this should be ``self``.
|
||||||
raw_name : str
|
raw_name : str
|
||||||
The name of the cog to get a data path for.
|
The name of the cog to get a data path for.
|
||||||
|
|
||||||
|
|||||||
@ -1,4 +1,6 @@
|
|||||||
__all__ = ["get_driver"]
|
from .red_base import IdentifierData
|
||||||
|
|
||||||
|
__all__ = ["get_driver", "IdentifierData"]
|
||||||
|
|
||||||
|
|
||||||
def get_driver(type, *args, **kwargs):
|
def get_driver(type, *args, **kwargs):
|
||||||
@ -26,8 +28,13 @@ def get_driver(type, *args, **kwargs):
|
|||||||
from .red_json import JSON
|
from .red_json import JSON
|
||||||
|
|
||||||
return JSON(*args, **kwargs)
|
return JSON(*args, **kwargs)
|
||||||
elif type == "MongoDB":
|
elif type == "MongoDBV2":
|
||||||
from .red_mongo import Mongo
|
from .red_mongo import Mongo
|
||||||
|
|
||||||
return Mongo(*args, **kwargs)
|
return Mongo(*args, **kwargs)
|
||||||
|
elif type == "Mongo":
|
||||||
|
raise RuntimeError(
|
||||||
|
"Please convert to JSON first to continue using the bot."
|
||||||
|
" This is a required conversion prior to using the new Mongo driver."
|
||||||
|
)
|
||||||
raise RuntimeError("Invalid driver type: '{}'".format(type))
|
raise RuntimeError("Invalid driver type: '{}'".format(type))
|
||||||
|
|||||||
@ -1,4 +1,70 @@
|
|||||||
__all__ = ["BaseDriver"]
|
from typing import Tuple
|
||||||
|
|
||||||
|
__all__ = ["BaseDriver", "IdentifierData"]
|
||||||
|
|
||||||
|
|
||||||
|
class IdentifierData:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
uuid: str,
|
||||||
|
category: str,
|
||||||
|
primary_key: Tuple[str],
|
||||||
|
identifiers: Tuple[str],
|
||||||
|
custom_group_data: dict,
|
||||||
|
is_custom: bool = False,
|
||||||
|
):
|
||||||
|
self._uuid = uuid
|
||||||
|
self._category = category
|
||||||
|
self._primary_key = primary_key
|
||||||
|
self._identifiers = identifiers
|
||||||
|
self.custom_group_data = custom_group_data
|
||||||
|
self._is_custom = is_custom
|
||||||
|
|
||||||
|
@property
|
||||||
|
def uuid(self):
|
||||||
|
return self._uuid
|
||||||
|
|
||||||
|
@property
|
||||||
|
def category(self):
|
||||||
|
return self._category
|
||||||
|
|
||||||
|
@property
|
||||||
|
def primary_key(self):
|
||||||
|
return self._primary_key
|
||||||
|
|
||||||
|
@property
|
||||||
|
def identifiers(self):
|
||||||
|
return self._identifiers
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_custom(self):
|
||||||
|
return self._is_custom
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return (
|
||||||
|
f"<IdentifierData uuid={self.uuid} category={self.category} primary_key={self.primary_key}"
|
||||||
|
f" identifiers={self.identifiers}>"
|
||||||
|
)
|
||||||
|
|
||||||
|
def add_identifier(self, *identifier: str) -> "IdentifierData":
|
||||||
|
if not all(isinstance(i, str) for i in identifier):
|
||||||
|
raise ValueError("Identifiers must be strings.")
|
||||||
|
|
||||||
|
return IdentifierData(
|
||||||
|
self.uuid,
|
||||||
|
self.category,
|
||||||
|
self.primary_key,
|
||||||
|
self.identifiers + identifier,
|
||||||
|
self.custom_group_data,
|
||||||
|
is_custom=self.is_custom,
|
||||||
|
)
|
||||||
|
|
||||||
|
def to_tuple(self):
|
||||||
|
return tuple(
|
||||||
|
item
|
||||||
|
for item in (self.uuid, self.category, *self.primary_key, *self.identifiers)
|
||||||
|
if len(item) > 0
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class BaseDriver:
|
class BaseDriver:
|
||||||
@ -6,14 +72,13 @@ class BaseDriver:
|
|||||||
self.cog_name = cog_name
|
self.cog_name = cog_name
|
||||||
self.unique_cog_identifier = identifier
|
self.unique_cog_identifier = identifier
|
||||||
|
|
||||||
async def get(self, *identifiers: str):
|
async def get(self, identifier_data: IdentifierData):
|
||||||
"""
|
"""
|
||||||
Finds the value indicate by the given identifiers.
|
Finds the value indicate by the given identifiers.
|
||||||
|
|
||||||
Parameters
|
Parameters
|
||||||
----------
|
----------
|
||||||
identifiers
|
identifier_data
|
||||||
A list of identifiers that correspond to nested dict accesses.
|
|
||||||
|
|
||||||
Returns
|
Returns
|
||||||
-------
|
-------
|
||||||
@ -33,20 +98,19 @@ class BaseDriver:
|
|||||||
"""
|
"""
|
||||||
raise NotImplementedError
|
raise NotImplementedError
|
||||||
|
|
||||||
async def set(self, *identifiers: str, value=None):
|
async def set(self, identifier_data: IdentifierData, value=None):
|
||||||
"""
|
"""
|
||||||
Sets the value of the key indicated by the given identifiers.
|
Sets the value of the key indicated by the given identifiers.
|
||||||
|
|
||||||
Parameters
|
Parameters
|
||||||
----------
|
----------
|
||||||
identifiers
|
identifier_data
|
||||||
A list of identifiers that correspond to nested dict accesses.
|
|
||||||
value
|
value
|
||||||
Any JSON serializable python object.
|
Any JSON serializable python object.
|
||||||
"""
|
"""
|
||||||
raise NotImplementedError
|
raise NotImplementedError
|
||||||
|
|
||||||
async def clear(self, *identifiers: str):
|
async def clear(self, identifier_data: IdentifierData):
|
||||||
"""
|
"""
|
||||||
Clears out the value specified by the given identifiers.
|
Clears out the value specified by the given identifiers.
|
||||||
|
|
||||||
@ -54,7 +118,6 @@ class BaseDriver:
|
|||||||
|
|
||||||
Parameters
|
Parameters
|
||||||
----------
|
----------
|
||||||
identifiers
|
identifier_data
|
||||||
A list of identifiers that correspond to nested dict accesses.
|
|
||||||
"""
|
"""
|
||||||
raise NotImplementedError
|
raise NotImplementedError
|
||||||
|
|||||||
@ -6,7 +6,7 @@ import logging
|
|||||||
|
|
||||||
from ..json_io import JsonIO
|
from ..json_io import JsonIO
|
||||||
|
|
||||||
from .red_base import BaseDriver
|
from .red_base import BaseDriver, IdentifierData
|
||||||
|
|
||||||
__all__ = ["JSON"]
|
__all__ = ["JSON"]
|
||||||
|
|
||||||
@ -93,16 +93,16 @@ class JSON(BaseDriver):
|
|||||||
self.data = {}
|
self.data = {}
|
||||||
self.jsonIO._save_json(self.data)
|
self.jsonIO._save_json(self.data)
|
||||||
|
|
||||||
async def get(self, *identifiers: Tuple[str]):
|
async def get(self, identifier_data: IdentifierData):
|
||||||
partial = self.data
|
partial = self.data
|
||||||
full_identifiers = (self.unique_cog_identifier, *identifiers)
|
full_identifiers = identifier_data.to_tuple()
|
||||||
for i in full_identifiers:
|
for i in full_identifiers:
|
||||||
partial = partial[i]
|
partial = partial[i]
|
||||||
return copy.deepcopy(partial)
|
return copy.deepcopy(partial)
|
||||||
|
|
||||||
async def set(self, *identifiers: str, value=None):
|
async def set(self, identifier_data: IdentifierData, value=None):
|
||||||
partial = self.data
|
partial = self.data
|
||||||
full_identifiers = (self.unique_cog_identifier, *identifiers)
|
full_identifiers = identifier_data.to_tuple()
|
||||||
for i in full_identifiers[:-1]:
|
for i in full_identifiers[:-1]:
|
||||||
if i not in partial:
|
if i not in partial:
|
||||||
partial[i] = {}
|
partial[i] = {}
|
||||||
@ -111,9 +111,9 @@ class JSON(BaseDriver):
|
|||||||
partial[full_identifiers[-1]] = copy.deepcopy(value)
|
partial[full_identifiers[-1]] = copy.deepcopy(value)
|
||||||
await self.jsonIO._threadsafe_save_json(self.data)
|
await self.jsonIO._threadsafe_save_json(self.data)
|
||||||
|
|
||||||
async def clear(self, *identifiers: str):
|
async def clear(self, identifier_data: IdentifierData):
|
||||||
partial = self.data
|
partial = self.data
|
||||||
full_identifiers = (self.unique_cog_identifier, *identifiers)
|
full_identifiers = identifier_data.to_tuple()
|
||||||
try:
|
try:
|
||||||
for i in full_identifiers[:-1]:
|
for i in full_identifiers[:-1]:
|
||||||
partial = partial[i]
|
partial = partial[i]
|
||||||
|
|||||||
@ -1,11 +1,12 @@
|
|||||||
import re
|
import re
|
||||||
from typing import Match, Pattern
|
from typing import Match, Pattern, Tuple
|
||||||
from urllib.parse import quote_plus
|
from urllib.parse import quote_plus
|
||||||
|
|
||||||
import motor.core
|
import motor.core
|
||||||
import motor.motor_asyncio
|
import motor.motor_asyncio
|
||||||
|
from motor.motor_asyncio import AsyncIOMotorCursor
|
||||||
|
|
||||||
from .red_base import BaseDriver
|
from .red_base import BaseDriver, IdentifierData
|
||||||
|
|
||||||
__all__ = ["Mongo"]
|
__all__ = ["Mongo"]
|
||||||
|
|
||||||
@ -64,66 +65,119 @@ class Mongo(BaseDriver):
|
|||||||
"""
|
"""
|
||||||
return _conn.get_database()
|
return _conn.get_database()
|
||||||
|
|
||||||
def get_collection(self) -> motor.core.Collection:
|
def get_collection(self, category: str) -> motor.core.Collection:
|
||||||
"""
|
"""
|
||||||
Gets a specified collection within the PyMongo database for this cog.
|
Gets a specified collection within the PyMongo database for this cog.
|
||||||
|
|
||||||
Unless you are doing custom stuff ``collection_name`` should be one of the class
|
Unless you are doing custom stuff ``category`` should be one of the class
|
||||||
attributes of :py:class:`core.config.Config`.
|
attributes of :py:class:`core.config.Config`.
|
||||||
|
|
||||||
:param str collection_name:
|
:param str category:
|
||||||
:return:
|
:return:
|
||||||
PyMongo collection object.
|
PyMongo collection object.
|
||||||
"""
|
"""
|
||||||
return self.db[self.cog_name]
|
return self.db[self.cog_name][category]
|
||||||
|
|
||||||
@staticmethod
|
def get_primary_key(self, identifier_data: IdentifierData) -> Tuple[str]:
|
||||||
def _parse_identifiers(identifiers):
|
# noinspection PyTypeChecker
|
||||||
uuid, identifiers = identifiers[0], identifiers[1:]
|
return identifier_data.primary_key
|
||||||
return uuid, identifiers
|
|
||||||
|
|
||||||
async def get(self, *identifiers: str):
|
async def rebuild_dataset(self, identifier_data: IdentifierData, cursor: AsyncIOMotorCursor):
|
||||||
mongo_collection = self.get_collection()
|
ret = {}
|
||||||
|
async for doc in cursor:
|
||||||
|
pkeys = doc["_id"]["RED_primary_key"]
|
||||||
|
del doc["_id"]
|
||||||
|
if len(pkeys) == 0:
|
||||||
|
# Global data
|
||||||
|
ret.update(**doc)
|
||||||
|
elif len(pkeys) > 0:
|
||||||
|
# All other data
|
||||||
|
partial = ret
|
||||||
|
for key in pkeys[:-1]:
|
||||||
|
if key in identifier_data.primary_key:
|
||||||
|
continue
|
||||||
|
if key not in partial:
|
||||||
|
partial[key] = {}
|
||||||
|
partial = partial[key]
|
||||||
|
if pkeys[-1] in identifier_data.primary_key:
|
||||||
|
partial.update(**doc)
|
||||||
|
else:
|
||||||
|
partial[pkeys[-1]] = doc
|
||||||
|
return ret
|
||||||
|
|
||||||
identifiers = (*map(self._escape_key, identifiers),)
|
async def get(self, identifier_data: IdentifierData):
|
||||||
dot_identifiers = ".".join(identifiers)
|
mongo_collection = self.get_collection(identifier_data.category)
|
||||||
|
|
||||||
partial = await mongo_collection.find_one(
|
pkey_filter = self.generate_primary_key_filter(identifier_data)
|
||||||
filter={"_id": self.unique_cog_identifier}, projection={dot_identifiers: True}
|
if len(identifier_data.identifiers) > 0:
|
||||||
)
|
dot_identifiers = ".".join(map(self._escape_key, identifier_data.identifiers))
|
||||||
|
proj = {"_id": False, dot_identifiers: True}
|
||||||
|
|
||||||
|
partial = await mongo_collection.find_one(filter=pkey_filter, projection=proj)
|
||||||
|
else:
|
||||||
|
# The case here is for partial primary keys like all_members()
|
||||||
|
cursor = mongo_collection.find(filter=pkey_filter)
|
||||||
|
partial = await self.rebuild_dataset(identifier_data, cursor)
|
||||||
|
|
||||||
if partial is None:
|
if partial is None:
|
||||||
raise KeyError("No matching document was found and Config expects a KeyError.")
|
raise KeyError("No matching document was found and Config expects a KeyError.")
|
||||||
|
|
||||||
for i in identifiers:
|
for i in identifier_data.identifiers:
|
||||||
partial = partial[i]
|
partial = partial[i]
|
||||||
if isinstance(partial, dict):
|
if isinstance(partial, dict):
|
||||||
return self._unescape_dict_keys(partial)
|
return self._unescape_dict_keys(partial)
|
||||||
return partial
|
return partial
|
||||||
|
|
||||||
async def set(self, *identifiers: str, value=None):
|
async def set(self, identifier_data: IdentifierData, value=None):
|
||||||
dot_identifiers = ".".join(map(self._escape_key, identifiers))
|
uuid = self._escape_key(identifier_data.uuid)
|
||||||
|
primary_key = list(map(self._escape_key, self.get_primary_key(identifier_data)))
|
||||||
|
dot_identifiers = ".".join(map(self._escape_key, identifier_data.identifiers))
|
||||||
if isinstance(value, dict):
|
if isinstance(value, dict):
|
||||||
|
if len(value) == 0:
|
||||||
|
await self.clear(identifier_data)
|
||||||
|
return
|
||||||
value = self._escape_dict_keys(value)
|
value = self._escape_dict_keys(value)
|
||||||
|
|
||||||
mongo_collection = self.get_collection()
|
mongo_collection = self.get_collection(identifier_data.category)
|
||||||
|
if len(dot_identifiers) > 0:
|
||||||
|
update_stmt = {"$set": {dot_identifiers: value}}
|
||||||
|
else:
|
||||||
|
update_stmt = {"$set": value}
|
||||||
|
|
||||||
await mongo_collection.update_one(
|
await mongo_collection.update_one(
|
||||||
{"_id": self.unique_cog_identifier},
|
{"_id": {"RED_uuid": uuid, "RED_primary_key": primary_key}},
|
||||||
update={"$set": {dot_identifiers: value}},
|
update=update_stmt,
|
||||||
upsert=True,
|
upsert=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
async def clear(self, *identifiers: str):
|
def generate_primary_key_filter(self, identifier_data: IdentifierData):
|
||||||
dot_identifiers = ".".join(map(self._escape_key, identifiers))
|
uuid = self._escape_key(identifier_data.uuid)
|
||||||
mongo_collection = self.get_collection()
|
primary_key = list(map(self._escape_key, self.get_primary_key(identifier_data)))
|
||||||
|
ret = {"_id.RED_uuid": uuid}
|
||||||
if len(identifiers) > 0:
|
if len(identifier_data.identifiers) > 0:
|
||||||
await mongo_collection.update_one(
|
ret["_id.RED_primary_key"] = primary_key
|
||||||
{"_id": self.unique_cog_identifier}, update={"$unset": {dot_identifiers: 1}}
|
elif len(identifier_data.primary_key) > 0:
|
||||||
)
|
for i, key in enumerate(primary_key):
|
||||||
|
keyname = f"_id.RED_primary_key.{i}"
|
||||||
|
ret[keyname] = key
|
||||||
else:
|
else:
|
||||||
await mongo_collection.delete_one({"_id": self.unique_cog_identifier})
|
ret["_id.RED_primary_key"] = {"$exists": True}
|
||||||
|
return ret
|
||||||
|
|
||||||
|
async def clear(self, identifier_data: IdentifierData):
|
||||||
|
# There are three cases here:
|
||||||
|
# 1) We're clearing out a subset of identifiers (aka identifiers is NOT empty)
|
||||||
|
# 2) We're clearing out full primary key and no identifiers
|
||||||
|
# 3) We're clearing out partial primary key and no identifiers
|
||||||
|
# 4) Primary key is empty, should wipe all documents in the collection
|
||||||
|
mongo_collection = self.get_collection(identifier_data.category)
|
||||||
|
pkey_filter = self.generate_primary_key_filter(identifier_data)
|
||||||
|
if len(identifier_data.identifiers) == 0:
|
||||||
|
# This covers cases 2-4
|
||||||
|
await mongo_collection.delete_many(pkey_filter)
|
||||||
|
else:
|
||||||
|
dot_identifiers = ".".join(map(self._escape_key, identifier_data.identifiers))
|
||||||
|
await mongo_collection.update_one(pkey_filter, update={"$unset": {dot_identifiers: 1}})
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _escape_key(key: str) -> str:
|
def _escape_key(key: str) -> str:
|
||||||
|
|||||||
@ -15,6 +15,7 @@ from pkg_resources import DistributionNotFound
|
|||||||
|
|
||||||
from .. import __version__ as red_version, version_info as red_version_info, VersionInfo
|
from .. import __version__ as red_version, version_info as red_version_info, VersionInfo
|
||||||
from . import commands
|
from . import commands
|
||||||
|
from .config import get_latest_confs
|
||||||
from .data_manager import storage_type
|
from .data_manager import storage_type
|
||||||
from .utils.chat_formatting import inline, bordered, format_perms_list, humanize_timedelta
|
from .utils.chat_formatting import inline, bordered, format_perms_list, humanize_timedelta
|
||||||
from .utils import fuzzy_command_search, format_fuzzy_results
|
from .utils import fuzzy_command_search, format_fuzzy_results
|
||||||
@ -236,7 +237,8 @@ def init_events(bot, cli_flags):
|
|||||||
await ctx.send(
|
await ctx.send(
|
||||||
"This command is on cooldown. Try again in {}.".format(
|
"This command is on cooldown. Try again in {}.".format(
|
||||||
humanize_timedelta(seconds=error.retry_after)
|
humanize_timedelta(seconds=error.retry_after)
|
||||||
)
|
),
|
||||||
|
delete_after=error.retry_after,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
log.exception(type(error).__name__, exc_info=error)
|
log.exception(type(error).__name__, exc_info=error)
|
||||||
@ -304,6 +306,14 @@ def init_events(bot, cli_flags):
|
|||||||
if command_obj is not None:
|
if command_obj is not None:
|
||||||
command_obj.enable_in(guild)
|
command_obj.enable_in(guild)
|
||||||
|
|
||||||
|
@bot.event
|
||||||
|
async def on_cog_add(cog: commands.Cog):
|
||||||
|
confs = get_latest_confs()
|
||||||
|
for c in confs:
|
||||||
|
uuid = c.unique_identifier
|
||||||
|
group_data = c.custom_groups
|
||||||
|
await bot.db.custom("CUSTOM_GROUPS", c.cog_name, uuid).set(group_data)
|
||||||
|
|
||||||
|
|
||||||
def _get_startup_screen_specs():
|
def _get_startup_screen_specs():
|
||||||
"""Get specs for displaying the startup screen on stdout.
|
"""Get specs for displaying the startup screen on stdout.
|
||||||
|
|||||||
@ -47,7 +47,6 @@ class JsonIO:
|
|||||||
And:
|
And:
|
||||||
https://www.mjmwired.net/kernel/Documentation/filesystems/ext4.txt#310
|
https://www.mjmwired.net/kernel/Documentation/filesystems/ext4.txt#310
|
||||||
"""
|
"""
|
||||||
log.debug("Saving file {}".format(self.path))
|
|
||||||
filename = self.path.stem
|
filename = self.path.stem
|
||||||
tmp_file = "{}-{}.tmp".format(filename, uuid4().fields[0])
|
tmp_file = "{}-{}.tmp".format(filename, uuid4().fields[0])
|
||||||
tmp_path = self.path.parent / tmp_file
|
tmp_path = self.path.parent / tmp_file
|
||||||
@ -80,7 +79,6 @@ class JsonIO:
|
|||||||
|
|
||||||
# noinspection PyUnresolvedReferences
|
# noinspection PyUnresolvedReferences
|
||||||
def _load_json(self):
|
def _load_json(self):
|
||||||
log.debug("Reading file {}".format(self.path))
|
|
||||||
with self.path.open(encoding="utf-8", mode="r") as f:
|
with self.path.open(encoding="utf-8", mode="r") as f:
|
||||||
data = json.load(f)
|
data = json.load(f)
|
||||||
return data
|
return data
|
||||||
|
|||||||
@ -746,7 +746,6 @@ async def register_casetypes(new_types: List[dict]) -> List[CaseType]:
|
|||||||
|
|
||||||
Raises
|
Raises
|
||||||
------
|
------
|
||||||
RuntimeError
|
|
||||||
KeyError
|
KeyError
|
||||||
ValueError
|
ValueError
|
||||||
AttributeError
|
AttributeError
|
||||||
@ -761,13 +760,9 @@ async def register_casetypes(new_types: List[dict]) -> List[CaseType]:
|
|||||||
try:
|
try:
|
||||||
ct = await register_casetype(**new_type)
|
ct = await register_casetype(**new_type)
|
||||||
except RuntimeError:
|
except RuntimeError:
|
||||||
raise
|
# We pass here because RuntimeError signifies the case was
|
||||||
except ValueError:
|
# already registered.
|
||||||
raise
|
pass
|
||||||
except AttributeError:
|
|
||||||
raise
|
|
||||||
except TypeError:
|
|
||||||
raise
|
|
||||||
else:
|
else:
|
||||||
type_list.append(ct)
|
type_list.append(ct)
|
||||||
else:
|
else:
|
||||||
|
|||||||
@ -1,126 +0,0 @@
|
|||||||
import json
|
|
||||||
from pathlib import Path
|
|
||||||
from redbot.core import Config
|
|
||||||
|
|
||||||
|
|
||||||
class DataConverter:
|
|
||||||
"""
|
|
||||||
Class for moving v2 data to v3
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, config_instance: Config):
|
|
||||||
self.config = config_instance
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def json_load(file_path: Path):
|
|
||||||
"""Utility function for quickly grabbing data from a JSON file
|
|
||||||
|
|
||||||
Parameters
|
|
||||||
----------
|
|
||||||
file_path: `pathlib.Path`
|
|
||||||
The path to the file to grabdata from
|
|
||||||
|
|
||||||
Raises
|
|
||||||
------
|
|
||||||
FileNotFoundError
|
|
||||||
The file doesn't exist
|
|
||||||
json.JsonDecodeError
|
|
||||||
The file isn't valid JSON
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
with file_path.open(mode="r", encoding="utf-8") as f:
|
|
||||||
data = json.load(f)
|
|
||||||
except (FileNotFoundError, json.JSONDecodeError):
|
|
||||||
raise
|
|
||||||
else:
|
|
||||||
return data
|
|
||||||
|
|
||||||
async def convert(self, file_path: Path, conversion_spec: object):
|
|
||||||
"""Converts v2 data to v3 format. If your v2 data uses multiple files
|
|
||||||
you will need to call this for each file.
|
|
||||||
|
|
||||||
Parameters
|
|
||||||
----------
|
|
||||||
file_path : `pathlib.Path`
|
|
||||||
This should be the path to a JSON settings file from v2
|
|
||||||
conversion_spec : `object`
|
|
||||||
This should be a function which takes a single argument argument
|
|
||||||
(the loaded JSON) and from it either
|
|
||||||
returns or yields one or more `dict`
|
|
||||||
whose items are in the form::
|
|
||||||
|
|
||||||
{(SCOPE, *IDENTIFIERS): {(key_tuple): value}}
|
|
||||||
|
|
||||||
an example of a possible entry of that dict::
|
|
||||||
|
|
||||||
{(Config.MEMBER, '133049272517001216', '78631113035100160'):
|
|
||||||
{('balance',): 9001}}
|
|
||||||
|
|
||||||
|
|
||||||
This allows for any amount of entries at each level
|
|
||||||
in each of the nested dictionaries returned by conversion_spec
|
|
||||||
but the nesting cannot be different to this and still get the
|
|
||||||
expected results
|
|
||||||
see documentation for Config for more details on scopes
|
|
||||||
and the identifiers they need
|
|
||||||
|
|
||||||
Returns
|
|
||||||
-------
|
|
||||||
None
|
|
||||||
|
|
||||||
Raises
|
|
||||||
------
|
|
||||||
FileNotFoundError
|
|
||||||
No such file at the specified path
|
|
||||||
json.JSONDecodeError
|
|
||||||
File is not valid JSON
|
|
||||||
AttributeError
|
|
||||||
Something goes wrong with your conversion and it provides
|
|
||||||
data in the wrong format
|
|
||||||
"""
|
|
||||||
|
|
||||||
v2data = self.json_load(file_path)
|
|
||||||
|
|
||||||
for entryset in conversion_spec(v2data):
|
|
||||||
for scope_id, values in entryset.items():
|
|
||||||
base = self.config._get_base_group(*scope_id)
|
|
||||||
for inner_k, inner_v in values.items():
|
|
||||||
await base.set_raw(*inner_k, value=inner_v)
|
|
||||||
|
|
||||||
async def dict_import(self, entrydict: dict):
|
|
||||||
"""This imports a dictionary in the correct format into Config
|
|
||||||
|
|
||||||
Parameters
|
|
||||||
----------
|
|
||||||
entrydict : `dict`
|
|
||||||
This should be a dictionary of values to set.
|
|
||||||
This is provided as an alternative
|
|
||||||
to providing a file and conversion specification
|
|
||||||
the dictionary should be in the following format::
|
|
||||||
|
|
||||||
{(SCOPE, *IDENTIFIERS): {(key_tuple): value}}`
|
|
||||||
|
|
||||||
an example of a possible entry of that dict::
|
|
||||||
|
|
||||||
{(Config.MEMBER, '133049272517001216', '78631113035100160'):
|
|
||||||
{('balance',): 9001}}
|
|
||||||
|
|
||||||
This allows for any amount of entries at each level
|
|
||||||
in each of the nested dictionaries returned by conversion_spec
|
|
||||||
but the nesting cannot be different to this and still get the
|
|
||||||
expected results
|
|
||||||
|
|
||||||
Returns
|
|
||||||
-------
|
|
||||||
None
|
|
||||||
|
|
||||||
Raises
|
|
||||||
------
|
|
||||||
AttributeError
|
|
||||||
Data not in the correct format.
|
|
||||||
"""
|
|
||||||
|
|
||||||
for scope_id, values in entrydict.items():
|
|
||||||
base = self.config._get_base_group(*scope_id)
|
|
||||||
for inner_k, inner_v in values.items():
|
|
||||||
await base.set_raw(*inner_k, value=inner_v)
|
|
||||||
@ -71,7 +71,7 @@ class Tunnel(metaclass=TunnelMeta):
|
|||||||
self.last_interaction = datetime.utcnow()
|
self.last_interaction = datetime.utcnow()
|
||||||
|
|
||||||
async def react_close(self, *, uid: int, message: str = ""):
|
async def react_close(self, *, uid: int, message: str = ""):
|
||||||
send_to = self.origin if uid == self.sender.id else self.sender
|
send_to = self.recipient if uid == self.sender.id else self.origin
|
||||||
closer = next(filter(lambda x: x.id == uid, (self.sender, self.recipient)), None)
|
closer = next(filter(lambda x: x.id == uid, (self.sender, self.recipient)), None)
|
||||||
await send_to.send(filter_mass_mentions(message.format(closer=closer)))
|
await send_to.send(filter_mass_mentions(message.format(closer=closer)))
|
||||||
|
|
||||||
|
|||||||
151
redbot/logging.py
Normal file
151
redbot/logging.py
Normal file
@ -0,0 +1,151 @@
|
|||||||
|
import logging.handlers
|
||||||
|
import pathlib
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
from typing import List, Tuple, Optional
|
||||||
|
|
||||||
|
MAX_OLD_LOGS = 8
|
||||||
|
|
||||||
|
|
||||||
|
class RotatingFileHandler(logging.handlers.RotatingFileHandler):
|
||||||
|
"""Custom rotating file handler.
|
||||||
|
|
||||||
|
This file handler rotates a bit differently to the one in stdlib.
|
||||||
|
|
||||||
|
For a start, this works off of a "stem" and a "directory". The stem
|
||||||
|
is the base name of the log file, without the extension. The
|
||||||
|
directory is where all log files (including backups) will be placed.
|
||||||
|
|
||||||
|
Secondly, this logger rotates files downwards, and new logs are
|
||||||
|
*started* with the backup number incremented. The stdlib handler
|
||||||
|
rotates files upwards, and this leaves the logs in reverse order.
|
||||||
|
|
||||||
|
Thirdly, naming conventions are not customisable with this class.
|
||||||
|
Logs will initially be named in the format "{stem}.log", and after
|
||||||
|
rotating, the first log file will be renamed "{stem}-part1.log",
|
||||||
|
and a new file "{stem}-part2.log" will be created for logging to
|
||||||
|
continue.
|
||||||
|
|
||||||
|
A few things can't be modified in this handler: it must use append
|
||||||
|
mode, it doesn't support use of the `delay` arg, and it will ignore
|
||||||
|
custom namers and rotators.
|
||||||
|
|
||||||
|
When this handler is instantiated, it will search through the
|
||||||
|
directory for logs from previous runtimes, and will open the file
|
||||||
|
with the highest backup number to append to.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
stem: str,
|
||||||
|
directory: pathlib.Path,
|
||||||
|
maxBytes: int = 0,
|
||||||
|
backupCount: int = 0,
|
||||||
|
encoding: Optional[str] = None,
|
||||||
|
) -> None:
|
||||||
|
self.baseStem = stem
|
||||||
|
self.directory = directory.resolve()
|
||||||
|
# Scan for existing files in directory, append to last part of existing log
|
||||||
|
log_part_re = re.compile(rf"{stem}-part(?P<partnum>\d+).log")
|
||||||
|
highest_part = 0
|
||||||
|
for path in directory.iterdir():
|
||||||
|
match = log_part_re.match(path.name)
|
||||||
|
if match and int(match["partnum"]) > highest_part:
|
||||||
|
highest_part = int(match["partnum"])
|
||||||
|
if highest_part:
|
||||||
|
filename = directory / f"{stem}-part{highest_part}.log"
|
||||||
|
else:
|
||||||
|
filename = directory / f"{stem}.log"
|
||||||
|
super().__init__(
|
||||||
|
filename,
|
||||||
|
mode="a",
|
||||||
|
maxBytes=maxBytes,
|
||||||
|
backupCount=backupCount,
|
||||||
|
encoding=encoding,
|
||||||
|
delay=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
def doRollover(self):
|
||||||
|
if self.stream:
|
||||||
|
self.stream.close()
|
||||||
|
self.stream = None
|
||||||
|
initial_path = self.directory / f"{self.baseStem}.log"
|
||||||
|
if self.backupCount > 0 and initial_path.exists():
|
||||||
|
initial_path.replace(self.directory / f"{self.baseStem}-part1.log")
|
||||||
|
|
||||||
|
match = re.match(
|
||||||
|
rf"{self.baseStem}(?:-part(?P<part>\d+)?)?.log", pathlib.Path(self.baseFilename).name
|
||||||
|
)
|
||||||
|
latest_part_num = int(match.groupdict(default="1").get("part", "1"))
|
||||||
|
if self.backupCount < 1:
|
||||||
|
# No backups, just delete the existing log and start again
|
||||||
|
pathlib.Path(self.baseFilename).unlink()
|
||||||
|
elif latest_part_num > self.backupCount:
|
||||||
|
# Rotate files down one
|
||||||
|
# red-part2.log becomes red-part1.log etc, a new log is added at the end.
|
||||||
|
for i in range(1, self.backupCount):
|
||||||
|
next_log = self.directory / f"{self.baseStem}-part{i + 1}.log"
|
||||||
|
if next_log.exists():
|
||||||
|
prev_log = self.directory / f"{self.baseStem}-part{i}.log"
|
||||||
|
next_log.replace(prev_log)
|
||||||
|
else:
|
||||||
|
# Simply start a new file
|
||||||
|
self.baseFilename = str(
|
||||||
|
self.directory / f"{self.baseStem}-part{latest_part_num + 1}.log"
|
||||||
|
)
|
||||||
|
|
||||||
|
self.stream = self._open()
|
||||||
|
|
||||||
|
|
||||||
|
def init_logging(level: int, location: pathlib.Path) -> None:
|
||||||
|
dpy_logger = logging.getLogger("discord")
|
||||||
|
dpy_logger.setLevel(logging.WARNING)
|
||||||
|
base_logger = logging.getLogger("red")
|
||||||
|
base_logger.setLevel(level)
|
||||||
|
|
||||||
|
formatter = logging.Formatter(
|
||||||
|
"[{asctime}] [{levelname}] {name}: {message}", datefmt="%Y-%m-%d %H:%M:%S", style="{"
|
||||||
|
)
|
||||||
|
|
||||||
|
stdout_handler = logging.StreamHandler(sys.stdout)
|
||||||
|
stdout_handler.setFormatter(formatter)
|
||||||
|
base_logger.addHandler(stdout_handler)
|
||||||
|
dpy_logger.addHandler(stdout_handler)
|
||||||
|
|
||||||
|
if not location.exists():
|
||||||
|
location.mkdir(parents=True, exist_ok=True)
|
||||||
|
# Rotate latest logs to previous logs
|
||||||
|
previous_logs: List[pathlib.Path] = []
|
||||||
|
latest_logs: List[Tuple[pathlib.Path, str]] = []
|
||||||
|
for path in location.iterdir():
|
||||||
|
match = re.match(r"latest(?P<part>-part\d+)?\.log", path.name)
|
||||||
|
if match:
|
||||||
|
part = match.groupdict(default="")["part"]
|
||||||
|
latest_logs.append((path, part))
|
||||||
|
match = re.match(r"previous(?:-part\d+)?.log", path.name)
|
||||||
|
if match:
|
||||||
|
previous_logs.append(path)
|
||||||
|
# Delete all previous.log files
|
||||||
|
for path in previous_logs:
|
||||||
|
path.unlink()
|
||||||
|
# Rename latest.log files to previous.log
|
||||||
|
for path, part in latest_logs:
|
||||||
|
path.replace(location / f"previous{part}.log")
|
||||||
|
|
||||||
|
latest_fhandler = RotatingFileHandler(
|
||||||
|
stem="latest",
|
||||||
|
directory=location,
|
||||||
|
maxBytes=1_000_000, # About 1MB per logfile
|
||||||
|
backupCount=MAX_OLD_LOGS,
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
all_fhandler = RotatingFileHandler(
|
||||||
|
stem="red",
|
||||||
|
directory=location,
|
||||||
|
maxBytes=1_000_000,
|
||||||
|
backupCount=MAX_OLD_LOGS,
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
for fhandler in (latest_fhandler, all_fhandler):
|
||||||
|
fhandler.setFormatter(formatter)
|
||||||
|
base_logger.addHandler(fhandler)
|
||||||
@ -1,11 +1,13 @@
|
|||||||
import random
|
import random
|
||||||
from collections import namedtuple
|
from collections import namedtuple
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
import weakref
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from _pytest.monkeypatch import MonkeyPatch
|
from _pytest.monkeypatch import MonkeyPatch
|
||||||
from redbot.core import Config
|
from redbot.core import Config
|
||||||
from redbot.core.bot import Red
|
from redbot.core.bot import Red
|
||||||
|
from redbot.core import config as config_module
|
||||||
|
|
||||||
from redbot.core.drivers import red_json
|
from redbot.core.drivers import red_json
|
||||||
|
|
||||||
@ -65,11 +67,11 @@ def json_driver(tmpdir_factory):
|
|||||||
|
|
||||||
@pytest.fixture()
|
@pytest.fixture()
|
||||||
def config(json_driver):
|
def config(json_driver):
|
||||||
|
config_module._config_cache = weakref.WeakValueDictionary()
|
||||||
conf = Config(
|
conf = Config(
|
||||||
cog_name="PyTest", unique_identifier=json_driver.unique_cog_identifier, driver=json_driver
|
cog_name="PyTest", unique_identifier=json_driver.unique_cog_identifier, driver=json_driver
|
||||||
)
|
)
|
||||||
yield conf
|
yield conf
|
||||||
conf._defaults = {}
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture()
|
@pytest.fixture()
|
||||||
@ -77,6 +79,7 @@ def config_fr(json_driver):
|
|||||||
"""
|
"""
|
||||||
Mocked config object with force_register enabled.
|
Mocked config object with force_register enabled.
|
||||||
"""
|
"""
|
||||||
|
config_module._config_cache = weakref.WeakValueDictionary()
|
||||||
conf = Config(
|
conf = Config(
|
||||||
cog_name="PyTest",
|
cog_name="PyTest",
|
||||||
unique_identifier=json_driver.unique_cog_identifier,
|
unique_identifier=json_driver.unique_cog_identifier,
|
||||||
@ -84,7 +87,6 @@ def config_fr(json_driver):
|
|||||||
force_registration=True,
|
force_registration=True,
|
||||||
)
|
)
|
||||||
yield conf
|
yield conf
|
||||||
conf._defaults = {}
|
|
||||||
|
|
||||||
|
|
||||||
# region Dpy Mocks
|
# region Dpy Mocks
|
||||||
|
|||||||
@ -1,12 +0,0 @@
|
|||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from redbot.cogs.dataconverter import core_specs
|
|
||||||
|
|
||||||
__all__ = ["get_specresolver"]
|
|
||||||
|
|
||||||
|
|
||||||
def get_specresolver(path):
|
|
||||||
here = Path(path)
|
|
||||||
|
|
||||||
resolver = core_specs.SpecResolver(here.parent)
|
|
||||||
return resolver
|
|
||||||
@ -114,7 +114,7 @@ def get_storage_type():
|
|||||||
print()
|
print()
|
||||||
print("Please choose your storage backend (if you're unsure, choose 1).")
|
print("Please choose your storage backend (if you're unsure, choose 1).")
|
||||||
print("1. JSON (file storage, requires no database).")
|
print("1. JSON (file storage, requires no database).")
|
||||||
print("2. MongoDB (not recommended, currently unstable)")
|
print("2. MongoDB")
|
||||||
storage = input("> ")
|
storage = input("> ")
|
||||||
try:
|
try:
|
||||||
storage = int(storage)
|
storage = int(storage)
|
||||||
@ -260,23 +260,28 @@ async def edit_instance():
|
|||||||
if confirm("Would you like to change the storage type? (y/n):"):
|
if confirm("Would you like to change the storage type? (y/n):"):
|
||||||
storage = get_storage_type()
|
storage = get_storage_type()
|
||||||
|
|
||||||
storage_dict = {1: "JSON", 2: "MongoDB"}
|
storage_dict = {1: "JSON", 2: "MongoDBV2"}
|
||||||
default_dirs["STORAGE_TYPE"] = storage_dict[storage]
|
default_dirs["STORAGE_TYPE"] = storage_dict[storage]
|
||||||
if storage_dict.get(storage, 1) == "MongoDB":
|
if storage_dict.get(storage, 1) == "MongoDBV2":
|
||||||
from redbot.core.drivers.red_mongo import get_config_details
|
from redbot.core.drivers.red_mongo import get_config_details
|
||||||
|
|
||||||
storage_details = get_config_details()
|
storage_details = get_config_details()
|
||||||
default_dirs["STORAGE_DETAILS"] = storage_details
|
default_dirs["STORAGE_DETAILS"] = storage_details
|
||||||
|
|
||||||
if instance_data["STORAGE_TYPE"] == "JSON":
|
if instance_data["STORAGE_TYPE"] == "JSON":
|
||||||
if confirm("Would you like to import your data? (y/n) "):
|
raise NotImplementedError("We cannot convert from JSON to MongoDB at this time.")
|
||||||
await json_to_mongo(current_data_dir, storage_details)
|
# if confirm("Would you like to import your data? (y/n) "):
|
||||||
else:
|
# await json_to_mongo(current_data_dir, storage_details)
|
||||||
|
elif storage_dict.get(storage, 1) == "JSON":
|
||||||
storage_details = instance_data["STORAGE_DETAILS"]
|
storage_details = instance_data["STORAGE_DETAILS"]
|
||||||
default_dirs["STORAGE_DETAILS"] = {}
|
default_dirs["STORAGE_DETAILS"] = {}
|
||||||
if instance_data["STORAGE_TYPE"] == "MongoDB":
|
if instance_data["STORAGE_TYPE"] == "MongoDB":
|
||||||
if confirm("Would you like to import your data? (y/n) "):
|
if confirm("Would you like to import your data? (y/n) "):
|
||||||
await mongo_to_json(current_data_dir, storage_details)
|
await mongo_to_json(current_data_dir, storage_details)
|
||||||
|
elif instance_data["STORAGE_TYPE"] == "MongoDBV2":
|
||||||
|
raise NotImplementedError(
|
||||||
|
"We cannot convert from this version of MongoDB to JSON at this time."
|
||||||
|
)
|
||||||
|
|
||||||
if name != selected:
|
if name != selected:
|
||||||
save_config(selected, {}, remove=True)
|
save_config(selected, {}, remove=True)
|
||||||
|
|||||||
@ -1,26 +0,0 @@
|
|||||||
{
|
|
||||||
"1" : {
|
|
||||||
"1" : [
|
|
||||||
"Test",
|
|
||||||
"Test2",
|
|
||||||
"TEST3"
|
|
||||||
],
|
|
||||||
"2" : [
|
|
||||||
"Test4",
|
|
||||||
"Test5",
|
|
||||||
"TEST6"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"2" : {
|
|
||||||
"1" : [
|
|
||||||
"Test",
|
|
||||||
"Test2",
|
|
||||||
"TEST3"
|
|
||||||
],
|
|
||||||
"2" : [
|
|
||||||
"Test4",
|
|
||||||
"Test5",
|
|
||||||
"TEST6"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,31 +0,0 @@
|
|||||||
import pytest
|
|
||||||
from collections import namedtuple
|
|
||||||
|
|
||||||
from redbot.pytest.dataconverter import *
|
|
||||||
from redbot.core.utils.data_converter import DataConverter
|
|
||||||
|
|
||||||
|
|
||||||
def mock_dpy_object(id_):
|
|
||||||
return namedtuple("DPYObject", "id")(int(id_))
|
|
||||||
|
|
||||||
|
|
||||||
def mock_dpy_member(guildid, userid):
|
|
||||||
return namedtuple("Member", "id guild")(int(userid), mock_dpy_object(guildid))
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_mod_nicknames(red):
|
|
||||||
specresolver = get_specresolver(__file__)
|
|
||||||
filepath, converter, cogname, attr, _id = specresolver.get_conversion_info("Past Nicknames")
|
|
||||||
conf = specresolver.get_config_object(red, cogname, attr, _id)
|
|
||||||
|
|
||||||
v2data = DataConverter.json_load(filepath)
|
|
||||||
|
|
||||||
await specresolver.convert(red, "Past Nicknames", config=conf)
|
|
||||||
|
|
||||||
for guildid, guild_data in v2data.items():
|
|
||||||
guild = mock_dpy_object(guildid)
|
|
||||||
for userid, user_data in guild_data.items():
|
|
||||||
member = mock_dpy_member(guildid, userid)
|
|
||||||
|
|
||||||
assert await conf.member(member).past_nicks() == user_data
|
|
||||||
@ -8,7 +8,7 @@ def test_trivia_lists():
|
|||||||
assert list_names
|
assert list_names
|
||||||
problem_lists = []
|
problem_lists = []
|
||||||
for l in list_names:
|
for l in list_names:
|
||||||
with l.open() as f:
|
with l.open(encoding="utf-8") as f:
|
||||||
try:
|
try:
|
||||||
dict_ = yaml.safe_load(f)
|
dict_ = yaml.safe_load(f)
|
||||||
except yaml.error.YAMLError as e:
|
except yaml.error.YAMLError as e:
|
||||||
|
|||||||
@ -490,3 +490,19 @@ async def test_cast_str_nested(config):
|
|||||||
config.register_global(foo={})
|
config.register_global(foo={})
|
||||||
await config.foo.set({123: True, 456: {789: False}})
|
await config.foo.set({123: True, 456: {789: False}})
|
||||||
assert await config.foo() == {"123": True, "456": {"789": False}}
|
assert await config.foo() == {"123": True, "456": {"789": False}}
|
||||||
|
|
||||||
|
|
||||||
|
def test_config_custom_noinit(config):
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
config.custom("TEST", 1, 2, 3)
|
||||||
|
|
||||||
|
|
||||||
|
def test_config_custom_init(config):
|
||||||
|
config.init_custom("TEST", 3)
|
||||||
|
config.custom("TEST", 1, 2, 3)
|
||||||
|
|
||||||
|
|
||||||
|
def test_config_custom_doubleinit(config):
|
||||||
|
config.init_custom("TEST", 3)
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
config.init_custom("TEST", 2)
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user