Compare commits

...

30 Commits

Author SHA1 Message Date
Toby Harradine
8bba860f85 Bump version to 3.0.0rc2
Signed-off-by: Toby Harradine <tobyharradine@gmail.com>
2018-10-16 09:37:23 +11:00
Toby Harradine
d2d26835c3 [Economy] Detect max balance and prevent OverflowError (#2211)
Resolves #2091.

This doesn't fix every OverflowError with MongoDB; but at least the seemingly easiest one to achieve with core cogs.

Signed-off-by: Toby Harradine <tobyharradine@gmail.com>
2018-10-16 09:30:53 +11:00
Toby Harradine
aff62a8006 [Downloader] Unload extensions on uninstall (#2243)
Resolves #2216.

Signed-off-by: Toby Harradine <tobyharradine@gmail.com>
2018-10-16 09:19:32 +11:00
Toby Harradine
b5fd28ef7c [CustomCom] Better display for [p]cc list (#2215)
Uses a menu, optionally embedded with respect to the embed settings, for scrolling through the custom command list, each cc with a ~50 character preview. Format is purposefully similar to the help menu.

Resolves #2104.

Signed-off-by: Toby Harradine <tobyharradine@gmail.com>
2018-10-16 08:39:44 +11:00
Michael H
c510ebe5e5 [Downloader] Only prompt reload of loaded cogs (#2233) 2018-10-15 23:29:56 +11:00
Toby Harradine
5ba95090d9 [Streams] Suppress HTTPExceptions on load (#2228)
Resolves #2227.

Signed-off-by: Toby Harradine <tobyharradine@gmail.com>
2018-10-15 22:31:14 +11:00
Toby Harradine
ad51fa830b [Cleanup] [p]cleanup bot includes aliases and CCs (#2213)
Resolves #1920.

Signed-off-by: Toby Harradine <tobyharradine@gmail.com>
2018-10-15 22:29:07 +11:00
Christopher Rice
1ba922eba2 [Downloader] Add missing prefix format kwarg (#2238)
Fixes #2237.
2018-10-14 17:11:16 +11:00
Christopher Rice
9588a5740c [Downloader] Define Translator in converters module (#2239)
Fixes #2236
2018-10-14 17:09:54 +11:00
Toby Harradine
7cd765d548 Fix permissions hook removal (#2234)
Some in-progress work slipped through #2149, and I figure it should be fixed before RC2.

I've also just decided to allow discovery of permissions hooks from superclasses as well. We should try to be more aware of the possibility of cog superclasses moving forward.

Signed-off-by: Toby Harradine <tobyharradine@gmail.com>
2018-10-14 11:52:39 +11:00
zephyrkul
6022c0f7d7 [Mod] Mute/unmute bugfixes (#2230)
- Helper methods mute_user and unmute_user now take GuildChannel instead of solely TextChannel.
- The bot will not attempt to mute a member with the Administrator permission, as that permission bypasses channel overwrites anyway. The bot will still unmute members with the Administrator permission (see #2076).
- Audit reasons are now specified for mass mutes / unmutes.
- A few typos and missing keyword specifiers were corrected.
- Streamlined some logic and used some already-existing functions.
2018-10-12 15:47:39 +11:00
palmtree5
0548744e94 [V3 Cleanup] fix error in cleanup user (#2225) 2018-10-11 14:36:45 +02:00
Toby Harradine
8b2d115335 [Audio] Rename current_build to current_version in Config (#2219)
Renames the `current_build` key to `current_version`. This means the `current_version` key will always be a dict and never a list, and `current_build` having no defaults means it won't mess with `[p]audioset settings`.

Signed-off-by: Toby Harradine <tobyharradine@gmail.com>
2018-10-11 12:02:02 +11:00
Toby Harradine
094735566d [Warnings] Fix actions not being taken (#2218)
When multiple warning actions were registered, and the user didn't exceed the points for the highest action on the list, no action was taken.

Resolves #2106.

Also commented out the casetype registration for warnings, since it's not actually using modlog yet.

Signed-off-by: Toby Harradine <tobyharradine@gmail.com>
2018-10-11 11:54:11 +11:00
Toby Harradine
f7b1f9f0dc [MongoDB] Escape special characters in keys (#2212)
Essentially resolves #2038, although this is escaping and not rejecting keys as that issue implies.

Signed-off-by: Toby Harradine <tobyharradine@gmail.com>
2018-10-11 11:20:42 +11:00
Toby Harradine
ce25011f0d [Config] Cast keys to str on get/set/clear (#2217)
This is a step towards a more consistent front-end behaviour of Config, where errors are either circumvented or raised in the same way regardless of the driver being used.

Signed-off-by: Toby Harradine <tobyharradine@gmail.com>
2018-10-11 11:18:57 +11:00
bobloy
f85034eb27 [Trivia] Add On/Off as alternatives for YAML bools (#2177) 2018-10-09 22:05:37 +11:00
Toby Harradine
849755ecd2 [Core] Fix errors with [p]backup on MongoDB (#2210)
Resolves #2094.

This command needs some more fixing and cleaning up than this, this is just a simple bugfix which gets it mostly working for now.

Signed-off-by: Toby Harradine <tobyharradine@gmail.com>
2018-10-09 15:30:27 +11:00
Toby Harradine
9217275908 [Permissions] Fix ValueError for "default" rule in config/ACL (#2200)
This was thrown when the "default" key existed and Permissions tried to iterate over the list mapping keys as ints.

Also fixed some issues with saving config with keys as `int` instead of `str`.

Signed-off-by: Toby Harradine <tobyharradine@gmail.com>
2018-10-09 15:14:59 +11:00
zephyrkul
9e13ca45e6 [Mod] Fix KeyError in modset (#2208) 2018-10-09 09:04:51 +11:00
aikaterna
46c38a28eb [Alias] Fix alias help (#2194)
Alias help would only return the first character of the invoked command previously. This change shows help for basic commands that are aliased (i.e. just `ping`) or aliased commands that have an argument included (i.e. `audioset role beep` with `beep` being a role name)
2018-10-08 18:23:32 +11:00
El Laggron
76bbcf2f8c [Core] Support already loaded packages in [p]load (#2116) 2018-10-08 08:18:28 +11:00
ASSASSIN0831
ee7e8aa782 [Economy] Revert change to payday message (#2203)
in the updates were for the i18n translation strings the payday command message was accidentally changed to +(amount) (new balance). This changes it back to its original message +(amount) (currency name)
2018-10-08 07:39:30 +11:00
Toby Harradine
fd0abc250d [Audio] Fix type mismatch between config defaults and set value (#2201)
current_build is now set as a dict, but its default was a list.

This resolves the error an `[p]audioset settings`.

Signed-off-by: Toby Harradine <tobyharradine@gmail.com>
2018-10-07 19:33:04 +11:00
Toby Harradine
847f9fc8d1 [CustomCommands] Find default converters properly (#2199)
The new `redbot.core.commands.converter` module was causing default converters to never be found.

Also cleaned up some of the other code (made some methods static, fixed some typing violations)

Signed-off-by: Toby Harradine <tobyharradine@gmail.com>
2018-10-07 12:58:08 +11:00
zephyrkul
046e98565e [Cleanup] use message_filter() over check() param (#2180)
Cleanup's helper method to collect messages to delete was incorrectly filtering by check rather than message_filter, causing delete_after to be ignored.
2018-10-07 10:19:56 +11:00
Toby Harradine
71eddc89ea [Mod] Fix unresolved reference to Member.permissions in reinvite logic
Signed-off-by: Toby Harradine <tobyharradine@gmail.com>
2018-10-07 09:42:25 +11:00
aikaterna
9730a424ec [Admin] Selfrole list formatting (#2193)
Selfrole list needed a return in between the header and the list.
2018-10-07 08:46:32 +11:00
aikaterna
7b260cdafc [Audio] Playlist list, local queue, DJ Role fix (#2191)
Fix for `playlist list`: added forgotten variable plus return for formatting (fixes #2190)

i18n addditions: DJ Role toggle, Thumbnail display toggle

`audioset settings`: added missed end of line return

`queue` fix: added indentation to not have the currently playing song info repeated in the queue display when playing local songs

`local play` and `local folder` now display the appropriate menu when DJ role is on.
2018-10-07 08:42:13 +11:00
aikaterna
4369095a51 [Economy] Fix for bank set (#2192)
i18n variable was wrong.
2018-10-07 08:26:33 +11:00
26 changed files with 729 additions and 429 deletions

View File

@@ -374,6 +374,21 @@ API Reference
inside the bot itself! Simply take a peek inside of the :code:`tests/core/test_config.py` file for examples of using
Config in all kinds of ways.
.. important::
When getting, setting or clearing values in Config, all keys are casted to `str` for you. This
includes keys within a `dict` when one is being set, as well as keys in nested dictionaries
within that `dict`. For example::
>>> conf = Config.get_conf(self, identifier=999)
>>> conf.register_global(foo={})
>>> await conf.foo.set_raw(123, value=True)
>>> await conf.foo()
{'123': True}
>>> await conf.foo.set({123: True, 456: {789: False}}
>>> await conf.foo()
{'123': True, '456': {'789': False}}
.. automodule:: redbot.core.config
Config

View File

@@ -359,7 +359,7 @@ class Admin(commands.Cog):
selfroles = await self._valid_selfroles(ctx.guild)
fmt_selfroles = "\n".join(["+ " + r.name for r in selfroles])
msg = _("Available Selfroles: {selfroles}").format(selfroles=fmt_selfroles)
msg = _("Available Selfroles:\n{selfroles}").format(selfroles=fmt_selfroles)
await ctx.send(box(msg, "diff"))
async def _serverlock_check(self, guild: discord.Guild) -> bool:

View File

@@ -288,7 +288,10 @@ class Alias(commands.Cog):
"""Try to execute help for the base command of the alias."""
is_alias, alias = await self.is_alias(ctx.guild, alias_name=alias_name)
if is_alias:
base_cmd = alias.command[0]
if self.is_command(alias.command):
base_cmd = alias.command
else:
base_cmd = alias.command.rsplit(" ", 1)[0]
new_msg = copy(ctx.message)
new_msg.content = _("{prefix}help {command}").format(

View File

@@ -34,14 +34,14 @@ async def download_lavalink(session):
async def maybe_download_lavalink(loop, cog):
jar_exists = LAVALINK_JAR_FILE.exists()
current_build = redbot.core.VersionInfo.from_json(await cog.config.current_build())
current_build = redbot.core.VersionInfo.from_json(await cog.config.current_version())
if not jar_exists or current_build < redbot.core.version_info:
log.info("Downloading Lavalink.jar")
LAVALINK_DOWNLOAD_DIR.mkdir(parents=True, exist_ok=True)
async with ClientSession(loop=loop) as session:
await download_lavalink(session)
await cog.config.current_build.set(redbot.core.version_info.to_json())
await cog.config.current_version.set(redbot.core.version_info.to_json())
shutil.copyfile(str(BUNDLED_APP_YML_FILE), str(APP_YML_FILE))

View File

@@ -48,7 +48,7 @@ class Audio(commands.Cog):
"ws_port": "2332",
"password": "youshallnotpass",
"status": False,
"current_build": [3, 0, 0, "alpha", 0],
"current_version": redbot.core.VersionInfo.from_str("3.0.0a0").to_json(),
"use_external_lavalink": False,
}
@@ -253,7 +253,9 @@ class Audio(commands.Cog):
dj_enabled = await self.config.guild(ctx.guild).dj_enabled()
await self.config.guild(ctx.guild).dj_enabled.set(not dj_enabled)
await self._embed_msg(ctx, "DJ role enabled: {}.".format(not dj_enabled))
await self._embed_msg(
ctx, _("DJ role enabled: {true_or_false}.".format(true_or_false=not dj_enabled))
)
@audioset.command()
@checks.mod_or_permissions(administrator=True)
@@ -332,7 +334,7 @@ class Audio(commands.Cog):
jarbuild = redbot.core.__version__
vote_percent = data["vote_percent"]
msg = "----" + _("Server Settings") + "----"
msg = "----" + _("Server Settings") + "----\n"
if emptydc_enabled:
msg += _("Disconnect timer: [{num_seconds}]\n").format(
num_seconds=self._dynamic_time(emptydc_timer)
@@ -370,7 +372,9 @@ class Audio(commands.Cog):
"""Toggle displaying a thumbnail on audio messages."""
thumbnail = await self.config.guild(ctx.guild).thumbnail()
await self.config.guild(ctx.guild).thumbnail.set(not thumbnail)
await self._embed_msg(ctx, _("Thumbnail display: {}.").format(not thumbnail))
await self._embed_msg(
ctx, _("Thumbnail display: {true_or_false}.").format(true_or_false=not thumbnail)
)
@audioset.command()
@checks.mod_or_permissions(administrator=True)
@@ -565,6 +569,8 @@ class Audio(commands.Cog):
if dj_enabled:
if not await self._can_instaskip(ctx, ctx.author):
return await menu(ctx, folder_page_list, DEFAULT_CONTROLS)
else:
await menu(ctx, folder_page_list, LOCAL_FOLDER_CONTROLS)
else:
await menu(ctx, folder_page_list, LOCAL_FOLDER_CONTROLS)
@@ -1097,7 +1103,7 @@ class Audio(commands.Cog):
(
bold(playlist_name),
_("Tracks: {num}").format(num=len(tracks)),
_("Author: {name}").format(self.bot.get_user(author)),
_("Author: {name}\n").format(name=self.bot.get_user(author)),
)
)
)
@@ -1533,9 +1539,9 @@ class Audio(commands.Cog):
)
else:
queue_list += _("Playing: ")
queue_list += "**[{current.title}]({current.uri})**\n".format(current=player.current)
queue_list += _("Requested by: **{user}**").format(user=player.current.requester)
queue_list += f"\n\n{arrow}`{pos}`/`{dur}`\n\n"
queue_list += "**[{current.title}]({current.uri})**\n".format(current=player.current)
queue_list += _("Requested by: **{user}**").format(user=player.current.requester)
queue_list += f"\n\n{arrow}`{pos}`/`{dur}`\n\n"
for i, track in enumerate(
player.queue[queue_idx_start:queue_idx_end], start=queue_idx_start
@@ -2360,7 +2366,7 @@ class Audio(commands.Cog):
if await self._check_external():
embed = discord.Embed(
colour=await ctx.embed_colour(),
title=_("Websocket port set to {}.").format(ws_port),
title=_("Websocket port set to {port}.").format(port=ws_port),
)
embed.set_footer(text=_("External lavalink server set to True."))
await ctx.send(embed=embed)

View File

@@ -1,6 +1,6 @@
import re
from datetime import datetime, timedelta
from typing import Union, List, Callable
from typing import Union, List, Callable, Set
import discord
@@ -94,7 +94,7 @@ class Cleanup(commands.Cog):
):
if message.created_at < two_weeks_ago:
break
if check(message):
if message_filter(message):
collected.append(message)
if number and number <= len(collected):
break
@@ -169,7 +169,7 @@ class Cleanup(commands.Cog):
member = None
try:
member = await commands.converter.MemberConverter().convert(ctx, user)
member = await commands.MemberConverter().convert(ctx, user)
except commands.BadArgument:
try:
_id = int(user)
@@ -323,15 +323,35 @@ class Cleanup(commands.Cog):
if "" in prefixes:
prefixes.remove("")
cc_cog = self.bot.get_cog("CustomCommands")
if cc_cog is not None:
command_names: Set[str] = await cc_cog.get_command_names(ctx.guild)
is_cc = lambda name: name in command_names
else:
is_cc = lambda name: False
alias_cog = self.bot.get_cog("Alias")
if alias_cog is not None:
alias_names: Set[str] = (
set((a.name for a in await alias_cog.unloaded_global_aliases()))
| set(a.name for a in await alias_cog.unloaded_aliases(ctx.guild))
)
is_alias = lambda name: name in alias_names
else:
is_alias = lambda name: False
bot_id = self.bot.user.id
def check(m):
if m.author.id == self.bot.user.id:
if m.author.id == bot_id:
return True
elif m == ctx.message:
return True
p = discord.utils.find(m.content.startswith, prefixes)
if p and len(p) > 0:
cmd_name = m.content[len(p) :].split(" ")[0]
return bool(self.bot.get_command(cmd_name))
return (
bool(self.bot.get_command(cmd_name)) or is_alias(cmd_name) or is_cc(cmd_name)
)
return False
to_delete = await self.get_messages_for_deletion(

View File

@@ -3,13 +3,14 @@ import random
from datetime import datetime, timedelta
from inspect import Parameter
from collections import OrderedDict
from typing import Mapping, Tuple, Dict
from typing import Mapping, Tuple, Dict, Set
import discord
from redbot.core import Config, checks, commands
from redbot.core.utils.chat_formatting import box, pagify
from redbot.core.i18n import Translator, cog_i18n
from redbot.core.utils import menus
from redbot.core.utils.chat_formatting import box, pagify, escape
from redbot.core.utils.predicates import MessagePredicate
_ = Translator("CustomCommands", __file__)
@@ -43,11 +44,8 @@ class CommandObj:
@staticmethod
async def get_commands(config) -> dict:
commands = await config.commands()
customcommands = {k: v for k, v in commands.items() if commands[k]}
if len(customcommands) == 0:
return None
return customcommands
_commands = await config.commands()
return {k: v for k, v in _commands.items() if _commands[k]}
async def get_responses(self, ctx):
intro = _(
@@ -79,7 +77,8 @@ class CommandObj:
responses.append(msg.content)
return responses
def get_now(self) -> str:
@staticmethod
def get_now() -> str:
# Get current time as a string, for 'created_at' and 'edited_at' fields
# in the ccinfo dict
return "{:%d/%m/%Y %H:%M:%S}".format(datetime.utcnow())
@@ -116,7 +115,7 @@ class CommandObj:
*,
response=None,
cooldowns: Mapping[str, int] = None,
ask_for: bool = True
ask_for: bool = True,
):
"""Edit an already existing custom command"""
ccinfo = await self.db(ctx.guild).commands.get_raw(command, default=None)
@@ -312,8 +311,6 @@ class CustomCommands(commands.Cog):
Example:
- `[p]customcom edit yourcommand Text you want`
"""
command = command.lower()
try:
await self.commandobj.edit(ctx=ctx, command=command, response=text)
await ctx.send(_("Custom command successfully edited."))
@@ -327,12 +324,16 @@ class CustomCommands(commands.Cog):
await ctx.send(e.args[0])
@customcom.command(name="list")
async def cc_list(self, ctx):
"""List all available custom commands."""
@checks.bot_has_permissions(add_reactions=True)
async def cc_list(self, ctx: commands.Context):
"""List all available custom commands.
response = await CommandObj.get_commands(self.config.guild(ctx.guild))
The list displays a preview of each command's response, with
markdown escaped and newlines replaced with spaces.
"""
cc_dict = await CommandObj.get_commands(self.config.guild(ctx.guild))
if not response:
if not cc_dict:
await ctx.send(
_(
"There are no custom commands in this server."
@@ -342,8 +343,7 @@ class CustomCommands(commands.Cog):
return
results = []
for command, body in response.items():
for command, body in sorted(cc_dict.items(), key=lambda t: t[0]):
responses = body["response"]
if isinstance(responses, list):
result = ", ".join(responses)
@@ -351,15 +351,33 @@ class CustomCommands(commands.Cog):
result = responses
else:
continue
results.append("{command:<15} : {result}".format(command=command, result=result))
# Replace newlines with spaces
# Cut preview to 52 characters max
if len(result) > 52:
result = result[:49] + "..."
# Replace newlines with spaces
result = result.replace("\n", " ")
# Escape markdown and mass mentions
result = escape(result, formatting=True, mass_mentions=True)
results.append((f"{ctx.clean_prefix}{command}", result))
commands = "\n".join(results)
if len(commands) < 1500:
await ctx.send(box(commands))
if await ctx.embed_requested():
content = "\n".join(map("**{0[0]}** {0[1]}".format, results))
pages = list(pagify(content, page_length=1024))
embed_pages = []
for idx, page in enumerate(pages, start=1):
embed = discord.Embed(
title=_("Custom Command List"),
description=page,
colour=await ctx.embed_colour(),
)
embed.set_footer(text=_("Page {num}/{total}").format(num=idx, total=len(pages)))
embed_pages.append(embed)
await menus.menu(ctx, embed_pages, menus.DEFAULT_CONTROLS)
else:
for page in pagify(commands, delims=[" ", "\n"]):
await ctx.author.send(box(page))
content = "\n".join(map("{0[0]:<12} : {0[1]}".format, results))
pages = list(map(box, pagify(content, page_length=2000, shorten_by=10)))
await menus.menu(ctx, pages, menus.DEFAULT_CONTROLS)
async def on_message(self, message):
is_private = isinstance(message.channel, discord.abc.PrivateChannel)
@@ -411,11 +429,11 @@ class CustomCommands(commands.Cog):
async def cc_command(self, ctx, *cc_args, raw_response, **cc_kwargs) -> None:
cc_args = (*cc_args, *cc_kwargs.values())
results = re.findall(r"\{([^}]+)\}", raw_response)
results = re.findall(r"{([^}]+)\}", raw_response)
for result in results:
param = self.transform_parameter(result, ctx.message)
raw_response = raw_response.replace("{" + result + "}", param)
results = re.findall(r"\{((\d+)[^\.}]*(\.[^:}]+)?[^}]*)\}", raw_response)
results = re.findall(r"{((\d+)[^.}]*(\.[^:}]+)?[^}]*)\}", raw_response)
if results:
low = min(int(result[1]) for result in results)
for result in results:
@@ -424,9 +442,10 @@ class CustomCommands(commands.Cog):
raw_response = raw_response.replace("{" + result[0] + "}", arg)
await ctx.send(raw_response)
def prepare_args(self, raw_response) -> Mapping[str, Parameter]:
args = re.findall(r"\{(\d+)[^:}]*(:[^\.}]*)?[^}]*\}", raw_response)
default = [["ctx", Parameter("ctx", Parameter.POSITIONAL_OR_KEYWORD)]]
@staticmethod
def prepare_args(raw_response) -> Mapping[str, Parameter]:
args = re.findall(r"{(\d+)[^:}]*(:[^.}]*)?[^}]*\}", raw_response)
default = [("ctx", Parameter("ctx", Parameter.POSITIONAL_OR_KEYWORD))]
if not args:
return OrderedDict(default)
allowed_builtins = {
@@ -466,7 +485,7 @@ class CustomCommands(commands.Cog):
try:
anno = getattr(discord, anno)
# force an AttributeError if there's no discord.py converter
getattr(commands.converter, anno.__name__ + "Converter")
getattr(commands, anno.__name__ + "Converter")
except AttributeError:
anno = allowed_builtins.get(anno.lower(), Parameter.empty)
if (
@@ -520,7 +539,8 @@ class CustomCommands(commands.Cog):
# only update cooldowns if the command isn't on cooldown
self.cooldowns.update(new_cooldowns)
def transform_arg(self, result, attr, obj) -> str:
@staticmethod
def transform_arg(result, attr, obj) -> str:
attr = attr[1:] # strip initial dot
if not attr:
return str(obj)
@@ -530,7 +550,8 @@ class CustomCommands(commands.Cog):
return raw_result
return str(getattr(obj, attr, raw_result))
def transform_parameter(self, result, message) -> str:
@staticmethod
def transform_parameter(result, message) -> str:
"""
For security reasons only specific objects are allowed
Internals are ignored
@@ -554,3 +575,14 @@ class CustomCommands(commands.Cog):
else:
return raw_result
return str(getattr(first, second, raw_result))
async def get_command_names(self, guild: discord.Guild) -> Set[str]:
"""Get all custom command names in a guild.
Returns
--------
Set[str]
A set of all custom command names.
"""
return set(await CommandObj.get_commands(self.config.guild(guild)))

View File

@@ -1,7 +1,10 @@
import discord
from redbot.core import commands
from redbot.core.i18n import Translator
from .installable import Installable
_ = Translator("Koala", __file__)
class InstalledCog(Installable):
@classmethod

View File

@@ -325,13 +325,12 @@ class Downloader(commands.Cog):
You may only uninstall cogs which were previously installed
by Downloader.
"""
# noinspection PyUnresolvedReferences,PyProtectedMember
real_name = cog.name
poss_installed_path = (await self.cog_install_path()) / real_name
if poss_installed_path.exists():
ctx.bot.unload_extension(real_name)
await self._delete_cog(poss_installed_path)
# noinspection PyTypeChecker
await self._remove_from_installed(cog)
await ctx.send(
_("Cog `{cog_name}` was successfully uninstalled.").format(cog_name=real_name)
@@ -344,7 +343,7 @@ class Downloader(commands.Cog):
" files manually if it is still usable."
" Also make sure you've unloaded the cog"
" with `{prefix}unload {cog_name}`."
).format(cog_name=real_name)
).format(prefix=ctx.prefix, cog_name=real_name)
)
@cog.command(name="update")
@@ -372,13 +371,18 @@ class Downloader(commands.Cog):
await self._reinstall_libraries(installed_and_updated)
message = _("Cog update completed successfully.")
cognames = [c.name for c in installed_and_updated]
cognames = {c.name for c in installed_and_updated}
message += _("\nUpdated: ") + humanize_list(tuple(map(inline, cognames)))
else:
await ctx.send(_("All installed cogs are already up to date."))
return
await ctx.send(message)
cognames &= set(ctx.bot.extensions.keys()) # only reload loaded cogs
if not cognames:
return await ctx.send(
_("None of the updated cogs were previously loaded. Update complete.")
)
message = _("Would you like to reload the updated cogs?")
can_react = ctx.channel.permissions_for(ctx.me).add_reactions
if not can_react:
@@ -402,7 +406,6 @@ class Downloader(commands.Cog):
if can_react:
with contextlib.suppress(discord.Forbidden):
await query.clear_reactions()
await ctx.invoke(ctx.bot.get_cog("Core").reload, *cognames)
else:
if can_react:

View File

@@ -8,7 +8,7 @@ from typing import cast, Iterable
import discord
from redbot.cogs.bank import check_global_setting_guildowner, check_global_setting_admin
from redbot.core import Config, bank, commands
from redbot.core import Config, bank, commands, errors
from redbot.core.i18n import Translator, cog_i18n
from redbot.core.utils.chat_formatting import box
from redbot.core.utils.menus import menu, DEFAULT_CONTROLS
@@ -171,7 +171,7 @@ class Economy(commands.Cog):
try:
await bank.transfer_credits(from_, to, amount)
except ValueError as e:
except (ValueError, errors.BalanceTooHigh) as e:
return await ctx.send(str(e))
await ctx.send(
@@ -195,36 +195,35 @@ class Economy(commands.Cog):
author = ctx.author
currency = await bank.get_currency_name(ctx.guild)
if creds.operation == "deposit":
await bank.deposit_credits(to, creds.sum)
await ctx.send(
_("{author} added {num} {currency} to {user}'s account.").format(
try:
if creds.operation == "deposit":
await bank.deposit_credits(to, creds.sum)
msg = _("{author} added {num} {currency} to {user}'s account.").format(
author=author.display_name,
num=creds.sum,
currency=currency,
user=to.display_name,
)
)
elif creds.operation == "withdraw":
await bank.withdraw_credits(to, creds.sum)
await ctx.send(
_("{author} removed {num} {currency} from {user}'s account.").format(
elif creds.operation == "withdraw":
await bank.withdraw_credits(to, creds.sum)
msg = _("{author} removed {num} {currency} from {user}'s account.").format(
author=author.display_name,
num=creds.sum,
currency=currency,
user=to.display_name,
)
)
else:
await bank.set_balance(to, creds.sum)
msg = _("{author} set {user}'s account balance to {num} {currency}.").format(
author=author.display_name,
num=creds.sum,
currency=currency,
user=to.display_name,
)
except (ValueError, errors.BalanceTooHigh) as e:
await ctx.send(str(e))
else:
await bank.set_balance(to, creds.sum)
await ctx.send(
_("{author} set {users}'s account balance to {num} {currency}.").format(
author=author.display_name,
num=creds.sum,
currency=currency,
user=to.display_name,
)
)
await ctx.send(msg)
@_bank.command()
@check_global_setting_guildowner()
@@ -260,7 +259,18 @@ class Economy(commands.Cog):
if await bank.is_global(): # Role payouts will not be used
next_payday = await self.config.user(author).next_payday()
if cur_time >= next_payday:
await bank.deposit_credits(author, await self.config.PAYDAY_CREDITS())
try:
await bank.deposit_credits(author, await self.config.PAYDAY_CREDITS())
except errors.BalanceTooHigh as exc:
await bank.set_balance(author, exc.max_balance)
await ctx.send(
_(
"You've reached the maximum amount of {currency}! (**{balance:,}**) "
"Please spend some more \N{GRIMACING FACE}\n\n"
"You currently have {new_balance} {currency}."
).format(currency=credits_name, new_balance=exc.max_balance)
)
return
next_payday = cur_time + await self.config.PAYDAY_TIME()
await self.config.user(author).next_payday.set(next_payday)
@@ -268,7 +278,7 @@ class Economy(commands.Cog):
await ctx.send(
_(
"{author.mention} Here, take some {currency}. "
"Enjoy! (+{amount} {new_balance}!)\n\n"
"Enjoy! (+{amount} {currency}!)\n\n"
"You currently have {new_balance} {currency}.\n\n"
"You are currently #{pos} on the global leaderboard!"
).format(
@@ -297,14 +307,25 @@ class Economy(commands.Cog):
).PAYDAY_CREDITS() # Nice variable name
if role_credits > credit_amount:
credit_amount = role_credits
await bank.deposit_credits(author, credit_amount)
try:
await bank.deposit_credits(author, credit_amount)
except errors.BalanceTooHigh as exc:
await bank.set_balance(author, exc.max_balance)
await ctx.send(
_(
"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)
)
return
next_payday = cur_time + await self.config.guild(guild).PAYDAY_TIME()
await self.config.member(author).next_payday.set(next_payday)
pos = await bank.get_leaderboard_position(author)
await ctx.send(
_(
"{author.mention} Here, take some {currency}. "
"Enjoy! (+{amount} {new_balance}!)\n\n"
"Enjoy! (+{amount} {currency}!)\n\n"
"You currently have {new_balance} {currency}.\n\n"
"You are currently #{pos} on the global leaderboard!"
).format(
@@ -444,7 +465,21 @@ class Economy(commands.Cog):
then = await bank.get_balance(author)
pay = payout["payout"](bid)
now = then - bid + pay
await bank.set_balance(author, now)
try:
await bank.set_balance(author, now)
except errors.BalanceTooHigh as exc:
await bank.set_balance(author, exc.max_balance)
await channel.send(
_(
"You've reached the maximum amount of {currency}! "
"Please spend some more \N{GRIMACING FACE}\n{old_balance} -> {new_balance}!"
).format(
currency=await bank.get_currency_name(getattr(channel, "guild", None)),
old_balance=then,
new_balance=exc.max_balance,
)
)
return
phrase = T_(payout["phrase"])
else:
then = await bank.get_balance(author)
@@ -561,10 +596,10 @@ class Economy(commands.Cog):
async def paydayamount(self, ctx: commands.Context, creds: int):
"""Set the amount earned each payday."""
guild = ctx.guild
credits_name = await bank.get_currency_name(guild)
if creds <= 0:
if creds <= 0 or creds > bank.MAX_BALANCE:
await ctx.send(_("Har har so funny."))
return
credits_name = await bank.get_currency_name(guild)
if await bank.is_global():
await self.config.PAYDAY_CREDITS.set(creds)
else:
@@ -579,6 +614,9 @@ class Economy(commands.Cog):
async def rolepaydayamount(self, ctx: commands.Context, role: discord.Role, creds: int):
"""Set the amount earned each payday for a role."""
guild = ctx.guild
if creds <= 0 or creds > bank.MAX_BALANCE:
await ctx.send(_("Har har so funny."))
return
credits_name = await bank.get_currency_name(guild)
if await bank.is_global():
await ctx.send(_("The bank must be per-server for per-role paydays to work."))

View File

@@ -193,7 +193,7 @@ class Mod(commands.Cog):
yes_or_no=_("Yes") if respect_hierarchy else _("No")
)
msg += _("Delete delay: {num_seconds}\n").format(
num_seconds=_("{num} seconds").format(delete_delay)
num_seconds=_("{num} seconds").format(num=delete_delay)
if delete_delay != -1
else _("None")
)
@@ -748,7 +748,8 @@ class Mod(commands.Cog):
to send the newly unbanned user
:returns: :class:`Invite`"""
guild = ctx.guild
if guild.me.permissions.manage_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()
@@ -824,7 +825,7 @@ class Mod(commands.Cog):
@admin_or_voice_permissions(mute_members=True, deafen_members=True)
@bot_has_voice_permissions(mute_members=True, deafen_members=True)
async def voiceunban(self, ctx: commands.Context, user: discord.Member, *, reason: str = None):
"""Unban a the user from speaking and listening in the server's voice channels."""
"""Unban a user from speaking and listening in the server's voice channels."""
user_voice_state = user.voice
if user_voice_state is None:
await ctx.send(_("No voice state for that user!"))
@@ -892,34 +893,33 @@ class Mod(commands.Cog):
author = ctx.author
if user_voice_state:
channel = user_voice_state.channel
if channel and channel.permissions_for(user).speak:
overwrites = channel.overwrites_for(user)
overwrites.speak = False
audit_reason = get_audit_reason(ctx.author, reason)
await channel.set_permissions(user, overwrite=overwrites, reason=audit_reason)
await ctx.send(
_("Muted {user} in channel {channel.name}").format(user, channel=channel)
)
try:
await modlog.create_case(
self.bot,
guild,
ctx.message.created_at,
"boicemute",
user,
author,
reason,
until=None,
channel=channel,
if 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
)
)
except RuntimeError as e:
await ctx.send(e)
return
elif channel.permissions_for(user).speak is False:
await ctx.send(
_("That user is already muted in {channel}!").format(channel=channel.name)
)
return
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 channel.send(issue)
else:
await ctx.send(_("That user is not in a voice channel right now!"))
else:
@@ -937,13 +937,7 @@ class Mod(commands.Cog):
author = ctx.message.author
channel = ctx.message.channel
guild = ctx.guild
if reason is None:
audit_reason = "Channel mute requested by {a} (ID {a.id})".format(a=author)
else:
audit_reason = "Channel mute requested by {a} (ID {a.id}). Reason: {r}".format(
a=author, r=reason
)
audit_reason = get_audit_reason(author, reason)
success, issue = await self.mute_user(guild, channel, author, user, audit_reason)
@@ -974,26 +968,12 @@ class Mod(commands.Cog):
"""Mutes user in the server"""
author = ctx.message.author
guild = ctx.guild
if reason is None:
audit_reason = "server mute requested by {author} (ID {author.id})".format(
author=author
)
else:
audit_reason = (
"server mute requested by {author} (ID {author.id}). Reason: {reason}"
).format(author=author, reason=reason)
audit_reason = get_audit_reason(author, reason)
mute_success = []
for channel in guild.channels:
if not isinstance(channel, discord.TextChannel):
if channel.permissions_for(user).speak:
overwrites = channel.overwrites_for(user)
overwrites.speak = False
audit_reason = get_audit_reason(ctx.author, reason)
await channel.set_permissions(user, overwrite=overwrites, reason=audit_reason)
else:
success, issue = await self.mute_user(guild, channel, author, user, audit_reason)
mute_success.append((success, issue))
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:
@@ -1014,7 +994,7 @@ class Mod(commands.Cog):
async def mute_user(
self,
guild: discord.Guild,
channel: discord.TextChannel,
channel: discord.abc.GuildChannel,
author: discord.Member,
user: discord.Member,
reason: str,
@@ -1022,25 +1002,32 @@ class Mod(commands.Cog):
"""Mutes the specified user in the specified channel"""
overwrites = channel.overwrites_for(user)
permissions = channel.permissions_for(user)
perms_cache = await self.settings.member(user).perms_cache()
if overwrites.send_messages is False or permissions.send_messages is False:
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"])
perms_cache[str(channel.id)] = {
"send_messages": overwrites.send_messages,
"add_reactions": overwrites.add_reactions,
}
overwrites.update(send_messages=False, add_reactions=False)
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).perms_cache.set(perms_cache)
await self.settings.member(user).set_raw(
"perms_cache", str(channel.id), value=old_overs
)
return True, None
@commands.group()
@@ -1060,37 +1047,39 @@ class Mod(commands.Cog):
):
"""Unmute a user in their current voice channel."""
user_voice_state = user.voice
guild = ctx.guild
author = ctx.author
if user_voice_state:
channel = user_voice_state.channel
if channel and channel.permissions_for(user).speak is False:
overwrites = channel.overwrites_for(user)
overwrites.speak = None
audit_reason = get_audit_reason(ctx.author, reason)
await channel.set_permissions(user, overwrite=overwrites, reason=audit_reason)
author = ctx.author
guild = ctx.guild
await ctx.send(
_("Unmuted {}#{} in channel {}").format(
user.name, user.discriminator, channel.name
)
if channel:
audit_reason = get_audit_reason(author, reason)
success, message = await self.unmute_user(
guild, channel, author, user, audit_reason
)
try:
await modlog.create_case(
self.bot,
guild,
ctx.message.created_at,
"voiceunmute",
user,
author,
reason,
until=None,
channel=channel,
if success:
await ctx.send(
_("Unmuted {user} in channel {channel.name}").format(
user=user, channel=channel
)
)
except RuntimeError as e:
await ctx.send(e)
elif channel.permissions_for(user).speak:
await ctx.send(_("That user is already unmuted in {}!").format(channel.name))
return
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))
else:
await ctx.send(_("That user is not in a voice channel right now!"))
else:
@@ -1108,8 +1097,9 @@ class Mod(commands.Cog):
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)
success, message = await self.unmute_user(guild, channel, author, user, audit_reason)
if success:
await ctx.send(_("User unmuted in this channel."))
@@ -1140,16 +1130,11 @@ class Mod(commands.Cog):
"""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:
if not isinstance(channel, discord.TextChannel):
if channel.permissions_for(user).speak is False:
overwrites = channel.overwrites_for(user)
overwrites.speak = None
audit_reason = get_audit_reason(author, reason)
await channel.set_permissions(user, overwrite=overwrites, reason=audit_reason)
success, message = await self.unmute_user(guild, channel, author, user)
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."))
@@ -1170,45 +1155,37 @@ class Mod(commands.Cog):
async def unmute_user(
self,
guild: discord.Guild,
channel: discord.TextChannel,
channel: discord.abc.GuildChannel,
author: discord.Member,
user: discord.Member,
reason: str,
) -> (bool, str):
overwrites = channel.overwrites_for(user)
permissions = channel.permissions_for(user)
perms_cache = await self.settings.member(user).perms_cache()
if overwrites.send_messages or permissions.send_messages:
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"])
if channel.id in perms_cache:
old_values = perms_cache[channel.id]
else:
old_values = {"send_messages": None, "add_reactions": None}
overwrites.update(
send_messages=old_values["send_messages"], add_reactions=old_values["add_reactions"]
)
is_empty = self.are_overwrites_empty(overwrites)
overwrites.update(**old_values)
try:
if not is_empty:
await channel.set_permissions(user, overwrite=overwrites)
else:
if overwrites.is_empty():
await channel.set_permissions(
user, overwrite=cast(discord.PermissionOverwrite, None)
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:
try:
del perms_cache[channel.id]
except KeyError:
pass
else:
await self.settings.member(user).perms_cache.set(perms_cache)
await self.settings.member(user).clear_raw("perms_cache", str(channel.id))
return True, None
@commands.group()
@@ -1694,20 +1671,15 @@ class Mod(commands.Cog):
while len(nick_list) > 20:
nick_list.pop(0)
@staticmethod
def are_overwrites_empty(overwrites):
"""There is currently no cleaner way to check if a
PermissionOverwrite object is empty"""
return [p for p in iter(overwrites)] == [p for p in iter(discord.PermissionOverwrite())]
_ = 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!"),
"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."
"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 "

View File

@@ -542,7 +542,8 @@ class Permissions(commands.Cog):
continue
conf = self.config.custom(category)
for cmd_name, cmd_rules in rules_dict.items():
await conf.set_raw(cmd_name, guild_id, value=cmd_rules)
cmd_rules = {str(model_id): rule for model_id, rule in cmd_rules.items()}
await conf.set_raw(cmd_name, str(guild_id), value=cmd_rules)
cmd_obj = getter(cmd_name)
if cmd_obj is not None:
self._load_rules_for(cmd_obj, {guild_id: cmd_rules})
@@ -651,14 +652,14 @@ class Permissions(commands.Cog):
if category in old_rules:
for name, rules in old_rules[category].items():
these_rules = new_rules.setdefault(name, {})
guild_rules = these_rules.setdefault(guild_id, {})
guild_rules = these_rules.setdefault(str(guild_id), {})
# Since allow rules would take precedence if the same model ID
# sat in both the allow and deny list, we add the deny entries
# first and let any conflicting allow entries overwrite.
for model_id in rules.get("deny", []):
guild_rules[model_id] = False
guild_rules[str(model_id)] = False
for model_id in rules.get("allow", []):
guild_rules[model_id] = True
guild_rules[str(model_id)] = True
if "default" in rules:
default = rules["default"]
if default == "allow":
@@ -689,7 +690,9 @@ class Permissions(commands.Cog):
"""
for guild_id, guild_dict in _int_key_map(rule_dict.items()):
for model_id, rule in _int_key_map(guild_dict.items()):
if rule is True:
if model_id == "default":
cog_or_command.set_default_rule(rule, guild_id=guild_id)
elif rule is True:
cog_or_command.allow_for(model_id, guild_id=guild_id)
elif rule is False:
cog_or_command.deny_to(model_id, guild_id=guild_id)
@@ -724,9 +727,16 @@ class Permissions(commands.Cog):
rules.
"""
for guild_id, guild_dict in _int_key_map(rule_dict.items()):
for model_id in map(int, guild_dict.keys()):
cog_or_command.clear_rule_for(model_id, guild_id)
for model_id in guild_dict.keys():
if model_id == "default":
cog_or_command.set_default_rule(None, guild_id=guild_id)
else:
cog_or_command.clear_rule_for(int(model_id), guild_id=guild_id)
def _int_key_map(items_view: ItemsView[str, Any]) -> Iterator[Tuple[int, Any]]:
return map(lambda tup: (int(tup[0]), tup[1]), items_view)
def _int_key_map(items_view: ItemsView[str, Any]) -> Iterator[Tuple[Union[str, int], Any]]:
for k, v in items_view:
if k == "default":
yield k, v
else:
yield int(k), v

View File

@@ -626,8 +626,13 @@ class Streams(commands.Cog):
raw_stream["_messages_cache"] = []
for raw_msg in raw_msg_cache:
chn = self.bot.get_channel(raw_msg["channel"])
msg = await chn.get_message(raw_msg["message"])
raw_stream["_messages_cache"].append(msg)
if chn is not None:
try:
msg = await chn.get_message(raw_msg["message"])
except discord.HTTPException:
pass
else:
raw_stream["_messages_cache"].append(msg)
token = await self.db.tokens.get_raw(_class.__name__, default=None)
if token is not None:
raw_stream["token"] = token
@@ -646,8 +651,13 @@ class Streams(commands.Cog):
raw_community["_messages_cache"] = []
for raw_msg in raw_msg_cache:
chn = self.bot.get_channel(raw_msg["channel"])
msg = await chn.get_message(raw_msg["message"])
raw_community["_messages_cache"].append(msg)
if chn is not None:
try:
msg = await chn.get_message(raw_msg["message"])
except discord.HTTPException:
pass
else:
raw_community["_messages_cache"].append(msg)
token = await self.db.tokens.get_raw(_class.__name__, default=None)
communities.append(_class(token=token, **raw_community))

View File

@@ -322,9 +322,9 @@ def _parse_answers(answers):
for answer in answers:
if isinstance(answer, bool):
if answer is True:
ret.extend(["True", "Yes", _("Yes")])
ret.extend(["True", "Yes", "On"])
else:
ret.extend(["False", "No", _("No")])
ret.extend(["False", "No", "Off"])
else:
ret.append(str(answer))
# Uniquify list

View File

@@ -19,9 +19,11 @@ async def warning_points_add_check(
act = {}
async with guild_settings.actions() as registered_actions:
for a in registered_actions:
# Actions are sorted in decreasing order of points.
# The first action we find where the user is above the threshold will be the
# highest action we can take.
if points >= a["points"]:
act = a
else:
break
if act and act["exceed_command"] is not None: # some action needs to be taken
await create_and_invoke_context(ctx, act["exceed_command"], user)

View File

@@ -9,7 +9,7 @@ from redbot.cogs.warnings.helpers import (
get_command_for_dropping_points,
warning_points_remove_check,
)
from redbot.core import Config, modlog, checks, commands
from redbot.core import Config, checks, commands
from redbot.core.bot import Red
from redbot.core.i18n import Translator, cog_i18n
from redbot.core.utils.mod import is_admin_or_superior
@@ -34,15 +34,14 @@ class Warnings(commands.Cog):
self.config.register_guild(**self.default_guild)
self.config.register_member(**self.default_member)
self.bot = bot
loop = asyncio.get_event_loop()
loop.create_task(self.register_warningtype())
@staticmethod
async def register_warningtype():
try:
await modlog.register_casetype("warning", True, "\N{WARNING SIGN}", "Warning", None)
except RuntimeError:
pass
# We're not utilising modlog yet - no need to register a casetype
# @staticmethod
# async def register_warningtype():
# try:
# await modlog.register_casetype("warning", True, "\N{WARNING SIGN}", "Warning", None)
# except RuntimeError:
# pass
@commands.group()
@commands.guild_only()

View File

@@ -148,5 +148,5 @@ class VersionInfo:
)
__version__ = "3.0.0rc1.post1"
__version__ = "3.0.0rc2"
version_info = VersionInfo.from_str(__version__)

View File

@@ -4,9 +4,10 @@ from typing import Union, List, Optional
import discord
from redbot.core import Config
from . import Config, errors
__all__ = [
"MAX_BALANCE",
"Account",
"get_balance",
"set_balance",
@@ -26,6 +27,8 @@ __all__ = [
"set_default_balance",
]
MAX_BALANCE = 2 ** 63 - 1
_DEFAULT_GLOBAL = {
"is_global": False,
"bank_name": "Twentysix bank",
@@ -170,10 +173,22 @@ async def set_balance(member: discord.Member, amount: int) -> int:
------
ValueError
If attempting to set the balance to a negative number.
BalanceTooHigh
If attempting to set the balance to a value greater than
``bank.MAX_BALANCE``
"""
if amount < 0:
raise ValueError("Not allowed to have negative balance.")
if amount > MAX_BALANCE:
currency = (
await get_currency_name()
if await is_global()
else await get_currency_name(member.guild)
)
raise errors.BalanceTooHigh(
user=member.display_name, max_balance=MAX_BALANCE, currency_name=currency
)
if await is_global():
group = _conf.user(member)
else:

View File

@@ -1,4 +1,5 @@
import asyncio
import inspect
import os
import logging
from collections import Counter
@@ -11,10 +12,10 @@ import discord
import sys
from discord.ext.commands import when_mentioned_or
from . import Config, i18n, commands, errors
from .cog_manager import CogManager
from . import Config, i18n, commands
from .rpc import RPCMixin
from .help_formatter import Help, help as help_
from .rpc import RPCMixin
from .sentry import SentryManager
from .utils import common_filters
@@ -217,7 +218,7 @@ class RedBase(commands.GroupMixin, commands.bot.BotBase, RPCMixin):
async def load_extension(self, spec: ModuleSpec):
name = spec.name.split(".")[-1]
if name in self.extensions:
raise discord.ClientException(f"there is already a package named {name} loaded")
raise errors.PackageAlreadyLoaded(spec)
lib = spec.loader.load_module()
if not hasattr(lib, "setup"):
@@ -236,20 +237,13 @@ class RedBase(commands.GroupMixin, commands.bot.BotBase, RPCMixin):
if cog is None:
return
for when in ("before", "after"):
for cls in inspect.getmro(cog.__class__):
try:
hook = getattr(cog, f"_{cog.__class__.__name__}__red_permissions_{when}")
hook = getattr(cog, f"_{cls.__name__}__permissions_hook")
except AttributeError:
pass
else:
self.remove_permissions_hook(hook, when)
try:
hook = getattr(cog, f"_{cog.__class__.__name__}__red_permissions_before")
except AttributeError:
pass
else:
self.remove_permissions_hook(hook)
self.remove_permissions_hook(hook)
super().remove_cog(cogname)
@@ -390,10 +384,17 @@ class RedBase(commands.GroupMixin, commands.bot.BotBase, RPCMixin):
)
if not hasattr(cog, "requires"):
commands.Cog.__init__(cog)
for cls in inspect.getmro(cog.__class__):
try:
hook = getattr(cog, f"_{cls.__name__}__permissions_hook")
except AttributeError:
pass
else:
self.add_permissions_hook(hook)
for attr in dir(cog):
_attr = getattr(cog, attr)
if attr == f"_{cog.__class__.__name__}__permissions_hook":
self.add_permissions_hook(_attr)
if isinstance(_attr, discord.ext.commands.Command) and not isinstance(
_attr, commands.Command
):

View File

@@ -39,17 +39,21 @@ class _ValueCtxManager(Awaitable[_T], AsyncContextManager[_T]):
async def __aenter__(self):
self.raw_value = await self
self.__original_value = deepcopy(self.raw_value)
if not isinstance(self.raw_value, (list, dict)):
raise TypeError(
"Type of retrieved value must be mutable (i.e. "
"list or dict) in order to use a config value as "
"a context manager."
)
self.__original_value = deepcopy(self.raw_value)
return self.raw_value
async def __aexit__(self, exc_type, exc, tb):
if self.raw_value != self.__original_value:
if isinstance(self.raw_value, dict):
raw_value = _str_key_dict(self.raw_value)
else:
raw_value = self.raw_value
if raw_value != self.__original_value:
await self.value_obj.set(self.raw_value)
@@ -58,7 +62,7 @@ class Value:
Attributes
----------
identifiers : `tuple` of `str`
identifiers : Tuple[str]
This attribute provides all the keys necessary to get a specific data
element from a json document.
default
@@ -69,15 +73,10 @@ class Value:
"""
def __init__(self, identifiers: Tuple[str], default_value, driver):
self._identifiers = identifiers
self.identifiers = identifiers
self.default = default_value
self.driver = driver
@property
def identifiers(self):
return tuple(str(i) for i in self._identifiers)
async def _get(self, default=...):
try:
ret = await self.driver.get(*self.identifiers)
@@ -149,6 +148,8 @@ class Value:
The new literal value of this attribute.
"""
if isinstance(value, dict):
value = _str_key_dict(value)
await self.driver.set(*self.identifiers, value=value)
async def clear(self):
@@ -192,7 +193,10 @@ class Group(Value):
async def _get(self, default: Dict[str, Any] = ...) -> Dict[str, Any]:
default = default if default is not ... else self.defaults
raw = await super()._get(default)
return self.nested_update(raw, default)
if isinstance(raw, dict):
return self.nested_update(raw, default)
else:
return raw
# noinspection PyTypeChecker
def __getattr__(self, item: str) -> Union["Group", Value]:
@@ -238,7 +242,7 @@ class Group(Value):
else:
return Value(identifiers=new_identifiers, default_value=None, driver=self.driver)
async def clear_raw(self, *nested_path: str):
async def clear_raw(self, *nested_path: Any):
"""
Allows a developer to clear data as if it was stored in a standard
Python dictionary.
@@ -254,44 +258,44 @@ class Group(Value):
Parameters
----------
nested_path : str
nested_path : Any
Multiple arguments that mirror the arguments passed in for nested
dict access.
dict access. These are casted to `str` for you.
"""
path = [str(p) for p in nested_path]
await self.driver.clear(*self.identifiers, *path)
def is_group(self, item: str) -> bool:
def is_group(self, item: Any) -> bool:
"""A helper method for `__getattr__`. Most developers will have no need
to use this.
Parameters
----------
item : str
item : Any
See `__getattr__`.
"""
default = self._defaults.get(item)
default = self._defaults.get(str(item))
return isinstance(default, dict)
def is_value(self, item: str) -> bool:
def is_value(self, item: Any) -> bool:
"""A helper method for `__getattr__`. Most developers will have no need
to use this.
Parameters
----------
item : str
item : Any
See `__getattr__`.
"""
try:
default = self._defaults[item]
default = self._defaults[str(item)]
except KeyError:
return False
return not isinstance(default, dict)
def get_attr(self, item: str):
def get_attr(self, item: Union[int, str]):
"""Manually get an attribute of this Group.
This is available to use as an alternative to using normal Python
@@ -312,7 +316,8 @@ class Group(Value):
Parameters
----------
item : str
The name of the data field in `Config`.
The name of the data field in `Config`. This is casted to
`str` for you.
Returns
-------
@@ -320,9 +325,11 @@ class Group(Value):
The attribute which was requested.
"""
if isinstance(item, int):
item = str(item)
return self.__getattr__(item)
async def get_raw(self, *nested_path: str, default=...):
async def get_raw(self, *nested_path: Any, default=...):
"""
Allows a developer to access data as if it was stored in a standard
Python dictionary.
@@ -345,7 +352,7 @@ class Group(Value):
----------
nested_path : str
Multiple arguments that mirror the arguments passed in for nested
dict access.
dict access. These are casted to `str` for you.
default
Default argument for the value attempting to be accessed. If the
value does not exist the default will be returned.
@@ -410,7 +417,6 @@ class Group(Value):
If no defaults are passed, then the instance attribute 'defaults'
will be used.
"""
if defaults is ...:
defaults = self.defaults
@@ -428,7 +434,7 @@ class Group(Value):
raise ValueError("You may only set the value of a group to be a dict.")
await super().set(value)
async def set_raw(self, *nested_path: str, value):
async def set_raw(self, *nested_path: Any, value):
"""
Allows a developer to set data as if it was stored in a standard
Python dictionary.
@@ -444,13 +450,15 @@ class Group(Value):
Parameters
----------
nested_path : str
nested_path : Any
Multiple arguments that mirror the arguments passed in for nested
dict access.
`dict` access. These are casted to `str` for you.
value
The value to store.
"""
path = [str(p) for p in nested_path]
if isinstance(value, dict):
value = _str_key_dict(value)
await self.driver.set(*self.identifiers, *path, value=value)
@@ -461,9 +469,11 @@ class Config:
`get_core_conf` for Config used in the core package.
.. important::
Most config data should be accessed through its respective group method (e.g. :py:meth:`guild`)
however the process for accessing global data is a bit different. There is no :python:`global` method
because global data is accessed by normal attribute access::
Most config data should be accessed through its respective
group method (e.g. :py:meth:`guild`) however the process for
accessing global data is a bit different. There is no
:python:`global` method because global data is accessed by
normal attribute access::
await conf.foo()
@@ -548,7 +558,7 @@ class Config:
A new Config object.
"""
if cog_instance is None and not cog_name is None:
if cog_instance is None and cog_name is not None:
cog_path_override = cog_data_path(raw_name=cog_name)
else:
cog_path_override = cog_data_path(cog_instance=cog_instance)
@@ -635,11 +645,8 @@ class Config:
def _get_defaults_dict(key: str, value) -> dict:
"""
Since we're allowing nested config stuff now, not storing the
_defaults as a flat dict sounds like a good idea. May turn
out to be an awful one but we'll see.
:param key:
:param value:
:return:
_defaults as a flat dict sounds like a good idea. May turn out
to be an awful one but we'll see.
"""
ret = {}
partial = ret
@@ -655,15 +662,12 @@ class Config:
return ret
@staticmethod
def _update_defaults(to_add: dict, _partial: dict):
def _update_defaults(to_add: Dict[str, Any], _partial: Dict[str, Any]):
"""
This tries to update the _defaults dictionary with the nested
partial dict generated by _get_defaults_dict. This WILL
throw an error if you try to have both a value and a group
registered under the same name.
:param to_add:
:param _partial:
:return:
partial dict generated by _get_defaults_dict. This WILL
throw an error if you try to have both a value and a group
registered under the same name.
"""
for k, v in to_add.items():
val_is_dict = isinstance(v, dict)
@@ -679,7 +683,7 @@ class Config:
else:
_partial[k] = v
def _register_default(self, key: str, **kwargs):
def _register_default(self, key: str, **kwargs: Any):
if key not in self._defaults:
self._defaults[key] = {}
@@ -720,8 +724,8 @@ class Config:
**_defaults
)
You can do the same thing without a :python:`_defaults` dict by using double underscore as a variable
name separator::
You can do the same thing without a :python:`_defaults` dict by
using double underscore as a variable name separator::
# This is equivalent to the previous example
conf.register_global(
@@ -802,7 +806,7 @@ class Config:
The guild's Group object.
"""
return self._get_base_group(self.GUILD, guild.id)
return self._get_base_group(self.GUILD, str(guild.id))
def channel(self, channel: discord.TextChannel) -> Group:
"""Returns a `Group` for the given channel.
@@ -820,7 +824,7 @@ class Config:
The channel's Group object.
"""
return self._get_base_group(self.CHANNEL, channel.id)
return self._get_base_group(self.CHANNEL, str(channel.id))
def role(self, role: discord.Role) -> Group:
"""Returns a `Group` for the given role.
@@ -836,7 +840,7 @@ class Config:
The role's Group object.
"""
return self._get_base_group(self.ROLE, role.id)
return self._get_base_group(self.ROLE, str(role.id))
def user(self, user: discord.abc.User) -> Group:
"""Returns a `Group` for the given user.
@@ -852,7 +856,7 @@ class Config:
The user's Group object.
"""
return self._get_base_group(self.USER, user.id)
return self._get_base_group(self.USER, str(user.id))
def member(self, member: discord.Member) -> Group:
"""Returns a `Group` for the given member.
@@ -866,8 +870,9 @@ class Config:
-------
`Group <redbot.core.config.Group>`
The member's Group object.
"""
return self._get_base_group(self.MEMBER, member.guild.id, member.id)
return self._get_base_group(self.MEMBER, str(member.guild.id), str(member.id))
def custom(self, group_identifier: str, *identifiers: str):
"""Returns a `Group` for the given custom group.
@@ -876,17 +881,17 @@ class Config:
----------
group_identifier : str
Used to identify the custom group.
identifiers : str
The attributes necessary to uniquely identify an entry in the
custom group.
custom group. These are casted to `str` for you.
Returns
-------
`Group <redbot.core.config.Group>`
The custom group's Group object.
"""
return self._get_base_group(group_identifier, *identifiers)
return self._get_base_group(str(group_identifier), *map(str, identifiers))
async def _all_from_scope(self, scope: str) -> Dict[int, Dict[Any, Any]]:
"""Get a dict of all values from a particular scope of data.
@@ -982,7 +987,8 @@ class Config:
"""
return await self._all_from_scope(self.USER)
def _all_members_from_guild(self, group: Group, guild_data: dict) -> dict:
@staticmethod
def _all_members_from_guild(group: Group, guild_data: dict) -> dict:
ret = {}
for member_id, member_data in guild_data.items():
new_member_data = group.defaults
@@ -1026,7 +1032,7 @@ class Config:
for guild_id, guild_data in dict_.items():
ret[int(guild_id)] = self._all_members_from_guild(group, guild_data)
else:
group = self._get_base_group(self.MEMBER, guild.id)
group = self._get_base_group(self.MEMBER, str(guild.id))
try:
guild_data = await self.driver.get(*group.identifiers)
except KeyError:
@@ -1054,7 +1060,8 @@ class Config:
"""
if not scopes:
group = Group(identifiers=[], defaults={}, driver=self.driver)
# noinspection PyTypeChecker
group = Group(identifiers=(), defaults={}, driver=self.driver)
else:
group = self._get_base_group(*scopes)
await group.clear()
@@ -1119,7 +1126,7 @@ class Config:
"""
if guild is not None:
await self._clear_scope(self.MEMBER, guild.id)
await self._clear_scope(self.MEMBER, str(guild.id))
return
await self._clear_scope(self.MEMBER)
@@ -1127,5 +1134,34 @@ class Config:
"""Clear all custom group data.
This resets all custom group data to its registered defaults.
Parameters
----------
group_identifier : str
The identifier for the custom group. This is casted to
`str` for you.
"""
await self._clear_scope(group_identifier)
await self._clear_scope(str(group_identifier))
def _str_key_dict(value: Dict[Any, _T]) -> Dict[str, _T]:
"""
Recursively casts all keys in the given `dict` to `str`.
Parameters
----------
value : Dict[Any, Any]
The `dict` to cast keys to `str`.
Returns
-------
Dict[str, Any]
The `dict` with keys (and nested keys) casted to `str`.
"""
ret = {}
for k, v in value.items():
if isinstance(v, dict):
v = _str_key_dict(v)
ret[str(k)] = v
return ret

View File

@@ -13,7 +13,7 @@ from collections import namedtuple
from pathlib import Path
from random import SystemRandom
from string import ascii_letters, digits
from typing import TYPE_CHECKING, Union
from typing import TYPE_CHECKING, Union, Tuple, List, Optional, Iterable, Sequence, Dict
import aiohttp
import discord
@@ -25,6 +25,7 @@ from redbot.core import (
VersionInfo,
checks,
commands,
errors,
i18n,
)
from .utils.predicates import MessagePredicate
@@ -59,7 +60,9 @@ class CoreLogic:
self.bot.register_rpc_handler(self._version_info)
self.bot.register_rpc_handler(self._invite_url)
async def _load(self, cog_names: list):
async def _load(
self, cog_names: Iterable[str]
) -> Tuple[List[str], List[str], List[str], List[str]]:
"""
Loads cogs by name.
Parameters
@@ -69,11 +72,12 @@ class CoreLogic:
Returns
-------
tuple
3 element tuple of loaded, failed, and not found cogs.
4-tuple of loaded, failed, not found and already loaded cogs.
"""
failed_packages = []
loaded_packages = []
notfound_packages = []
alreadyloaded_packages = []
bot = self.bot
@@ -98,6 +102,8 @@ class CoreLogic:
try:
self._cleanup_and_refresh_modules(spec.name)
await bot.load_extension(spec)
except errors.PackageAlreadyLoaded:
alreadyloaded_packages.append(name)
except Exception as e:
log.exception("Package loading failed", exc_info=e)
@@ -109,9 +115,10 @@ class CoreLogic:
await bot.add_loaded_package(name)
loaded_packages.append(name)
return loaded_packages, failed_packages, notfound_packages
return loaded_packages, failed_packages, notfound_packages, alreadyloaded_packages
def _cleanup_and_refresh_modules(self, module_name: str):
@staticmethod
def _cleanup_and_refresh_modules(module_name: str) -> None:
"""Interally reloads modules so that changes are detected"""
splitted = module_name.split(".")
@@ -123,6 +130,7 @@ class CoreLogic:
else:
importlib._bootstrap._exec(lib.__spec__, lib)
# noinspection PyTypeChecker
modules = itertools.accumulate(splitted, "{}.{}".format)
for m in modules:
maybe_reload(m)
@@ -131,7 +139,10 @@ class CoreLogic:
for child_name, lib in children.items():
importlib._bootstrap._exec(lib.__spec__, lib)
def _get_package_strings(self, packages: list, fmt: str, other: tuple = None):
@staticmethod
def _get_package_strings(
packages: List[str], fmt: str, other: Optional[Tuple[str, ...]] = None
) -> str:
"""
Gets the strings needed for the load, unload and reload commands
"""
@@ -147,7 +158,7 @@ class CoreLogic:
final_string = fmt.format(**form)
return final_string
async def _unload(self, cog_names: list):
async def _unload(self, cog_names: Iterable[str]) -> Tuple[List[str], List[str]]:
"""
Unloads cogs with the given names.
@@ -175,14 +186,16 @@ class CoreLogic:
return unloaded_packages, failed_packages
async def _reload(self, cog_names):
async def _reload(
self, cog_names: Sequence[str]
) -> Tuple[List[str], List[str], List[str], List[str]]:
await self._unload(cog_names)
loaded, load_failed, not_found = await self._load(cog_names)
loaded, load_failed, not_found, already_loaded = await self._load(cog_names)
return loaded, load_failed, not_found
return loaded, load_failed, not_found, already_loaded
async def _name(self, name: str = None):
async def _name(self, name: Optional[str] = None) -> str:
"""
Gets or sets the bot's username.
@@ -201,7 +214,7 @@ class CoreLogic:
return self.bot.user.name
async def _prefixes(self, prefixes: list = None):
async def _prefixes(self, prefixes: Optional[Sequence[str]] = None) -> List[str]:
"""
Gets or sets the bot's global prefixes.
@@ -220,7 +233,8 @@ class CoreLogic:
await self.bot.db.prefix.set(prefixes)
return await self.bot.db.prefix()
async def _version_info(self):
@classmethod
async def _version_info(cls) -> Dict[str, str]:
"""
Version information for Red and discord.py
@@ -231,7 +245,7 @@ class CoreLogic:
"""
return {"redbot": __version__, "discordpy": discord.__version__}
async def _invite_url(self):
async def _invite_url(self) -> str:
"""
Generates the invite URL for the bot.
@@ -248,11 +262,8 @@ class CoreLogic:
class Core(commands.Cog, CoreLogic):
"""Commands related to core functions"""
def __init__(self, bot):
super().__init__(bot)
@commands.command(hidden=True)
async def ping(self, ctx):
async def ping(self, ctx: commands.Context):
"""Pong."""
await ctx.send("Pong.")
@@ -313,7 +324,7 @@ class Core(commands.Cog, CoreLogic):
passed = self.get_bot_uptime()
await ctx.send("Been up for: **{}** (since {} UTC)".format(passed, since))
def get_bot_uptime(self, *, brief=False):
def get_bot_uptime(self, *, brief: bool = False):
# Courtesy of Danny
now = datetime.datetime.utcnow()
delta = now - self.bot.uptime
@@ -416,7 +427,7 @@ class Core(commands.Cog, CoreLogic):
@commands.command()
@checks.is_owner()
async def traceback(self, ctx, public: bool = False):
async def traceback(self, ctx: commands.Context, public: bool = False):
"""Sends to the owner the last command exception that has occurred
If public (yes is specified), it will be sent to the chat instead"""
@@ -433,14 +444,14 @@ class Core(commands.Cog, CoreLogic):
@commands.command()
@checks.is_owner()
async def invite(self, ctx):
async def invite(self, ctx: commands.Context):
"""Show's Red's invite url"""
await ctx.author.send(await self._invite_url())
@commands.command()
@commands.guild_only()
@checks.is_owner()
async def leave(self, ctx):
async def leave(self, ctx: commands.Context):
"""Leaves server"""
await ctx.send("Are you sure you want me to leave this server? (y/n)")
@@ -460,7 +471,7 @@ class Core(commands.Cog, CoreLogic):
@commands.command()
@checks.is_owner()
async def servers(self, ctx):
async def servers(self, ctx: commands.Context):
"""Lists and allows to leave servers"""
guilds = sorted(list(self.bot.guilds), key=lambda s: s.name.lower())
msg = ""
@@ -502,18 +513,21 @@ class Core(commands.Cog, CoreLogic):
@commands.command()
@checks.is_owner()
async def load(self, ctx, *, cog_name: str):
async def load(self, ctx: commands.Context, *cogs: str):
"""Loads packages"""
cog_names = [c.strip() for c in cog_name.split(" ")]
async with ctx.typing():
loaded, failed, not_found = await self._load(cog_names)
loaded, failed, not_found, already_loaded = await self._load(cogs)
if loaded:
fmt = "Loaded {packs}."
formed = self._get_package_strings(loaded, fmt)
await ctx.send(formed)
if already_loaded:
fmt = "The package{plural} {packs} {other} already loaded."
formed = self._get_package_strings(already_loaded, fmt, ("is", "are"))
await ctx.send(formed)
if failed:
fmt = (
"Failed to load package{plural} {packs}. Check your console or "
@@ -529,12 +543,9 @@ class Core(commands.Cog, CoreLogic):
@commands.command()
@checks.is_owner()
async def unload(self, ctx, *, cog_name: str):
async def unload(self, ctx: commands.Context, *cogs: str):
"""Unloads packages"""
cog_names = [c.strip() for c in cog_name.split(" ")]
unloaded, failed = await self._unload(cog_names)
unloaded, failed = await self._unload(cogs)
if unloaded:
fmt = "Package{plural} {packs} {other} unloaded."
@@ -548,10 +559,10 @@ class Core(commands.Cog, CoreLogic):
@commands.command(name="reload")
@checks.is_owner()
async def reload(self, ctx, *cogs: str):
async def reload(self, ctx: commands.Context, *cogs: str):
"""Reloads packages"""
async with ctx.typing():
loaded, failed, not_found = await self._reload(cogs)
loaded, failed, not_found, already_loaded = await self._reload(cogs)
if loaded:
fmt = "Package{plural} {packs} {other} reloaded."
@@ -570,34 +581,30 @@ class Core(commands.Cog, CoreLogic):
@commands.command(name="shutdown")
@checks.is_owner()
async def _shutdown(self, ctx, silently: bool = False):
async def _shutdown(self, ctx: commands.Context, silently: bool = False):
"""Shuts down the bot"""
wave = "\N{WAVING HAND SIGN}"
skin = "\N{EMOJI MODIFIER FITZPATRICK TYPE-3}"
try: # We don't want missing perms to stop our shutdown
with contextlib.suppress(discord.HTTPException):
if not silently:
await ctx.send(_("Shutting down... ") + wave + skin)
except:
pass
await ctx.bot.shutdown()
@commands.command(name="restart")
@checks.is_owner()
async def _restart(self, ctx, silently: bool = False):
async def _restart(self, ctx: commands.Context, silently: bool = False):
"""Attempts to restart Red
Makes Red quit with exit code 26
The restart is not guaranteed: it must be dealt
with by the process manager in use"""
try:
with contextlib.suppress(discord.HTTPException):
if not silently:
await ctx.send(_("Restarting..."))
except:
pass
await ctx.bot.shutdown(restart=True)
@commands.group(name="set")
async def _set(self, ctx):
async def _set(self, ctx: commands.Context):
"""Changes Red's settings"""
if ctx.invoked_subcommand is None:
if ctx.guild:
@@ -629,7 +636,7 @@ class Core(commands.Cog, CoreLogic):
@_set.command()
@checks.guildowner()
@commands.guild_only()
async def adminrole(self, ctx, *, role: discord.Role):
async def adminrole(self, ctx: commands.Context, *, role: discord.Role):
"""Sets the admin role for this server"""
await ctx.bot.db.guild(ctx.guild).admin_role.set(role.id)
await ctx.send(_("The admin role for this guild has been set."))
@@ -637,7 +644,7 @@ class Core(commands.Cog, CoreLogic):
@_set.command()
@checks.guildowner()
@commands.guild_only()
async def modrole(self, ctx, *, role: discord.Role):
async def modrole(self, ctx: commands.Context, *, role: discord.Role):
"""Sets the mod role for this server"""
await ctx.bot.db.guild(ctx.guild).mod_role.set(role.id)
await ctx.send(_("The mod role for this guild has been set."))
@@ -645,7 +652,7 @@ class Core(commands.Cog, CoreLogic):
@_set.command(aliases=["usebotcolor"])
@checks.guildowner()
@commands.guild_only()
async def usebotcolour(self, ctx):
async def usebotcolour(self, ctx: commands.Context):
"""
Toggle whether to use the bot owner-configured colour for embeds.
@@ -663,7 +670,7 @@ class Core(commands.Cog, CoreLogic):
@_set.command()
@checks.guildowner()
@commands.guild_only()
async def serverfuzzy(self, ctx):
async def serverfuzzy(self, ctx: commands.Context):
"""
Toggle whether to enable fuzzy command search for the server.
@@ -679,7 +686,7 @@ class Core(commands.Cog, CoreLogic):
@_set.command()
@checks.is_owner()
async def fuzzy(self, ctx):
async def fuzzy(self, ctx: commands.Context):
"""
Toggle whether to enable fuzzy command search in DMs.
@@ -695,7 +702,7 @@ class Core(commands.Cog, CoreLogic):
@_set.command(aliases=["color"])
@checks.is_owner()
async def colour(self, ctx, *, colour: discord.Colour = None):
async def colour(self, ctx: commands.Context, *, colour: discord.Colour = None):
"""
Sets a default colour to be used for the bot's embeds.
@@ -713,7 +720,7 @@ class Core(commands.Cog, CoreLogic):
@_set.command()
@checks.is_owner()
async def avatar(self, ctx, url: str):
async def avatar(self, ctx: commands.Context, url: str):
"""Sets Red's avatar"""
async with aiohttp.ClientSession() as session:
async with session.get(url) as r:
@@ -737,7 +744,7 @@ class Core(commands.Cog, CoreLogic):
@_set.command(name="game")
@checks.bot_in_a_guild()
@checks.is_owner()
async def _game(self, ctx, *, game: str = None):
async def _game(self, ctx: commands.Context, *, game: str = None):
"""Sets Red's playing status"""
if game:
@@ -751,7 +758,7 @@ class Core(commands.Cog, CoreLogic):
@_set.command(name="listening")
@checks.bot_in_a_guild()
@checks.is_owner()
async def _listening(self, ctx, *, listening: str = None):
async def _listening(self, ctx: commands.Context, *, listening: str = None):
"""Sets Red's listening status"""
status = ctx.bot.guilds[0].me.status if len(ctx.bot.guilds) > 0 else discord.Status.online
@@ -765,7 +772,7 @@ class Core(commands.Cog, CoreLogic):
@_set.command(name="watching")
@checks.bot_in_a_guild()
@checks.is_owner()
async def _watching(self, ctx, *, watching: str = None):
async def _watching(self, ctx: commands.Context, *, watching: str = None):
"""Sets Red's watching status"""
status = ctx.bot.guilds[0].me.status if len(ctx.bot.guilds) > 0 else discord.Status.online
@@ -779,7 +786,7 @@ class Core(commands.Cog, CoreLogic):
@_set.command()
@checks.bot_in_a_guild()
@checks.is_owner()
async def status(self, ctx, *, status: str):
async def status(self, ctx: commands.Context, *, status: str):
"""Sets Red's status
Available statuses:
@@ -808,7 +815,7 @@ class Core(commands.Cog, CoreLogic):
@_set.command()
@checks.bot_in_a_guild()
@checks.is_owner()
async def stream(self, ctx, streamer=None, *, stream_title=None):
async def stream(self, ctx: commands.Context, streamer=None, *, stream_title=None):
"""Sets Red's streaming status
Leaving both streamer and stream_title empty will clear it."""
@@ -829,7 +836,7 @@ class Core(commands.Cog, CoreLogic):
@_set.command(name="username", aliases=["name"])
@checks.is_owner()
async def _username(self, ctx, *, username: str):
async def _username(self, ctx: commands.Context, *, username: str):
"""Sets Red's username"""
try:
await self._name(name=username)
@@ -848,7 +855,7 @@ class Core(commands.Cog, CoreLogic):
@_set.command(name="nickname")
@checks.admin()
@commands.guild_only()
async def _nickname(self, ctx, *, nickname: str = None):
async def _nickname(self, ctx: commands.Context, *, nickname: str = None):
"""Sets Red's nickname"""
try:
await ctx.guild.me.edit(nick=nickname)
@@ -859,7 +866,7 @@ class Core(commands.Cog, CoreLogic):
@_set.command(aliases=["prefixes"])
@checks.is_owner()
async def prefix(self, ctx, *prefixes):
async def prefix(self, ctx: commands.Context, *prefixes: str):
"""Sets Red's global prefix(es)"""
if not prefixes:
await ctx.send_help()
@@ -870,7 +877,7 @@ class Core(commands.Cog, CoreLogic):
@_set.command(aliases=["serverprefixes"])
@checks.admin()
@commands.guild_only()
async def serverprefix(self, ctx, *prefixes):
async def serverprefix(self, ctx: commands.Context, *prefixes: str):
"""Sets Red's server prefix(es)"""
if not prefixes:
await ctx.bot.db.guild(ctx.guild).prefix.set([])
@@ -882,7 +889,7 @@ class Core(commands.Cog, CoreLogic):
@_set.command()
@commands.cooldown(1, 60 * 10, commands.BucketType.default)
async def owner(self, ctx):
async def owner(self, ctx: commands.Context):
"""Sets Red's main owner"""
# According to the Python docs this is suitable for cryptographic use
random = SystemRandom()
@@ -926,7 +933,7 @@ class Core(commands.Cog, CoreLogic):
@_set.command()
@checks.is_owner()
async def token(self, ctx, token: str):
async def token(self, ctx: commands.Context, token: str):
"""Change bot token."""
if not isinstance(ctx.channel, discord.DMChannel):
@@ -1071,7 +1078,7 @@ class Core(commands.Cog, CoreLogic):
@commands.command()
@checks.is_owner()
async def backup(self, ctx, backup_path: str = None):
async def backup(self, ctx: commands.Context, backup_path: str = None):
"""Creates a backup of all data for the instance."""
from redbot.core.data_manager import basic_config, instance_name
from redbot.core.drivers.red_json import JSON
@@ -1080,21 +1087,20 @@ class Core(commands.Cog, CoreLogic):
if basic_config["STORAGE_TYPE"] == "MongoDB":
from redbot.core.drivers.red_mongo import Mongo
m = Mongo("Core", **basic_config["STORAGE_DETAILS"])
m = Mongo("Core", "0", **basic_config["STORAGE_DETAILS"])
db = m.db
collection_names = await db.collection_names(include_system_collections=False)
collection_names = await db.list_collection_names()
for c_name in collection_names:
if c_name == "Core":
c_data_path = data_dir / basic_config["CORE_PATH_APPEND"]
else:
c_data_path = data_dir / basic_config["COG_PATH_APPEND"]
output = {}
c_data_path = data_dir / basic_config["COG_PATH_APPEND"] / c_name
docs = await db[c_name].find().to_list(None)
for item in docs:
item_id = str(item.pop("_id"))
output[item_id] = item
target = JSON(c_name, data_path_override=c_data_path)
await target.jsonIO._threadsafe_save_json(output)
output = item
target = JSON(c_name, item_id, data_path_override=c_data_path)
await target.jsonIO._threadsafe_save_json(output)
backup_filename = "redv3-{}-{}.tar.gz".format(
instance_name, ctx.message.created_at.strftime("%Y-%m-%d %H-%M-%S")
)
@@ -1134,7 +1140,7 @@ class Core(commands.Cog, CoreLogic):
tar.add(str(f), recursive=False)
print(str(backup_file))
await ctx.send(
_("A backup has been made of this instance. It is at {}.").format((backup_file))
_("A backup has been made of this instance. It is at {}.").format(backup_file)
)
await ctx.send(_("Would you like to receive a copy via DM? (y/n)"))
@@ -1157,7 +1163,7 @@ class Core(commands.Cog, CoreLogic):
@commands.command()
@commands.cooldown(1, 60, commands.BucketType.user)
async def contact(self, ctx, *, message: str):
async def contact(self, ctx: commands.Context, *, message: str):
"""Sends a message to the owner"""
guild = ctx.message.guild
owner = discord.utils.get(ctx.bot.get_all_members(), id=ctx.bot.owner_id)
@@ -1200,7 +1206,7 @@ class Core(commands.Cog, CoreLogic):
await ctx.send(
_("I cannot send your message, I'm unable to find my owner... *sigh*")
)
except:
except discord.HTTPException:
await ctx.send(_("I'm unable to deliver your message. Sorry."))
else:
await ctx.send(_("Your message has been sent."))
@@ -1212,14 +1218,14 @@ class Core(commands.Cog, CoreLogic):
await ctx.send(
_("I cannot send your message, I'm unable to find my owner... *sigh*")
)
except:
except discord.HTTPException:
await ctx.send(_("I'm unable to deliver your message. Sorry."))
else:
await ctx.send(_("Your message has been sent."))
@commands.command()
@checks.is_owner()
async def dm(self, ctx, user_id: int, *, message: str):
async def dm(self, ctx: commands.Context, user_id: int, *, message: str):
"""Sends a DM to a user
This command needs a user id to work.
@@ -1253,7 +1259,7 @@ class Core(commands.Cog, CoreLogic):
try:
await destination.send(embed=e)
except:
except discord.HTTPException:
await ctx.send(
_("Sorry, I couldn't deliver your message to {}").format(destination)
)
@@ -1263,7 +1269,7 @@ class Core(commands.Cog, CoreLogic):
response = "{}\nMessage:\n\n{}".format(description, message)
try:
await destination.send("{}\n{}".format(box(response), content))
except:
except discord.HTTPException:
await ctx.send(
_("Sorry, I couldn't deliver your message to {}").format(destination)
)
@@ -1272,7 +1278,7 @@ class Core(commands.Cog, CoreLogic):
@commands.group()
@checks.is_owner()
async def whitelist(self, ctx):
async def whitelist(self, ctx: commands.Context):
"""
Whitelist management commands.
"""
@@ -1290,7 +1296,7 @@ class Core(commands.Cog, CoreLogic):
await ctx.send(_("User added to whitelist."))
@whitelist.command(name="list")
async def whitelist_list(self, ctx):
async def whitelist_list(self, ctx: commands.Context):
"""
Lists whitelisted users.
"""
@@ -1304,7 +1310,7 @@ class Core(commands.Cog, CoreLogic):
await ctx.send(box(page))
@whitelist.command(name="remove")
async def whitelist_remove(self, ctx, user: discord.User):
async def whitelist_remove(self, ctx: commands.Context, user: discord.User):
"""
Removes user from whitelist.
"""
@@ -1321,7 +1327,7 @@ class Core(commands.Cog, CoreLogic):
await ctx.send(_("User was not in the whitelist."))
@whitelist.command(name="clear")
async def whitelist_clear(self, ctx):
async def whitelist_clear(self, ctx: commands.Context):
"""
Clears the whitelist.
"""
@@ -1330,19 +1336,19 @@ class Core(commands.Cog, CoreLogic):
@commands.group()
@checks.is_owner()
async def blacklist(self, ctx):
async def blacklist(self, ctx: commands.Context):
"""
blacklist management commands.
"""
pass
@blacklist.command(name="add")
async def blacklist_add(self, ctx, user: discord.User):
async def blacklist_add(self, ctx: commands.Context, user: discord.User):
"""
Adds a user to the blacklist.
"""
if await ctx.bot.is_owner(user):
ctx.send(_("You cannot blacklist an owner!"))
await ctx.send(_("You cannot blacklist an owner!"))
return
async with ctx.bot.db.blacklist() as curr_list:
@@ -1352,7 +1358,7 @@ class Core(commands.Cog, CoreLogic):
await ctx.send(_("User added to blacklist."))
@blacklist.command(name="list")
async def blacklist_list(self, ctx):
async def blacklist_list(self, ctx: commands.Context):
"""
Lists blacklisted users.
"""
@@ -1366,7 +1372,7 @@ class Core(commands.Cog, CoreLogic):
await ctx.send(box(page))
@blacklist.command(name="remove")
async def blacklist_remove(self, ctx, user: discord.User):
async def blacklist_remove(self, ctx: commands.Context, user: discord.User):
"""
Removes user from blacklist.
"""
@@ -1383,7 +1389,7 @@ class Core(commands.Cog, CoreLogic):
await ctx.send(_("User was not in the blacklist."))
@blacklist.command(name="clear")
async def blacklist_clear(self, ctx):
async def blacklist_clear(self, ctx: commands.Context):
"""
Clears the blacklist.
"""
@@ -1393,14 +1399,14 @@ class Core(commands.Cog, CoreLogic):
@commands.group()
@commands.guild_only()
@checks.admin_or_permissions(administrator=True)
async def localwhitelist(self, ctx):
async def localwhitelist(self, ctx: commands.Context):
"""
Whitelist management commands.
"""
pass
@localwhitelist.command(name="add")
async def localwhitelist_add(self, ctx, *, user_or_role: str):
async def localwhitelist_add(self, ctx: commands.Context, *, user_or_role: str):
"""
Adds a user or role to the whitelist.
"""
@@ -1421,7 +1427,7 @@ class Core(commands.Cog, CoreLogic):
await ctx.send(_("Role added to whitelist."))
@localwhitelist.command(name="list")
async def localwhitelist_list(self, ctx):
async def localwhitelist_list(self, ctx: commands.Context):
"""
Lists whitelisted users and roles.
"""
@@ -1435,7 +1441,7 @@ class Core(commands.Cog, CoreLogic):
await ctx.send(box(page))
@localwhitelist.command(name="remove")
async def localwhitelist_remove(self, ctx, *, user_or_role: str):
async def localwhitelist_remove(self, ctx: commands.Context, *, user_or_role: str):
"""
Removes user or role from whitelist.
"""
@@ -1465,7 +1471,7 @@ class Core(commands.Cog, CoreLogic):
await ctx.send(_("Role was not in the whitelist."))
@localwhitelist.command(name="clear")
async def localwhitelist_clear(self, ctx):
async def localwhitelist_clear(self, ctx: commands.Context):
"""
Clears the whitelist.
"""
@@ -1475,14 +1481,14 @@ class Core(commands.Cog, CoreLogic):
@commands.group()
@commands.guild_only()
@checks.admin_or_permissions(administrator=True)
async def localblacklist(self, ctx):
async def localblacklist(self, ctx: commands.Context):
"""
blacklist management commands.
"""
pass
@localblacklist.command(name="add")
async def localblacklist_add(self, ctx, *, user_or_role: str):
async def localblacklist_add(self, ctx: commands.Context, *, user_or_role: str):
"""
Adds a user or role to the blacklist.
"""
@@ -1495,7 +1501,7 @@ class Core(commands.Cog, CoreLogic):
user = True
if user and await ctx.bot.is_owner(obj):
ctx.send(_("You cannot blacklist an owner!"))
await ctx.send(_("You cannot blacklist an owner!"))
return
async with ctx.bot.db.guild(ctx.guild).blacklist() as curr_list:
@@ -1508,7 +1514,7 @@ class Core(commands.Cog, CoreLogic):
await ctx.send(_("Role added to blacklist."))
@localblacklist.command(name="list")
async def localblacklist_list(self, ctx):
async def localblacklist_list(self, ctx: commands.Context):
"""
Lists blacklisted users and roles.
"""
@@ -1522,7 +1528,7 @@ class Core(commands.Cog, CoreLogic):
await ctx.send(box(page))
@localblacklist.command(name="remove")
async def localblacklist_remove(self, ctx, *, user_or_role: str):
async def localblacklist_remove(self, ctx: commands.Context, *, user_or_role: str):
"""
Removes user or role from blacklist.
"""
@@ -1552,7 +1558,7 @@ class Core(commands.Cog, CoreLogic):
await ctx.send(_("Role was not in the blacklist."))
@localblacklist.command(name="clear")
async def localblacklist_clear(self, ctx):
async def localblacklist_clear(self, ctx: commands.Context):
"""
Clears the blacklist.
"""
@@ -1703,8 +1709,8 @@ class Core(commands.Cog, CoreLogic):
@autoimmune_group.command(name="list")
async def autoimmune_list(self, ctx: commands.Context):
"""
Get's the current members and roles
Get's the current members and roles
configured for automatic moderation action immunity
"""
ai_ids = await ctx.bot.db.guild(ctx.guild).autoimmune_ids()

View File

@@ -1,7 +1,12 @@
import motor.motor_asyncio
from .red_base import BaseDriver
import re
from typing import Match, Pattern
from urllib.parse import quote_plus
import motor.core
import motor.motor_asyncio
from .red_base import BaseDriver
__all__ = ["Mongo"]
@@ -80,6 +85,7 @@ class Mongo(BaseDriver):
async def get(self, *identifiers: str):
mongo_collection = self.get_collection()
identifiers = (*map(self._escape_key, identifiers),)
dot_identifiers = ".".join(identifiers)
partial = await mongo_collection.find_one(
@@ -91,10 +97,14 @@ class Mongo(BaseDriver):
for i in 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(identifiers)
dot_identifiers = ".".join(map(self._escape_key, identifiers))
if isinstance(value, dict):
value = self._escape_dict_keys(value)
mongo_collection = self.get_collection()
@@ -105,7 +115,7 @@ class Mongo(BaseDriver):
)
async def clear(self, *identifiers: str):
dot_identifiers = ".".join(identifiers)
dot_identifiers = ".".join(map(self._escape_key, identifiers))
mongo_collection = self.get_collection()
if len(identifiers) > 0:
@@ -115,6 +125,62 @@ class Mongo(BaseDriver):
else:
await mongo_collection.delete_one({"_id": self.unique_cog_identifier})
@staticmethod
def _escape_key(key: str) -> str:
return _SPECIAL_CHAR_PATTERN.sub(_replace_with_escaped, key)
@staticmethod
def _unescape_key(key: str) -> str:
return _CHAR_ESCAPE_PATTERN.sub(_replace_with_unescaped, key)
@classmethod
def _escape_dict_keys(cls, data: dict) -> dict:
"""Recursively escape all keys in a dict."""
ret = {}
for key, value in data.items():
key = cls._escape_key(key)
if isinstance(value, dict):
value = cls._escape_dict_keys(value)
ret[key] = value
return ret
@classmethod
def _unescape_dict_keys(cls, data: dict) -> dict:
"""Recursively unescape all keys in a dict."""
ret = {}
for key, value in data.items():
key = cls._unescape_key(key)
if isinstance(value, dict):
value = cls._unescape_dict_keys(value)
ret[key] = value
return ret
_SPECIAL_CHAR_PATTERN: Pattern[str] = re.compile(r"([.$]|\\U0000002E|\\U00000024)")
_SPECIAL_CHARS = {
".": "\\U0000002E",
"$": "\\U00000024",
"\\U0000002E": "\\U&0000002E",
"\\U00000024": "\\U&00000024",
}
def _replace_with_escaped(match: Match[str]) -> str:
return _SPECIAL_CHARS[match[0]]
_CHAR_ESCAPE_PATTERN: Pattern[str] = re.compile(r"(\\U0000002E|\\U00000024)")
_CHAR_ESCAPES = {
"\\U0000002E": ".",
"\\U00000024": "$",
"\\U&0000002E": "\\U0000002E",
"\\U&00000024": "\\U00000024",
}
def _replace_with_unescaped(match: Match[str]) -> str:
return _CHAR_ESCAPES[match[0]]
def get_config_details():
uri = None

44
redbot/core/errors.py Normal file
View File

@@ -0,0 +1,44 @@
import importlib.machinery
from typing import Optional
import discord
from .i18n import Translator
_ = Translator(__name__, __file__)
class RedError(Exception):
"""Base error class for Red-related errors."""
class PackageAlreadyLoaded(RedError):
"""Raised when trying to load an already-loaded package."""
def __init__(self, spec: importlib.machinery.ModuleSpec, *args, **kwargs):
super().__init__(*args, **kwargs)
self.spec: importlib.machinery.ModuleSpec = spec
def __str__(self) -> str:
return f"There is already a package named {self.spec.name.split('.')[-1]} loaded"
class BankError(RedError):
"""Base error class for bank-related errors."""
class BalanceTooHigh(BankError, OverflowError):
"""Raised when trying to set a user's balance to higher than the maximum."""
def __init__(
self, user: discord.abc.User, max_balance: int, currency_name: str, *args, **kwargs
):
super().__init__(*args, **kwargs)
self.user = user
self.max_balance = max_balance
self.currency_name = currency_name
def __str__(self) -> str:
return _("{user}'s balance cannot rise above {max:,} {currency}.").format(
user=self.user, max=self.max_balance, currency=self.currency_name
)

View File

@@ -3,8 +3,6 @@ import re
from pathlib import Path
from typing import Callable, Union
from . import commands
__all__ = ["get_locale", "set_locale", "reload_locales", "cog_i18n", "Translator"]
_current_locale = "en_us"
@@ -219,6 +217,12 @@ class Translator(Callable[[str], str]):
self.translations.update({untranslated: translated})
# This import to be down here to avoid circular import issues.
# This will be cleaned up at a later date
# noinspection PyPep8
from . import commands
def cog_i18n(translator: Translator):
"""Get a class decorator to link the translator to this cog."""

View File

@@ -3,7 +3,7 @@ from redbot.cogs.permissions.permissions import Permissions, GLOBAL
def test_schema_update():
old = {
GLOBAL: {
str(GLOBAL): {
"owner_models": {
"cogs": {
"Admin": {"allow": [78631113035100160], "deny": [96733288462286848]},
@@ -19,7 +19,7 @@ def test_schema_update():
},
}
},
43733288462286848: {
"43733288462286848": {
"owner_models": {
"cogs": {
"Admin": {
@@ -43,22 +43,22 @@ def test_schema_update():
assert new == (
{
"Admin": {
GLOBAL: {78631113035100160: True, 96733288462286848: False},
43733288462286848: {24231113035100160: True, 35533288462286848: False},
str(GLOBAL): {"78631113035100160": True, "96733288462286848": False},
"43733288462286848": {"24231113035100160": True, "35533288462286848": False},
},
"Audio": {GLOBAL: {133049272517001216: True, "default": False}},
"General": {43733288462286848: {133049272517001216: True, "default": False}},
"Audio": {str(GLOBAL): {"133049272517001216": True, "default": False}},
"General": {"43733288462286848": {"133049272517001216": True, "default": False}},
},
{
"cleanup bot": {
GLOBAL: {78631113035100160: True, "default": False},
43733288462286848: {17831113035100160: True, "default": True},
str(GLOBAL): {"78631113035100160": True, "default": False},
"43733288462286848": {"17831113035100160": True, "default": True},
},
"ping": {GLOBAL: {96733288462286848: True, "default": True}},
"ping": {str(GLOBAL): {"96733288462286848": True, "default": True}},
"set adminrole": {
43733288462286848: {
87733288462286848: True,
95433288462286848: False,
"43733288462286848": {
"87733288462286848": True,
"95433288462286848": False,
"default": True,
}
},

View File

@@ -475,3 +475,18 @@ async def test_get_raw_mixes_defaults(config):
subgroup = await config.get_raw("subgroup")
assert subgroup == {"foo": True, "bar": False}
@pytest.mark.asyncio
async def test_cast_str_raw(config):
await config.set_raw(123, 456, value=True)
assert await config.get_raw(123, 456) is True
assert await config.get_raw("123", "456") is True
await config.clear_raw("123", 456)
@pytest.mark.asyncio
async def test_cast_str_nested(config):
config.register_global(foo={})
await config.foo.set({123: True, 456: {789: False}})
assert await config.foo() == {"123": True, "456": {"789": False}}