Compare commits

..

26 Commits

Author SHA1 Message Date
Toby Harradine
a91cda4995 Version bump 3.0.0b19 (#2005) 2018-08-13 10:32:39 +10:00
Toby Harradine
7959654dc8 [Cleanup] Hotfix for [p]cleanup after (#2004)
* [Cleanup] Hotfix for [p]cleanup after

* Reformat

* Log warning for any 3rd party devs using broken helper
2018-08-12 14:46:30 +10:00
Toby Harradine
dc9a85ca98 [Streams] Add docstring for [p]streamalert list (#2001) 2018-08-12 12:09:38 +10:00
Kowlin
591ed50ac3 [Mod] Omit empty fields from [p]userinfo (#1829)
* Hide roles if none are found.

* Clarify about omitted fields in help doc
2018-08-11 13:59:43 +10:00
Toby Harradine
47350328e6 [CogManager] Correctly separate core, install and other cog paths (#1983)
* [CogManager] Correctly manage core and install paths

This keeps the core and install paths separate from those set in the
config.

It also displays the core path separately in `[p]paths`

Resolves #1982.

* [CogManager] Fix old reference to method removed in previous commit

Also did a bit of a general cleanup of cog_manager.py's code

* Make the core path a class attribute

* [CogManager] Paths should default to a list
2018-08-11 13:31:26 +10:00
Toby Harradine
75ed749cb3 [Admin] Fix [p]addrole error when a hierarchy issue occurs (#1995) 2018-08-11 12:11:15 +10:00
Toby Harradine
f44ea8b749 [ModLog] Call correct method for case message content (#1981) 2018-08-11 12:06:58 +10:00
Toby Harradine
76c0071f57 Pin lavalink to 0.1.0 (#2000) 2018-08-11 12:01:23 +10:00
El Laggron
2a396b4438 [Modlog] Make the case number optional in [p]reason (#1979)
* [V3 Modlog] Make the case number optional

If not specified, it will be the latest case.

* Fix some errors

* Black reformat

* More info for usage string

* Use isdigit instead of isnumeric
2018-08-11 11:44:17 +10:00
Toby Harradine
51a54863c5 [Streams] Don't raise KeyError on missing token (#1994)
Some streaming services don't require a token/clientID.

Resolves #1932
2018-08-10 15:07:30 +10:00
Redjumpman
06f986b92e [General] Fix lmgtfy with '+' in search (#1991) 2018-08-10 14:41:23 +10:00
Toby Harradine
652ceba845 [Bank] Raise TypeError when passing a non-int transaction amount (#1976)
* Raise TypeError when passing a non-int bank amount

* Add a test

* Add some full stops
2018-08-09 22:13:31 +10:00
Michael H
16d0f54d8f [V3 Crowdin] dont upload tox/tests/etc (#1849) 2018-08-09 15:10:30 +10:00
Caleb Johnson
872cce784a [V3 Core] Fix unload_extension for module-less cogs (#1984)
Fixes #1943

This just skips cogs when  `__module__` is None. Also:
- backported rapptz/discord.py#621
- moved RPC handler unregister to remove_cog()
- raise an exception when a load would overwrite an existing extension
2018-08-08 10:26:14 +10:00
Toby Harradine
aec3ad382a [CI] Re-enable and fix linkchecking for docs build (#1990) 2018-08-07 18:24:44 +10:00
lionirdeadman
9d4f9ef73c [General] UI improvements to [p]urban (#1871)
* Changed urban dictionary for my embed version

* Used black for formatting

* Fixed everything according to Tobotimus's review

* Fixed the description limit to 2048 characters

* Better fix adding "..." at the end if neccessary

* Add non-embed version

* Blackify
2018-08-07 16:33:39 +10:00
Caleb Johnson
cf7cafc261 [sentry] use raven-aiohttp (#1967) 2018-08-04 15:44:58 +10:00
Kowlin
e3bff7e87c Version bump v3.0.0b18 (#1959)
Administrative Merge: Solo beta release.
2018-07-25 06:15:36 +02:00
Michael H
4b19421075 [V3] use uvloop if available (#1935)
* use uvloop if available

* Update __main__.py

requested changes made
2018-07-25 05:55:55 +02:00
Michael H
cf371e8093 fix invalidation (#1945) 2018-07-25 04:44:25 +02:00
Kowlin
5eeadc6399 Commented out the link checking (#1958) 2018-07-25 04:33:43 +02:00
Redjumpman
f6823ea3d1 Update bank.py (#1937) 2018-07-25 02:57:25 +02:00
aikaterna
f24290c423 [V3 Help] Exception for help when bot is blocked (#1955)
Fix for #1901.
Administrative merge: Travis CI failed due to docs issue, see #1957
2018-07-25 02:39:51 +02:00
Michael H
f8a36885fe doc string corretion (#1944)
Administrative merge: Travis CI failed due to docs issue, see #1957
2018-07-25 02:33:17 +02:00
Kowlin
a555eff2cc Fixed a bug with the JSON Driver (#1953)
Administrative merge: Travis CI failed due to docs issue, see #1957
2018-07-25 02:18:54 +02:00
Kowlin
05c389623c Removed warnings as errors argument (#1954) 2018-07-23 02:04:30 +02:00
23 changed files with 296 additions and 153 deletions

View File

@@ -1,5 +1,5 @@
api_key_env: CROWDIN_API_KEY api_key_env: CROWDIN_API_KEY
project_identifier_env: CROWDIN_PROJECT_ID project_identifier_env: CROWDIN_PROJECT_ID
files: files:
- source: /**/*.pot - source: /redbot/**/*.pot
translation: /%original_path%/%locale%.po translation: /%original_path%/%locale%.po

View File

@@ -190,6 +190,13 @@ texinfo_documents = [
] ]
# -- Options for linkcheck builder ----------------------------------------
# A list of regular expressions that match URIs that should not be
# checked when doing a linkcheck build.
linkcheck_ignore = [r"https://java.com*"]
# Example configuration for intersphinx: refer to the Python standard library. # Example configuration for intersphinx: refer to the Python standard library.
intersphinx_mapping = { intersphinx_mapping = {
"python": ("https://docs.python.org/3.6", None), "python": ("https://docs.python.org/3.6", None),

View File

@@ -8,7 +8,7 @@ Installing Red on Windows
Needed Software Needed Software
--------------- ---------------
* `Python <https://python.org/downloads/>`_ - Red needs Python 3.6 * `Python <https://www.python.org/downloads/>`_ - Red needs Python 3.6
.. note:: Please make sure that the box to add Python to PATH is CHECKED, otherwise .. note:: Please make sure that the box to add Python to PATH is CHECKED, otherwise
you may run into issues when trying to run Red you may run into issues when trying to run Red

View File

@@ -19,6 +19,15 @@ import logging.handlers
import logging import logging
import os import os
# Let's not force this dependency, uvloop is much faster on cpython
if sys.implementation.name == "cpython":
try:
import uvloop
except ImportError:
pass
else:
asyncio.set_event_loop_policy(uvloop.EventLoopPolicy())
# #
# Red - Discord Bot v3 # Red - Discord Bot v3

View File

@@ -136,7 +136,7 @@ class Admin:
# noinspection PyTypeChecker # noinspection PyTypeChecker
await self._addrole(ctx, user, rolename) await self._addrole(ctx, user, rolename)
else: else:
await self.complain(ctx, USER_HIERARCHY_ISSUE, member=ctx.author) await self.complain(ctx, USER_HIERARCHY_ISSUE, member=ctx.author, role=rolename)
@commands.command() @commands.command()
@commands.guild_only() @commands.guild_only()

View File

@@ -68,6 +68,12 @@ class Cleanup:
- The message is less than 14 days old - The message is less than 14 days old
- The message is not pinned - The message is not pinned
""" """
if after is not None:
log.error(
"The `after` parameter for the `Cleanup.get_messages_for_deletion` method is "
"currently broken, see PRs #1980 and #2004 for details."
)
to_delete = [] to_delete = []
too_old = False too_old = False
@@ -238,23 +244,35 @@ class Cleanup:
await ctx.send(_("This command can only be used on bots with bot accounts.")) await ctx.send(_("This command can only be used on bots with bot accounts."))
return return
after = await channel.get_message(message_id) try:
message = await channel.get_message(message_id)
if not after: except discord.NotFound:
await ctx.send(_("Message not found.")) await ctx.send(_("Message not found."))
return return
to_delete = await self.get_messages_for_deletion( if (ctx.message.created_at - message.created_at).days >= 14:
ctx, channel, 0, limit=None, after=after, delete_pinned=delete_pinned await ctx.send("The specified message must be less than 14 days old.")
) return
if not delete_pinned:
pinned_msgs = await channel.pins()
to_exclude = set(m for m in pinned_msgs if m.created_at > message.created_at)
else:
to_exclude = None
if to_exclude:
to_delete = await channel.history(limit=None, after=message).flatten()
to_delete = set(to_delete) - to_exclude
await channel.delete_messages(to_delete)
num_deleted = len(to_delete)
else:
num_deleted = len(await channel.purge(limit=None, after=message))
reason = "{}({}) deleted {} messages in channel {}.".format( reason = "{}({}) deleted {} messages in channel {}.".format(
author.name, author.id, len(to_delete), channel.name author.name, author.id, num_deleted, channel.name
) )
log.info(reason) log.info(reason)
await mass_purge(to_delete, channel)
@cleanup.command() @cleanup.command()
@commands.guild_only() @commands.guild_only()
async def messages(self, ctx: commands.Context, number: int, delete_pinned: bool = False): async def messages(self, ctx: commands.Context, number: int, delete_pinned: bool = False):

View File

@@ -3,12 +3,11 @@ import time
from enum import Enum from enum import Enum
from random import randint, choice from random import randint, choice
from urllib.parse import quote_plus from urllib.parse import quote_plus
import aiohttp import aiohttp
import discord import discord
from redbot.core import commands from redbot.core import commands
from redbot.core.i18n import Translator, cog_i18n from redbot.core.i18n import Translator, cog_i18n
from redbot.core.utils.menus import menu, DEFAULT_CONTROLS
from redbot.core.utils.chat_formatting import escape, italics, pagify from redbot.core.utils.chat_formatting import escape, italics, pagify
_ = Translator("General", __file__) _ = Translator("General", __file__)
@@ -164,7 +163,9 @@ class General:
@commands.command() @commands.command()
async def lmgtfy(self, ctx, *, search_terms: str): async def lmgtfy(self, ctx, *, search_terms: str):
"""Creates a lmgtfy link""" """Creates a lmgtfy link"""
search_terms = escape(search_terms.replace(" ", "+"), mass_mentions=True) search_terms = escape(
search_terms.replace("+", "%2B").replace(" ", "+"), mass_mentions=True
)
await ctx.send("https://lmgtfy.com/?q={}".format(search_terms)) await ctx.send("https://lmgtfy.com/?q={}".format(search_terms))
@commands.command(hidden=True) @commands.command(hidden=True)
@@ -224,49 +225,89 @@ class General:
await ctx.send(_("I need the `Embed links` permission to send this.")) await ctx.send(_("I need the `Embed links` permission to send this."))
@commands.command() @commands.command()
async def urban(self, ctx, *, search_terms: str, definition_number: int = 1): async def urban(self, ctx, *, word):
"""Urban Dictionary search """Searches urban dictionary entries using the unofficial api"""
Definition number must be between 1 and 10"""
def encode(s):
return quote_plus(s, encoding="utf-8", errors="replace")
# definition_number is just there to show up in the help
# all this mess is to avoid forcing double quotes on the user
search_terms = search_terms.split(" ")
try: try:
if len(search_terms) > 1: url = "https://api.urbandictionary.com/v0/define?term=" + str(word).lower()
pos = int(search_terms[-1]) - 1
search_terms = search_terms[:-1] headers = {"content-type": "application/json"}
else:
pos = 0
if pos not in range(0, 11): # API only provides the
pos = 0 # top 10 definitions
except ValueError:
pos = 0
search_terms = {"term": "+".join([s for s in search_terms])}
url = "http://api.urbandictionary.com/v0/define"
try:
async with aiohttp.ClientSession() as session: async with aiohttp.ClientSession() as session:
async with session.get(url, params=search_terms) as r: async with session.get(url, headers=headers) as response:
result = await r.json() data = await response.json()
item_list = result["list"]
if item_list:
definition = item_list[pos]["definition"]
example = item_list[pos]["example"]
defs = len(item_list)
msg = "**Definition #{} out of {}:\n**{}\n\n**Example:\n**{}".format(
pos + 1, defs, definition, example
)
msg = pagify(msg, ["\n"])
for page in msg:
await ctx.send(page)
else:
await ctx.send(_("Your search terms gave no results."))
except IndexError:
await ctx.send(_("There is no definition #{}").format(pos + 1))
except: except:
await ctx.send(_("Error.")) await ctx.send(
_(("No Urban dictionary entries were found or there was an error in the process"))
)
if data.get("error") != 404:
if await ctx.embed_requested():
# a list of embeds
embeds = []
for ud in data["list"]:
embed = discord.Embed()
embed.title = _("{} by {}".format(ud["word"].capitalize(), ud["author"]))
embed.url = ud["permalink"]
description = "{} \n \n **Example : ** {}".format(
ud["definition"], ud.get("example", "N/A")
)
if len(description) > 2048:
description = "{}...".format(description[:2045])
embed.description = _(description)
embed.set_footer(
text=_(
"{} Down / {} Up , Powered by urban dictionary".format(
ud["thumbs_down"], ud["thumbs_up"]
)
)
)
embeds.append(embed)
if embeds is not None and len(embeds) > 0:
await menu(
ctx,
pages=embeds,
controls=DEFAULT_CONTROLS,
message=None,
page=0,
timeout=30,
)
else:
messages = []
for ud in data["list"]:
description = "{} \n \n **Example : ** {}".format(
ud["definition"], ud.get("example", "N/A")
)
if len(description) > 2048:
description = "{}...".format(description[:2045])
description = _(description)
message = "<{}> \n {} by {} \n \n {} \n \n {} Down / {} Up , Powered by urban dictionary".format(
ud["permalink"],
ud["word"].capitalize(),
ud["author"],
description,
ud["thumbs_down"],
ud["thumbs_up"],
)
messages.append(message)
if messages is not None and len(messages) > 0:
await menu(
ctx,
pages=messages,
controls=DEFAULT_CONTROLS,
message=None,
page=0,
timeout=30,
)
else:
await ctx.send(
_(("No Urban dictionary entries were found or there was an error in the process"))
)
return

View File

@@ -1266,7 +1266,14 @@ class Mod:
@commands.command() @commands.command()
@commands.guild_only() @commands.guild_only()
async def userinfo(self, ctx, *, user: discord.Member = None): async def userinfo(self, ctx, *, user: discord.Member = None):
"""Shows users's informations""" """Shows information for a user.
This includes fields for status, discord join date, server
join date, voice state and previous names/nicknames.
If the user has none of roles, previous names or previous
nicknames, these fields will be omitted.
"""
author = ctx.author author = ctx.author
guild = ctx.guild guild = ctx.guild
@@ -1306,11 +1313,12 @@ class Mod:
if roles: if roles:
roles = ", ".join([x.name for x in roles]) roles = ", ".join([x.name for x in roles])
else: else:
roles = _("None") roles = None
data = discord.Embed(description=activity, colour=user.colour) data = discord.Embed(description=activity, colour=user.colour)
data.add_field(name=_("Joined Discord on"), value=created_on) data.add_field(name=_("Joined Discord on"), value=created_on)
data.add_field(name=_("Joined this server on"), value=joined_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) data.add_field(name=_("Roles"), value=roles, inline=False)
if names: if names:
data.add_field(name=_("Previous Names"), value=", ".join(names), inline=False) data.add_field(name=_("Previous Names"), value=", ".join(names), inline=False)

View File

@@ -95,19 +95,29 @@ class ModLog:
await ctx.send(_("That case does not exist for that server")) await ctx.send(_("That case does not exist for that server"))
return return
else: else:
await ctx.send(embed=await case.get_case_msg_content()) if await ctx.embed_requested():
await ctx.send(embed=await case.message_content(embed=True))
else:
await ctx.send(await case.message_content(embed=False))
@commands.command() @commands.command(usage="[case] <reason>")
@commands.guild_only() @commands.guild_only()
async def reason(self, ctx: commands.Context, case: int, *, reason: str = ""): async def reason(self, ctx: commands.Context, *, reason: str):
"""Lets you specify a reason for mod-log's cases """Lets you specify a reason for mod-log's cases
Please note that you can only edit cases you are Please note that you can only edit cases you are
the owner of unless you are a mod/admin or the server owner""" the owner of unless you are a mod/admin or the server owner.
If no number is specified, the latest case will be used."""
author = ctx.author author = ctx.author
guild = ctx.guild guild = ctx.guild
if not reason: potential_case = reason.split()[0]
await ctx.send_help() if potential_case.isdigit():
return case = int(potential_case)
reason = reason.replace(potential_case, "")
else:
case = str(int(await modlog.get_next_case_number(guild)) - 1)
# latest case
try: try:
case_before = await modlog.get_case(case, guild, self.bot) case_before = await modlog.get_case(case, guild, self.bot)
except RuntimeError: except RuntimeError:

View File

@@ -635,7 +635,7 @@ class Permissions:
stil_valid = [ stil_valid = [
(k, v) for k, v in self.cache.items() if not any(obj in k for obj in to_invalidate) (k, v) for k, v in self.cache.items() if not any(obj in k for obj in to_invalidate)
] ]
self.cache = LRUDict(*stil_valid, size=self.cache.size) self.cache = LRUDict(stil_valid, size=self.cache.size)
def find_object_uniquely(self, info: str) -> int: def find_object_uniquely(self, info: str) -> int:
""" """

View File

@@ -209,6 +209,7 @@ class Streams:
@streamalert.command(name="list") @streamalert.command(name="list")
async def streamalert_list(self, ctx: commands.Context): async def streamalert_list(self, ctx: commands.Context):
"""List all active stream alerts in this server."""
streams_list = defaultdict(list) streams_list = defaultdict(list)
guild_channels_ids = [c.id for c in ctx.guild.channels] guild_channels_ids = [c.id for c in ctx.guild.channels]
msg = _("Active alerts:\n\n") msg = _("Active alerts:\n\n")
@@ -608,16 +609,12 @@ class Streams:
chn = self.bot.get_channel(raw_msg["channel"]) chn = self.bot.get_channel(raw_msg["channel"])
msg = await chn.get_message(raw_msg["message"]) msg = await chn.get_message(raw_msg["message"])
raw_stream["_messages_cache"].append(msg) raw_stream["_messages_cache"].append(msg)
token = await self.db.tokens.get_raw(_class.__name__) token = await self.db.tokens.get_raw(_class.__name__, default=None)
streams.append(_class(token=token, **raw_stream)) if token is not None:
raw_stream["token"] = token
streams.append(_class(**raw_stream))
# issue 1191 extended resolution: Remove this after suitable period return streams
# Fast dedupe below
seen = set()
seen_add = seen.add
return [x for x in streams if not (x.name.lower() in seen or seen_add(x.name.lower()))]
# return streams
async def load_communities(self): async def load_communities(self):
communities = [] communities = []

View File

@@ -36,5 +36,5 @@ class VersionInfo:
return [self.major, self.minor, self.micro, self.releaselevel, self.serial] return [self.major, self.minor, self.micro, self.releaselevel, self.serial]
__version__ = "3.0.0b17" __version__ = "3.0.0b19"
version_info = VersionInfo(3, 0, 0, "beta", 17) version_info = VersionInfo(3, 0, 0, "beta", 19)

View File

@@ -191,7 +191,7 @@ async def set_balance(member: discord.Member, amount: int) -> int:
def _invalid_amount(amount: int) -> bool: def _invalid_amount(amount: int) -> bool:
return amount <= 0 return amount < 0
async def withdraw_credits(member: discord.Member, amount: int) -> int: async def withdraw_credits(member: discord.Member, amount: int) -> int:
@@ -214,10 +214,14 @@ async def withdraw_credits(member: discord.Member, amount: int) -> int:
ValueError ValueError
If the withdrawal amount is invalid or if the account has insufficient If the withdrawal amount is invalid or if the account has insufficient
funds. funds.
TypeError
If the withdrawal amount is not an `int`.
""" """
if not isinstance(amount, int):
raise TypeError("Withdrawal amount must be of type int, not {}.".format(type(amount)))
if _invalid_amount(amount): if _invalid_amount(amount):
raise ValueError("Invalid withdrawal amount {} <= 0".format(amount)) raise ValueError("Invalid withdrawal amount {} < 0".format(amount))
bal = await get_balance(member) bal = await get_balance(member)
if amount > bal: if amount > bal:
@@ -245,8 +249,12 @@ async def deposit_credits(member: discord.Member, amount: int) -> int:
------ ------
ValueError ValueError
If the deposit amount is invalid. If the deposit amount is invalid.
TypeError
If the deposit amount is not an `int`.
""" """
if not isinstance(amount, int):
raise TypeError("Deposit amount must be of type int, not {}.".format(type(amount)))
if _invalid_amount(amount): if _invalid_amount(amount):
raise ValueError("Invalid deposit amount {} <= 0".format(amount)) raise ValueError("Invalid deposit amount {} <= 0".format(amount))
@@ -269,14 +277,18 @@ async def transfer_credits(from_: discord.Member, to: discord.Member, amount: in
Returns Returns
------- -------
int int
The new balance. The new balance of the member gaining credits.
Raises Raises
------ ------
ValueError ValueError
If the amount is invalid or if ``from_`` has insufficient funds. If the amount is invalid or if ``from_`` has insufficient funds.
TypeError
If the amount is not an `int`.
""" """
if not isinstance(amount, int):
raise TypeError("Transfer amount must be of type int, not {}.".format(type(amount)))
if _invalid_amount(amount): if _invalid_amount(amount):
raise ValueError("Invalid transfer amount {} <= 0".format(amount)) raise ValueError("Invalid transfer amount {} <= 0".format(amount))

View File

@@ -24,6 +24,10 @@ from .help_formatter import Help, help as help_
from .sentry import SentryManager from .sentry import SentryManager
def _is_submodule(parent, child):
return parent == child or child.startswith(parent + ".")
class RedBase(BotBase, RPCMixin): class RedBase(BotBase, RPCMixin):
"""Mixin for the main bot class. """Mixin for the main bot class.
@@ -211,12 +215,12 @@ class RedBase(BotBase, RPCMixin):
async def load_extension(self, spec: ModuleSpec): async def load_extension(self, spec: ModuleSpec):
name = spec.name.split(".")[-1] name = spec.name.split(".")[-1]
if name in self.extensions: if name in self.extensions:
return raise discord.ClientException(f"there is already a package named {name} loaded")
lib = spec.loader.load_module() lib = spec.loader.load_module()
if not hasattr(lib, "setup"): if not hasattr(lib, "setup"):
del lib del lib
raise discord.ClientException("extension does not have a setup function") raise discord.ClientException(f"extension {name} does not have a setup function")
if asyncio.iscoroutinefunction(lib.setup): if asyncio.iscoroutinefunction(lib.setup):
await lib.setup(self) await lib.setup(self)
@@ -225,44 +229,41 @@ class RedBase(BotBase, RPCMixin):
self.extensions[name] = lib self.extensions[name] = lib
def remove_cog(self, cogname):
super().remove_cog(cogname)
for meth in self.rpc_handlers.pop(cogname.upper(), ()):
self.unregister_rpc_handler(meth)
def unload_extension(self, name): def unload_extension(self, name):
lib = self.extensions.get(name) lib = self.extensions.get(name)
if lib is None: if lib is None:
return return
lib_name = lib.__name__ # Thank you lib_name = lib.__name__ # Thank you
# find all references to the module # find all references to the module
cog_names = []
# remove the cogs registered from the module # remove the cogs registered from the module
for cogname, cog in self.cogs.copy().items(): for cogname, cog in self.cogs.copy().items():
if cog.__module__.startswith(lib_name): if cog.__module__ and _is_submodule(lib_name, cog.__module__):
self.remove_cog(cogname) self.remove_cog(cogname)
cog_names.append(cogname)
# remove all rpc handlers
for cogname in cog_names:
if cogname.upper() in self.rpc_handlers:
methods = self.rpc_handlers[cogname]
for meth in methods:
self.unregister_rpc_handler(meth)
del self.rpc_handlers[cogname]
# first remove all the commands from the module # first remove all the commands from the module
for cmd in self.all_commands.copy().values(): for cmd in self.all_commands.copy().values():
if cmd.module.startswith(lib_name): if cmd.module and _is_submodule(lib_name, cmd.module):
if isinstance(cmd, GroupMixin): if isinstance(cmd, GroupMixin):
cmd.recursively_remove_all_commands() cmd.recursively_remove_all_commands()
self.remove_command(cmd.name) self.remove_command(cmd.name)
# then remove all the listeners from the module # then remove all the listeners from the module
for event_list in self.extra_events.copy().values(): for event_list in self.extra_events.copy().values():
remove = [] remove = []
for index, event in enumerate(event_list): for index, event in enumerate(event_list):
if event.__module__.startswith(lib_name): if event.__module__ and _is_submodule(lib_name, event.__module__):
remove.append(index) remove.append(index)
for index in reversed(remove): for index in reversed(remove):
@@ -282,11 +283,12 @@ class RedBase(BotBase, RPCMixin):
pkg_name = lib.__package__ pkg_name = lib.__package__
del lib del lib
del self.extensions[name] del self.extensions[name]
for m, _ in sys.modules.copy().items():
if m.startswith(pkg_name):
del sys.modules[m]
if pkg_name.startswith("redbot.cogs"): for module in list(sys.modules):
if _is_submodule(lib_name, module):
del sys.modules[module]
if pkg_name.startswith("redbot.cogs."):
del sys.modules["redbot.cogs"].__dict__[name] del sys.modules["redbot.cogs"].__dict__[name]
@@ -295,6 +297,13 @@ class Red(RedBase, discord.AutoShardedClient):
You're welcome Caleb. You're welcome Caleb.
""" """
async def logout(self):
"""Logs out of Discord and closes all connections."""
if self._sentry_mgr:
await self._sentry_mgr.close()
await super().logout()
async def shutdown(self, *, restart: bool = False): async def shutdown(self, *, restart: bool = False):
"""Gracefully quit Red. """Gracefully quit Red.

View File

@@ -35,12 +35,13 @@ class CogManager:
bot directory. bot directory.
""" """
CORE_PATH = Path(redbot.cogs.__path__[0])
def __init__(self, paths: Tuple[str] = ()): def __init__(self, paths: Tuple[str] = ()):
self.conf = Config.get_conf(self, 2938473984732, True) self.conf = Config.get_conf(self, 2938473984732, True)
tmp_cog_install_path = cog_data_path(self) / "cogs" tmp_cog_install_path = cog_data_path(self) / "cogs"
tmp_cog_install_path.mkdir(parents=True, exist_ok=True) tmp_cog_install_path.mkdir(parents=True, exist_ok=True)
self.conf.register_global(paths=(), install_path=str(tmp_cog_install_path)) self.conf.register_global(paths=[], install_path=str(tmp_cog_install_path))
self._paths = [Path(p) for p in paths] self._paths = [Path(p) for p in paths]
async def paths(self) -> Tuple[Path, ...]: async def paths(self) -> Tuple[Path, ...]:
@@ -54,18 +55,13 @@ class CogManager:
""" """
conf_paths = [Path(p) for p in await self.conf.paths()] conf_paths = [Path(p) for p in await self.conf.paths()]
other_paths = self._paths other_paths = self._paths
core_paths = await self.core_paths()
all_paths = _deduplicate(list(conf_paths) + list(other_paths) + core_paths) all_paths = _deduplicate(list(conf_paths) + list(other_paths) + [self.CORE_PATH])
if self.install_path not in all_paths: if self.install_path not in all_paths:
all_paths.insert(0, await self.install_path()) all_paths.insert(0, await self.install_path())
return tuple(p.resolve() for p in all_paths if p.is_dir()) return tuple(p.resolve() for p in all_paths if p.is_dir())
async def core_paths(self) -> List[Path]:
core_paths = [Path(p) for p in redbot.cogs.__path__]
return core_paths
async def install_path(self) -> Path: async def install_path(self) -> Path:
"""Get the install path for 3rd party cogs. """Get the install path for 3rd party cogs.
@@ -155,10 +151,12 @@ class CogManager:
if path == await self.install_path(): if path == await self.install_path():
raise ValueError("Cannot add the install path as an additional path.") raise ValueError("Cannot add the install path as an additional path.")
if path == self.CORE_PATH:
raise ValueError("Cannot add the core path as an additional path.")
all_paths = _deduplicate(await self.paths() + (path,)) async with self.conf.paths() as paths:
# noinspection PyTypeChecker if not any(Path(p) == path for p in paths):
await self.set_paths(all_paths) paths.append(str(path))
async def remove_path(self, path: Union[Path, str]) -> Tuple[Path, ...]: async def remove_path(self, path: Union[Path, str]) -> Tuple[Path, ...]:
"""Remove a path from the current paths list. """Remove a path from the current paths list.
@@ -174,12 +172,14 @@ class CogManager:
Tuple of new valid paths. Tuple of new valid paths.
""" """
path = self._ensure_path_obj(path) path = self._ensure_path_obj(path).resolve()
all_paths = list(await self.paths())
if path in all_paths: paths = [Path(p) for p in await self.conf.paths()]
all_paths.remove(path) # Modifies in place if path in paths:
await self.set_paths(all_paths) paths.remove(path)
return tuple(all_paths) await self.set_paths(paths)
return tuple(paths)
async def set_paths(self, paths_: List[Path]): async def set_paths(self, paths_: List[Path]):
"""Set the current paths list. """Set the current paths list.
@@ -213,9 +213,8 @@ class CogManager:
When no matching spec can be found. When no matching spec can be found.
""" """
resolved_paths = _deduplicate(await self.paths()) resolved_paths = _deduplicate(await self.paths())
core_paths = _deduplicate(await self.core_paths())
real_paths = [str(p) for p in resolved_paths if p not in core_paths] real_paths = [str(p) for p in resolved_paths if p != self.CORE_PATH]
for finder, module_name, _ in pkgutil.iter_modules(real_paths): for finder, module_name, _ in pkgutil.iter_modules(real_paths):
if name == module_name: if name == module_name:
@@ -228,7 +227,8 @@ class CogManager:
" in any available path.".format(name) " in any available path.".format(name)
) )
async def _find_core_cog(self, name: str) -> ModuleSpec: @staticmethod
async def _find_core_cog(name: str) -> ModuleSpec:
""" """
Attempts to find a spec for a core cog. Attempts to find a spec for a core cog.
@@ -310,7 +310,8 @@ _ = Translator("CogManagerUI", __file__)
class CogManagerUI: class CogManagerUI:
"""Commands to interface with Red's cog manager.""" """Commands to interface with Red's cog manager."""
async def visible_paths(self, ctx): @staticmethod
async def visible_paths(ctx):
install_path = await ctx.bot.cog_mgr.install_path() install_path = await ctx.bot.cog_mgr.install_path()
cog_paths = await ctx.bot.cog_mgr.paths() cog_paths = await ctx.bot.cog_mgr.paths()
cog_paths = [p for p in cog_paths if p != install_path] cog_paths = [p for p in cog_paths if p != install_path]
@@ -322,11 +323,15 @@ class CogManagerUI:
""" """
Lists current cog paths in order of priority. Lists current cog paths in order of priority.
""" """
install_path = await ctx.bot.cog_mgr.install_path() cog_mgr = ctx.bot.cog_mgr
cog_paths = await ctx.bot.cog_mgr.paths() install_path = await cog_mgr.install_path()
cog_paths = [p for p in cog_paths if p != install_path] core_path = cog_mgr.CORE_PATH
cog_paths = await cog_mgr.paths()
cog_paths = [p for p in cog_paths if p not in (install_path, core_path)]
msg = _("Install Path: {}\n\n").format(install_path) msg = _("Install Path: {install_path}\nCore Path: {core_path}\n\n").format(
install_path=install_path, core_path=core_path
)
partial = [] partial = []
for i, p in enumerate(cog_paths, start=1): for i, p in enumerate(cog_paths, start=1):
@@ -428,16 +433,16 @@ class CogManagerUI:
""" """
loaded = set(ctx.bot.extensions.keys()) loaded = set(ctx.bot.extensions.keys())
all = set(await ctx.bot.cog_mgr.available_modules()) all_cogs = set(await ctx.bot.cog_mgr.available_modules())
unloaded = all - loaded unloaded = all_cogs - loaded
loaded = sorted(list(loaded), key=str.lower) loaded = sorted(list(loaded), key=str.lower)
unloaded = sorted(list(unloaded), key=str.lower) unloaded = sorted(list(unloaded), key=str.lower)
if await ctx.embed_requested(): if await ctx.embed_requested():
loaded = ("**{} loaded:**\n").format(len(loaded)) + ", ".join(loaded) loaded = _("**{} loaded:**\n").format(len(loaded)) + ", ".join(loaded)
unloaded = ("**{} unloaded:**\n").format(len(unloaded)) + ", ".join(unloaded) unloaded = _("**{} unloaded:**\n").format(len(unloaded)) + ", ".join(unloaded)
for page in pagify(loaded, delims=[", ", "\n"], page_length=1800): for page in pagify(loaded, delims=[", ", "\n"], page_length=1800):
e = discord.Embed(description=page, colour=discord.Colour.dark_green()) e = discord.Embed(description=page, colour=discord.Colour.dark_green())
@@ -447,9 +452,9 @@ class CogManagerUI:
e = discord.Embed(description=page, colour=discord.Colour.dark_red()) e = discord.Embed(description=page, colour=discord.Colour.dark_red())
await ctx.send(embed=e) await ctx.send(embed=e)
else: else:
loaded_count = "**{} loaded:**\n".format(len(loaded)) loaded_count = _("**{} loaded:**\n").format(len(loaded))
loaded = ", ".join(loaded) loaded = ", ".join(loaded)
unloaded_count = "**{} unloaded:**\n".format(len(unloaded)) unloaded_count = _("**{} unloaded:**\n").format(len(unloaded))
unloaded = ", ".join(unloaded) unloaded = ", ".join(unloaded)
loaded_count_sent = False loaded_count_sent = False
unloaded_count_sent = False unloaded_count_sent = False

View File

@@ -84,7 +84,7 @@ class Dev:
author - command author's member object author - command author's member object
message - the command's message object message - the command's message object
discord - discord.py library discord - discord.py library
commands - discord.py commands extension commands - redbot.core.commands
_ - The result of the last dev command. _ - The result of the last dev command.
""" """
env = { env = {
@@ -138,7 +138,7 @@ class Dev:
author - command author's member object author - command author's member object
message - the command's message object message - the command's message object
discord - discord.py library discord - discord.py library
commands - discord.py commands extension commands - redbot.core.commands
_ - The result of the last dev command. _ - The result of the last dev command.
""" """
env = { env = {

View File

@@ -407,7 +407,7 @@ async def help(ctx, *cmds: str):
max_pages_in_guild = await ctx.bot.db.help.max_pages_in_guild() max_pages_in_guild = await ctx.bot.db.help.max_pages_in_guild()
if len(embeds) > max_pages_in_guild: if len(embeds) > max_pages_in_guild:
destination = ctx.author destination = ctx.author
try:
for embed in embeds: for embed in embeds:
if use_embeds: if use_embeds:
try: try:
@@ -421,6 +421,10 @@ async def help(ctx, *cmds: str):
except discord.HTTPException: except discord.HTTPException:
destination = ctx.author destination = ctx.author
await destination.send(embed) await destination.send(embed)
except discord.Forbidden:
await ctx.channel.send(
"I couldn't send the help message to you in DM. Either you blocked me or you disabled DMs in this server."
)
@help.error @help.error

View File

@@ -11,8 +11,8 @@ from pathlib import Path
log = logging.getLogger("red") log = logging.getLogger("red")
PRETTY = {"indent": 4, "sort_keys": True, "separators": (",", " : ")} PRETTY = {"indent": 4, "sort_keys": False, "separators": (",", " : ")}
MINIFIED = {"sort_keys": True, "separators": (",", ":")} MINIFIED = {"sort_keys": False, "separators": (",", ":")}
class JsonIO: class JsonIO:

View File

@@ -111,7 +111,7 @@ class RPCMixin:
super().__init__(**kwargs) super().__init__(**kwargs)
self.rpc = RPC() self.rpc = RPC()
self.rpc_handlers = {} # Lowered cog name to method self.rpc_handlers = {} # Uppercase cog name to method
def register_rpc_handler(self, method): def register_rpc_handler(self, method):
""" """
@@ -132,6 +132,7 @@ class RPCMixin:
self.rpc.add_method(method) self.rpc.add_method(method)
cog_name = method.__self__.__class__.__name__.upper() cog_name = method.__self__.__class__.__name__.upper()
if cog_name not in self.rpc_handlers: if cog_name not in self.rpc_handlers:
self.rpc_handlers[cog_name] = [] self.rpc_handlers[cog_name] = []

View File

@@ -1,6 +1,8 @@
import asyncio
import logging import logging
from raven import Client from raven import Client
from raven.handlers.logging import SentryHandler from raven.handlers.logging import SentryHandler
from raven_aiohttp import AioHttpTransport
from redbot.core import __version__ from redbot.core import __version__
@@ -19,6 +21,7 @@ class SentryManager:
release=__version__, release=__version__,
include_paths=["redbot"], include_paths=["redbot"],
enable_breadcrumbs=False, enable_breadcrumbs=False,
transport=AioHttpTransport,
) )
self.handler = SentryHandler(self.client) self.handler = SentryHandler(self.client)
self.logger = logger self.logger = logger
@@ -30,3 +33,9 @@ class SentryManager:
def disable(self): def disable(self):
"""Disable error reporting for Sentry.""" """Disable error reporting for Sentry."""
self.logger.removeHandler(self.handler) self.logger.removeHandler(self.handler)
loop = asyncio.get_event_loop()
loop.create_task(self.close())
async def close(self):
"""Wait for the Sentry client to send pending messages and shut down."""
await self.client.remote.get_transport().close()

View File

@@ -8,3 +8,4 @@ pyyaml==3.12
fuzzywuzzy[speedup]<=0.16.0 fuzzywuzzy[speedup]<=0.16.0
Red-Trivia>=1.1.1 Red-Trivia>=1.1.1
async-timeout<3.0.0 async-timeout<3.0.0
raven-aiohttp==0.7.0

View File

@@ -135,7 +135,7 @@ setup(
"test": ["pytest>3", "pytest-asyncio"], "test": ["pytest>3", "pytest-asyncio"],
"mongo": ["motor"], "mongo": ["motor"],
"docs": ["sphinx>=1.7", "sphinxcontrib-asyncio", "sphinx_rtd_theme"], "docs": ["sphinx>=1.7", "sphinxcontrib-asyncio", "sphinx_rtd_theme"],
"voice": ["red-lavalink>=0.0.4"], "voice": ["red-lavalink==0.1.0"],
"style": ["black==18.5b1"], "style": ["black==18.5b1"],
}, },
) )

View File

@@ -69,3 +69,15 @@ async def test_set_default_balance(bank, guild_factory):
await bank.set_default_balance(500, guild) await bank.set_default_balance(500, guild)
default_bal = await bank.get_default_balance(guild) default_bal = await bank.get_default_balance(guild)
assert default_bal == 500 assert default_bal == 500
@pytest.mark.asyncio
async def test_nonint_transaction_amount(bank, member_factory):
mbr1 = member_factory.get()
mbr2 = member_factory.get()
with pytest.raises(TypeError):
await bank.deposit_credits(mbr1, 1.0)
with pytest.raises(TypeError):
await bank.withdraw_credits(mbr1, 1.0)
with pytest.raises(TypeError):
await bank.transfer_credits(mbr1, mbr2, 1.0)