From 050300040c3780abb91bf11543884f54c64e3da6 Mon Sep 17 00:00:00 2001 From: aikaterna <20862007+aikaterna@users.noreply.github.com> Date: Thu, 28 Mar 2019 10:41:17 -0700 Subject: [PATCH 01/30] [V3 Audio] Add Spotify support (#2328) * [V3 Audio] Add Spotify support * [V3 Audio] Update LICENSE * Appeasing the style gods * Extra word removal on LICENSE * Update for #2389 Thanks to TrustyJAID for the help. * Playlist command support for Spotify URLs or codes * Add exception for dc while loading Spotify tracks * Allow Spotify urls by default in audioset restrict Matches the behavior of Spotify codes already being allowed by default. * Update audio.py * .format() moving * Added a character to try to make Travis behave --- LICENSE | 28 ++ redbot/cogs/audio/audio.py | 517 ++++++++++++++++++++++++++++++++----- 2 files changed, 486 insertions(+), 59 deletions(-) diff --git a/LICENSE b/LICENSE index fad70566e..7333f92db 100644 --- a/LICENSE +++ b/LICENSE @@ -672,3 +672,31 @@ may consider it more useful to permit linking proprietary applications with the library. If this is what you want to do, use the GNU Lesser General Public License instead of this License. But first, please read . + +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. diff --git a/redbot/cogs/audio/audio.py b/redbot/cogs/audio/audio.py index 28d663b8c..42b1c4241 100644 --- a/redbot/cogs/audio/audio.py +++ b/redbot/cogs/audio/audio.py @@ -1,5 +1,6 @@ import aiohttp import asyncio +import base64 import datetime import discord from fuzzywuzzy import process @@ -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: @@ -1021,6 +1062,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 +1078,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"): + 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 +1226,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 +1266,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() @@ -1680,21 +1966,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 @@ -2715,6 +3022,17 @@ class Audio(commands.Cog): self._restart_connect() + 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 +3199,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 +3296,7 @@ class Audio(commands.Cog): "vimeo.com", "mixer.com", "twitch.tv", + "spotify.com", "localtracks", ] query_url = urlparse(url) @@ -2989,6 +3314,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: From c7608aeb1728c1f06d46b2bc441e4c78dd5e157d Mon Sep 17 00:00:00 2001 From: Michael H Date: Mon, 1 Apr 2019 07:34:27 -0400 Subject: [PATCH 02/30] [V3 Mod] Use a composite class for mod (#2501) * [V3 Mod] Use a composite class for mod This turns mod.py into acomposite class I've split things in this up based on purpose, including `movetocore` `movetocore` is a set of things which likely belong in the core bot and not relying on mod being loaded. This is part of #2500 Per discussion in discord, this should be the first thing in #2500 merged * Move this back, mod was importable, and this was intended as non-breaking * Prevent fix from being lost if merged before this. see Cog-Creators/Red-DiscordBot#2510 * Move case creation to before sending see #2515 * fix failed merge done in web --- redbot/cogs/mod/abc.py | 33 + redbot/cogs/mod/casetypes.py | 100 ++ redbot/cogs/mod/converters.py | 16 + redbot/cogs/mod/events.py | 176 ++++ redbot/cogs/mod/kickban.py | 565 +++++++++++ redbot/cogs/mod/mod.py | 1743 +-------------------------------- redbot/cogs/mod/movetocore.py | 125 +++ redbot/cogs/mod/mutes.py | 465 +++++++++ redbot/cogs/mod/names.py | 185 ++++ redbot/cogs/mod/settings.py | 168 ++++ 10 files changed, 1852 insertions(+), 1724 deletions(-) create mode 100644 redbot/cogs/mod/abc.py create mode 100644 redbot/cogs/mod/casetypes.py create mode 100644 redbot/cogs/mod/converters.py create mode 100644 redbot/cogs/mod/events.py create mode 100644 redbot/cogs/mod/kickban.py create mode 100644 redbot/cogs/mod/movetocore.py create mode 100644 redbot/cogs/mod/mutes.py create mode 100644 redbot/cogs/mod/names.py create mode 100644 redbot/cogs/mod/settings.py diff --git a/redbot/cogs/mod/abc.py b/redbot/cogs/mod/abc.py new file mode 100644 index 000000000..d27d80933 --- /dev/null +++ b/redbot/cogs/mod/abc.py @@ -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() diff --git a/redbot/cogs/mod/casetypes.py b/redbot/cogs/mod/casetypes.py new file mode 100644 index 000000000..e5aad3ca4 --- /dev/null +++ b/redbot/cogs/mod/casetypes.py @@ -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", + }, +] diff --git a/redbot/cogs/mod/converters.py b/redbot/cogs/mod/converters.py new file mode 100644 index 000000000..8bed65c77 --- /dev/null +++ b/redbot/cogs/mod/converters.py @@ -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)) diff --git a/redbot/cogs/mod/events.py b/redbot/cogs/mod/events.py new file mode 100644 index 000000000..ca42a8019 --- /dev/null +++ b/redbot/cogs/mod/events.py @@ -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) diff --git a/redbot/cogs/mod/kickban.py b/redbot/cogs/mod/kickban.py new file mode 100644 index 000000000..159d109bd --- /dev/null +++ b/redbot/cogs/mod/kickban.py @@ -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) + ) diff --git a/redbot/cogs/mod/mod.py b/redbot/cogs/mod/mod.py index bec9275e7..7c4c639b7 100644 --- a/redbot/cogs/mod/mod.py +++ b/redbot/cogs/mod/mod.py @@ -1,42 +1,23 @@ -import asyncio -import contextlib -from datetime import datetime, timedelta -from collections import deque, defaultdict, namedtuple -from typing import Optional, Union, cast +from collections import deque, defaultdict +from typing import List, Tuple import discord - -from discord.ext.commands.converter import Converter, Greedy -from discord.ext.commands.errors import BadArgument -from redbot.core import checks, Config, modlog, commands +from redbot.core import Config, modlog, commands from redbot.core.bot import Red from redbot.core.i18n import Translator, cog_i18n -from redbot.core.utils.chat_formatting import box, escape, pagify, format_perms_list -from redbot.core.utils.common_filters import ( - filter_invites, - filter_various_mentions, - escape_spoilers, -) -from redbot.core.utils.mod import is_mod_or_superior, is_allowed_by_hierarchy, get_audit_reason -from .log import log +from .casetypes import CASETYPES +from .events import Events +from .kickban import KickBanMixin +from .movetocore import MoveToCore +from .mutes import MuteMixin +from .names import ModInfo +from .settings import ModSettings _ = T_ = 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)) - - @cog_i18n(_) -class Mod(commands.Cog): +class Mod(ModSettings, Events, KickBanMixin, MoveToCore, MuteMixin, ModInfo, commands.Cog): """Moderation tools.""" default_guild_settings = { @@ -58,19 +39,18 @@ class Mod(commands.Cog): def __init__(self, bot: Red): super().__init__() self.bot = bot - self.settings = Config.get_conf(self, 4961522000, force_registration=True) + self.settings = Config.get_conf(self, 4961522000, force_registration=True) self.settings.register_guild(**self.default_guild_settings) self.settings.register_channel(**self.default_channel_settings) self.settings.register_member(**self.default_member_settings) self.settings.register_user(**self.default_user_settings) - self.ban_queue = [] - self.unban_queue = [] - self.cache = defaultdict(lambda: deque(maxlen=3)) - + self.ban_queue: List[Tuple[int, int]] = [] + self.unban_queue: List[Tuple[int, int]] = [] + self.cache: dict = defaultdict(lambda: deque(maxlen=3)) self.registration_task = self.bot.loop.create_task(self._casetype_registration()) self.tban_expiry_task = self.bot.loop.create_task(self.check_tempban_expirations()) - self.last_case = defaultdict(dict) + self.last_case: dict = defaultdict(dict) def __unload(self): self.registration_task.cancel() @@ -78,1284 +58,13 @@ class Mod(commands.Cog): @staticmethod async def _casetype_registration(): - casetypes_to_register = [ - { - "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", - }, - ] try: - await modlog.register_casetypes(casetypes_to_register) + await modlog.register_casetypes(CASETYPES) except RuntimeError: pass - @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 - `` 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" - ) - ) - - @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: - await ctx.send(_("Done. That felt good.")) - - 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) - - @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: Optional[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: 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""" - - 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 (days >= 0 and 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: - await ctx.send(_("Done. Enough chaos for now")) - - 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) - - @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: - await ctx.send(_("Done. Enough chaos.")) - 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) - - @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: - await ctx.send(_("Unbanned that user from this server")) - - 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) - - 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) - ) - - @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 - - @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 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 - await ctx.send(_("User has been banned from speaking or listening in voice channels")) - - 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) - - @commands.command() - @commands.guild_only() - 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 - await ctx.send(_("User is now allowed to speak and listen in voice channels")) - 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) - - @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.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: - await ctx.send( - _("Muted {user} in channel {channel.name}").format(user=user, channel=channel) - ) - 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) - 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: - await channel.send(_("User has been muted in this channel.")) - 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) - 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) - await ctx.send(_("User has been muted in this server.")) - 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) - - 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, T_(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, T_(mute_unmute_issues["already_muted"]) - - elif not await is_allowed_by_hierarchy(self.bot, self.settings, guild, author, user): - return False, T_(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, T_(mute_unmute_issues["permissions_issue"]) - else: - await self.settings.member(user).set_raw( - "perms_cache", str(channel.id), value=old_overs - ) - return True, None - - @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: - await ctx.send( - _("Unmuted {user} in channel {channel.name}").format(user=user, channel=channel) - ) - 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) - 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: - await ctx.send(_("User unmuted in this channel.")) - 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) - 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) - await ctx.send(_("User has been unmuted in this server.")) - 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) - - 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, T_(mute_unmute_issues["already_unmuted"]) - - elif not await is_allowed_by_hierarchy(self.bot, self.settings, guild, author, user): - return False, T_(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, T_(mute_unmute_issues["permissions_issue"]) - else: - await self.settings.member(user).clear_raw("perms_cache", str(channel.id)) - return True, None - - @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) - + # TODO: Move this to core. + # This would be in .movetocore , but the double-under name here makes that more trouble async def __global_check(self, ctx): """Global check to see if a channel or server is ignored. @@ -1375,382 +84,6 @@ class Mod(commands.Cog): chann_ignored = await self.settings.channel(ctx.channel).ignored() return not (guild_ignored or chann_ignored and not perms.manage_channels) - @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.")) - - async def ban_user( - self, - user: discord.Member, - ctx: commands.Context, - days: Optional[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 (days >= 0 and 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 - - 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 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(escape(name, mass_mentions=True)) for name in names if name] - if nicks: - nicks = [escape_spoilers(escape(nick, mass_mentions=True)) for nick in nicks if nick] - return names, nicks - - 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) - - 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_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) - - 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) - @classmethod async def get_audit_entry_info( cls, guild: discord.Guild, action: discord.AuditLogAction, target @@ -1806,41 +139,3 @@ class Mod(commands.Cog): async for entry in guild.audit_logs(action=action): if entry.target == target: return entry - - 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) - - -_ = 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." - ), -} diff --git a/redbot/cogs/mod/movetocore.py b/redbot/cogs/mod/movetocore.py new file mode 100644 index 000000000..d914532bf --- /dev/null +++ b/redbot/cogs/mod/movetocore.py @@ -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) diff --git a/redbot/cogs/mod/mutes.py b/redbot/cogs/mod/mutes.py new file mode 100644 index 000000000..5ee6126cc --- /dev/null +++ b/redbot/cogs/mod/mutes.py @@ -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 diff --git a/redbot/cogs/mod/names.py b/redbot/cogs/mod/names.py new file mode 100644 index 000000000..d072aa233 --- /dev/null +++ b/redbot/cogs/mod/names.py @@ -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.")) diff --git a/redbot/cogs/mod/settings.py b/redbot/cogs/mod/settings.py new file mode 100644 index 000000000..62d3293ae --- /dev/null +++ b/redbot/cogs/mod/settings.py @@ -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 + `` 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" + ) + ) From 80fc639480309f8894cf1d6b31397e27c34960cb Mon Sep 17 00:00:00 2001 From: aikaterna <20862007+aikaterna@users.noreply.github.com> Date: Tue, 2 Apr 2019 18:12:55 -0700 Subject: [PATCH 03/30] [V3 Audio] Queue clean and queue clear addition (#2476) * [V3 Audio] Queue clean and queue clear addition * Use DJ role and existing checks inst. of mod/admin * Remove unneeded .format() --- redbot/cogs/audio/audio.py | 51 +++++++++++++++++++++++++++++++++++++- 1 file changed, 50 insertions(+), 1 deletion(-) diff --git a/redbot/cogs/audio/audio.py b/redbot/cogs/audio/audio.py index 42b1c4241..a603fdf1f 100644 --- a/redbot/cogs/audio/audio.py +++ b/redbot/cogs/audio/audio.py @@ -2051,7 +2051,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. @@ -2237,6 +2237,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): From 2a486cad66c7af4058c76c1bc2ad8d0e21aaa6cc Mon Sep 17 00:00:00 2001 From: aikaterna <20862007+aikaterna@users.noreply.github.com> Date: Tue, 2 Apr 2019 18:48:21 -0700 Subject: [PATCH 04/30] [V3 Audio] Playlist info improvements (#2274) * [V3 Audio] Playlist info improvements * Add pagify import, reformat for Black * Change from code block to embed with links --- redbot/cogs/audio/audio.py | 51 +++++++++++++++++++++++++++----------- 1 file changed, 36 insertions(+), 15 deletions(-) diff --git a/redbot/cogs/audio/audio.py b/redbot/cogs/audio/audio.py index a603fdf1f..cbc4d4de4 100644 --- a/redbot/cogs/audio/audio.py +++ b/redbot/cogs/audio/audio.py @@ -18,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, @@ -1605,25 +1605,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): From de7d08ee755015c4e21656efe477ead26daa047f Mon Sep 17 00:00:00 2001 From: aikaterna <20862007+aikaterna@users.noreply.github.com> Date: Tue, 2 Apr 2019 19:22:57 -0700 Subject: [PATCH 05/30] [Audio] Match v2 behavior for channel change (#2521) * [Audio] Match v2 behavior for channel change * Use move_to instead of connect --- redbot/cogs/audio/audio.py | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/redbot/cogs/audio/audio.py b/redbot/cogs/audio/audio.py index cbc4d4de4..f4ff62fb6 100644 --- a/redbot/cogs/audio/audio.py +++ b/redbot/cogs/audio/audio.py @@ -2833,6 +2833,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 @@ -2842,6 +2843,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): @@ -3092,6 +3094,32 @@ 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": ""} From 14a2f98418bba28d27f55196ba3cb957fe728e36 Mon Sep 17 00:00:00 2001 From: Flame442 <34169552+Flame442@users.noreply.github.com> Date: Tue, 2 Apr 2019 22:24:20 -0400 Subject: [PATCH 06/30] [Docs] Adds `self` recommendation to `cog_data_path` (#2539) This change adds the help text of "If calling from a command or method of your cog, this should be self." from `bundled_data_path` to `cog_data_path`. This bit of text can help people who are unsure of what a "cog instance" is to understand how to use `cog_data_path`. --- redbot/core/data_manager.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/redbot/core/data_manager.py b/redbot/core/data_manager.py index 3faf00b33..cc2bd3263 100644 --- a/redbot/core/data_manager.py +++ b/redbot/core/data_manager.py @@ -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. From e7b1fa5ab51e728702065d211fae829b28a72a3f Mon Sep 17 00:00:00 2001 From: FixedThink <41233185+FixedThink@users.noreply.github.com> Date: Wed, 3 Apr 2019 04:33:40 +0200 Subject: [PATCH 07/30] [V3 Trivia] Update "World" trivias to reflect legislative changes (#2526) * Update worldmap.yaml * Update worldflags.yaml * Update worldcapitals.yaml --- redbot/cogs/trivia/data/lists/worldcapitals.yaml | 9 +++++---- redbot/cogs/trivia/data/lists/worldflags.yaml | 5 +++-- redbot/cogs/trivia/data/lists/worldmap.yaml | 7 ++++--- 3 files changed, 12 insertions(+), 9 deletions(-) diff --git a/redbot/cogs/trivia/data/lists/worldcapitals.yaml b/redbot/cogs/trivia/data/lists/worldcapitals.yaml index db495fd89..1706f9f6c 100644 --- a/redbot/cogs/trivia/data/lists/worldcapitals.yaml +++ b/redbot/cogs/trivia/data/lists/worldcapitals.yaml @@ -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?: diff --git a/redbot/cogs/trivia/data/lists/worldflags.yaml b/redbot/cogs/trivia/data/lists/worldflags.yaml index 26ce759a3..34d3c1b3a 100644 --- a/redbot/cogs/trivia/data/lists/worldflags.yaml +++ b/redbot/cogs/trivia/data/lists/worldflags.yaml @@ -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/JVcBYTS.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: diff --git a/redbot/cogs/trivia/data/lists/worldmap.yaml b/redbot/cogs/trivia/data/lists/worldmap.yaml index 73a3e9fab..3945a0bdd 100644 --- a/redbot/cogs/trivia/data/lists/worldmap.yaml +++ b/redbot/cogs/trivia/data/lists/worldmap.yaml @@ -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: From e08e95c04e98b1969f427023a5fe3ac0819c9bc8 Mon Sep 17 00:00:00 2001 From: jack1142 <6032823+jack1142@users.noreply.github.com> Date: Wed, 3 Apr 2019 04:35:21 +0200 Subject: [PATCH 08/30] [V3 Downloader] Tell user how to load the cog after [p]cog install (#2523) * tell user how to load the cog after install * use code block --- redbot/cogs/downloader/downloader.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/redbot/cogs/downloader/downloader.py b/redbot/cogs/downloader/downloader.py index 5454e5bc6..354020f38 100644 --- a/redbot/cogs/downloader/downloader.py +++ b/redbot/cogs/downloader/downloader.py @@ -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)) From 8ab39512d9cb4833c37b5249c8d13e455f786b79 Mon Sep 17 00:00:00 2001 From: Michael H Date: Tue, 2 Apr 2019 22:42:13 -0400 Subject: [PATCH 09/30] [Filter] Performance increases. (#2509) * [Filter] Performance increases. The filter was already using re to split words, this just does the entire search in re instead. A further improvement to this would cache patterns used and update them if the wordlist changes. * Add a pattern cache * exit on no-words * formatting pass * keep the return type consistent, even though this doesnt break core since this is available to cogs * ... * Quit being an idiot * Slight further improvements, a fix, and restructure Moved actual set creation out of the inner portion. Reduced config lookups in case of no filter. Fixed channel wordlist fetching. * I really should go back to using a pre-commit hook for the style stuff --- redbot/cogs/filter/filter.py | 50 ++++++++++++++++++++++++------------ 1 file changed, 34 insertions(+), 16 deletions(-) diff --git a/redbot/cogs/filter/filter.py b/redbot/cogs/filter/filter.py index 716bd5af1..b445f6e38 100644 --- a/redbot/cogs/filter/filter.py +++ b/redbot/cogs/filter/filter.py @@ -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): From 466b2b82d05b5aecab88a0ed6071b7987c2dca2e Mon Sep 17 00:00:00 2001 From: Michael H Date: Tue, 2 Apr 2019 22:46:12 -0400 Subject: [PATCH 10/30] Fix localwhitelist/localblacklist add/remove (#2531) --- redbot/core/core_commands.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/redbot/core/core_commands.py b/redbot/core/core_commands.py index b67226889..4d5d46c89 100644 --- a/redbot/core/core_commands.py +++ b/redbot/core/core_commands.py @@ -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: From 0f9501f93ac0cd406578f694799fc4e8c2bc3c66 Mon Sep 17 00:00:00 2001 From: jack1142 <6032823+jack1142@users.noreply.github.com> Date: Wed, 3 Apr 2019 04:50:01 +0200 Subject: [PATCH 11/30] Tunnel - Send message after close (#2507) --- redbot/core/utils/tunnel.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/redbot/core/utils/tunnel.py b/redbot/core/utils/tunnel.py index bd9d89c16..bae10b04c 100644 --- a/redbot/core/utils/tunnel.py +++ b/redbot/core/utils/tunnel.py @@ -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))) From 30af83aa6a978093a3f9ecb849a023420b868d4e Mon Sep 17 00:00:00 2001 From: DiscordLiz <47602820+DiscordLiz@users.noreply.github.com> Date: Tue, 2 Apr 2019 22:53:07 -0400 Subject: [PATCH 12/30] [Permissions] Allow for multiple IDs in permissions rule commands. (#2448) Use `commands.Greedy` to apply multiple rules at once. closes #2214 --- redbot/cogs/permissions/permissions.py | 57 +++++++++++++------------- 1 file changed, 28 insertions(+), 29 deletions(-) diff --git a/redbot/cogs/permissions/permissions.py b/redbot/cogs/permissions/permissions.py index d0adab069..85d440372 100644 --- a/redbot/cogs/permissions/permissions.py +++ b/redbot/cogs/permissions/permissions.py @@ -278,7 +278,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 +287,15 @@ class Permissions(commands.Cog): `` is the cog or command to add the rule to. This is case sensitive. - `` is the user, channel, role or server the rule - is for. + `` 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 +306,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 +315,15 @@ class Permissions(commands.Cog): `` is the cog or command to add the rule to. This is case sensitive. - `` is the user, channel or role the rule is for. + `` 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 +332,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. `` is the cog or command to remove the rule from. This is case sensitive. - `` is the user, channel, role or server the rule - is for. + `` 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 +352,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. `` is the cog or command to remove the rule from. This is case sensitive. - `` is the user, channel or role the rule is for. + `` 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() From 301c80031972949ab0f6cdc822886c5769547ae9 Mon Sep 17 00:00:00 2001 From: Toby Harradine Date: Wed, 3 Apr 2019 13:58:34 +1100 Subject: [PATCH 13/30] Logging enhancements and cleanup (#2502) * Logging enhancements and cleanup - Removed debug log messages every time `Config.get_conf` is used or a JSON file is read/saved. The basic configuration is now logged once with DEBUG when the bot starts up instead. - Changed logging output format to reverse date order, include seconds, and use the logger's name instead of the module, function and line number. - Log files are now kept in the `DATAPATH/core/logs` directory. Each time Red is restarted, a new log is created, and the old ones renamed in a rotating fashion. There can be a maximum of 9 logs in total. - Each log file now has a smaller max size of 500KB before it will be split into multiple parts. There are also a maximum of 9 parts of each log. - Discord.py logger now uses the same output formatter as red's loggers - Moved logging setup code into `redbot.logging` module. Signed-off-by: Toby Harradine * Reformat Signed-off-by: Toby Harradine * Implement discussed changes - We maintain a red.log over multiple runtimes, alongside a latest.log and previous.log for individual runtimes. - Naming convention changed a bit. E.g. when latest.log is just one part, it will be named latest.log. When it becomes two parts, they will both be named latest-part1.log and latest-part2.log. - Rotation direction is reversed. This means as the files end up being named in chronological order. Signed-off-by: Toby Harradine --- redbot/__main__.py | 75 +++++++------------- redbot/core/cli.py | 10 ++- redbot/core/config.py | 4 -- redbot/core/json_io.py | 2 - redbot/logging.py | 151 +++++++++++++++++++++++++++++++++++++++++ 5 files changed, 183 insertions(+), 59 deletions(-) create mode 100644 redbot/logging.py diff --git a/redbot/__main__.py b/redbot/__main__.py index c73bd5cb4..4db8fdb0c 100644 --- a/redbot/__main__.py +++ b/redbot/__main__.py @@ -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 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) @@ -125,13 +88,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) diff --git a/redbot/core/cli.py b/redbot/core/cli.py index af29bc44c..41cd59b13 100644 --- a/redbot/core/cli.py +++ b/redbot/core/cli.py @@ -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", diff --git a/redbot/core/config.py b/redbot/core/config.py index 9f67bedf3..c513e07f9 100644 --- a/redbot/core/config.py +++ b/redbot/core/config.py @@ -569,13 +569,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 ) diff --git a/redbot/core/json_io.py b/redbot/core/json_io.py index a2d01f73f..08cf5c4d5 100644 --- a/redbot/core/json_io.py +++ b/redbot/core/json_io.py @@ -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 diff --git a/redbot/logging.py b/redbot/logging.py new file mode 100644 index 000000000..676a8de30 --- /dev/null +++ b/redbot/logging.py @@ -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\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\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\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) From 82cda4b57a4a023a0c3fea96d4990ce086b9cb3a Mon Sep 17 00:00:00 2001 From: PredaaA <46051820+PredaaA@users.noreply.github.com> Date: Wed, 3 Apr 2019 05:06:30 +0200 Subject: [PATCH 14/30] Delete cooldown messages when expired (#2469) --- redbot/core/events.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/redbot/core/events.py b/redbot/core/events.py index f641c6d54..855a68d6e 100644 --- a/redbot/core/events.py +++ b/redbot/core/events.py @@ -236,7 +236,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) From d6d6d1497796a468138fbf0d7dc1af6ea71f9dfb Mon Sep 17 00:00:00 2001 From: zephyrkul Date: Tue, 2 Apr 2019 21:08:28 -0600 Subject: [PATCH 15/30] [V3 Alias] Customize Parameters (#2455) * [alias] custom parameters Signed-off-by: zephyrkul * [alias] quoted words remain quoted Signed-off-by: zephyrkul * [alias] fix no-parameter aliases Signed-off-by: zephyrkul * [alias] remove unneeded error dispatch it was expensive and did nothing anyway from my own testing --- redbot/cogs/alias/alias.py | 72 ++++++++++++++++++++++++++++++++++---- 1 file changed, 65 insertions(+), 7 deletions(-) diff --git a/redbot/cogs/alias/alias.py b/redbot/cogs/alias/alias.py index c267bf90b..529b83be2 100644 --- a/redbot/cogs/alias/alias.py +++ b/redbot/cogs/alias/alias.py @@ -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( From 1cd7e41f331723371a33668bb4b3520aa0abf078 Mon Sep 17 00:00:00 2001 From: Will Date: Wed, 3 Apr 2019 09:04:47 -0400 Subject: [PATCH 16/30] [V3 Config] Update Mongo document organization to bypass doc size restriction (#2536) * modify config to use identifier data class and update json driver * move identifier data attributes into read only properties * Update mongo get and set methods * Update get/set to use UUID separately, make clear work * Remove not implemented and fix get_raw * Update remaining untouched get/set/clear * Fix get_raw * Finally fix get_raw and set_raw * style * This is better * Sorry guys * Update get behavior to handle "all" calls as expected * style again * Why do you do this to me * style once more * Update mongo schema --- redbot/core/config.py | 66 ++++++++++------- redbot/core/drivers/__init__.py | 4 +- redbot/core/drivers/red_base.py | 64 ++++++++++++++--- redbot/core/drivers/red_json.py | 14 ++-- redbot/core/drivers/red_mongo.py | 118 ++++++++++++++++++++++--------- redbot/setup.py | 5 +- 6 files changed, 194 insertions(+), 77 deletions(-) diff --git a/redbot/core/config.py b/redbot/core/config.py index c513e07f9..f0c6bb5d3 100644 --- a/redbot/core/config.py +++ b/redbot/core/config.py @@ -6,7 +6,7 @@ from typing import Any, Union, Tuple, Dict, Awaitable, AsyncContextManager, Type 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 @@ -72,14 +72,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 +150,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 +178,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 +229,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 +268,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 +375,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 +387,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 +464,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: @@ -779,11 +788,17 @@ class Config: """ self._register_default(group_identifier, **kwargs) - def _get_base_group(self, key: str, *identifiers: str) -> Group: + def _get_base_group(self, category: str, *primary_keys: str) -> Group: # noinspection PyTypeChecker + identifier_data = IdentifierData( + uuid=self.unique_identifier, + category=category, + primary_key=primary_keys, + identifiers=(), + ) 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, ) @@ -904,7 +919,7 @@ class Config: ret = {} try: - dict_ = await self.driver.get(*group.identifiers) + dict_ = await self.driver.get(group.identifier_data) except KeyError: pass else: @@ -1021,7 +1036,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: @@ -1030,7 +1045,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: @@ -1057,7 +1072,8 @@ class Config: """ if not scopes: # noinspection PyTypeChecker - group = Group(identifiers=(), defaults={}, driver=self.driver) + identifier_data = IdentifierData(self.unique_identifier, "", (), ()) + group = Group(identifier_data, defaults={}, driver=self.driver) else: group = self._get_base_group(*scopes) await group.clear() diff --git a/redbot/core/drivers/__init__.py b/redbot/core/drivers/__init__.py index 6cf6ca2ca..2809427d9 100644 --- a/redbot/core/drivers/__init__.py +++ b/redbot/core/drivers/__init__.py @@ -1,4 +1,6 @@ -__all__ = ["get_driver"] +from .red_base import IdentifierData + +__all__ = ["get_driver", "IdentifierData"] def get_driver(type, *args, **kwargs): diff --git a/redbot/core/drivers/red_base.py b/redbot/core/drivers/red_base.py index 38b8bb14f..11454008e 100644 --- a/redbot/core/drivers/red_base.py +++ b/redbot/core/drivers/red_base.py @@ -1,4 +1,51 @@ -__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]): + self._uuid = uuid + self._category = category + self._primary_key = primary_key + self._identifiers = identifiers + + @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 + + def __repr__(self): + return ( + f"" + ) + + 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 + ) + + 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 +53,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 +79,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 +99,6 @@ class BaseDriver: Parameters ---------- - identifiers - A list of identifiers that correspond to nested dict accesses. + identifier_data """ raise NotImplementedError diff --git a/redbot/core/drivers/red_json.py b/redbot/core/drivers/red_json.py index 0bb4f9ae8..b9bf3ca00 100644 --- a/redbot/core/drivers/red_json.py +++ b/redbot/core/drivers/red_json.py @@ -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] diff --git a/redbot/core/drivers/red_mongo.py b/redbot/core/drivers/red_mongo.py index 6f8415bbd..2d00d468a 100644 --- a/redbot/core/drivers/red_mongo.py +++ b/redbot/core/drivers/red_mongo.py @@ -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) == 1: + # Global data + ret.update(**doc) + elif len(pkeys) > 1: + # All other data + partial = ret + for key in pkeys[1:-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 + else: + raise RuntimeError("This should not happen.") + 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 else: - await mongo_collection.delete_one({"_id": self.unique_cog_identifier}) + for i, key in enumerate(primary_key): + keyname = f"RED_primary_key.{i}" + ret["_id"][keyname] = key + 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: diff --git a/redbot/setup.py b/redbot/setup.py index d34bb8035..00a93f8a3 100644 --- a/redbot/setup.py +++ b/redbot/setup.py @@ -269,8 +269,9 @@ async def edit_instance(): 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) + 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) else: storage_details = instance_data["STORAGE_DETAILS"] default_dirs["STORAGE_DETAILS"] = {} From c63d069f699b3241529ae3048e1123020da3f080 Mon Sep 17 00:00:00 2001 From: jack1142 <6032823+jack1142@users.noreply.github.com> Date: Wed, 3 Apr 2019 17:40:20 +0200 Subject: [PATCH 17/30] [Trivia] Fix of dead image link (world flags) (#2540) * New image link for Sao Tome and Principe Current link is dead * Someone didn't pay attention on geography lessons --- redbot/cogs/trivia/data/lists/worldflags.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/redbot/cogs/trivia/data/lists/worldflags.yaml b/redbot/cogs/trivia/data/lists/worldflags.yaml index 34d3c1b3a..ec8c71a3d 100644 --- a/redbot/cogs/trivia/data/lists/worldflags.yaml +++ b/redbot/cogs/trivia/data/lists/worldflags.yaml @@ -62,7 +62,7 @@ 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/JVcBYTS.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 From fb722c79bea6f8882e94323a7dd182a0b0d13a05 Mon Sep 17 00:00:00 2001 From: Will Date: Thu, 4 Apr 2019 18:59:14 -0400 Subject: [PATCH 18/30] [V3 ModLog] Change register_casetypes behavior (#2551) * Ignore runtime error in register_casetypes * Fix documentation --- redbot/core/modlog.py | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/redbot/core/modlog.py b/redbot/core/modlog.py index 6824b2c27..e096e08f4 100644 --- a/redbot/core/modlog.py +++ b/redbot/core/modlog.py @@ -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: From 0852d1be9fed936b30743a342f3f1bc37e19f209 Mon Sep 17 00:00:00 2001 From: Will Date: Thu, 4 Apr 2019 21:47:08 -0400 Subject: [PATCH 19/30] [V3 Config] Require custom group initialization before usage (#2545) * Require custom group initialization before usage and write that data to disk * Style * add tests * remove custom info update method from drivers * clean up remnant * Turn config objects into a singleton to deal with custom group identifiers * Fix dumbassery * Stupid stupid stupid --- redbot/cogs/permissions/permissions.py | 2 ++ redbot/cogs/reports/reports.py | 1 + redbot/core/config.py | 43 +++++++++++++++++++++++++- redbot/core/drivers/red_base.py | 23 ++++++++++++-- redbot/pytest/core.py | 6 ++-- tests/core/test_config.py | 16 ++++++++++ 6 files changed, 86 insertions(+), 5 deletions(-) diff --git a/redbot/cogs/permissions/permissions.py b/redbot/cogs/permissions/permissions.py index 85d440372..1420de82c 100644 --- a/redbot/cogs/permissions/permissions.py +++ b/redbot/cogs/permissions/permissions.py @@ -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() diff --git a/redbot/cogs/reports/reports.py b/redbot/cogs/reports/reports.py index 181f51a8a..bd299b18e 100644 --- a/redbot/cogs/reports/reports.py +++ b/redbot/cogs/reports/reports.py @@ -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 = [] diff --git a/redbot/core/config.py b/redbot/core/config.py index f0c6bb5d3..400e5de7c 100644 --- a/redbot/core/config.py +++ b/redbot/core/config.py @@ -2,6 +2,7 @@ import logging import collections from copy import deepcopy from typing import Any, Union, Tuple, Dict, Awaitable, AsyncContextManager, TypeVar, TYPE_CHECKING +import weakref import discord @@ -15,6 +16,8 @@ log = logging.getLogger("red.config") _T = TypeVar("_T") +_config_cache = weakref.WeakValueDictionary() + class _ValueCtxManager(Awaitable[_T], AsyncContextManager[_T]): """Context manager implementation of config values. @@ -514,6 +517,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, @@ -529,6 +545,8 @@ class Config: self.force_registration = force_registration self._defaults = defaults or {} + self.custom_groups = {} + @property def defaults(self): return deepcopy(self._defaults) @@ -788,13 +806,32 @@ class Config: """ self._register_default(group_identifier, **kwargs) + 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( identifier_data=identifier_data, @@ -902,6 +939,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]]: @@ -1072,7 +1111,9 @@ class Config: """ if not scopes: # noinspection PyTypeChecker - identifier_data = IdentifierData(self.unique_identifier, "", (), ()) + identifier_data = IdentifierData( + self.unique_identifier, "", (), (), self.custom_groups + ) group = Group(identifier_data, defaults={}, driver=self.driver) else: group = self._get_base_group(*scopes) diff --git a/redbot/core/drivers/red_base.py b/redbot/core/drivers/red_base.py index 11454008e..236146d48 100644 --- a/redbot/core/drivers/red_base.py +++ b/redbot/core/drivers/red_base.py @@ -4,11 +4,21 @@ __all__ = ["BaseDriver", "IdentifierData"] class IdentifierData: - def __init__(self, uuid: str, category: str, primary_key: Tuple[str], identifiers: Tuple[str]): + 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): @@ -26,6 +36,10 @@ class IdentifierData: def identifiers(self): return self._identifiers + @property + def is_custom(self): + return self._is_custom + def __repr__(self): return ( f" Date: Tue, 9 Apr 2019 17:01:04 -0400 Subject: [PATCH 20/30] Kill DataConverter (#2554) * Kill DataConverter * remove the tests --- README.md | 2 - docs/cog_dataconverter.rst | 62 ------- docs/framework_utils.rst | 6 - docs/guide_data_conversion.rst | 154 ---------------- docs/index.rst | 2 - redbot/cogs/dataconverter/__init__.py | 6 - redbot/cogs/dataconverter/core_specs.py | 174 ------------------ redbot/cogs/dataconverter/dataconverter.py | 73 -------- redbot/cogs/dataconverter/locales/ar-SA.po | 56 ------ redbot/cogs/dataconverter/locales/bg-BG.po | 56 ------ redbot/cogs/dataconverter/locales/da-DK.po | 56 ------ redbot/cogs/dataconverter/locales/de-DE.po | 60 ------ redbot/cogs/dataconverter/locales/el-GR.po | 56 ------ redbot/cogs/dataconverter/locales/en-PT.po | 56 ------ redbot/cogs/dataconverter/locales/es-ES.po | 57 ------ redbot/cogs/dataconverter/locales/fi-FI.po | 56 ------ redbot/cogs/dataconverter/locales/fr-FR.po | 57 ------ redbot/cogs/dataconverter/locales/hu-HU.po | 56 ------ redbot/cogs/dataconverter/locales/id-ID.po | 56 ------ redbot/cogs/dataconverter/locales/it-IT.po | 56 ------ redbot/cogs/dataconverter/locales/ja-JP.po | 56 ------ redbot/cogs/dataconverter/locales/ko-KR.po | 56 ------ redbot/cogs/dataconverter/locales/lol-US.po | 56 ------ redbot/cogs/dataconverter/locales/nl-NL.po | 60 ------ redbot/cogs/dataconverter/locales/no-NO.po | 56 ------ redbot/cogs/dataconverter/locales/pl-PL.po | 56 ------ redbot/cogs/dataconverter/locales/pt-BR.po | 56 ------ redbot/cogs/dataconverter/locales/pt-PT.po | 56 ------ redbot/cogs/dataconverter/locales/ru-RU.po | 61 ------ redbot/cogs/dataconverter/locales/sk-SK.po | 56 ------ redbot/cogs/dataconverter/locales/sv-SE.po | 56 ------ redbot/cogs/dataconverter/locales/tr-TR.po | 56 ------ redbot/cogs/dataconverter/locales/zh-CN.po | 56 ------ redbot/core/utils/data_converter.py | 126 ------------- redbot/pytest/dataconverter.py | 12 -- tests/cogs/dataconverter/__init__.py | 0 .../data/mod/past_nicknames.json | 26 --- .../cogs/dataconverter/test_dataconverter.py | 31 ---- 38 files changed, 2089 deletions(-) delete mode 100644 docs/cog_dataconverter.rst delete mode 100644 docs/guide_data_conversion.rst delete mode 100644 redbot/cogs/dataconverter/__init__.py delete mode 100644 redbot/cogs/dataconverter/core_specs.py delete mode 100644 redbot/cogs/dataconverter/dataconverter.py delete mode 100644 redbot/cogs/dataconverter/locales/ar-SA.po delete mode 100644 redbot/cogs/dataconverter/locales/bg-BG.po delete mode 100644 redbot/cogs/dataconverter/locales/da-DK.po delete mode 100644 redbot/cogs/dataconverter/locales/de-DE.po delete mode 100644 redbot/cogs/dataconverter/locales/el-GR.po delete mode 100644 redbot/cogs/dataconverter/locales/en-PT.po delete mode 100644 redbot/cogs/dataconverter/locales/es-ES.po delete mode 100644 redbot/cogs/dataconverter/locales/fi-FI.po delete mode 100644 redbot/cogs/dataconverter/locales/fr-FR.po delete mode 100644 redbot/cogs/dataconverter/locales/hu-HU.po delete mode 100644 redbot/cogs/dataconverter/locales/id-ID.po delete mode 100644 redbot/cogs/dataconverter/locales/it-IT.po delete mode 100644 redbot/cogs/dataconverter/locales/ja-JP.po delete mode 100644 redbot/cogs/dataconverter/locales/ko-KR.po delete mode 100644 redbot/cogs/dataconverter/locales/lol-US.po delete mode 100644 redbot/cogs/dataconverter/locales/nl-NL.po delete mode 100644 redbot/cogs/dataconverter/locales/no-NO.po delete mode 100644 redbot/cogs/dataconverter/locales/pl-PL.po delete mode 100644 redbot/cogs/dataconverter/locales/pt-BR.po delete mode 100644 redbot/cogs/dataconverter/locales/pt-PT.po delete mode 100644 redbot/cogs/dataconverter/locales/ru-RU.po delete mode 100644 redbot/cogs/dataconverter/locales/sk-SK.po delete mode 100644 redbot/cogs/dataconverter/locales/sv-SE.po delete mode 100644 redbot/cogs/dataconverter/locales/tr-TR.po delete mode 100644 redbot/cogs/dataconverter/locales/zh-CN.po delete mode 100644 redbot/core/utils/data_converter.py delete mode 100644 redbot/pytest/dataconverter.py delete mode 100644 tests/cogs/dataconverter/__init__.py delete mode 100644 tests/cogs/dataconverter/data/mod/past_nicknames.json delete mode 100644 tests/cogs/dataconverter/test_dataconverter.py diff --git a/README.md b/README.md index 807896a91..776e402c5 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/docs/cog_dataconverter.rst b/docs/cog_dataconverter.rst deleted file mode 100644 index 4f5e23b4f..000000000 --- a/docs/cog_dataconverter.rst +++ /dev/null @@ -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' diff --git a/docs/framework_utils.rst b/docs/framework_utils.rst index 5b7037809..468d880f0 100644 --- a/docs/framework_utils.rst +++ b/docs/framework_utils.rst @@ -40,12 +40,6 @@ Mod Helpers .. automodule:: redbot.core.utils.mod :members: -V2 Data Conversion -================== - -.. automodule:: redbot.core.utils.data_converter - :members: DataConverter - Tunnel ====== diff --git a/docs/guide_data_conversion.rst b/docs/guide_data_conversion.rst deleted file mode 100644 index a25c58204..000000000 --- a/docs/guide_data_conversion.rst +++ /dev/null @@ -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` \ No newline at end of file diff --git a/docs/index.rst b/docs/index.rst index 43f9cda67..8826fe389 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -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 diff --git a/redbot/cogs/dataconverter/__init__.py b/redbot/cogs/dataconverter/__init__.py deleted file mode 100644 index 24b5d0f3a..000000000 --- a/redbot/cogs/dataconverter/__init__.py +++ /dev/null @@ -1,6 +0,0 @@ -from redbot.core.bot import Red -from .dataconverter import DataConverter - - -def setup(bot: Red): - bot.add_cog(DataConverter(bot)) diff --git a/redbot/cogs/dataconverter/core_specs.py b/redbot/cogs/dataconverter/core_specs.py deleted file mode 100644 index 08cc2ce57..000000000 --- a/redbot/cogs/dataconverter/core_specs.py +++ /dev/null @@ -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) diff --git a/redbot/cogs/dataconverter/dataconverter.py b/redbot/cogs/dataconverter/dataconverter.py deleted file mode 100644 index ee17f7a68..000000000 --- a/redbot/cogs/dataconverter/dataconverter.py +++ /dev/null @@ -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." - ) - ) diff --git a/redbot/cogs/dataconverter/locales/ar-SA.po b/redbot/cogs/dataconverter/locales/ar-SA.po deleted file mode 100644 index 0be42a091..000000000 --- a/redbot/cogs/dataconverter/locales/ar-SA.po +++ /dev/null @@ -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 \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 "" - diff --git a/redbot/cogs/dataconverter/locales/bg-BG.po b/redbot/cogs/dataconverter/locales/bg-BG.po deleted file mode 100644 index d115edbe5..000000000 --- a/redbot/cogs/dataconverter/locales/bg-BG.po +++ /dev/null @@ -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 \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 "" - diff --git a/redbot/cogs/dataconverter/locales/da-DK.po b/redbot/cogs/dataconverter/locales/da-DK.po deleted file mode 100644 index 3b36c65f4..000000000 --- a/redbot/cogs/dataconverter/locales/da-DK.po +++ /dev/null @@ -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 \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 "" - diff --git a/redbot/cogs/dataconverter/locales/de-DE.po b/redbot/cogs/dataconverter/locales/de-DE.po deleted file mode 100644 index 04820a72b..000000000 --- a/redbot/cogs/dataconverter/locales/de-DE.po +++ /dev/null @@ -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 \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." - diff --git a/redbot/cogs/dataconverter/locales/el-GR.po b/redbot/cogs/dataconverter/locales/el-GR.po deleted file mode 100644 index f48380279..000000000 --- a/redbot/cogs/dataconverter/locales/el-GR.po +++ /dev/null @@ -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 \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 "" - diff --git a/redbot/cogs/dataconverter/locales/en-PT.po b/redbot/cogs/dataconverter/locales/en-PT.po deleted file mode 100644 index 974e66eae..000000000 --- a/redbot/cogs/dataconverter/locales/en-PT.po +++ /dev/null @@ -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 \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 "" - diff --git a/redbot/cogs/dataconverter/locales/es-ES.po b/redbot/cogs/dataconverter/locales/es-ES.po deleted file mode 100644 index 399a2a08d..000000000 --- a/redbot/cogs/dataconverter/locales/es-ES.po +++ /dev/null @@ -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 \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." - diff --git a/redbot/cogs/dataconverter/locales/fi-FI.po b/redbot/cogs/dataconverter/locales/fi-FI.po deleted file mode 100644 index 1fd27ec77..000000000 --- a/redbot/cogs/dataconverter/locales/fi-FI.po +++ /dev/null @@ -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 \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 "" - diff --git a/redbot/cogs/dataconverter/locales/fr-FR.po b/redbot/cogs/dataconverter/locales/fr-FR.po deleted file mode 100644 index 4932cd902..000000000 --- a/redbot/cogs/dataconverter/locales/fr-FR.po +++ /dev/null @@ -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 \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." - diff --git a/redbot/cogs/dataconverter/locales/hu-HU.po b/redbot/cogs/dataconverter/locales/hu-HU.po deleted file mode 100644 index 4d1240ba6..000000000 --- a/redbot/cogs/dataconverter/locales/hu-HU.po +++ /dev/null @@ -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 \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 "" - diff --git a/redbot/cogs/dataconverter/locales/id-ID.po b/redbot/cogs/dataconverter/locales/id-ID.po deleted file mode 100644 index 19279046a..000000000 --- a/redbot/cogs/dataconverter/locales/id-ID.po +++ /dev/null @@ -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 \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 "" - diff --git a/redbot/cogs/dataconverter/locales/it-IT.po b/redbot/cogs/dataconverter/locales/it-IT.po deleted file mode 100644 index f8e78714d..000000000 --- a/redbot/cogs/dataconverter/locales/it-IT.po +++ /dev/null @@ -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 \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 "" - diff --git a/redbot/cogs/dataconverter/locales/ja-JP.po b/redbot/cogs/dataconverter/locales/ja-JP.po deleted file mode 100644 index 3a70ce7b0..000000000 --- a/redbot/cogs/dataconverter/locales/ja-JP.po +++ /dev/null @@ -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 \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 "" - diff --git a/redbot/cogs/dataconverter/locales/ko-KR.po b/redbot/cogs/dataconverter/locales/ko-KR.po deleted file mode 100644 index b1bb21a60..000000000 --- a/redbot/cogs/dataconverter/locales/ko-KR.po +++ /dev/null @@ -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 \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 "" - diff --git a/redbot/cogs/dataconverter/locales/lol-US.po b/redbot/cogs/dataconverter/locales/lol-US.po deleted file mode 100644 index fbc6214c2..000000000 --- a/redbot/cogs/dataconverter/locales/lol-US.po +++ /dev/null @@ -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 \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 "" - diff --git a/redbot/cogs/dataconverter/locales/nl-NL.po b/redbot/cogs/dataconverter/locales/nl-NL.po deleted file mode 100644 index fd7308c0a..000000000 --- a/redbot/cogs/dataconverter/locales/nl-NL.po +++ /dev/null @@ -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 \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." - diff --git a/redbot/cogs/dataconverter/locales/no-NO.po b/redbot/cogs/dataconverter/locales/no-NO.po deleted file mode 100644 index a63b26504..000000000 --- a/redbot/cogs/dataconverter/locales/no-NO.po +++ /dev/null @@ -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 \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 "" - diff --git a/redbot/cogs/dataconverter/locales/pl-PL.po b/redbot/cogs/dataconverter/locales/pl-PL.po deleted file mode 100644 index b6d30746e..000000000 --- a/redbot/cogs/dataconverter/locales/pl-PL.po +++ /dev/null @@ -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 \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 "" - diff --git a/redbot/cogs/dataconverter/locales/pt-BR.po b/redbot/cogs/dataconverter/locales/pt-BR.po deleted file mode 100644 index 9a7e68da6..000000000 --- a/redbot/cogs/dataconverter/locales/pt-BR.po +++ /dev/null @@ -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 \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 "" - diff --git a/redbot/cogs/dataconverter/locales/pt-PT.po b/redbot/cogs/dataconverter/locales/pt-PT.po deleted file mode 100644 index 3275414c0..000000000 --- a/redbot/cogs/dataconverter/locales/pt-PT.po +++ /dev/null @@ -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 \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 "" - diff --git a/redbot/cogs/dataconverter/locales/ru-RU.po b/redbot/cogs/dataconverter/locales/ru-RU.po deleted file mode 100644 index 12b5468e8..000000000 --- a/redbot/cogs/dataconverter/locales/ru-RU.po +++ /dev/null @@ -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 \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" -"Возможно, в будущем я смогу конвертировать еще больше вещей." - diff --git a/redbot/cogs/dataconverter/locales/sk-SK.po b/redbot/cogs/dataconverter/locales/sk-SK.po deleted file mode 100644 index 13746e444..000000000 --- a/redbot/cogs/dataconverter/locales/sk-SK.po +++ /dev/null @@ -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 \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 "" - diff --git a/redbot/cogs/dataconverter/locales/sv-SE.po b/redbot/cogs/dataconverter/locales/sv-SE.po deleted file mode 100644 index fb956d6fe..000000000 --- a/redbot/cogs/dataconverter/locales/sv-SE.po +++ /dev/null @@ -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 \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 "" - diff --git a/redbot/cogs/dataconverter/locales/tr-TR.po b/redbot/cogs/dataconverter/locales/tr-TR.po deleted file mode 100644 index 98404bf4e..000000000 --- a/redbot/cogs/dataconverter/locales/tr-TR.po +++ /dev/null @@ -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 \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 "" - diff --git a/redbot/cogs/dataconverter/locales/zh-CN.po b/redbot/cogs/dataconverter/locales/zh-CN.po deleted file mode 100644 index 493d6abab..000000000 --- a/redbot/cogs/dataconverter/locales/zh-CN.po +++ /dev/null @@ -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 \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 "" - diff --git a/redbot/core/utils/data_converter.py b/redbot/core/utils/data_converter.py deleted file mode 100644 index d01aaaa11..000000000 --- a/redbot/core/utils/data_converter.py +++ /dev/null @@ -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) diff --git a/redbot/pytest/dataconverter.py b/redbot/pytest/dataconverter.py deleted file mode 100644 index f608cf721..000000000 --- a/redbot/pytest/dataconverter.py +++ /dev/null @@ -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 diff --git a/tests/cogs/dataconverter/__init__.py b/tests/cogs/dataconverter/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/tests/cogs/dataconverter/data/mod/past_nicknames.json b/tests/cogs/dataconverter/data/mod/past_nicknames.json deleted file mode 100644 index f44b2c0d6..000000000 --- a/tests/cogs/dataconverter/data/mod/past_nicknames.json +++ /dev/null @@ -1,26 +0,0 @@ -{ - "1" : { - "1" : [ - "Test", - "Test2", - "TEST3" - ], - "2" : [ - "Test4", - "Test5", - "TEST6" - ] - }, - "2" : { - "1" : [ - "Test", - "Test2", - "TEST3" - ], - "2" : [ - "Test4", - "Test5", - "TEST6" - ] - } -} \ No newline at end of file diff --git a/tests/cogs/dataconverter/test_dataconverter.py b/tests/cogs/dataconverter/test_dataconverter.py deleted file mode 100644 index 5c8d9a3e2..000000000 --- a/tests/cogs/dataconverter/test_dataconverter.py +++ /dev/null @@ -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 From 972fbecc94ef548be9bbaabee653bb66cbec5c81 Mon Sep 17 00:00:00 2001 From: jack1142 <6032823+jack1142@users.noreply.github.com> Date: Tue, 9 Apr 2019 23:03:34 +0200 Subject: [PATCH 21/30] [V3 Trivia] Make sure that test will use utf-8 encoding (#2565) --- tests/cogs/test_trivia.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/cogs/test_trivia.py b/tests/cogs/test_trivia.py index 528c74304..3c0fef1a0 100644 --- a/tests/cogs/test_trivia.py +++ b/tests/cogs/test_trivia.py @@ -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: From 56b220b92efa8dcac3f69fac78ee4a41cfec7c2c Mon Sep 17 00:00:00 2001 From: aikaterna <20862007+aikaterna@users.noreply.github.com> Date: Tue, 9 Apr 2019 14:10:25 -0700 Subject: [PATCH 22/30] [Audio] Fix for prev command display (#2556) --- redbot/cogs/audio/audio.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/redbot/cogs/audio/audio.py b/redbot/cogs/audio/audio.py index f4ff62fb6..5adea3b1a 100644 --- a/redbot/cogs/audio/audio.py +++ b/redbot/cogs/audio/audio.py @@ -2064,7 +2064,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"), From 39b64b75709be13e28b0f7cf75354128b43dda94 Mon Sep 17 00:00:00 2001 From: aikaterna <20862007+aikaterna@users.noreply.github.com> Date: Tue, 9 Apr 2019 14:13:57 -0700 Subject: [PATCH 23/30] [Audio] Fix for localtrack playing (#2557) --- redbot/cogs/audio/audio.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/redbot/cogs/audio/audio.py b/redbot/cogs/audio/audio.py index 5adea3b1a..1326e1c7f 100644 --- a/redbot/cogs/audio/audio.py +++ b/redbot/cogs/audio/audio.py @@ -1181,7 +1181,7 @@ class Audio(commands.Cog): 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"): + if not (query.startswith("http") or query.startswith("localtracks")): query = f"ytsearch:{query}" tracks = await player.get_tracks(query) if not tracks: From c85af62401c72143ea0da90f07e23cebd0dbe36f Mon Sep 17 00:00:00 2001 From: kennnyshiwa <44236678+kennnyshiwa@users.noreply.github.com> Date: Tue, 9 Apr 2019 18:54:44 -0400 Subject: [PATCH 24/30] Fix message when user hits max credits (#2563) * Fix message when user hits max credits Fixes the error message when a users issues the payday command when having max credits * Update economy.py Changed message when user hits max payday and bank is global to match message when bank is per server * Update economy.py made statements match --- redbot/cogs/economy/economy.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/redbot/cogs/economy/economy.py b/redbot/cogs/economy/economy.py index e3138d454..4f5299649 100644 --- a/redbot/cogs/economy/economy.py +++ b/redbot/cogs/economy/economy.py @@ -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) From e347ffa336b0754406a533d5ff36fac2dba1e098 Mon Sep 17 00:00:00 2001 From: jack1142 <6032823+jack1142@users.noreply.github.com> Date: Wed, 10 Apr 2019 02:33:19 +0200 Subject: [PATCH 25/30] Bot can join voice channel with user limit if it has move members perm (#2525) --- redbot/cogs/audio/audio.py | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/redbot/cogs/audio/audio.py b/redbot/cogs/audio/audio.py index 1326e1c7f..15d657aaf 100644 --- a/redbot/cogs/audio/audio.py +++ b/redbot/cogs/audio/audio.py @@ -1027,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.") @@ -1952,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.") @@ -2406,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.") From ba19179e4f3b3e1d383754842cabb663d9811444 Mon Sep 17 00:00:00 2001 From: Will Date: Tue, 9 Apr 2019 22:02:50 -0400 Subject: [PATCH 26/30] [V3 Config] Record custom group information using cog_add event (#2550) * Do things differently * Uncomment critical lines * Reduce, reuse, recycle * Check groups on all new config objects after a cog loads * I don't know why this is failing now or why we need the global keyword * gotta fix this too --- redbot/core/bot.py | 5 +++++ redbot/core/config.py | 11 +++++++++++ redbot/core/events.py | 9 +++++++++ 3 files changed, 25 insertions(+) diff --git a/redbot/core/bot.py b/redbot/core/bot.py index 522f1228a..601c3a633 100644 --- a/redbot/core/bot.py +++ b/redbot/core/bot.py @@ -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() diff --git a/redbot/core/config.py b/redbot/core/config.py index 400e5de7c..b950315fb 100644 --- a/redbot/core/config.py +++ b/redbot/core/config.py @@ -12,11 +12,22 @@ 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]): diff --git a/redbot/core/events.py b/redbot/core/events.py index 855a68d6e..5bdc00b51 100644 --- a/redbot/core/events.py +++ b/redbot/core/events.py @@ -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 @@ -305,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. From 2776db0cf9113976d9328588f47b9597885eb8d7 Mon Sep 17 00:00:00 2001 From: jack1142 <6032823+jack1142@users.noreply.github.com> Date: Thu, 11 Apr 2019 01:32:20 +0200 Subject: [PATCH 27/30] [V3 Core] Print actual version, when version flag is used (#2567) --- redbot/__main__.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/redbot/__main__.py b/redbot/__main__.py index 4db8fdb0c..786f06a7f 100644 --- a/redbot/__main__.py +++ b/redbot/__main__.py @@ -18,7 +18,7 @@ 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, data_manager +from redbot.core import __version__, modlog, bank, data_manager from signal import SIGTERM # Let's not force this dependency, uvloop is much faster on cpython @@ -81,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!") From c82ac5ae689f5134270eab71e53c2ec06dc6c9ae Mon Sep 17 00:00:00 2001 From: Will Date: Wed, 10 Apr 2019 20:42:28 -0400 Subject: [PATCH 28/30] Add some errors for backend conversions and only allow MongoV2 creation (#2570) * Add some errors for conversions and only allow mongoV2 creation * Add another message * Fixed message to be more clear --- redbot/core/drivers/__init__.py | 7 ++++++- redbot/setup.py | 12 ++++++++---- 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/redbot/core/drivers/__init__.py b/redbot/core/drivers/__init__.py index 2809427d9..a3f3c7d56 100644 --- a/redbot/core/drivers/__init__.py +++ b/redbot/core/drivers/__init__.py @@ -28,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)) diff --git a/redbot/setup.py b/redbot/setup.py index 00a93f8a3..e55aa3660 100644 --- a/redbot/setup.py +++ b/redbot/setup.py @@ -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,9 +260,9 @@ 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() @@ -272,12 +272,16 @@ async def edit_instance(): 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) - else: + 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) From 8b3c3e89e9c8d8697a2e3ff94d58747e6c239618 Mon Sep 17 00:00:00 2001 From: Will Date: Sat, 13 Apr 2019 15:24:50 -0400 Subject: [PATCH 29/30] [V3 Mongo] Fix all behavior (#2580) --- redbot/core/drivers/red_mongo.py | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/redbot/core/drivers/red_mongo.py b/redbot/core/drivers/red_mongo.py index 2d00d468a..a2283c436 100644 --- a/redbot/core/drivers/red_mongo.py +++ b/redbot/core/drivers/red_mongo.py @@ -93,7 +93,7 @@ class Mongo(BaseDriver): elif len(pkeys) > 1: # All other data partial = ret - for key in pkeys[1:-1]: + for key in pkeys[:-1]: if key in identifier_data.primary_key: continue if key not in partial: @@ -155,13 +155,15 @@ class Mongo(BaseDriver): 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}} + ret = {"_id.RED_uuid": uuid} if len(identifier_data.identifiers) > 0: - ret["_id"]["RED_primary_key"] = primary_key - else: + ret["_id.RED_primary_key"] = primary_key + elif len(identifier_data.primary_key) > 0: for i, key in enumerate(primary_key): - keyname = f"RED_primary_key.{i}" - ret["_id"][keyname] = key + keyname = f"_id.RED_primary_key.{i}" + ret[keyname] = key + else: + ret["_id.RED_primary_key"] = {"$exists": True} return ret async def clear(self, identifier_data: IdentifierData): From 0652dd344bf51e01a54a9e090f07948d3993b0fe Mon Sep 17 00:00:00 2001 From: Will Date: Sat, 13 Apr 2019 15:51:49 -0400 Subject: [PATCH 30/30] [V3 Mongo] Correct dictionary rebuilding process for global all case (#2581) --- redbot/core/drivers/red_mongo.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/redbot/core/drivers/red_mongo.py b/redbot/core/drivers/red_mongo.py index a2283c436..733105f2f 100644 --- a/redbot/core/drivers/red_mongo.py +++ b/redbot/core/drivers/red_mongo.py @@ -87,10 +87,10 @@ class Mongo(BaseDriver): async for doc in cursor: pkeys = doc["_id"]["RED_primary_key"] del doc["_id"] - if len(pkeys) == 1: + if len(pkeys) == 0: # Global data ret.update(**doc) - elif len(pkeys) > 1: + elif len(pkeys) > 0: # All other data partial = ret for key in pkeys[:-1]: @@ -103,8 +103,6 @@ class Mongo(BaseDriver): partial.update(**doc) else: partial[pkeys[-1]] = doc - else: - raise RuntimeError("This should not happen.") return ret async def get(self, identifier_data: IdentifierData):