Merge remote-tracking branch 'release/V3/develop' into V3/develop

This commit is contained in:
palmtree5 2019-04-16 18:32:38 -08:00
commit 62f15e52a0
78 changed files with 3127 additions and 4121 deletions

28
LICENSE
View File

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

View File

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

View File

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

View File

@ -40,12 +40,6 @@ Mod Helpers
.. automodule:: redbot.core.utils.mod
:members:
V2 Data Conversion
==================
.. automodule:: redbot.core.utils.data_converter
:members: DataConverter
Tunnel
======

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,6 +0,0 @@
from redbot.core.bot import Red
from .dataconverter import DataConverter
def setup(bot: Red):
bot.add_cog(DataConverter(bot))

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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"
"Возможно, в будущем я смогу конвертировать еще больше вещей."

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

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

View File

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

View File

@ -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 = []

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,26 +0,0 @@
{
"1" : {
"1" : [
"Test",
"Test2",
"TEST3"
],
"2" : [
"Test4",
"Test5",
"TEST6"
]
},
"2" : {
"1" : [
"Test",
"Test2",
"TEST3"
],
"2" : [
"Test4",
"Test5",
"TEST6"
]
}
}

View File

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

View File

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

View File

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