Compare commits

..

43 Commits
3.1.0 ... 3.1.2

Author SHA1 Message Date
Flame442
652d9fe950 Update version number to 3.1.2 🎉 (#2751) 2019-05-31 17:58:04 -04:00
Ryan
e956e6e320 [Streams] Ignore lack of rerun info where not available (#2748) 2019-05-31 16:41:23 -04:00
aikaterna
2e58922d01 [Audio] Lavalink jar bump (#2750) 2019-05-31 16:26:20 -04:00
Michael H
33b7652b62 [Help] Detatch menu usage into a task (#2725)
* [Help] Detatch menu usage into a task

  - This resolves #2712
  - This is a minor API change. Conceptually, the difference is minor in
  nature `bot.send_help_for` returns when help has been sent, however
  this can now be prior to when the help menu (if one is in use) is
  closed.

  - This should not be considered breaking as there is and has been a
  a warning about this file's APIs being still up for unannounced modifications
  No developers should be currently relying on this behavior.

* operator precendence
2019-05-31 21:41:04 +02:00
Michael H
0e9086ca1f [Core] Fix error handling in loading extensions. (#2688)
* fixes 2687

* raise the right exception
2019-05-31 21:38:44 +02:00
Michael H
3ca2a9af28 [Help] Fix long cog helps (#2730)
* [Help] Fix long cog helps

  - Why do people thing a category help of over 250 characters is more
  useful than putting the help in relevent commands?!

* toss an MD fix in here too I guess
2019-05-31 21:37:50 +02:00
DiscordLiz
e7b615d921 [Commands] Adds support for non interactive use (#2746)
Adds assume_yes to context
Changes cleanup's 100+ check
Changes cog update.
2019-05-31 05:54:27 -04:00
Matan Kushner
2cb6e98092 [Readme] Anilist → AniList (#2747) 2019-05-31 05:39:14 -04:00
DiscordLiz
1ccc441aab [Core] Make contact use configured destinations (#2743)
* Make contact use configured destinations
2019-05-31 05:32:26 -04:00
DiscordLiz
8ddc5aa63e [Core] Add commands to manage owner notification destinations. (#2745) 2019-05-31 05:13:36 -04:00
DevilXD
f894b62bfe [CustomCom] Fixed KeyError on specific message edge-case (#2739)
fixes #2679
2019-05-29 22:36:32 -04:00
DevilXD
aac9369f3f [Mod] Add [p]slowmode (#2734)
Makes use of new timedelta converter
2019-05-29 10:01:27 +10:00
DiscordLiz
1581604f71 Add a timedelta converter (#2736)
* Add a timedelta converter

This reuses a lot of logic from @mikeshardmind 's scheduler cog with permission

It adds a timedelta converter
It keeps it generalized as requested
It keeps the function available for non converter use as requested

* Handle feedback

* style fix
2019-05-28 12:43:55 -04:00
DiscordLiz
56161c0a88 Send to owners (#2738)
* Add bot.send_to_owner

resolves #2665

Adds some basic methods and config entries.

Does not add commands for modifying this yet.

* Use send_to_owners in events

* handle feedback
2019-05-28 12:37:02 -04:00
DiscordLiz
242df83785 [Image] Fix some issues in strings (#2737) 2019-05-28 02:47:44 -04:00
Stonedestroyer
2338ad8223 [Image] Fix giphy api (#2653)
* Remove hardcoded Giphy key.

Allows you to set your own Giphy API key.

* Run black

* Fix Giphy name

On their website it's spelled GIPHY.
2019-05-27 21:04:15 -04:00
PredaaA
b4f4e080af [core_commands] Using humanize_timedelta for [p]uptime command (#2735)
* Update core_commands.py
2019-05-27 20:39:36 -04:00
Flame442
132545e057 [Audio] Clarity changes for the API info commands (#2733)
* Clarity changes for the API info commands

* Remove unnecessary f-string
2019-05-27 20:27:56 -04:00
Michael H
68590dfdb8 [Core] Improve API token converter (#2692)
* improve api converter

* make usage more clear
2019-05-25 23:58:14 +02:00
Neuro Assassin
2e271d695b Add respectable aliases for consistency (#2731)
* Add respectable aliases for consistency

* General command name for alias.py

* Forgot one for alias

* General command for filter

* General command for warnings

* Whoops

Resolves #1749
2019-05-24 18:22:17 -04:00
Stonedestroyer
cd745d35c2 [Core] Add debug info command (#2728)
Shows useful debug information for debugging.
2019-05-24 17:55:48 -04:00
Flame442
6928e2aca2 Fixes some issues with API help commands (#2729)
* Fixes some issues with `[p]streamset youtubekey/twitchtoken`

Lots of general formatting bugs and clarity issues.

* General formatting bugs and clarity issues
2019-05-24 17:52:43 -04:00
Kowlin
49e86614c5 Adding support for GitHub Funding (#2732)
(I'm lazy, I know I should use a fork... sorry <3)
2019-05-24 20:10:55 +02:00
jack1142
51dcf65fd7 [Downloader] Fix problem with copying directory tree. (#2690)
* fix(downloader): clear paths in `distutils.dir_util._path_created` before copying tree

fix #2685

* style(downloader): add comment about PR
2019-05-23 03:08:14 -04:00
Michael H
c6c0165214 [Help] Special case fixing for empty docstring (#2722)
Resolves #2415
2019-05-22 18:22:31 +10:00
PredaaA
342935de49 [Trivia] Remove bold on a box (#2716) 2019-05-21 17:50:51 -04:00
jack1142
ced5bb4631 docs(install): remove information about voice extra (#2717) 2019-05-21 17:49:25 -04:00
DiscordLiz
0a832cee9c [Utils] Allow functools.partial use with menu (#2720) 2019-05-21 16:52:44 -04:00
Brenden Campbell
1cfce8b72c Fix minor typo (#2713) 2019-05-20 13:24:05 -04:00
jack1142
cdcde26dfc [Setup] Fix: wrong var used for instance data in remove_instance (#2709) 2019-05-19 10:13:01 -04:00
Fixator10
1ffb79f852 [events] send help on BadUnionArgument exception (#2707)
* [events] send help on BadUnionArgument exception

* Update redbot/core/events.py

Co-Authored-By: Michael H <michael@michaelhall.tech>
2019-05-19 06:14:12 -04:00
Michael H
644aaf0c0e [Help] Continuing work and bug-fixes (#2676)
* [Help] Add settings for various things

  - Fixes a small issue in a previously not-exposed logic path
  - Fixes an issue with denied commands help invocation
  - Adds some global usage settings

* remove outdated comment

* improve intent of strings

* added punctuation

* Add DM forbidden handling

* use a slightly different method for shortening embed width specifically
2019-05-18 06:54:02 -04:00
Michael H
cdea03792d [Streams] Fix NameError (#2699)
- fixes #2696
 - My fault for just looking at the github diff and seeing the logic fix
 on this one, without verifying the name validity in surrounding
 context. (see my review in #2679)
2019-05-18 00:59:26 -08:00
Flame442
b190e7417e [Downloader] Adds ctx.typing() to [p]pipinstall (#2700) 2019-05-17 21:40:42 -04:00
zephyrkul
7dd3ff7c8d [Core] Strip commas in user input for load, reload, unload (#2693)
rstrip commas, closes #2695
2019-05-16 23:54:17 -04:00
palmtree5
21a253103e [V3 Streams] fix an issue with stream commands not dealing with reruns (#2679) 2019-05-16 22:28:26 -04:00
El Laggron
db3fb29b30 [docs] Fix typo (#2691) 2019-05-16 17:03:09 -04:00
Neuro Assassin
c5d2ae5831 Mention voice channel in [p]userinfo (#2680)
* Mention voice channels, due to new discord update

* Add ID part back in, as Discord doesn't have copy ID for it yet
2019-05-16 15:12:09 +02:00
jack1142
9d0ca00f89 [General]: shorten descriptions properly with disabled embeds in urban (#2684)
fix #2683
2019-05-16 02:06:46 -04:00
DevilXD
79e5d2c9d7 Fixed command doc formatting in code blocks (#2678) 2019-05-15 20:01:02 -04:00
Fixator10
3a62d392b4 [Mod] [p]names utilize consume-rest (#2675)
Currently, `[p]names` requires quotes, if "user" arg contains spaces
2019-05-15 10:33:10 -04:00
Michael H
f2858ea48c [Core] Fix update notification (#2677)
removes a problematic await
2019-05-15 16:31:00 +02:00
Michael H
3c78fb420b [Help] Fixes some issues with fuzzy help (#2674)
* Fixes some issues with fuzzy help

 - also cleans up some name shadowing which wasn't causing issues
 (curently)

* ver

* style
2019-05-14 23:52:06 -07:00
31 changed files with 904 additions and 235 deletions

3
.github/FUNDING.yml vendored Normal file
View File

@@ -0,0 +1,3 @@
# These are supported funding model platforms
patreon: Red_Devs

View File

@@ -106,7 +106,7 @@ plugins directly from Discord! A few examples are:
- Casino
- Reaction roles
- Slow Mode
- Anilist
- AniList
- And much, much more!
Feel free to take a [peek](https://cogboard.red/t/approved-repositories/210) at a list of

View File

@@ -169,7 +169,7 @@ Trivia
Utility Functions
-----------------
* New: ``chat_formatting.humaize_timedelta`` (`#2412`_)
* New: ``chat_formatting.humanize_timedelta`` (`#2412`_)
* ``Tunnel`` - Spelling correction of method name - changed ``files_from_attatch`` to ``files_from_attach`` (old name is left for backwards compatibility) (`#2496`_)
* ``Tunnel`` - fixed behavior of ``react_close()``, now when tunnel closes message will be sent to other end (`#2507`_)
* ``chat_formatting.humanize_list`` - Improved error handling of empty lists (`#2597`_)

View File

@@ -188,23 +188,17 @@ Choose one of the following commands to install Red.
If you're not inside an activated virtual environment, include the ``--user`` flag with all
``python3.7 -m pip`` commands.
To install without audio support:
To install without MongoDB support:
.. code-block:: none
python3.7 -m pip install -U Red-DiscordBot
Or, to install with audio support:
Or, to install with MongoDB support:
.. code-block:: none
python3.7 -m pip install -U Red-DiscordBot[voice]
Or, install with audio and MongoDB support:
.. code-block:: none
python3.7 -m pip install -U Red-DiscordBot[voice,mongo]
python3.7 -m pip install -U Red-DiscordBot[mongo]
.. note::

View File

@@ -62,23 +62,17 @@ Installing Red
If you're not inside an activated virtual environment, include the ``--user`` flag with all
``pip`` commands.
* No audio:
* No MongoDB support:
.. code-block:: none
python -m pip install -U Red-DiscordBot
* With audio:
* With MongoDB support:
.. code-block:: none
python -m pip install -U Red-DiscordBot[voice]
* With audio and MongoDB support:
.. code-block:: none
python -m pip install -U Red-DiscordBot[voice,mongo]
python -m pip install -U Red-DiscordBot[mongo]
.. note::

View File

@@ -174,7 +174,7 @@ class VersionInfo:
)
__version__ = "3.1.0"
__version__ = "3.1.2"
version_info = VersionInfo.from_str(__version__)
# Filter fuzzywuzzy slow sequence matcher warning

View File

@@ -375,7 +375,7 @@ class Alias(commands.Cog):
await ctx.send(_("There is no alias with the name `{name}`").format(name=alias_name))
@checks.mod_or_permissions(manage_guild=True)
@alias.command(name="del")
@alias.command(name="delete", aliases=["del", "remove"])
@commands.guild_only()
async def _del_alias(self, ctx: commands.Context, alias_name: str):
"""Delete an existing alias on this server."""
@@ -394,7 +394,7 @@ class Alias(commands.Cog):
await ctx.send(_("Alias with name `{name}` was not found.").format(name=alias_name))
@checks.is_owner()
@global_.command(name="del")
@global_.command(name="delete", aliases=["del", "remove"])
async def _del_global_alias(self, ctx: commands.Context, alias_name: str):
"""Delete an existing global alias."""
aliases = await self.unloaded_global_aliases()

View File

@@ -592,15 +592,15 @@ class Audio(commands.Cog):
async def spotifyapi(self, ctx):
"""Instructions to set the Spotify API tokens."""
message = _(
f"1. Go to Spotify developers and log in with your Spotify account\n"
"1. Go to Spotify developers and log in with your Spotify account.\n"
"(https://developer.spotify.com/dashboard/applications)\n"
'2. Click "Create An App"\n'
'2. Click "Create An App".\n'
"3. Fill out the form provided with your app name, etc.\n"
'4. When asked if you\'re developing commercial integration select "No"\n'
'4. When asked if you\'re developing commercial integration select "No".\n'
"5. Accept the terms and conditions.\n"
"6. Copy your client ID and your client secret into:\n"
"`{prefix}set api spotify client_id,your_client_id "
"client_secret,your_client_secret`"
"`{prefix}set api spotify client_id,<your_client_id_here> "
"client_secret,<your_client_secret_here>`"
).format(prefix=ctx.prefix)
await ctx.maybe_send_embed(message)
@@ -660,7 +660,7 @@ class Audio(commands.Cog):
"6. Click on Create Credential at the top.\n"
'7. At the top click the link for "API key".\n'
"8. No application restrictions are needed. Click Create at the bottom.\n"
"9. You now have a key to add to `{prefix}set api youtube api_key,your_api_key`"
"9. You now have a key to add to `{prefix}set api youtube api_key,<your_api_key_here>`"
).format(prefix=ctx.prefix)
await ctx.maybe_send_embed(message)

View File

@@ -14,7 +14,7 @@ import aiohttp
from redbot.core import data_manager
JAR_VERSION = "3.2.0.3"
JAR_BUILD = 772
JAR_BUILD = 796
LAVALINK_DOWNLOAD_URL = (
f"https://github.com/Cog-Creators/Lavalink-Jars/releases/download/{JAR_VERSION}_{JAR_BUILD}/"
f"Lavalink.jar"

View File

@@ -33,6 +33,9 @@ class Cleanup(commands.Cog):
Tries its best to cleanup after itself if the response is positive.
"""
if ctx.assume_yes:
return True
prompt = await ctx.send(
_("Are you sure you want to delete {number} messages? (y/n)").format(number=number)
)

View File

@@ -84,6 +84,8 @@ class CommandObj:
return "{:%d/%m/%Y %H:%M:%S}".format(datetime.utcnow())
async def get(self, message: discord.Message, command: str) -> Tuple[str, Dict]:
if not command:
raise NotFound()
ccinfo = await self.db(message.guild).commands.get_raw(command, default=None)
if not ccinfo:
raise NotFound()
@@ -296,7 +298,7 @@ class CustomCommands(commands.Cog):
)
)
@customcom.command(name="delete")
@customcom.command(name="delete", aliases=["del", "remove"])
@checks.mod_or_permissions(administrator=True)
async def cc_delete(self, ctx, command: str.lower):
"""Delete a custom command.

View File

@@ -199,7 +199,8 @@ class Downloader(commands.Cog):
if not deps:
return await ctx.send_help()
repo = Repo("", "", "", Path.cwd(), loop=ctx.bot.loop)
success = await repo.install_raw_requirements(deps, self.LIB_PATH)
async with ctx.typing():
success = await repo.install_raw_requirements(deps, self.LIB_PATH)
if success:
await ctx.send(_("Libraries installed."))
@@ -245,7 +246,7 @@ class Downloader(commands.Cog):
if repo.install_msg is not None:
await ctx.send(repo.install_msg.replace("[p]", ctx.prefix))
@repo.command(name="delete", aliases=["remove"], usage="<repo_name>")
@repo.command(name="delete", aliases=["remove", "del"], usage="<repo_name>")
async def _repo_del(self, ctx, repo: Repo):
"""Remove a repo and its files."""
await self._repo_manager.delete_repo(repo.name)
@@ -423,35 +424,39 @@ class Downloader(commands.Cog):
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:
message += " (y/n)"
query: discord.Message = await ctx.send(message)
if can_react:
# noinspection PyAsyncCall
start_adding_reactions(query, ReactionPredicate.YES_OR_NO_EMOJIS, ctx.bot.loop)
pred = ReactionPredicate.yes_or_no(query, ctx.author)
event = "reaction_add"
else:
pred = MessagePredicate.yes_or_no(ctx)
event = "message"
try:
await ctx.bot.wait_for(event, check=pred, timeout=30)
except asyncio.TimeoutError:
await query.delete()
return
if pred.result is True:
if not ctx.assume_yes:
message = _("Would you like to reload the updated cogs?")
can_react = ctx.channel.permissions_for(ctx.me).add_reactions
if not can_react:
message += " (y/n)"
query: discord.Message = await ctx.send(message)
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:
await query.delete()
# noinspection PyAsyncCall
start_adding_reactions(query, ReactionPredicate.YES_OR_NO_EMOJIS, ctx.bot.loop)
pred = ReactionPredicate.yes_or_no(query, ctx.author)
event = "reaction_add"
else:
await ctx.send(_("OK then."))
pred = MessagePredicate.yes_or_no(ctx)
event = "message"
try:
await ctx.bot.wait_for(event, check=pred, timeout=30)
except asyncio.TimeoutError:
await query.delete()
return
if not pred.result:
if can_react:
await query.delete()
else:
await ctx.send(_("OK then."))
return
else:
if can_react:
with contextlib.suppress(discord.Forbidden):
await query.clear_reactions()
await ctx.invoke(ctx.bot.get_cog("Core").reload, *cognames)
@cog.command(name="list", usage="<repo_name>")
async def _cog_list(self, ctx, repo: Repo):

View File

@@ -114,6 +114,8 @@ class Installable(RepoJSONMixin):
if self._location.is_file():
copy_func = shutil.copy2
else:
# clear copy_tree's cache to make sure missing directories are created (GH-2690)
distutils.dir_util._path_created = {}
copy_func = distutils.dir_util.copy_tree
# noinspection PyBroadException

View File

@@ -236,7 +236,7 @@ class Filter(commands.Cog):
else:
await ctx.send(_("Those words were already in the filter."))
@_filter.command(name="remove")
@_filter.command(name="delete", aliases=["remove", "del"])
async def filter_remove(self, ctx: commands.Context, *, words: str):
"""Remove words from the filter.

View File

@@ -307,14 +307,17 @@ class General(commands.Cog):
messages = []
for ud in data["list"]:
ud.setdefault("example", "N/A")
description = _("{definition}\n\n**Example:** {example}").format(**ud)
if len(description) > 2048:
description = "{}...".format(description[:2045])
message = _(
"<{permalink}>\n {word} by {author}\n\n{description}\n\n"
"{thumbs_down} Down / {thumbs_up} Up, Powered by Urban Dictionary."
).format(word=ud.pop("word").capitalize(), description=description, **ud)
).format(word=ud.pop("word").capitalize(), description="{description}", **ud)
max_desc_len = 2000 - len(message)
description = _("{definition}\n\n**Example:** {example}").format(**ud)
if len(description) > max_desc_len:
description = "{}...".format(description[: max_desc_len - 3])
message = message.format(description=description)
messages.append(message)
if messages is not None and len(messages) > 0:

View File

@@ -7,8 +7,6 @@ from redbot.core import checks, Config, commands
_ = Translator("Image", __file__)
GIPHY_API_KEY = "dc6zaTOxFJmzC"
@cog_i18n(_)
class Image(commands.Cog):
@@ -138,20 +136,20 @@ class Image(commands.Cog):
@checks.is_owner()
@commands.command()
async def imgurcreds(self, ctx):
"""Explain how to set imgur API tokens"""
"""Explain how to set imgur API tokens."""
message = _(
"To get an Imgur Client ID:\n"
"1. Login to an Imgur account.\n"
"2. Visit [this](https://api.imgur.com/oauth2/addclient) page\n"
"3. Enter a name for your application\n"
"4. Select *Anonymous usage without user authorization* for the auth type\n"
"5. Set the authorization callback URL to `https://localhost`\n"
"6. Leave the app website blank\n"
"7. Enter a valid email address and a description\n"
"8. Check the captcha box and click next\n"
"2. Visit this page https://api.imgur.com/oauth2/addclient.\n"
"3. Enter a name for your application.\n"
"4. Select *Anonymous usage without user authorization* for the auth type.\n"
"5. Set the authorization callback URL to `https://localhost`.\n"
"6. Leave the app website blank.\n"
"7. Enter a valid email address and a description.\n"
"8. Check the captcha box and click next.\n"
"9. Your Client ID will be on the next page.\n"
"10. do `{prefix}set api imgur client_id,your_client_id`\n"
"10. Run the command `{prefix}set api imgur client_id,<your_client_id_here>`.\n"
).format(prefix=ctx.prefix)
await ctx.maybe_send_embed(message)
@@ -166,8 +164,17 @@ class Image(commands.Cog):
await ctx.send_help()
return
giphy_api_key = await ctx.bot.db.api_tokens.get_raw("GIPHY", default=None)
if not giphy_api_key:
await ctx.send(
_("An API key has not been set! Please set one with `{prefix}giphycreds`.").format(
prefix=ctx.prefix
)
)
return
url = "http://api.giphy.com/v1/gifs/search?&api_key={}&q={}".format(
GIPHY_API_KEY, keywords
giphy_api_key["api_key"], keywords
)
async with self.session.get(url) as r:
@@ -190,8 +197,17 @@ class Image(commands.Cog):
await ctx.send_help()
return
giphy_api_key = await ctx.bot.db.api_tokens.get_raw("GIPHY", default=None)
if not giphy_api_key:
await ctx.send(
_("An API key has not been set! Please set one with `{prefix}giphycreds`.").format(
prefix=ctx.prefix
)
)
return
url = "http://api.giphy.com/v1/gifs/random?&api_key={}&tag={}".format(
GIPHY_API_KEY, keywords
giphy_api_key["api_key"], keywords
)
async with self.session.get(url) as r:
@@ -203,3 +219,21 @@ class Image(commands.Cog):
await ctx.send(_("No results found."))
else:
await ctx.send(_("Error contacting the API."))
@checks.is_owner()
@commands.command()
async def giphycreds(self, ctx):
"""Explain how to set Giphy API tokens"""
message = _(
"To get a Giphy API Key:\n"
"1. Login to a Giphy account.\n"
"2. Visit [this](https://developers.giphy.com/dashboard) page\n"
"3. Press `Create an App`\n"
"4. Write an app name, example: `Red Bot`\n"
"5. Write an app description, example: `Used for Red Bot`\n"
"6. Copy the API key shown.\n"
"7. Do `{prefix}set api GIPHY api_key,your_api_key`\n"
).format(prefix=ctx.prefix)
await ctx.maybe_send_embed(message)

View File

@@ -12,6 +12,7 @@ from .kickban import KickBanMixin
from .movetocore import MoveToCore
from .mutes import MuteMixin
from .names import ModInfo
from .slowmode import Slowmode
from .settings import ModSettings
_ = T_ = Translator("Mod", __file__)
@@ -36,6 +37,7 @@ class Mod(
MoveToCore,
MuteMixin,
ModInfo,
Slowmode,
commands.Cog,
metaclass=CompositeMetaClass,
):

View File

@@ -145,7 +145,7 @@ class ModInfo(MixinMeta):
if voice_state and voice_state.channel:
data.add_field(
name=_("Current voice channel"),
value="{0.name} (ID {0.id})".format(voice_state.channel),
value="{0.mention} ID: {0.id}".format(voice_state.channel),
inline=False,
)
data.set_footer(text=_("Member #{} | User ID: {}").format(member_number, user.id))
@@ -164,7 +164,7 @@ class ModInfo(MixinMeta):
await ctx.send(embed=data)
@commands.command()
async def names(self, ctx: commands.Context, user: discord.Member):
async def names(self, ctx: commands.Context, *, user: discord.Member):
"""Show previous names and nicknames of a user."""
names, nicks = await self.get_names_and_nicks(user)
msg = ""

View File

@@ -0,0 +1,41 @@
import re
from .abc import MixinMeta
from datetime import timedelta
from redbot.core import commands, i18n, checks
from redbot.core.utils.chat_formatting import humanize_timedelta
_ = i18n.Translator("Mod", __file__)
class Slowmode(MixinMeta):
"""
Commands regarding channel slowmode management.
"""
@commands.command()
@commands.guild_only()
@commands.bot_has_permissions(manage_channels=True)
@checks.admin_or_permissions(manage_channels=True)
async def slowmode(
self,
ctx,
*,
interval: commands.TimedeltaConverter(
minimum=timedelta(seconds=0), maximum=timedelta(hours=6)
) = timedelta(seconds=0),
):
"""Changes channel's slowmode setting.
Interval can be anything from 0 seconds to 6 hours.
Use without parameters to disable.
"""
seconds = interval.total_seconds()
await ctx.channel.edit(slowmode_delay=seconds)
if seconds > 0:
await ctx.send(
_("Slowmode interval is now {interval}.").format(
interval=humanize_timedelta(timedelta=interval)
)
)
else:
await ctx.send(_("Slowmode has been disabled."))

View File

@@ -128,10 +128,9 @@ class Streams(commands.Cog):
stream = PicartoStream(name=channel_name)
await self.check_online(ctx, stream)
@staticmethod
async def check_online(ctx: commands.Context, stream):
async def check_online(self, ctx: commands.Context, stream):
try:
embed = await stream.is_online()
info = await stream.is_online()
except OfflineStream:
await ctx.send(_("That user is offline."))
except StreamNotFound:
@@ -155,6 +154,14 @@ class Streams(commands.Cog):
_("Something went wrong whilst trying to contact the stream service's API.")
)
else:
if isinstance(info, tuple):
embed, is_rerun = info
ignore_reruns = await self.db.guild(ctx.channel.guild).ignore_reruns()
if ignore_reruns and is_rerun:
await ctx.send(_("That user is offline."))
return
else:
embed = info
await ctx.send(embed=embed)
@commands.group()
@@ -309,18 +316,19 @@ class Streams(commands.Cog):
@streamset.command()
@checks.is_owner()
async def twitchtoken(self, ctx: commands.Context):
"""Explain how to set the twitch token"""
"""Explain how to set the twitch token."""
message = _(
"To set the twitch API tokens, follow these steps:\n"
"1. Go to this page: https://dev.twitch.tv/dashboard/apps.\n"
"2. Click *Register Your Application*\n"
"3. Enter a name, set the OAuth Redirect URI to `http://localhost`, and \n"
"select an Application Category of your choosing."
"4. Click *Register*, and on the following page, copy the Client ID.\n"
"5. do `{prefix}set api twitch client_id,your_client_id`\n\n"
"2. Click *Register Your Application*.\n"
"3. Enter a name, set the OAuth Redirect URI to `http://localhost`, and "
"select an Application Category of your choosing.\n"
"4. Click *Register*.\n"
"5. On the following page, copy the Client ID.\n"
"6. Run the command `{prefix}set api twitch client_id,<your_client_id_here>`\n\n"
"Note: These tokens are sensitive and should only be used in a private channel\n"
"or in DM with the bot.)\n"
"or in DM with the bot.\n"
).format(prefix=ctx.prefix)
await ctx.maybe_send_embed(message)
@@ -328,17 +336,18 @@ class Streams(commands.Cog):
@streamset.command()
@checks.is_owner()
async def youtubekey(self, ctx: commands.Context):
"""Explain how to set the YouTube token"""
"""Explain how to set the YouTube token."""
message = _(
"To get one, do the following:\n"
"1. Create a project\n"
"(see https://support.google.com/googleapi/answer/6251787 for details)\n"
"2. Enable the YouTube Data API v3 \n"
"(see https://support.google.com/googleapi/answer/6158841for instructions)\n"
"(see https://support.google.com/googleapi/answer/6158841 for instructions)\n"
"3. Set up your API key \n"
"(see https://support.google.com/googleapi/answer/6158862 for instructions)\n"
"4. Copy your API key and do `{prefix}set api youtube api_key,your_api_key`\n\n"
"4. Copy your API key and run the command "
"`{prefix}set api youtube api_key,<your_api_key_here>`\n\n"
"Note: These tokens are sensitive and should only be used in a private channel\n"
"or in DM with the bot.\n"
).format(prefix=ctx.prefix)
@@ -538,7 +547,11 @@ class Streams(commands.Cog):
for stream in self.streams:
with contextlib.suppress(Exception):
try:
embed, is_rerun = await stream.is_online()
if stream.__class__.__name__ == "TwitchStream":
embed, is_rerun = await stream.is_online()
else:
embed = await stream.is_online()
is_rerun = False
except OfflineStream:
if not stream._messages_cache:
continue

View File

@@ -57,7 +57,7 @@ class Trivia(commands.Cog):
settings_dict = await settings.all()
msg = box(
_(
"**Current settings**\n"
"Current settings\n"
"Bot gains points: {bot_plays}\n"
"Answer time limit: {delay} seconds\n"
"Lack of response timeout: {timeout} seconds\n"

View File

@@ -125,7 +125,7 @@ class Warnings(commands.Cog):
registered_actions.sort(key=lambda a: a["points"], reverse=True)
await ctx.send(_("Action {name} has been added.").format(name=name))
@warnaction.command(name="del")
@warnaction.command(name="delete", aliases=["del", "remove"])
@commands.guild_only()
async def action_del(self, ctx: commands.Context, action_name: str):
"""Delete the action with the specified name."""
@@ -175,7 +175,7 @@ class Warnings(commands.Cog):
await ctx.send(_("The new reason has been registered."))
@warnreason.command(name="del", aliases=["remove"])
@warnreason.command(name="delete", aliases=["remove", "del"])
@commands.guild_only()
async def reason_del(self, ctx: commands.Context, reason_name: str):
"""Delete a warning reason."""

View File

@@ -20,6 +20,8 @@ from .utils import common_filters
CUSTOM_GROUPS = "CUSTOM_GROUPS"
log = logging.getLogger("redbot")
def _is_submodule(parent, child):
return parent == child or child.startswith(parent + ".")
@@ -52,10 +54,16 @@ class RedBase(commands.GroupMixin, commands.bot.BotBase, RPCMixin):
custom_info=None,
help__page_char_limit=1000,
help__max_pages_in_guild=2,
help__use_menus=False,
help__show_hidden=False,
help__verify_checks=True,
help__verify_exists=False,
help__tagline="",
disabled_commands=[],
disabled_command_msg="That command is disabled.",
api_tokens={},
extra_owner_destinations=[],
owner_opt_out_list=[],
)
self.db.register_guild(
@@ -253,8 +261,8 @@ class RedBase(commands.GroupMixin, commands.bot.BotBase, RPCMixin):
lib.setup(self)
except Exception as e:
self._remove_module_references(lib.__name__)
self._call_module_finalizers(lib, key)
raise errors.ExtensionFailed(key, e) from e
self._call_module_finalizers(lib, name)
raise errors.CogLoadError() from e
else:
self._BotBase__extensions[name] = lib
@@ -460,6 +468,47 @@ class RedBase(commands.GroupMixin, commands.bot.BotBase, RPCMixin):
ctx.permission_state = commands.PermState.DENIED_BY_HOOK
return False
async def get_owner_notification_destinations(self) -> List[discord.abc.Messageable]:
"""
Gets the users and channels to send to
"""
destinations = []
opt_outs = await self.db.owner_opt_out_list()
for user_id in (self.owner_id, *self._co_owners):
if user_id not in opt_outs:
user = self.get_user(user_id)
if user:
destinations.append(user)
channel_ids = await self.db.extra_owner_destinations()
for channel_id in channel_ids:
channel = self.get_channel(channel_id)
if channel:
destinations.append(channel)
return destinations
async def send_to_owners(self, content=None, **kwargs):
"""
This sends something to all owners and their configured extra destinations.
This takes the same arguments as discord.abc.Messageable.send
This logs failing sends
"""
destinations = await self.get_owner_notification_destinations()
async def wrapped_send(location, content=None, **kwargs):
try:
await location.send(content, **kwargs)
except Exception as _exc:
log.exception(
f"I could not send an owner notification to ({location.id}){location}"
)
sends = [wrapped_send(d, content, **kwargs) for d in destinations]
await asyncio.gather(*sends)
class Red(RedBase, discord.AutoShardedClient):
"""

View File

@@ -23,6 +23,7 @@ class Context(commands.Context):
"""
def __init__(self, **attrs):
self.assume_yes = attrs.pop("assume_yes", False)
super().__init__(**attrs)
self.permission_state: PermState = PermState.NORMAL

View File

@@ -1,21 +1,112 @@
import re
from typing import TYPE_CHECKING
import functools
from datetime import timedelta
from typing import TYPE_CHECKING, Optional, List, Dict
import discord
from discord.ext import commands as dpy_commands
from . import BadArgument
from ..i18n import Translator
from ..utils.chat_formatting import humanize_timedelta
if TYPE_CHECKING:
from .context import Context
__all__ = ["GuildConverter"]
__all__ = [
"APIToken",
"DictConverter",
"GuildConverter",
"TimedeltaConverter",
"get_dict_converter",
"get_timedelta_converter",
"parse_timedelta",
]
_ = Translator("commands.converter", __file__)
ID_REGEX = re.compile(r"([0-9]{15,21})")
# Taken with permission from
# https://github.com/mikeshardmind/SinbadCogs/blob/816f3bc2ba860243f75112904b82009a8a9e1f99/scheduler/time_utils.py#L9-L19
TIME_RE_STRING = r"\s?".join(
[
r"((?P<weeks>\d+?)\s?(weeks?|w))?",
r"((?P<days>\d+?)\s?(days?|d))?",
r"((?P<hours>\d+?)\s?(hours?|hrs|hr?))?",
r"((?P<minutes>\d+?)\s?(minutes?|mins?|m(?!o)))?", # prevent matching "months"
r"((?P<seconds>\d+?)\s?(seconds?|secs?|s))?",
]
)
TIME_RE = re.compile(TIME_RE_STRING, re.I)
def parse_timedelta(
argument: str,
*,
maximum: Optional[timedelta] = None,
minimum: Optional[timedelta] = None,
allowed_units: Optional[List[str]] = None,
) -> Optional[timedelta]:
"""
This converts a user provided string into a timedelta
The units should be in order from largest to smallest.
This works with or without whitespace.
Parameters
----------
argument : str
The user provided input
maximum : Optional[timedelta]
If provided, any parsed value higher than this will raise an exception
minimum : Optional[timedelta]
If provided, any parsed value lower than this will raise an exception
allowed_units : Optional[List[str]]
If provided, you can constrain a user to expressing the amount of time
in specific units. The units you can chose to provide are the same as the
parser understands. `weeks` `days` `hours` `minutes` `seconds`
Returns
-------
Optional[timedelta]
If matched, the timedelta which was parsed. This can return `None`
Raises
------
BadArgument
If the argument passed uses a unit not allowed, but understood
or if the value is out of bounds.
"""
matches = TIME_RE.match(argument)
allowed_units = allowed_units or ["weeks", "days", "hours", "minutes", "seconds"]
if matches:
params = {k: int(v) for k, v in matches.groupdict().items() if v is not None}
for k in params.keys():
if k not in allowed_units:
raise BadArgument(
_("`{unit}` is not a valid unit of time for this command").format(unit=k)
)
if params:
delta = timedelta(**params)
if maximum and maximum < delta:
raise BadArgument(
_(
"This amount of time is too large for this command. (maximum: {maximum})"
).format(maximum=humanize_timedelta(timedelta=maximum))
)
if minimum and delta < minimum:
raise BadArgument(
_(
"This amount of time is too small for this command. (minimum: {minimum})"
).format(minimum=humanize_timedelta(timedelta=minimum))
)
return delta
return None
class GuildConverter(discord.Guild):
"""Converts to a `discord.Guild` object.
@@ -47,16 +138,20 @@ class APIToken(discord.ext.commands.Converter):
This will parse the input argument separating the key value pairs into a
format to be used for the core bots API token storage.
This will split the argument by either `;` or `,` and return a dict
This will split the argument by either `;` ` `, or `,` and return a dict
to be stored. Since all API's are different and have different naming convention,
this leaves the onus on the cog creator to clearly define how to setup the correct
credential names for their cogs.
Note: Core usage of this has been replaced with DictConverter use instead.
This may be removed at a later date (with warning)
"""
async def convert(self, ctx, argument) -> dict:
bot = ctx.bot
result = {}
match = re.split(r";|,", argument)
match = re.split(r";|,| ", argument)
# provide two options to split incase for whatever reason one is part of the api key we're using
if len(match) > 1:
result[match[0]] = "".join(r for r in match[1:])
@@ -65,3 +160,125 @@ class APIToken(discord.ext.commands.Converter):
if not result:
raise BadArgument(_("The provided tokens are not in a valid format."))
return result
class DictConverter(dpy_commands.Converter):
"""
Converts pairs of space seperated values to a dict
"""
def __init__(self, *expected_keys: str, delims: Optional[List[str]] = None):
self.expected_keys = expected_keys
self.delims = delims or [" "]
self.pattern = re.compile(r"|".join(re.escape(d) for d in self.delims))
async def convert(self, ctx: "Context", argument: str) -> Dict[str, str]:
ret: Dict[str, str] = {}
args = self.pattern.split(argument)
if len(args) % 2 != 0:
raise BadArgument()
iterator = iter(args)
for key in iterator:
if self.expected_keys and key not in self.expected_keys:
raise BadArgument(_("Unexpected key {key}").format(key))
ret[key] = next(iterator)
return ret
def get_dict_converter(*expected_keys: str, delims: Optional[List[str]] = None) -> type:
"""
Returns a typechecking safe `DictConverter` suitable for use with discord.py
"""
class PartialMeta(type(DictConverter)):
__call__ = functools.partialmethod(
type(DictConverter).__call__, *expected_keys, delims=delims
)
class ValidatedConverter(DictConverter, metaclass=PartialMeta):
pass
return ValidatedConverter
class TimedeltaConverter(dpy_commands.Converter):
"""
This is a converter for timedeltas.
The units should be in order from largest to smallest.
This works with or without whitespace.
See `parse_timedelta` for more information about how this functions.
Attributes
----------
maximum : Optional[timedelta]
If provided, any parsed value higher than this will raise an exception
minimum : Optional[timedelta]
If provided, any parsed value lower than this will raise an exception
allowed_units : Optional[List[str]]
If provided, you can constrain a user to expressing the amount of time
in specific units. The units you can chose to provide are the same as the
parser understands. `weeks` `days` `hours` `minutes` `seconds`
"""
def __init__(self, *, minimum=None, maximum=None, allowed_units=None):
self.allowed_units = allowed_units
self.minimum = minimum
self.maximum = maximum
async def convert(self, ctx: "Context", argument: str) -> timedelta:
delta = parse_timedelta(
argument, minimum=self.minimum, maximum=self.maximum, allowed_units=self.allowed_units
)
if delta is not None:
return delta
raise BadArgument() # This allows this to be a required argument.
def get_timedelta_converter(
*,
maximum: Optional[timedelta] = None,
minimum: Optional[timedelta] = None,
allowed_units: Optional[List[str]] = None,
) -> type:
"""
This creates a type suitable for typechecking which works with discord.py's
commands.
See `parse_timedelta` for more information about how this functions.
Parameters
----------
maximum : Optional[timedelta]
If provided, any parsed value higher than this will raise an exception
minimum : Optional[timedelta]
If provided, any parsed value lower than this will raise an exception
allowed_units : Optional[List[str]]
If provided, you can constrain a user to expressing the amount of time
in specific units. The units you can chose to provide are the same as the
parser understands. `weeks` `days` `hours` `minutes` `seconds`
Returns
-------
type
The converter class, which will be a subclass of `TimedeltaConverter`
"""
class PartialMeta(type(TimedeltaConverter)):
__call__ = functools.partialmethod(
type(DictConverter).__call__,
allowed_units=allowed_units,
minimum=minimum,
maximum=maximum,
)
class ValidatedConverter(TimedeltaConverter, metaclass=PartialMeta):
pass
return ValidatedConverter

View File

@@ -26,6 +26,7 @@
# Additionally, this gives our users a bit more customization options including by
# 3rd party cogs down the road.
import asyncio
from collections import namedtuple
from typing import Union, List, AsyncIterator, Iterable, cast
@@ -77,14 +78,6 @@ class RedHelpFormatter:
should not need or want a shared state.
"""
# Class vars for things which should be configurable at a later date but aren't now
# Technically, someone can just use a cog to switch these in real time for now.
USE_MENU = False
CONFIRM_UNAVAILABLE_COMMAND_EXISTENCES = False
SHOW_HIDDEN = False
VERIFY_CHECKS = True
async def send_help(self, ctx: Context, help_for: HelpTarget = None):
"""
This delegates to other functions.
@@ -102,7 +95,7 @@ class RedHelpFormatter:
await self.command_not_found(ctx, help_for)
return
except NoSubCommand as exc:
if self.CONFIRM_UNAVAILABLE_COMMAND_EXISTENCES:
if await ctx.bot.db.help.verify_exists():
await self.subcommand_not_found(ctx, exc.last, exc.not_found)
return
help_for = exc.last
@@ -138,7 +131,7 @@ class RedHelpFormatter:
async def format_command_help(self, ctx: Context, obj: commands.Command):
send = self.CONFIRM_UNAVAILABLE_COMMAND_EXISTENCES
send = await ctx.bot.db.help.verify_exists()
if not send:
async for _ in self.help_filter_func(ctx, (obj,), bypass_hidden=True):
# This is a really lazy option for not
@@ -174,7 +167,7 @@ class RedHelpFormatter:
if command.help:
splitted = command.help.split("\n\n")
name = "__{0}__".format(splitted[0])
name = splitted[0]
value = "\n\n".join(splitted[1:]).replace("[p]", ctx.clean_prefix)
if not value:
value = EMPTY_STRING
@@ -182,8 +175,14 @@ class RedHelpFormatter:
emb["fields"].append(field)
if subcommands:
def shorten_line(a_line: str) -> str:
if len(a_line) < 70: # embed max width needs to be lower
return a_line
return a_line[:67] + "..."
subtext = "\n".join(
f"**{name}** {command.short_doc}"
shorten_line(f"**{name}** {command.short_doc}")
for name, command in sorted(subcommands.items())
)
for i, page in enumerate(pagify(subtext, page_length=1000, shorten_by=0)):
@@ -208,7 +207,7 @@ class RedHelpFormatter:
doc_max_width = 80 - max_width
for nm, com in sorted(cmds):
width_gap = discord.utils._string_width(nm) - len(nm)
doc = command.short_doc
doc = com.short_doc
if len(doc) > doc_max_width:
doc = doc[: doc_max_width - 3] + "..."
yield nm, doc, max_width - width_gap
@@ -251,6 +250,12 @@ class RedHelpFormatter:
author_info = {"name": f"{ctx.me.display_name} Help Menu", "icon_url": ctx.me.avatar_url}
if not field_groups: # This can happen on single command without a docstring
embed = discord.Embed(color=color, **embed_dict["embed"])
embed.set_author(**author_info)
embed.set_footer(**embed_dict["footer"])
pages.append(embed)
for i, group in enumerate(field_groups, 1):
embed = discord.Embed(color=color, **embed_dict["embed"])
@@ -271,8 +276,8 @@ class RedHelpFormatter:
async def format_cog_help(self, ctx: Context, obj: commands.Cog):
commands = await self.get_cog_help_mapping(ctx, obj)
if not (commands or self.CONFIRM_UNAVAILABLE_COMMAND_EXISTENCES):
coms = await self.get_cog_help_mapping(ctx, obj)
if not (coms or await ctx.bot.db.help.verify_exists()):
return
description = obj.help
@@ -283,11 +288,24 @@ class RedHelpFormatter:
emb["footer"]["text"] = tagline
if description:
emb["embed"]["title"] = f"*{description[:2044]}*"
splitted = description.split("\n\n")
name = splitted[0]
value = "\n\n".join(splitted[1:]).replace("[p]", ctx.clean_prefix)
if not value:
value = EMPTY_STRING
field = EmbedField(name[:252], value[:1024], False)
emb["fields"].append(field)
if coms:
def shorten_line(a_line: str) -> str:
if len(a_line) < 70: # embed max width needs to be lower
return a_line
return a_line[:67] + "..."
if commands:
command_text = "\n".join(
f"**{name}** {command.short_doc}" for name, command in sorted(commands.items())
shorten_line(f"**{name}** {command.short_doc}")
for name, command in sorted(coms.items())
)
for i, page in enumerate(pagify(command_text, page_length=1000, shorten_by=0)):
if i == 0:
@@ -300,11 +318,11 @@ class RedHelpFormatter:
await self.make_and_send_embeds(ctx, emb)
else:
commands_text = None
commands_header = None
if commands:
subtext = None
subtext_header = None
if coms:
subtext_header = "Commands:"
max_width = max(discord.utils._string_width(name) for name in commands.keys())
max_width = max(discord.utils._string_width(name) for name in coms.keys())
def width_maker(cmds):
doc_max_width = 80 - max_width
@@ -316,20 +334,17 @@ class RedHelpFormatter:
yield nm, doc, max_width - width_gap
subtext = "\n".join(
f" {name:<{width}} {doc}"
for name, doc, width in width_maker(commands.items())
f" {name:<{width}} {doc}" for name, doc, width in width_maker(coms.items())
)
to_page = "\n\n".join(
filter(None, (description, signature[1:-1], subtext_header, subtext))
)
to_page = "\n\n".join(filter(None, (description, subtext_header, subtext)))
pages = [box(p) for p in pagify(to_page)]
await self.send_pages(ctx, pages, embed=False)
async def format_bot_help(self, ctx: Context):
commands = await self.get_bot_help_mapping(ctx)
if not commands:
coms = await self.get_bot_help_mapping(ctx)
if not coms:
return
description = ctx.bot.description or ""
@@ -343,15 +358,21 @@ class RedHelpFormatter:
if description:
emb["embed"]["title"] = f"*{description[:2044]}*"
for cog_name, data in commands:
for cog_name, data in coms:
if cog_name:
title = f"**__{cog_name}:__**"
else:
title = f"**__No Category:__**"
def shorten_line(a_line: str) -> str:
if len(a_line) < 70: # embed max width needs to be lower
return a_line
return a_line[:67] + "..."
cog_text = "\n".join(
f"**{name}** {command.short_doc}" for name, command in sorted(data.items())
shorten_line(f"**{name}** {command.short_doc}")
for name, command in sorted(data.items())
)
for i, page in enumerate(pagify(cog_text, page_length=1000, shorten_by=0)):
@@ -362,11 +383,12 @@ class RedHelpFormatter:
await self.make_and_send_embeds(ctx, emb)
else:
to_join = []
if description:
to_join = [f"{description}\n"]
to_join.append(f"{description}\n")
names = []
for k, v in commands:
for k, v in coms:
names.extend(list(v.name for v in v.values()))
max_width = max(
@@ -382,7 +404,7 @@ class RedHelpFormatter:
doc = doc[: doc_max_width - 3] + "..."
yield nm, doc, max_width - width_gap
for cog_name, data in commands:
for cog_name, data in coms:
title = f"{cog_name}:" if cog_name else "No Category:"
to_join.append(title)
@@ -401,17 +423,25 @@ class RedHelpFormatter:
"""
This does most of actual filtering.
"""
show_hidden = bypass_hidden or await ctx.bot.db.help.show_hidden()
verify_checks = await ctx.bot.db.help.verify_checks()
# TODO: Settings for this in core bot db
for obj in objects:
if self.VERIFY_CHECKS and not (self.SHOW_HIDDEN or bypass_hidden):
if verify_checks and not show_hidden:
# Default Red behavior, can_see includes a can_run check.
if await obj.can_see(ctx):
yield obj
elif self.VERIFY_CHECKS:
if await obj.can_run(ctx):
elif verify_checks:
try:
can_run = await obj.can_run(ctx)
except discord.DiscordException:
can_run = False
if can_run:
yield obj
elif not (self.SHOW_HIDDEN or bypass_hidden):
if getattr(obj, "hidden", False): # Cog compatibility
elif not show_hidden:
if not getattr(obj, "hidden", False): # Cog compatibility
yield obj
else:
yield obj
@@ -426,17 +456,17 @@ class RedHelpFormatter:
if fuzzy_commands:
ret = await format_fuzzy_results(ctx, fuzzy_commands, embed=use_embeds)
if use_embeds:
ret.set_author()
ret.set_author(name=f"{ctx.me.display_name} Help Menu", icon_url=ctx.me.avatar_url)
tagline = (await ctx.bot.db.help.tagline()) or self.get_default_tagline(ctx)
ret.set_footer(text=tagline)
await ctx.send(embed=ret)
else:
await ctx.send(ret)
elif self.CONFIRM_UNAVAILABLE_COMMAND_EXISTENCES:
ret = T_("Command *{command_name}* not found.").format(command_name=command_name)
elif await ctx.bot.db.help.verify_exists():
ret = T_("Help topic for *{command_name}* not found.").format(command_name=help_for)
if use_embeds:
emb = discord.Embed(color=(await ctx.embed_color()), description=ret)
emb.set_author(name=f"{ctx.me.display_name} Help Menu", icon_url=ctx.me.avatar_url)
ret = discord.Embed(color=(await ctx.embed_color()), description=ret)
ret.set_author(name=f"{ctx.me.display_name} Help Menu", icon_url=ctx.me.avatar_url)
tagline = (await ctx.bot.db.help.tagline()) or self.get_default_tagline(ctx)
ret.set_footer(text=tagline)
await ctx.send(embed=ret)
@@ -447,10 +477,17 @@ class RedHelpFormatter:
"""
Sends an error
"""
ret = T_("Command *{command_name}* has no subcommands.").format(
command_name=command.qualified_name
ret = T_("Command *{command_name}* has no subcommand named *{not_found}*.").format(
command_name=command.qualified_name, not_found=not_found[0]
)
await ctx.send(ret)
if await ctx.embed_requested():
ret = discord.Embed(color=(await ctx.embed_color()), description=ret)
ret.set_author(name=f"{ctx.me.display_name} Help Menu", icon_url=ctx.me.avatar_url)
tagline = (await ctx.bot.db.help.tagline()) or self.get_default_tagline(ctx)
ret.set_footer(text=tagline)
await ctx.send(embed=ret)
else:
await ctx.send(ret)
@staticmethod
def parse_command(ctx, help_for: str):
@@ -489,19 +526,43 @@ class RedHelpFormatter:
Sends pages based on settings.
"""
if not self.USE_MENU:
if not (
ctx.channel.permissions_for(ctx.me).add_reactions and await ctx.bot.db.help.use_menus()
):
max_pages_in_guild = await ctx.bot.db.help.max_pages_in_guild()
destination = ctx.author if len(pages) > max_pages_in_guild else ctx
if embed:
for page in pages:
await destination.send(embed=page)
try:
await destination.send(embed=page)
except discord.Forbidden:
await ctx.send(
T_(
"I couldn't send the help message to you in DM. "
"Either you blocked me or you disabled DMs in this server."
)
)
else:
for page in pages:
await destination.send(page)
try:
await destination.send(page)
except discord.Forbidden:
await ctx.send(
T_(
"I couldn't send the help message to you in DM. "
"Either you blocked me or you disabled DMs in this server."
)
)
else:
await menus.menu(ctx, pages, menus.DEFAULT_CONTROLS)
# Specifically ensuring the menu's message is sent prior to returning
m = await (ctx.send(embed=pages[0]) if embed else ctx.send(pages[0]))
c = menus.DEFAULT_CONTROLS if len(pages) > 1 else {"\N{CROSS MARK}": menus.close_menu}
# Allow other things to happen during menu timeout/interaction.
asyncio.create_task(menus.menu(ctx, pages, c, message=m))
# menu needs reactions added manually since we fed it a messsage
menus.start_adding_reactions(m, c.keys())
@commands.command(name="help", hidden=True, i18n=T_)

View File

@@ -8,6 +8,9 @@ import logging
import os
import pathlib
import sys
import platform
import getpass
import pip
import tarfile
import traceback
from collections import namedtuple
@@ -30,7 +33,7 @@ from redbot.core import (
i18n,
)
from .utils.predicates import MessagePredicate
from .utils.chat_formatting import pagify, box, inline
from .utils.chat_formatting import humanize_timedelta, pagify, box, inline
if TYPE_CHECKING:
from redbot.core.bot import Red
@@ -42,6 +45,8 @@ log = logging.getLogger("red")
_ = i18n.Translator("Core", __file__)
TokenConverter = commands.get_dict_converter(delims=[" ", ",", ";"])
class CoreLogic:
def __init__(self, bot: "Red"):
@@ -328,28 +333,12 @@ class Core(commands.Cog, CoreLogic):
async def uptime(self, ctx: commands.Context):
"""Shows Red's uptime"""
since = ctx.bot.uptime.strftime("%Y-%m-%d %H:%M:%S")
passed = self.get_bot_uptime()
await ctx.send(_("Been up for: **{}** (since {} UTC)").format(passed, since))
def get_bot_uptime(self, *, brief: bool = False):
# Courtesy of Danny
now = datetime.datetime.utcnow()
delta = now - self.bot.uptime
hours, remainder = divmod(int(delta.total_seconds()), 3600)
minutes, seconds = divmod(remainder, 60)
days, hours = divmod(hours, 24)
if not brief:
if days:
fmt = _("{d} days, {h} hours, {m} minutes, and {s} seconds")
else:
fmt = _("{h} hours, {m} minutes, and {s} seconds")
else:
fmt = _("{h}h {m}m {s}s")
if days:
fmt = _("{d}d ") + fmt
return fmt.format(d=days, h=hours, m=minutes, s=seconds)
delta = datetime.datetime.utcnow() - self.bot.uptime
await ctx.send(
_("Been up for: **{}** (since {} UTC)").format(
humanize_timedelta(timedelta=delta), since
)
)
@commands.group()
async def embedset(self, ctx: commands.Context):
@@ -529,6 +518,7 @@ class Core(commands.Cog, CoreLogic):
"""Loads packages"""
if not cogs:
return await ctx.send_help()
cogs = tuple(map(lambda cog: cog.rstrip(","), cogs))
async with ctx.typing():
loaded, failed, not_found, already_loaded, failed_with_reason = await self._load(cogs)
@@ -571,6 +561,7 @@ class Core(commands.Cog, CoreLogic):
"""Unloads packages"""
if not cogs:
return await ctx.send_help()
cogs = tuple(map(lambda cog: cog.rstrip(","), cogs))
unloaded, failed = await self._unload(cogs)
if unloaded:
@@ -589,6 +580,7 @@ class Core(commands.Cog, CoreLogic):
"""Reloads packages"""
if not cogs:
return await ctx.send_help()
cogs = tuple(map(lambda cog: cog.rstrip(","), cogs))
async with ctx.typing():
loaded, failed, not_found, already_loaded, failed_with_reason = await self._reload(
cogs
@@ -1057,7 +1049,7 @@ class Core(commands.Cog, CoreLogic):
@_set.command()
@checks.is_owner()
async def api(self, ctx: commands.Context, service: str, *tokens: commands.converter.APIToken):
async def api(self, ctx: commands.Context, service: str, *, tokens: TokenConverter):
"""Set various external API tokens.
This setting will be asked for by some 3rd party cogs and some core cogs.
@@ -1070,8 +1062,7 @@ class Core(commands.Cog, CoreLogic):
"""
if ctx.channel.permissions_for(ctx.me).manage_messages:
await ctx.message.delete()
entry = {k: v for t in tokens for k, v in t.items()}
await ctx.bot.db.api_tokens.set_raw(service, value=entry)
await ctx.bot.db.api_tokens.set_raw(service, value=tokens)
await ctx.send(_("`{service}` API tokens have been set.").format(service=service))
@commands.group()
@@ -1080,6 +1071,80 @@ class Core(commands.Cog, CoreLogic):
"""Manage settings for the help command."""
pass
@helpset.command(name="usemenus")
async def helpset_usemenus(self, ctx: commands.Context, use_menus: bool = None):
"""
Allows the help command to be sent as a paginated menu instead of seperate
messages.
This defaults to False.
Using this without a setting will toggle.
"""
if use_menus is None:
use_menus = not await ctx.bot.db.help.use_menus()
await ctx.bot.db.help.use_menus.set(use_menus)
if use_menus:
await ctx.send(_("Help will use menus."))
else:
await ctx.send(_("Help will not use menus."))
@helpset.command(name="showhidden")
async def helpset_showhidden(self, ctx: commands.Context, show_hidden: bool = None):
"""
This allows the help command to show hidden commands
This defaults to False.
Using this without a setting will toggle.
"""
if show_hidden is None:
show_hidden = not await ctx.bot.db.help.show_hidden()
await ctx.bot.db.help.show_hidden.set(show_hidden)
if show_hidden:
await ctx.send(_("Help will not filter hidden commands"))
else:
await ctx.send(_("Help will filter hidden commands."))
@helpset.command(name="verifychecks")
async def helpset_permfilter(self, ctx: commands.Context, verify: bool = None):
"""
Sets if commands which can't be run in the current context should be
filtered from help
Defaults to True.
Using this without a setting will toggle.
"""
if verify is None:
verify = not await ctx.bot.db.help.verify_checks()
await ctx.bot.db.help.verify_checks.set(verify)
if verify:
await ctx.send(_("Help will only show for commands which can be run."))
else:
await ctx.send(_("Help will show up without checking if the commands can be run."))
@helpset.command(name="verifyexists")
async def helpset_verifyexists(self, ctx: commands.Context, verify: bool = None):
"""
This allows the bot to respond indicating the existence of a specific
help topic even if the user can't use it.
Note: This setting on it's own does not fully prevent command enumeration.
Defaults to False.
Using this without a setting will toggle.
"""
if verify is None:
verify = not await ctx.bot.db.help.verify_exists()
await ctx.bot.db.help.verify_exists.set(verify)
if verify:
await ctx.send(_("Help will verify the existence of help topics."))
else:
await ctx.send(
_(
"Help will only verify the existence of "
"help topics via fuzzy help (if enabled)."
)
)
@helpset.command(name="pagecharlimit")
async def helpset_pagecharlimt(self, ctx: commands.Context, limit: int):
"""Set the character limit for each page in the help message.
@@ -1270,7 +1335,6 @@ class Core(commands.Cog, CoreLogic):
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)
author = ctx.message.author
footer = _("User ID: {}").format(author.id)
@@ -1291,41 +1355,81 @@ class Core(commands.Cog, CoreLogic):
description = _("Sent by {} {}").format(author, source)
if isinstance(author, discord.Member):
colour = author.colour
else:
colour = discord.Colour.red()
destinations = await ctx.bot.get_owner_notification_destinations()
if await ctx.embed_requested():
e = discord.Embed(colour=colour, description=message)
if author.avatar_url:
e.set_author(name=description, icon_url=author.avatar_url)
else:
e.set_author(name=description)
e.set_footer(text=footer)
if not destinations:
await ctx.send(_("I've been configured not to send this anywhere."))
return
try:
await owner.send(content, embed=e)
except discord.InvalidArgument:
await ctx.send(
_("I cannot send your message, I'm unable to find my owner... *sigh*")
)
except discord.HTTPException:
await ctx.send(_("I'm unable to deliver your message. Sorry."))
successful = False
for destination in destinations:
is_dm = isinstance(destination, discord.User)
send_embed = None
if is_dm:
send_embed = await ctx.bot.db.user(destination).embeds()
else:
await ctx.send(_("Your message has been sent."))
if not destination.permissions_for(destination.guild.me).send_messages:
continue
if destination.permissions_for(destination.guild.me).embed_links:
send_embed = await ctx.bot.db.guild(destination.guild).embeds()
else:
send_embed = False
if send_embed is None:
send_embed = await ctx.bot.db.embeds()
if send_embed:
if not is_dm and await self.bot.db.guild(destination.guild).use_bot_color():
color = destination.guild.me.color
else:
color = ctx.bot.color
e = discord.Embed(colour=color, description=message)
if author.avatar_url:
e.set_author(name=description, icon_url=author.avatar_url)
else:
e.set_author(name=description)
e.set_footer(text=footer)
try:
await destination.send(embed=e)
except discord.Forbidden:
log.exception(f"Contact failed to {destination}({destination.id})")
# Should this automatically opt them out?
except discord.HTTPException:
log.exception(
f"An unexpected error happened while attempting to"
f" send contact to {destination}({destination.id})"
)
else:
successful = True
else:
msg_text = "{}\nMessage:\n\n{}\n{}".format(description, message, footer)
try:
await destination.send("{}\n{}".format(content, box(msg_text)))
except discord.Forbidden:
log.exception(f"Contact failed to {destination}({destination.id})")
# Should this automatically opt them out?
except discord.HTTPException:
log.exception(
f"An unexpected error happened while attempting to"
f" send contact to {destination}({destination.id})"
)
else:
successful = True
if successful:
await ctx.send(_("Your message has been sent."))
else:
msg_text = "{}\nMessage:\n\n{}\n{}".format(description, message, footer)
try:
await owner.send("{}\n{}".format(content, box(msg_text)))
except discord.InvalidArgument:
await ctx.send(
_("I cannot send your message, I'm unable to find my owner... *sigh*")
)
except discord.HTTPException:
await ctx.send(_("I'm unable to deliver your message. Sorry."))
else:
await ctx.send(_("Your message has been sent."))
await ctx.send(_("I'm unable to deliver your message. Sorry."))
@commands.command()
@checks.is_owner()
@@ -1390,6 +1494,59 @@ class Core(commands.Cog, CoreLogic):
msg = _("Data path: {path}").format(path=data_dir)
await ctx.send(box(msg))
@commands.command(hidden=True)
@checks.is_owner()
async def debuginfo(self, ctx: commands.Context):
"""Shows debug information useful for debugging.."""
if sys.platform == "linux":
import distro
IS_WINDOWS = os.name == "nt"
IS_MAC = sys.platform == "darwin"
IS_LINUX = sys.platform == "linux"
pyver = "{}.{}.{} ({})".format(*sys.version_info[:3], platform.architecture()[0])
pipver = pip.__version__
redver = red_version_info
dpy_version = discord.__version__
if IS_WINDOWS:
os_info = platform.uname()
osver = "{} {} (version {})".format(os_info.system, os_info.release, os_info.version)
elif IS_MAC:
os_info = platform.mac_ver()
osver = "Mac OSX {} {}".format(os_info[0], os_info[2])
elif IS_LINUX:
os_info = distro.linux_distribution()
osver = "{} {}".format(os_info[0], os_info[1]).strip()
else:
osver = "Could not parse OS, report this on Github."
user_who_ran = getpass.getuser()
if await ctx.embed_requested():
e = discord.Embed(color=await ctx.embed_colour())
e.title = "Debug Info for Red"
e.add_field(name="Red version", value=redver, inline=True)
e.add_field(name="Python version", value=pyver, inline=True)
e.add_field(name="Discord.py version", value=dpy_version, inline=True)
e.add_field(name="Pip version", value=pipver, inline=True)
e.add_field(name="System arch", value=platform.machine(), inline=True)
e.add_field(name="User", value=user_who_ran, inline=True)
e.add_field(name="OS version", value=osver, inline=False)
await ctx.send(embed=e)
else:
info = (
"Debug Info for Red\n\n"
+ "Red version: {}\n".format(redver)
+ "Python version: {}\n".format(pyver)
+ "Discord.py version: {}\n".format(dpy_version)
+ "Pip version: {}\n".format(pipver)
+ "System arch: {}\n".format(platform.machine())
+ "User: {}\n".format(user_who_ran)
+ "OS version: {}\n".format(osver)
)
await ctx.send(box(info))
@commands.group()
@checks.is_owner()
async def whitelist(self, ctx: commands.Context):
@@ -1602,7 +1759,7 @@ class Core(commands.Cog, CoreLogic):
"""
user = isinstance(user_or_role, discord.Member)
if user and await ctx.bot.is_owner(obj):
if user and await ctx.bot.is_owner(user_or_role):
await ctx.send(_("You cannot blacklist an owner!"))
return
@@ -1871,6 +2028,102 @@ class Core(commands.Cog, CoreLogic):
else:
await ctx.send(_("They are not Immune"))
@checks.is_owner()
@_set.group()
async def ownernotifications(self, ctx: commands.Context):
"""
Commands for configuring owner notifications.
"""
pass
@ownernotifications.command()
async def optin(self, ctx: commands.Context):
"""
Opt-in on recieving owner notifications.
This is the default state.
"""
async with ctx.bot.db.owner_opt_out_list() as opt_outs:
if ctx.author.id in opt_outs:
opt_outs.remove(ctx.author.id)
await ctx.tick()
@ownernotifications.command()
async def optout(self, ctx: commands.Context):
"""
Opt-out of recieving owner notifications.
"""
async with ctx.bot.db.owner_opt_out_list() as opt_outs:
if ctx.author.id not in opt_outs:
opt_outs.append(ctx.author.id)
await ctx.tick()
@ownernotifications.command()
async def adddestination(
self, ctx: commands.Context, *, channel: Union[discord.TextChannel, int]
):
"""
Adds a destination text channel to recieve owner notifications
"""
try:
channel_id = channel.id
except AttributeError:
channel_id = channel
async with ctx.bot.db.extra_owner_destinations() as extras:
if channel_id not in extras:
extras.append(channel_id)
await ctx.tick()
@ownernotifications.command(aliases=["remdestination", "deletedestination", "deldestination"])
async def removedestination(
self, ctx: commands.Context, *, channel: Union[discord.TextChannel, int]
):
"""
Removes a destination text channel from recieving owner notifications.
"""
try:
channel_id = channel.id
except AttributeError:
channel_id = channel
async with ctx.bot.db.extra_owner_destinations() as extras:
if channel_id in extras:
extras.remove(channel_id)
await ctx.tick()
@ownernotifications.command()
async def listdestinations(self, ctx: commands.Context):
"""
Lists the configured extra destinations for owner notifications
"""
channel_ids = await ctx.bot.db.extra_owner_destinations()
if not channel_ids:
await ctx.send(_("There are no extra channels being sent to."))
return
data = []
for channel_id in channel_ids:
channel = ctx.bot.get_channel(channel_id)
if channel:
# This includes the channel name in case the user can't see the channel.
data.append(f"{channel.mention} {channel} ({channel.id})")
else:
data.append(_("Unknown channel with id: {id}").format(id=channel_id))
output = "\n".join(data)
for page in pagify(output):
await ctx.send(page)
# RPC handlers
async def rpc_load(self, request):
cog_name = request.params[0]

View File

@@ -120,24 +120,12 @@ def init_events(bot, cli_flags):
"but you're using {}".format(data["info"]["version"], red_version)
)
owners = []
owner = bot.get_user(bot.owner_id)
if owner is not None:
owners.append(owner)
for co_owner in bot._co_owners:
co_owner = await bot.get_user(co_owner)
if co_owner is not None:
owners.append(co_owner)
for owner in owners:
with contextlib.suppress(discord.HTTPException):
await owner.send(
"Your Red instance is out of date! {} is the current "
"version, however you are using {}!".format(
data["info"]["version"], red_version
)
)
await bot.send_to_owners(
"Your Red instance is out of date! {} is the current "
"version, however you are using {}!".format(
data["info"]["version"], red_version
)
)
INFO2 = []
mongo_enabled = storage_type() != "JSON"
@@ -191,7 +179,7 @@ def init_events(bot, cli_flags):
await ctx.send(error.args[0])
else:
await ctx.send_help()
elif isinstance(error, commands.BadArgument):
elif isinstance(error, commands.UserInputError):
await ctx.send_help()
elif isinstance(error, commands.DisabledCommand):
disabled_message = await bot.db.disabled_command_msg()

View File

@@ -4,6 +4,7 @@
# Ported to Red V3 by Palm\_\_ (https://github.com/palmtree5)
import asyncio
import contextlib
import functools
from typing import Union, Iterable, Optional
import discord
@@ -60,7 +61,10 @@ async def menu(
):
raise RuntimeError("All pages must be of the same type")
for key, value in controls.items():
if not asyncio.iscoroutinefunction(value):
maybe_coro = value
if isinstance(value, functools.partial):
maybe_coro = value.func
if not asyncio.iscoroutinefunction(maybe_coro):
raise RuntimeError("Function must be a coroutine")
current_page = pages[page]

View File

@@ -254,7 +254,7 @@ async def reset_red():
"please select option 5 in the launcher."
)
await asyncio.sleep(2)
print("\nIf you continue you will remove these instanes.\n")
print("\nIf you continue you will remove these instances.\n")
for instance in list(instances.keys()):
print(" - {}".format(instance))
await asyncio.sleep(3)

View File

@@ -440,7 +440,7 @@ async def remove_instance(instance):
collection = await db.get_collection(name)
await collection.drop()
else:
pth = Path(instance_data["DATA_PATH"])
pth = Path(instance_vals["DATA_PATH"])
safe_delete(pth)
save_config(instance, {}, remove=True)
print("The instance {} has been removed\n".format(instance))