mirror of
https://github.com/Cog-Creators/Red-DiscordBot.git
synced 2025-11-08 04:08:56 -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
|
||||
Public License instead of this License. But first, please read
|
||||
<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)
|
||||
- [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
|
||||
[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
|
||||
:members:
|
||||
|
||||
V2 Data Conversion
|
||||
==================
|
||||
|
||||
.. automodule:: redbot.core.utils.data_converter
|
||||
:members: DataConverter
|
||||
|
||||
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_linux_mac
|
||||
venv_guide
|
||||
cog_dataconverter
|
||||
autostart_systemd
|
||||
|
||||
.. toctree::
|
||||
@ -30,7 +29,6 @@ Welcome to Red - Discord Bot's documentation!
|
||||
|
||||
guide_migration
|
||||
guide_cog_creation
|
||||
guide_data_conversion
|
||||
framework_bank
|
||||
framework_bot
|
||||
framework_checks
|
||||
|
||||
@ -2,29 +2,31 @@
|
||||
|
||||
# Discord Version check
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
|
||||
import discord
|
||||
|
||||
import redbot.logging
|
||||
from redbot.core.bot import Red, ExitCodes
|
||||
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.global_checks import init_global_checks
|
||||
from redbot.core.events import init_events
|
||||
from redbot.core.cli import interactive_config, confirm, parse_cli_flags
|
||||
from redbot.core.core_commands import Core
|
||||
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
|
||||
import asyncio
|
||||
import logging.handlers
|
||||
import logging
|
||||
import os
|
||||
|
||||
# Let's not force this dependency, uvloop is much faster on cpython
|
||||
if sys.implementation.name == "cpython":
|
||||
try:
|
||||
import uvloop
|
||||
except ImportError:
|
||||
uvloop = None
|
||||
pass
|
||||
else:
|
||||
asyncio.set_event_loop_policy(uvloop.EventLoopPolicy())
|
||||
@ -32,6 +34,7 @@ if sys.implementation.name == "cpython":
|
||||
if sys.platform == "win32":
|
||||
asyncio.set_event_loop(asyncio.ProactorEventLoop())
|
||||
|
||||
log = logging.getLogger("red.main")
|
||||
|
||||
#
|
||||
# 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):
|
||||
"""
|
||||
Again, please blame <@269933075037814786> for this.
|
||||
@ -91,14 +54,14 @@ async def _get_prefix_and_token(red, indict):
|
||||
|
||||
|
||||
def list_instances():
|
||||
if not config_file.exists():
|
||||
if not data_manager.config_file.exists():
|
||||
print(
|
||||
"No instances have been configured! Configure one "
|
||||
"using `redbot-setup` before trying to run the bot!"
|
||||
)
|
||||
sys.exit(1)
|
||||
else:
|
||||
data = JsonIO(config_file)._load_json()
|
||||
data = JsonIO(data_manager.config_file)._load_json()
|
||||
text = "Configured Instances:\n\n"
|
||||
for instance_name in sorted(data.keys()):
|
||||
text += "{}\n".format(instance_name)
|
||||
@ -118,6 +81,7 @@ def main():
|
||||
list_instances()
|
||||
elif cli_flags.version:
|
||||
print(description)
|
||||
print("Current Version: {}".format(__version__))
|
||||
sys.exit(0)
|
||||
elif not cli_flags.instance_name and not cli_flags.no_instance:
|
||||
print("Error: No instance name was provided!")
|
||||
@ -125,13 +89,21 @@ def main():
|
||||
if cli_flags.no_instance:
|
||||
print(
|
||||
"\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"
|
||||
)
|
||||
cli_flags.instance_name = "temporary_red"
|
||||
create_temp_config()
|
||||
load_basic_configuration(cli_flags.instance_name)
|
||||
log = init_loggers(cli_flags)
|
||||
data_manager.create_temp_config()
|
||||
data_manager.load_basic_configuration(cli_flags.instance_name)
|
||||
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)
|
||||
init_global_checks(red)
|
||||
init_events(red, cli_flags)
|
||||
|
||||
@ -1,8 +1,10 @@
|
||||
from copy import copy
|
||||
from re import search
|
||||
from re import findall, search
|
||||
from string import Formatter
|
||||
from typing import Generator, Tuple, Iterable, Optional
|
||||
|
||||
import discord
|
||||
from discord.ext.commands.view import StringView, quoted_word
|
||||
from redbot.core import Config, commands, checks
|
||||
from redbot.core.i18n import Translator, cog_i18n
|
||||
from redbot.core.utils.chat_formatting import box
|
||||
@ -13,6 +15,21 @@ from .alias_entry import AliasEntry
|
||||
_ = 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(_)
|
||||
class Alias(commands.Cog):
|
||||
"""Create aliases for commands.
|
||||
@ -80,8 +97,25 @@ class Alias(commands.Cog):
|
||||
return not bool(search(r"\s", alias_name)) and alias_name.isprintable()
|
||||
|
||||
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:
|
||||
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_)
|
||||
|
||||
if global_:
|
||||
@ -142,7 +176,17 @@ class Alias(commands.Cog):
|
||||
:return:
|
||||
"""
|
||||
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
|
||||
|
||||
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):
|
||||
new_message = copy(message)
|
||||
args = self.get_extra_args_from_alias(message, prefix, alias)
|
||||
try:
|
||||
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
|
||||
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)
|
||||
|
||||
@commands.group()
|
||||
@ -228,7 +280,10 @@ class Alias(commands.Cog):
|
||||
# At this point we know we need to make a new alias
|
||||
# and that the alias name is valid.
|
||||
|
||||
await self.add_alias(ctx, alias_name, command)
|
||||
try:
|
||||
await self.add_alias(ctx, alias_name, command)
|
||||
except ArgParseError as e:
|
||||
return await ctx.send(" ".join(e.args))
|
||||
|
||||
await ctx.send(
|
||||
_("A new alias with the trigger `{name}` has been created.").format(name=alias_name)
|
||||
@ -274,7 +329,10 @@ class Alias(commands.Cog):
|
||||
return
|
||||
# endregion
|
||||
|
||||
await self.add_alias(ctx, alias_name, command, global_=True)
|
||||
try:
|
||||
await self.add_alias(ctx, alias_name, command, global_=True)
|
||||
except ArgParseError as e:
|
||||
return await ctx.send(" ".join(e.args))
|
||||
|
||||
await ctx.send(
|
||||
_("A new global alias with the trigger `{name}` has been created.").format(
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import aiohttp
|
||||
import asyncio
|
||||
import base64
|
||||
import datetime
|
||||
import discord
|
||||
from fuzzywuzzy import process
|
||||
@ -17,7 +18,7 @@ import redbot.core
|
||||
from redbot.core import Config, commands, checks, bank
|
||||
from redbot.core.data_manager import cog_data_path
|
||||
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 (
|
||||
menu,
|
||||
DEFAULT_CONTROLS,
|
||||
@ -32,8 +33,8 @@ from .manager import shutdown_lavalink_server, start_lavalink_server, maybe_down
|
||||
|
||||
_ = Translator("Audio", __file__)
|
||||
|
||||
__version__ = "0.0.8"
|
||||
__author__ = ["aikaterna", "billy/bollo/ati"]
|
||||
__version__ = "0.0.8b"
|
||||
__author__ = ["aikaterna"]
|
||||
|
||||
log = logging.getLogger("red.audio")
|
||||
|
||||
@ -84,6 +85,8 @@ class Audio(commands.Cog):
|
||||
self._connect_task = None
|
||||
self._disconnect_task = None
|
||||
self._cleaned_up = False
|
||||
self.spotify_token = None
|
||||
self.play_lock = {}
|
||||
|
||||
async def initialize(self):
|
||||
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_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()
|
||||
@checks.mod_or_permissions(administrator=True)
|
||||
async def maxlength(self, ctx, seconds):
|
||||
@ -354,35 +378,6 @@ class Audio(commands.Cog):
|
||||
|
||||
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()
|
||||
@checks.mod_or_permissions(manage_messages=True)
|
||||
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)
|
||||
)
|
||||
|
||||
@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()
|
||||
async def settings(self, ctx):
|
||||
"""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"))
|
||||
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()
|
||||
@checks.mod_or_permissions(administrator=True)
|
||||
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_enabled.set(enabled)
|
||||
|
||||
@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)
|
||||
)
|
||||
@checks.is_owner()
|
||||
async def youtubeapi(self, ctx):
|
||||
"""Instructions to set the YouTube API key."""
|
||||
message = _(
|
||||
f"1. Go to Google Developers Console and log in with your Google account.\n"
|
||||
"(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.guild_only()
|
||||
async def audiostats(self, ctx):
|
||||
"""Audio stats."""
|
||||
server_num = len([p for p in lavalink.players if p.current is not None])
|
||||
total_num = len([p for p in lavalink.players])
|
||||
server_list = []
|
||||
|
||||
for p in lavalink.players:
|
||||
@ -549,7 +588,7 @@ class Audio(commands.Cog):
|
||||
servers = "\n".join(server_list)
|
||||
embed = discord.Embed(
|
||||
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,
|
||||
)
|
||||
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."))
|
||||
else:
|
||||
self._play_lock(ctx, False)
|
||||
await lavalink.get_player(ctx.guild.id).stop()
|
||||
await lavalink.get_player(ctx.guild.id).disconnect()
|
||||
|
||||
@ -977,6 +1017,7 @@ class Audio(commands.Cog):
|
||||
@commands.guild_only()
|
||||
async def play(self, ctx, *, query):
|
||||
"""Play a URL or search for a track."""
|
||||
|
||||
guild_data = await self.config.guild(ctx.guild).all()
|
||||
restrict = await self.config.restrict()
|
||||
if restrict:
|
||||
@ -986,8 +1027,10 @@ class Audio(commands.Cog):
|
||||
return await self._embed_msg(ctx, _("That URL is not allowed."))
|
||||
if not self._player_check(ctx):
|
||||
try:
|
||||
if not ctx.author.voice.channel.permissions_for(ctx.me).connect or self._userlimit(
|
||||
ctx.author.voice.channel
|
||||
if (
|
||||
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(
|
||||
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."))
|
||||
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:"):
|
||||
await self._localtracks_check(ctx)
|
||||
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)):
|
||||
query = "ytsearch:{}".format(query)
|
||||
|
||||
tracks = await player.get_tracks(query)
|
||||
if not tracks:
|
||||
return await self._embed_msg(ctx, _("Nothing found."))
|
||||
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)
|
||||
if not tracks:
|
||||
return await self._embed_msg(ctx, _("Nothing found."))
|
||||
else:
|
||||
tracks = query
|
||||
|
||||
queue_duration = await self._queue_duration(ctx)
|
||||
queue_total_duration = lavalink.utils.format_time(queue_duration)
|
||||
@ -1071,14 +1228,20 @@ class Audio(commands.Cog):
|
||||
if not player.current:
|
||||
await player.play()
|
||||
else:
|
||||
single_track = tracks[0]
|
||||
if guild_data["maxlength"] > 0:
|
||||
if self._track_limit(ctx, single_track, guild_data["maxlength"]):
|
||||
player.add(ctx.author, single_track)
|
||||
try:
|
||||
single_track = tracks[0]
|
||||
if guild_data["maxlength"] > 0:
|
||||
if self._track_limit(ctx, single_track, guild_data["maxlength"]):
|
||||
player.add(ctx.author, single_track)
|
||||
else:
|
||||
return await self._embed_msg(ctx, _("Track exceeds maximum length."))
|
||||
|
||||
else:
|
||||
return await self._embed_msg(ctx, _("Track exceeds maximum length."))
|
||||
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 not single_track.title == "Unknown title":
|
||||
@ -1105,6 +1268,131 @@ class Audio(commands.Cog):
|
||||
if not player.current:
|
||||
await player.play()
|
||||
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.guild_only()
|
||||
@ -1319,25 +1607,46 @@ class Audio(commands.Cog):
|
||||
author_id = playlists[playlist_name]["author"]
|
||||
except KeyError:
|
||||
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:
|
||||
track_len = len(playlists[playlist_name]["tracks"])
|
||||
except TypeError:
|
||||
track_len = 0
|
||||
if playlist_url is None:
|
||||
playlist_url = _("**Custom playlist.**")
|
||||
|
||||
msg = ""
|
||||
track_idx = 0
|
||||
if track_len > 0:
|
||||
for track in playlists[playlist_name]["tracks"]:
|
||||
track_idx = track_idx + 1
|
||||
spaces = abs(len(str(track_idx)) - 5)
|
||||
msg += "`{}.` **[{}]({})**\n".format(
|
||||
track_idx, track["info"]["title"], track["info"]["uri"]
|
||||
)
|
||||
else:
|
||||
playlist_url = _("URL: <{url}>").format(url=playlist_url)
|
||||
embed = discord.Embed(
|
||||
colour=await ctx.embed_colour(),
|
||||
title=_("Playlist info for {playlist_name}:").format(playlist_name=playlist_name),
|
||||
description=_("Author: **{author_name}**\n{url}").format(
|
||||
author_name=author_obj, url=playlist_url
|
||||
),
|
||||
)
|
||||
embed.set_footer(text=_("{num} track(s)").format(num=track_len))
|
||||
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")
|
||||
async def _playlist_list(self, ctx):
|
||||
@ -1645,8 +1954,10 @@ class Audio(commands.Cog):
|
||||
return False
|
||||
if not self._player_check(ctx):
|
||||
try:
|
||||
if not ctx.author.voice.channel.permissions_for(ctx.me).connect or self._userlimit(
|
||||
ctx.author.voice.channel
|
||||
if (
|
||||
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(
|
||||
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):
|
||||
search = False
|
||||
tracklist = []
|
||||
if type(query) is tuple:
|
||||
query = " ".join(query)
|
||||
if not query.startswith("http"):
|
||||
query = " ".join(query)
|
||||
query = "ytsearch:{}".format(query)
|
||||
search = True
|
||||
tracks = await player.get_tracks(query)
|
||||
if not tracks:
|
||||
return await self._embed_msg(ctx, _("Nothing found."))
|
||||
tracklist = []
|
||||
if not search:
|
||||
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 = "ytsearch:{}".format(query)
|
||||
search = True
|
||||
tracks = await player.get_tracks(query)
|
||||
if not tracks:
|
||||
return await self._embed_msg(ctx, _("Nothing found."))
|
||||
else:
|
||||
tracks = await player.get_tracks(query)
|
||||
if not search and len(tracklist) == 0:
|
||||
for track in tracks:
|
||||
track_obj = self._track_creator(player, other_track=track)
|
||||
tracklist.append(track_obj)
|
||||
elif len(tracklist) == 0:
|
||||
track_obj = self._track_creator(player, other_track=tracks[0])
|
||||
tracklist.append(track_obj)
|
||||
return tracklist
|
||||
@ -1736,7 +2068,7 @@ class Audio(commands.Cog):
|
||||
player.current.title, player.current.uri.replace("localtracks/", "")
|
||||
)
|
||||
else:
|
||||
description = f"**[{player.current.title}]({player.current.title})**"
|
||||
description = f"**[{player.current.title}]({player.current.uri})**"
|
||||
embed = discord.Embed(
|
||||
colour=await ctx.embed_colour(),
|
||||
title=_("Replaying Track"),
|
||||
@ -1744,7 +2076,7 @@ class Audio(commands.Cog):
|
||||
)
|
||||
await ctx.send(embed=embed)
|
||||
|
||||
@commands.command()
|
||||
@commands.group(invoke_without_command=True)
|
||||
@commands.guild_only()
|
||||
async def queue(self, ctx, *, page="1"):
|
||||
"""List the queue.
|
||||
@ -1930,6 +2262,55 @@ class Audio(commands.Cog):
|
||||
)
|
||||
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.guild_only()
|
||||
async def repeat(self, ctx):
|
||||
@ -2029,8 +2410,10 @@ class Audio(commands.Cog):
|
||||
|
||||
if not self._player_check(ctx):
|
||||
try:
|
||||
if not ctx.author.voice.channel.permissions_for(ctx.me).connect or self._userlimit(
|
||||
ctx.author.voice.channel
|
||||
if (
|
||||
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(
|
||||
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_bot = member.bot is True
|
||||
is_other_channel = await self._channel_check(ctx)
|
||||
|
||||
return (
|
||||
is_active_dj
|
||||
@ -2465,6 +2849,7 @@ class Audio(commands.Cog):
|
||||
or is_admin
|
||||
or is_mod
|
||||
or is_bot
|
||||
or is_other_channel
|
||||
)
|
||||
|
||||
async def _is_alone(self, ctx, member):
|
||||
@ -2715,6 +3100,43 @@ class Audio(commands.Cog):
|
||||
|
||||
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):
|
||||
external = await self.config.use_external_lavalink()
|
||||
if not external:
|
||||
@ -2881,6 +3303,12 @@ class Audio(commands.Cog):
|
||||
return True
|
||||
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
|
||||
def _player_check(ctx):
|
||||
try:
|
||||
@ -2972,6 +3400,7 @@ class Audio(commands.Cog):
|
||||
"vimeo.com",
|
||||
"mixer.com",
|
||||
"twitch.tv",
|
||||
"spotify.com",
|
||||
"localtracks",
|
||||
]
|
||||
query_url = urlparse(url)
|
||||
@ -2989,6 +3418,80 @@ class Audio(commands.Cog):
|
||||
else:
|
||||
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):
|
||||
if after.channel != before.channel:
|
||||
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 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:
|
||||
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 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"
|
||||
"You currently have {new_balance} {currency}."
|
||||
).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.utils.chat_formatting import pagify
|
||||
|
||||
RE_WORD_SPLIT = re.compile(r"[^\w]")
|
||||
_ = Translator("Filter", __file__)
|
||||
|
||||
|
||||
@ -32,6 +31,7 @@ class Filter(commands.Cog):
|
||||
self.settings.register_member(**default_member_settings)
|
||||
self.settings.register_channel(**default_channel_settings)
|
||||
self.register_task = self.bot.loop.create_task(self.register_filterban())
|
||||
self.pattern_cache = {}
|
||||
|
||||
def __unload(self):
|
||||
self.register_task.cancel()
|
||||
@ -165,6 +165,7 @@ class Filter(commands.Cog):
|
||||
tmp += word + " "
|
||||
added = await self.add_to_filter(channel, word_list)
|
||||
if added:
|
||||
self.invalidate_cache(ctx.guild, ctx.channel)
|
||||
await ctx.send(_("Words added to filter."))
|
||||
else:
|
||||
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)
|
||||
if removed:
|
||||
await ctx.send(_("Words removed from filter."))
|
||||
self.invalidate_cache(ctx.guild, ctx.channel)
|
||||
else:
|
||||
await ctx.send(_("Those words weren't in the filter."))
|
||||
|
||||
@ -229,6 +231,7 @@ class Filter(commands.Cog):
|
||||
tmp += word + " "
|
||||
added = await self.add_to_filter(server, word_list)
|
||||
if added:
|
||||
self.invalidate_cache(ctx.guild)
|
||||
await ctx.send(_("Words successfully added to filter."))
|
||||
else:
|
||||
await ctx.send(_("Those words were already in the filter."))
|
||||
@ -261,6 +264,7 @@ class Filter(commands.Cog):
|
||||
tmp += word + " "
|
||||
removed = await self.remove_from_filter(server, word_list)
|
||||
if removed:
|
||||
self.invalidate_cache(ctx.guild)
|
||||
await ctx.send(_("Words successfully removed from filter."))
|
||||
else:
|
||||
await ctx.send(_("Those words weren't in the filter."))
|
||||
@ -279,6 +283,10 @@ class Filter(commands.Cog):
|
||||
else:
|
||||
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(
|
||||
self, server_or_channel: Union[discord.Guild, discord.TextChannel], words: list
|
||||
) -> bool:
|
||||
@ -322,24 +330,34 @@ class Filter(commands.Cog):
|
||||
async def filter_hits(
|
||||
self, text: str, server_or_channel: Union[discord.Guild, discord.TextChannel]
|
||||
) -> Set[str]:
|
||||
if isinstance(server_or_channel, discord.Guild):
|
||||
word_list = set(await self.settings.guild(server_or_channel).filter())
|
||||
elif isinstance(server_or_channel, discord.TextChannel):
|
||||
word_list = set(
|
||||
await self.settings.guild(server_or_channel.guild).filter()
|
||||
+ await self.settings.channel(server_or_channel).filter()
|
||||
)
|
||||
else:
|
||||
raise TypeError("%r should be Guild or TextChannel" % server_or_channel)
|
||||
|
||||
content = text.lower()
|
||||
msg_words = set(RE_WORD_SPLIT.split(content))
|
||||
try:
|
||||
guild = server_or_channel.guild
|
||||
channel = server_or_channel
|
||||
except AttributeError:
|
||||
guild = server_or_channel
|
||||
channel = None
|
||||
|
||||
filtered_phrases = {x for x in word_list if len(RE_WORD_SPLIT.split(x)) > 1}
|
||||
filtered_words = word_list - filtered_phrases
|
||||
hits: Set[str] = set()
|
||||
|
||||
hits = {p for p in filtered_phrases if p in content}
|
||||
hits |= filtered_words & msg_words
|
||||
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:
|
||||
pattern = None
|
||||
|
||||
self.pattern_cache[(guild, channel)] = pattern
|
||||
|
||||
if pattern:
|
||||
hits |= set(pattern.findall(text))
|
||||
return hits
|
||||
|
||||
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.
|
||||
self.config = config.Config.get_conf(self, identifier=78631113035100160)
|
||||
self.config.register_global(version="")
|
||||
self.config.init_custom(COG, 1)
|
||||
self.config.register_custom(COG)
|
||||
self.config.init_custom(COMMAND, 1)
|
||||
self.config.register_custom(COMMAND)
|
||||
|
||||
@commands.group()
|
||||
@ -278,7 +280,7 @@ class Permissions(commands.Cog):
|
||||
ctx: commands.Context,
|
||||
allow_or_deny: RuleType,
|
||||
cog_or_command: CogOrCommand,
|
||||
who_or_what: GlobalUniqueObjectFinder,
|
||||
who_or_what: commands.Greedy[GlobalUniqueObjectFinder],
|
||||
):
|
||||
"""Add a global rule to a command.
|
||||
|
||||
@ -287,15 +289,15 @@ class Permissions(commands.Cog):
|
||||
`<cog_or_command>` is the cog or command to add the rule to.
|
||||
This is case sensitive.
|
||||
|
||||
`<who_or_what>` is the user, channel, role or server the rule
|
||||
is for.
|
||||
`<who_or_what>` is one or more users, channels or roles the rule is for.
|
||||
"""
|
||||
await self._add_rule(
|
||||
rule=cast(bool, allow_or_deny),
|
||||
cog_or_cmd=cog_or_command,
|
||||
model_id=who_or_what.id,
|
||||
guild_id=0,
|
||||
)
|
||||
for w in who_or_what:
|
||||
await self._add_rule(
|
||||
rule=cast(bool, allow_or_deny),
|
||||
cog_or_cmd=cog_or_command,
|
||||
model_id=w.id,
|
||||
guild_id=0,
|
||||
)
|
||||
await ctx.send(_("Rule added."))
|
||||
|
||||
@commands.guild_only()
|
||||
@ -306,7 +308,7 @@ class Permissions(commands.Cog):
|
||||
ctx: commands.Context,
|
||||
allow_or_deny: RuleType,
|
||||
cog_or_command: CogOrCommand,
|
||||
who_or_what: GuildUniqueObjectFinder,
|
||||
who_or_what: commands.Greedy[GuildUniqueObjectFinder],
|
||||
):
|
||||
"""Add a rule to a command in this server.
|
||||
|
||||
@ -315,14 +317,15 @@ class Permissions(commands.Cog):
|
||||
`<cog_or_command>` is the cog or command to add the rule to.
|
||||
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.
|
||||
"""
|
||||
await self._add_rule(
|
||||
rule=cast(bool, allow_or_deny),
|
||||
cog_or_cmd=cog_or_command,
|
||||
model_id=who_or_what.id,
|
||||
guild_id=ctx.guild.id,
|
||||
)
|
||||
for w in who_or_what:
|
||||
await self._add_rule(
|
||||
rule=cast(bool, allow_or_deny),
|
||||
cog_or_cmd=cog_or_command,
|
||||
model_id=w.id,
|
||||
guild_id=ctx.guild.id,
|
||||
)
|
||||
await ctx.send(_("Rule added."))
|
||||
|
||||
@checks.is_owner()
|
||||
@ -331,19 +334,17 @@ class Permissions(commands.Cog):
|
||||
self,
|
||||
ctx: commands.Context,
|
||||
cog_or_command: CogOrCommand,
|
||||
who_or_what: GlobalUniqueObjectFinder,
|
||||
who_or_what: commands.Greedy[GlobalUniqueObjectFinder],
|
||||
):
|
||||
"""Remove a global rule from a command.
|
||||
|
||||
`<cog_or_command>` is the cog or command to remove the rule
|
||||
from. This is case sensitive.
|
||||
|
||||
`<who_or_what>` is the user, channel, role or server the rule
|
||||
is for.
|
||||
`<who_or_what>` is one or more users, channels or roles the rule is for.
|
||||
"""
|
||||
await self._remove_rule(
|
||||
cog_or_cmd=cog_or_command, model_id=who_or_what.id, guild_id=GLOBAL
|
||||
)
|
||||
for w in who_or_what:
|
||||
await self._remove_rule(cog_or_cmd=cog_or_command, model_id=w.id, guild_id=GLOBAL)
|
||||
await ctx.send(_("Rule removed."))
|
||||
|
||||
@commands.guild_only()
|
||||
@ -353,19 +354,19 @@ class Permissions(commands.Cog):
|
||||
self,
|
||||
ctx: commands.Context,
|
||||
cog_or_command: CogOrCommand,
|
||||
*,
|
||||
who_or_what: GuildUniqueObjectFinder,
|
||||
who_or_what: commands.Greedy[GlobalUniqueObjectFinder],
|
||||
):
|
||||
"""Remove a server rule from a command.
|
||||
|
||||
`<cog_or_command>` is the cog or command to remove the rule
|
||||
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.
|
||||
"""
|
||||
await self._remove_rule(
|
||||
cog_or_cmd=cog_or_command, model_id=who_or_what.id, guild_id=ctx.guild.id
|
||||
)
|
||||
for w in who_or_what:
|
||||
await self._remove_rule(
|
||||
cog_or_cmd=cog_or_command, model_id=w.id, guild_id=ctx.guild.id
|
||||
)
|
||||
await ctx.send(_("Rule removed."))
|
||||
|
||||
@commands.guild_only()
|
||||
|
||||
@ -45,6 +45,7 @@ class Reports(commands.Cog):
|
||||
self.bot = bot
|
||||
self.config = Config.get_conf(self, 78631113035100160, force_registration=True)
|
||||
self.config.register_guild(**self.default_guild_settings)
|
||||
self.config.init_custom("REPORT", 2)
|
||||
self.config.register_custom("REPORT", **self.default_report)
|
||||
self.antispam = {}
|
||||
self.user_cache = []
|
||||
|
||||
@ -74,7 +74,7 @@ What is the capital of Bulgaria?:
|
||||
What is the capital of Burkina Faso?:
|
||||
- Ouagadougou
|
||||
What is the capital of Burundi?:
|
||||
- Bujumbura
|
||||
- Gitega
|
||||
What is the capital of Cabo Verde?:
|
||||
- Praia
|
||||
What is the capital of Cambodia?:
|
||||
@ -235,8 +235,6 @@ What is the capital of Lithuania?:
|
||||
- Vilnius
|
||||
What is the capital of Luxembourg?:
|
||||
- Luxembourg
|
||||
What is the capital of Macedonia?:
|
||||
- Skopje
|
||||
What is the capital of Madagascar?:
|
||||
- Antananarivo
|
||||
What is the capital of Malawi?:
|
||||
@ -292,6 +290,8 @@ What is the capital of Nigeria?:
|
||||
What is the capital of North Korea?:
|
||||
- Pyongyang
|
||||
- pyong yang
|
||||
What is the capital of North Macedonia?:
|
||||
- Skopje
|
||||
What is the capital of Norway?:
|
||||
- Oslo
|
||||
What is the capital of Oman?:
|
||||
@ -338,7 +338,7 @@ What is the capital of Samoa?:
|
||||
What is the capital of San Marino?:
|
||||
- San Marino
|
||||
- sanmarino
|
||||
What is the capital of Sao Tome and Principe?:
|
||||
What is the capital of São Tomé and Príncipe?:
|
||||
- São Tomé
|
||||
- sao tome
|
||||
- saotome
|
||||
@ -371,6 +371,7 @@ What is the capital of Spain?:
|
||||
What is the capital of Sri Lanka?:
|
||||
- Sri Jayawardenepura Kotte
|
||||
- srijawawardenpurakotte
|
||||
- Kotte
|
||||
What is the capital of Sudan?:
|
||||
- Khartoum
|
||||
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:
|
||||
- Samoa
|
||||
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:
|
||||
- Germany
|
||||
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
|
||||
What country is represented by this flag? https://i.imgur.com/8OzbswS.png:
|
||||
- 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
|
||||
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:
|
||||
- Guinea
|
||||
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
|
||||
What country is highlighted on this map? https://i.imgur.com/1xVJiLb.png:
|
||||
- Nepal
|
||||
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:
|
||||
- Tonga
|
||||
What country is highlighted on this map? https://i.imgur.com/EQSChbH.png:
|
||||
- Macedonia
|
||||
- FYROM
|
||||
- the former Yugoslav Republic of Macedonia
|
||||
- North Macedonia
|
||||
What country is highlighted on this map? https://i.imgur.com/EdETzhx.png:
|
||||
- Paraguay
|
||||
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:
|
||||
- Cote d'Ivoire
|
||||
- Ivory Coast
|
||||
- Cote Divoire
|
||||
What country is highlighted on this map? https://i.imgur.com/lHMAntb.png:
|
||||
- Pakistan
|
||||
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 .utils import common_filters
|
||||
|
||||
CUSTOM_GROUPS = "CUSTOM_GROUPS"
|
||||
|
||||
|
||||
def _is_submodule(parent, child):
|
||||
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.init_custom(CUSTOM_GROUPS, 2)
|
||||
self.db.register_custom(CUSTOM_GROUPS)
|
||||
|
||||
async def prefix_manager(bot, message):
|
||||
if not cli_flags.prefix:
|
||||
global_prefix = await bot.db.prefix()
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import argparse
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
|
||||
def confirm(m=""):
|
||||
@ -97,7 +98,14 @@ def parse_cli_flags(args):
|
||||
"login. This is useful for testing the boot "
|
||||
"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(
|
||||
"--mentionable",
|
||||
|
||||
@ -2,19 +2,33 @@ import logging
|
||||
import collections
|
||||
from copy import deepcopy
|
||||
from typing import Any, Union, Tuple, Dict, Awaitable, AsyncContextManager, TypeVar, TYPE_CHECKING
|
||||
import weakref
|
||||
|
||||
import discord
|
||||
|
||||
from .data_manager import cog_data_path, core_data_path
|
||||
from .drivers import get_driver
|
||||
from .drivers import get_driver, IdentifierData
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .drivers.red_base import BaseDriver
|
||||
|
||||
__all__ = ["Config", "get_latest_confs"]
|
||||
|
||||
log = logging.getLogger("red.config")
|
||||
|
||||
_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]):
|
||||
"""Context manager implementation of config values.
|
||||
@ -72,14 +86,14 @@ class Value:
|
||||
|
||||
"""
|
||||
|
||||
def __init__(self, identifiers: Tuple[str], default_value, driver):
|
||||
self.identifiers = identifiers
|
||||
def __init__(self, identifier_data: IdentifierData, default_value, driver):
|
||||
self.identifier_data = identifier_data
|
||||
self.default = default_value
|
||||
self.driver = driver
|
||||
|
||||
async def _get(self, default=...):
|
||||
try:
|
||||
ret = await self.driver.get(*self.identifiers)
|
||||
ret = await self.driver.get(self.identifier_data)
|
||||
except KeyError:
|
||||
return default if default is not ... else self.default
|
||||
return ret
|
||||
@ -150,13 +164,13 @@ class Value:
|
||||
"""
|
||||
if isinstance(value, dict):
|
||||
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):
|
||||
"""
|
||||
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):
|
||||
@ -178,13 +192,17 @@ class Group(Value):
|
||||
"""
|
||||
|
||||
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.force_registration = force_registration
|
||||
self.driver = driver
|
||||
|
||||
super().__init__(identifiers, {}, self.driver)
|
||||
super().__init__(identifier_data, {}, self.driver)
|
||||
|
||||
@property
|
||||
def defaults(self):
|
||||
@ -225,22 +243,24 @@ class Group(Value):
|
||||
"""
|
||||
is_group = self.is_group(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:
|
||||
return Group(
|
||||
identifiers=new_identifiers,
|
||||
identifier_data=new_identifiers,
|
||||
defaults=self._defaults[item],
|
||||
driver=self.driver,
|
||||
force_registration=self.force_registration,
|
||||
)
|
||||
elif is_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:
|
||||
raise AttributeError("'{}' is not a valid registered Group or value.".format(item))
|
||||
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):
|
||||
"""
|
||||
@ -262,8 +282,9 @@ class Group(Value):
|
||||
Multiple arguments that mirror the arguments passed in for nested
|
||||
dict access. These are casted to `str` for you.
|
||||
"""
|
||||
path = [str(p) for p in nested_path]
|
||||
await self.driver.clear(*self.identifiers, *path)
|
||||
path = tuple(str(p) for p in nested_path)
|
||||
identifier_data = self.identifier_data.add_identifier(*path)
|
||||
await self.driver.clear(identifier_data)
|
||||
|
||||
def is_group(self, item: Any) -> bool:
|
||||
"""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.
|
||||
|
||||
"""
|
||||
path = [str(p) for p in nested_path]
|
||||
path = tuple(str(p) for p in nested_path)
|
||||
|
||||
if default is ...:
|
||||
poss_default = self.defaults
|
||||
@ -380,8 +401,9 @@ class Group(Value):
|
||||
else:
|
||||
default = poss_default
|
||||
|
||||
identifier_data = self.identifier_data.add_identifier(*path)
|
||||
try:
|
||||
raw = await self.driver.get(*self.identifiers, *path)
|
||||
raw = await self.driver.get(identifier_data)
|
||||
except KeyError:
|
||||
if default is not ...:
|
||||
return default
|
||||
@ -456,10 +478,11 @@ class Group(Value):
|
||||
value
|
||||
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):
|
||||
value = _str_key_dict(value)
|
||||
await self.driver.set(*self.identifiers, *path, value=value)
|
||||
await self.driver.set(identifier_data, value=value)
|
||||
|
||||
|
||||
class Config:
|
||||
@ -505,6 +528,19 @@ class Config:
|
||||
USER = "USER"
|
||||
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__(
|
||||
self,
|
||||
cog_name: str,
|
||||
@ -520,6 +556,8 @@ class Config:
|
||||
self.force_registration = force_registration
|
||||
self._defaults = defaults or {}
|
||||
|
||||
self.custom_groups = {}
|
||||
|
||||
@property
|
||||
def defaults(self):
|
||||
return deepcopy(self._defaults)
|
||||
@ -569,13 +607,9 @@ class Config:
|
||||
# We have to import this here otherwise we have a circular dependency
|
||||
from .data_manager import basic_config
|
||||
|
||||
log.debug("Basic config: \n\n{}".format(basic_config))
|
||||
|
||||
driver_name = basic_config.get("STORAGE_TYPE", "JSON")
|
||||
driver_details = basic_config.get("STORAGE_DETAILS", {})
|
||||
|
||||
log.debug("Using driver: '{}'".format(driver_name))
|
||||
|
||||
driver = get_driver(
|
||||
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)
|
||||
|
||||
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
|
||||
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(
|
||||
identifiers=(key, *identifiers),
|
||||
defaults=self.defaults.get(key, {}),
|
||||
identifier_data=identifier_data,
|
||||
defaults=self.defaults.get(category, {}),
|
||||
driver=self.driver,
|
||||
force_registration=self.force_registration,
|
||||
)
|
||||
@ -891,6 +950,8 @@ class Config:
|
||||
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))
|
||||
|
||||
async def _all_from_scope(self, scope: str) -> Dict[int, Dict[Any, Any]]:
|
||||
@ -908,7 +969,7 @@ class Config:
|
||||
ret = {}
|
||||
|
||||
try:
|
||||
dict_ = await self.driver.get(*group.identifiers)
|
||||
dict_ = await self.driver.get(group.identifier_data)
|
||||
except KeyError:
|
||||
pass
|
||||
else:
|
||||
@ -1025,7 +1086,7 @@ class Config:
|
||||
if guild is None:
|
||||
group = self._get_base_group(self.MEMBER)
|
||||
try:
|
||||
dict_ = await self.driver.get(*group.identifiers)
|
||||
dict_ = await self.driver.get(group.identifier_data)
|
||||
except KeyError:
|
||||
pass
|
||||
else:
|
||||
@ -1034,7 +1095,7 @@ class Config:
|
||||
else:
|
||||
group = self._get_base_group(self.MEMBER, str(guild.id))
|
||||
try:
|
||||
guild_data = await self.driver.get(*group.identifiers)
|
||||
guild_data = await self.driver.get(group.identifier_data)
|
||||
except KeyError:
|
||||
pass
|
||||
else:
|
||||
@ -1061,7 +1122,10 @@ class Config:
|
||||
"""
|
||||
if not scopes:
|
||||
# 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:
|
||||
group = self._get_base_group(*scopes)
|
||||
await group.clear()
|
||||
|
||||
@ -1491,8 +1491,8 @@ class Core(commands.Cog, CoreLogic):
|
||||
"""
|
||||
user = isinstance(user_or_role, discord.Member)
|
||||
async with ctx.bot.db.guild(ctx.guild).whitelist() as curr_list:
|
||||
if obj.id not in curr_list:
|
||||
curr_list.append(obj.id)
|
||||
if user_or_role.id not in curr_list:
|
||||
curr_list.append(user_or_role.id)
|
||||
|
||||
if user:
|
||||
await ctx.send(_("User added to whitelist."))
|
||||
@ -1524,9 +1524,9 @@ class Core(commands.Cog, CoreLogic):
|
||||
|
||||
removed = False
|
||||
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
|
||||
curr_list.remove(obj.id)
|
||||
curr_list.remove(user_or_role.id)
|
||||
|
||||
if removed:
|
||||
if user:
|
||||
@ -1570,8 +1570,8 @@ class Core(commands.Cog, CoreLogic):
|
||||
return
|
||||
|
||||
async with ctx.bot.db.guild(ctx.guild).blacklist() as curr_list:
|
||||
if obj.id not in curr_list:
|
||||
curr_list.append(obj.id)
|
||||
if user_or_role.id not in curr_list:
|
||||
curr_list.append(user_or_role.id)
|
||||
|
||||
if user:
|
||||
await ctx.send(_("User added to blacklist."))
|
||||
@ -1603,9 +1603,9 @@ class Core(commands.Cog, CoreLogic):
|
||||
user = isinstance(user_or_role, discord.Member)
|
||||
|
||||
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
|
||||
curr_list.remove(obj.id)
|
||||
curr_list.remove(user_or_role.id)
|
||||
|
||||
if removed:
|
||||
if user:
|
||||
|
||||
@ -112,7 +112,8 @@ def cog_data_path(cog_instance=None, raw_name: str = None) -> Path:
|
||||
Parameters
|
||||
----------
|
||||
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
|
||||
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):
|
||||
@ -26,8 +28,13 @@ def get_driver(type, *args, **kwargs):
|
||||
from .red_json import JSON
|
||||
|
||||
return JSON(*args, **kwargs)
|
||||
elif type == "MongoDB":
|
||||
elif type == "MongoDBV2":
|
||||
from .red_mongo import Mongo
|
||||
|
||||
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))
|
||||
|
||||
@ -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:
|
||||
@ -6,14 +72,13 @@ class BaseDriver:
|
||||
self.cog_name = cog_name
|
||||
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.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
identifiers
|
||||
A list of identifiers that correspond to nested dict accesses.
|
||||
identifier_data
|
||||
|
||||
Returns
|
||||
-------
|
||||
@ -33,20 +98,19 @@ class BaseDriver:
|
||||
"""
|
||||
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.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
identifiers
|
||||
A list of identifiers that correspond to nested dict accesses.
|
||||
identifier_data
|
||||
value
|
||||
Any JSON serializable python object.
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
async def clear(self, *identifiers: str):
|
||||
async def clear(self, identifier_data: IdentifierData):
|
||||
"""
|
||||
Clears out the value specified by the given identifiers.
|
||||
|
||||
@ -54,7 +118,6 @@ class BaseDriver:
|
||||
|
||||
Parameters
|
||||
----------
|
||||
identifiers
|
||||
A list of identifiers that correspond to nested dict accesses.
|
||||
identifier_data
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
@ -6,7 +6,7 @@ import logging
|
||||
|
||||
from ..json_io import JsonIO
|
||||
|
||||
from .red_base import BaseDriver
|
||||
from .red_base import BaseDriver, IdentifierData
|
||||
|
||||
__all__ = ["JSON"]
|
||||
|
||||
@ -93,16 +93,16 @@ class JSON(BaseDriver):
|
||||
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
|
||||
full_identifiers = (self.unique_cog_identifier, *identifiers)
|
||||
full_identifiers = identifier_data.to_tuple()
|
||||
for i in full_identifiers:
|
||||
partial = partial[i]
|
||||
return copy.deepcopy(partial)
|
||||
|
||||
async def set(self, *identifiers: str, value=None):
|
||||
async def set(self, identifier_data: IdentifierData, value=None):
|
||||
partial = self.data
|
||||
full_identifiers = (self.unique_cog_identifier, *identifiers)
|
||||
full_identifiers = identifier_data.to_tuple()
|
||||
for i in full_identifiers[:-1]:
|
||||
if i not in partial:
|
||||
partial[i] = {}
|
||||
@ -111,9 +111,9 @@ class JSON(BaseDriver):
|
||||
partial[full_identifiers[-1]] = copy.deepcopy(value)
|
||||
await self.jsonIO._threadsafe_save_json(self.data)
|
||||
|
||||
async def clear(self, *identifiers: str):
|
||||
async def clear(self, identifier_data: IdentifierData):
|
||||
partial = self.data
|
||||
full_identifiers = (self.unique_cog_identifier, *identifiers)
|
||||
full_identifiers = identifier_data.to_tuple()
|
||||
try:
|
||||
for i in full_identifiers[:-1]:
|
||||
partial = partial[i]
|
||||
|
||||
@ -1,11 +1,12 @@
|
||||
import re
|
||||
from typing import Match, Pattern
|
||||
from typing import Match, Pattern, Tuple
|
||||
from urllib.parse import quote_plus
|
||||
|
||||
import motor.core
|
||||
import motor.motor_asyncio
|
||||
from motor.motor_asyncio import AsyncIOMotorCursor
|
||||
|
||||
from .red_base import BaseDriver
|
||||
from .red_base import BaseDriver, IdentifierData
|
||||
|
||||
__all__ = ["Mongo"]
|
||||
|
||||
@ -64,66 +65,119 @@ class Mongo(BaseDriver):
|
||||
"""
|
||||
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.
|
||||
|
||||
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`.
|
||||
|
||||
:param str collection_name:
|
||||
:param str category:
|
||||
:return:
|
||||
PyMongo collection object.
|
||||
"""
|
||||
return self.db[self.cog_name]
|
||||
return self.db[self.cog_name][category]
|
||||
|
||||
@staticmethod
|
||||
def _parse_identifiers(identifiers):
|
||||
uuid, identifiers = identifiers[0], identifiers[1:]
|
||||
return uuid, identifiers
|
||||
def get_primary_key(self, identifier_data: IdentifierData) -> Tuple[str]:
|
||||
# noinspection PyTypeChecker
|
||||
return identifier_data.primary_key
|
||||
|
||||
async def get(self, *identifiers: str):
|
||||
mongo_collection = self.get_collection()
|
||||
async def rebuild_dataset(self, identifier_data: IdentifierData, cursor: AsyncIOMotorCursor):
|
||||
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),)
|
||||
dot_identifiers = ".".join(identifiers)
|
||||
async def get(self, identifier_data: IdentifierData):
|
||||
mongo_collection = self.get_collection(identifier_data.category)
|
||||
|
||||
partial = await mongo_collection.find_one(
|
||||
filter={"_id": self.unique_cog_identifier}, projection={dot_identifiers: True}
|
||||
)
|
||||
pkey_filter = self.generate_primary_key_filter(identifier_data)
|
||||
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:
|
||||
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]
|
||||
if isinstance(partial, dict):
|
||||
return self._unescape_dict_keys(partial)
|
||||
return partial
|
||||
|
||||
async def set(self, *identifiers: str, value=None):
|
||||
dot_identifiers = ".".join(map(self._escape_key, identifiers))
|
||||
async def set(self, identifier_data: IdentifierData, value=None):
|
||||
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 len(value) == 0:
|
||||
await self.clear(identifier_data)
|
||||
return
|
||||
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(
|
||||
{"_id": self.unique_cog_identifier},
|
||||
update={"$set": {dot_identifiers: value}},
|
||||
{"_id": {"RED_uuid": uuid, "RED_primary_key": primary_key}},
|
||||
update=update_stmt,
|
||||
upsert=True,
|
||||
)
|
||||
|
||||
async def clear(self, *identifiers: str):
|
||||
dot_identifiers = ".".join(map(self._escape_key, identifiers))
|
||||
mongo_collection = self.get_collection()
|
||||
|
||||
if len(identifiers) > 0:
|
||||
await mongo_collection.update_one(
|
||||
{"_id": self.unique_cog_identifier}, update={"$unset": {dot_identifiers: 1}}
|
||||
)
|
||||
def generate_primary_key_filter(self, identifier_data: IdentifierData):
|
||||
uuid = self._escape_key(identifier_data.uuid)
|
||||
primary_key = list(map(self._escape_key, self.get_primary_key(identifier_data)))
|
||||
ret = {"_id.RED_uuid": uuid}
|
||||
if len(identifier_data.identifiers) > 0:
|
||||
ret["_id.RED_primary_key"] = primary_key
|
||||
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:
|
||||
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
|
||||
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 commands
|
||||
from .config import get_latest_confs
|
||||
from .data_manager import storage_type
|
||||
from .utils.chat_formatting import inline, bordered, format_perms_list, humanize_timedelta
|
||||
from .utils import fuzzy_command_search, format_fuzzy_results
|
||||
@ -236,7 +237,8 @@ def init_events(bot, cli_flags):
|
||||
await ctx.send(
|
||||
"This command is on cooldown. Try again in {}.".format(
|
||||
humanize_timedelta(seconds=error.retry_after)
|
||||
)
|
||||
),
|
||||
delete_after=error.retry_after,
|
||||
)
|
||||
else:
|
||||
log.exception(type(error).__name__, exc_info=error)
|
||||
@ -304,6 +306,14 @@ def init_events(bot, cli_flags):
|
||||
if command_obj is not None:
|
||||
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():
|
||||
"""Get specs for displaying the startup screen on stdout.
|
||||
|
||||
@ -47,7 +47,6 @@ class JsonIO:
|
||||
And:
|
||||
https://www.mjmwired.net/kernel/Documentation/filesystems/ext4.txt#310
|
||||
"""
|
||||
log.debug("Saving file {}".format(self.path))
|
||||
filename = self.path.stem
|
||||
tmp_file = "{}-{}.tmp".format(filename, uuid4().fields[0])
|
||||
tmp_path = self.path.parent / tmp_file
|
||||
@ -80,7 +79,6 @@ class JsonIO:
|
||||
|
||||
# noinspection PyUnresolvedReferences
|
||||
def _load_json(self):
|
||||
log.debug("Reading file {}".format(self.path))
|
||||
with self.path.open(encoding="utf-8", mode="r") as f:
|
||||
data = json.load(f)
|
||||
return data
|
||||
|
||||
@ -746,7 +746,6 @@ async def register_casetypes(new_types: List[dict]) -> List[CaseType]:
|
||||
|
||||
Raises
|
||||
------
|
||||
RuntimeError
|
||||
KeyError
|
||||
ValueError
|
||||
AttributeError
|
||||
@ -761,13 +760,9 @@ async def register_casetypes(new_types: List[dict]) -> List[CaseType]:
|
||||
try:
|
||||
ct = await register_casetype(**new_type)
|
||||
except RuntimeError:
|
||||
raise
|
||||
except ValueError:
|
||||
raise
|
||||
except AttributeError:
|
||||
raise
|
||||
except TypeError:
|
||||
raise
|
||||
# We pass here because RuntimeError signifies the case was
|
||||
# already registered.
|
||||
pass
|
||||
else:
|
||||
type_list.append(ct)
|
||||
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()
|
||||
|
||||
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)
|
||||
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
|
||||
from collections import namedtuple
|
||||
from pathlib import Path
|
||||
import weakref
|
||||
|
||||
import pytest
|
||||
from _pytest.monkeypatch import MonkeyPatch
|
||||
from redbot.core import Config
|
||||
from redbot.core.bot import Red
|
||||
from redbot.core import config as config_module
|
||||
|
||||
from redbot.core.drivers import red_json
|
||||
|
||||
@ -65,11 +67,11 @@ def json_driver(tmpdir_factory):
|
||||
|
||||
@pytest.fixture()
|
||||
def config(json_driver):
|
||||
config_module._config_cache = weakref.WeakValueDictionary()
|
||||
conf = Config(
|
||||
cog_name="PyTest", unique_identifier=json_driver.unique_cog_identifier, driver=json_driver
|
||||
)
|
||||
yield conf
|
||||
conf._defaults = {}
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
@ -77,6 +79,7 @@ def config_fr(json_driver):
|
||||
"""
|
||||
Mocked config object with force_register enabled.
|
||||
"""
|
||||
config_module._config_cache = weakref.WeakValueDictionary()
|
||||
conf = Config(
|
||||
cog_name="PyTest",
|
||||
unique_identifier=json_driver.unique_cog_identifier,
|
||||
@ -84,7 +87,6 @@ def config_fr(json_driver):
|
||||
force_registration=True,
|
||||
)
|
||||
yield conf
|
||||
conf._defaults = {}
|
||||
|
||||
|
||||
# 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("Please choose your storage backend (if you're unsure, choose 1).")
|
||||
print("1. JSON (file storage, requires no database).")
|
||||
print("2. MongoDB (not recommended, currently unstable)")
|
||||
print("2. MongoDB")
|
||||
storage = input("> ")
|
||||
try:
|
||||
storage = int(storage)
|
||||
@ -260,23 +260,28 @@ async def edit_instance():
|
||||
if confirm("Would you like to change the storage type? (y/n):"):
|
||||
storage = get_storage_type()
|
||||
|
||||
storage_dict = {1: "JSON", 2: "MongoDB"}
|
||||
storage_dict = {1: "JSON", 2: "MongoDBV2"}
|
||||
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
|
||||
|
||||
storage_details = get_config_details()
|
||||
default_dirs["STORAGE_DETAILS"] = storage_details
|
||||
|
||||
if instance_data["STORAGE_TYPE"] == "JSON":
|
||||
if confirm("Would you like to import your data? (y/n) "):
|
||||
await json_to_mongo(current_data_dir, storage_details)
|
||||
else:
|
||||
raise NotImplementedError("We cannot convert from JSON to MongoDB at this time.")
|
||||
# if confirm("Would you like to import your data? (y/n) "):
|
||||
# await json_to_mongo(current_data_dir, storage_details)
|
||||
elif storage_dict.get(storage, 1) == "JSON":
|
||||
storage_details = instance_data["STORAGE_DETAILS"]
|
||||
default_dirs["STORAGE_DETAILS"] = {}
|
||||
if instance_data["STORAGE_TYPE"] == "MongoDB":
|
||||
if confirm("Would you like to import your data? (y/n) "):
|
||||
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:
|
||||
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
|
||||
problem_lists = []
|
||||
for l in list_names:
|
||||
with l.open() as f:
|
||||
with l.open(encoding="utf-8") as f:
|
||||
try:
|
||||
dict_ = yaml.safe_load(f)
|
||||
except yaml.error.YAMLError as e:
|
||||
|
||||
@ -490,3 +490,19 @@ async def test_cast_str_nested(config):
|
||||
config.register_global(foo={})
|
||||
await config.foo.set({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